Mana

Stop wiring Nix dependencies by hand. Declare them once and Mana resolves the whole transitive tree, deduplicates it, and injects each into your project.

mana.nix
{
  name = "<YOUR PROJECT>";
  entrypoint = ./entrypoint.nix;
  dependencies = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
  };
  shares = [ "nixpkgs" ];      # transitive deps use YOUR nixpkgs
}

mana update does the rest: resolves the full tree, pins it in lock.json, and injects each dependency into your build.

How it works โ€” click a stage

What you author

mana.nix
# '<YOUR PROJECT>' - Manifest
# --------------------
{
  name = "<YOUR PROJECT>";
  # description = "";

  entrypoint = ./entrypoint.nix;

  dependencies = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
  };

  # Share these dependencies with all transitive dependencies.
  # Libraries that also depend on nixpkgs will use YOUR version.
  shares = [ "nixpkgs" ];
}

Resolve + lock โ€” fetch the transitive graph and pin it.

terminal
$ mana update
Updating all dependencies
lock.json updated

Runs check, evaluates scripts/update.nix โ†’ next_lock.json, verifies it parses, then mvs it to lock.json. No args updates everything; mana update nixpkgs updates one dependency.

The resolved, pinned graph โ€” { deps, sources }.

lock.json
{
  "deps": {
    "": {
      "nixpkgs": "/nixpkgs"
    }
  },
  "sources": {
    "/nixpkgs": {
      "args": {
        "owner": "nixos",
        "ref": "nixos-unstable",
        "repo": "nixpkgs",
        "type": "github"
      },
      "locked": {
        "lastModified": 1775710090,
        "lastModifiedDate": "20260409044810",
        "narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=",
        "rev": "4c1018dae018162ec878d42fec712642d214fdfa",
        "shortRev": "4c1018d"
      }
    }
  }
}

sources maps lockKey โ†’ { args, locked }. deps maps parentKey โ†’ { name โ†’ lockKey } โ€” the root parent is "". A lockKey is the dependency's tree path (/A, /A/B); / is banned in names so keys never collide. Two deps with the same narHash collapse to one source โ€” that is the deduplication.

Evaluation entry point โ€” selects enabled groups.

default.nix
(import ./nix/importer.nix) {
  # evaluation arguments
  #
  # groups = [ "eval" "dev" ]
  # : Enabled evaluation groups, see README.md
  # : default = [ "eval" ]
}

Build a target with nix build -f default.nix hello. Default enabled groups: [ "eval" ].

Resolve + inject โ€” the shim mana writes and upgrades for you.

nix/importer.nix โ€” excerpt
# Import the entrypoint, passing resolved deps as args
importEntrypoint =
  f: if builtins.isFunction f then f (builtins.intersectAttrs (builtins.functionArgs f) scope) else f;
โ€ฆ
scope = importNode {
  nodeLockKey = ""; # Start from "root"
  nodeManifest = normalizeManifest { } manifest;
  nodeGroups = groups;
};

f = import manifest.entrypoint;
in
f (builtins.intersectAttrs (builtins.functionArgs f) scope)

Walks deps, fetchTrees each sources entry, imports each entrypoint, and injects only the arguments a function declares โ€” via builtins.intersectAttrs (functionArgs f) scope. A dependency outside every enabled group throws on access, listing the groups that would include it.

Resolution kinds โ€” set debug = true to trace
trace
trace: [mana] <root>
  groups: eval, dev
  deps: nixpkgs, treefmt-nix
trace: [mana] <root>/nixpkgs [raw]
trace: [mana] <root>/treefmt-nix [entrypoint: entrypoint.nix]
trace: [mana] /treefmt-nix
  groups: eval
  deps: nixpkgs
trace: [mana] /treefmt-nix/nixpkgs [raw]
  • [raw] โ€” entrypoint = null; returns the source path, no import
  • [custom: path] โ€” consumer overrides the entrypoint
  • [entrypoint: path] โ€” dependency's own entrypoint from its mana.nix
  • [default.nix] โ€” no mana.nix; falls back to default.nix

Receives the resolved dependencies as function arguments.

entrypoint.nix
# manifest dependencies, injected by the nix/importer.nix
# โ†“ dependencies controlled by mana
{ nixpkgs }:
# โ†“ your parameters
{system ? builtins.currentSystem, ... }:
let
  pkgs = nixpkgs { inherit system; };
in
{
  hello = pkgs.hello;
  # ...more of your own stuff
}

Build output โ€” pkgs.hello, fully resolved.

terminal
$ nix build -f default.nix hello
$ ./result/bin/hello
Hello, world!

One version across the tree โ€” toggle

By default every dependency re-locks its own nixpkgs. shares unifies the subtree; pins protects a dependency from an ancestor's share.

  • root nixpkgs@unstable shared โ†“
    • A nixpkgs@25.11 nixpkgs@unstable shared
      • B nixpkgs@26.05 nixpkgs@unstable shared nixpkgs@26.05 pinned pinned
        • C nixpkgs@25.11 nixpkgs@unstable shared nixpkgs@25.11 own

pins = [ "nixpkgs" ] at B protects its own nixpkgs@26.05 from the ancestor share. Below the pin the share no longer reaches: C keeps /A/B/C/nixpkgs (25.11).

Optional dependencies โ€” toggle groups

A dependency is fetched only when its group is enabled โ€” so groups double as feature flags. Gate a heavy, optional dependency behind its own group (say, a pinned CUDA toolkit); only builds that enable it pay the download. One entrypoint.nix serves every group; accessing a disabled dependency throws.

nixpkgseval
available
throws on access
treefmt-nixdev
available
throws on access
cudatoolkitcuda
available
throws on access
groups = {
  eval = { nixpkgs = [ ]; };
  dev  = { treefmt-nix = [ ]; };
  cuda = { cudatoolkit = [ ]; };
};