anvil-pkg is a package manager you configure in Emacs Lisp, backed by the Nix store. It is a sub-system of anvil.el (the AI tool workbench), exposing package install / search / definition both as Elisp API and as Claude Code MCP tools.
The same idea as GNU Guix (Scheme over the Nix store) — but in Emacs Lisp, integrated with the anvil + NeLisp tool ecosystem so that AI agents can install anything by writing one Lisp form.
Phase 1+2+3+4-A+4-B+4-C+4-D+4-E+4-F+4-G+4-H SHIPPED + REAL-NIX VERIFIED (2026-05-06) —
Full DSL stack on top of the Nix store with multiple fetcher /
build-system support, an emacs-package backend that round-trips
Elisp libraries through trivialBuild / melpaBuild with optional
:native-comp, an opt-in :async install path, automatic
:depends-on derivation from Package-Requires for github / tarball /
git sources with cross-session caching in anvil-pkg-state, profile
generation rollback (pkg-list-generations / pkg-rollback /
pkg-rollback-package / pkg-history), Nix 2.34 install=→=add
dispatch, MELPA recipe auto-synthesis via postUnpack with optional
upstream MELPA recipe lookup, a one-shot async-installer
importer that auto-populates :depends-on from local clones, and
private repo support via env-var credentials
(GITHUB_TOKEN / GITLAB_TOKEN / CODEBERG_TOKEN) wired through
the L18 pre-fetch, git HTTPS clone, and nix --option
extra-access-tokens paths.
- ERT 112/112 PASS (mocked, runs without a nix binary).
- Real-nix smoke test runs on every push (Phase 4-H,
smoke.yml):make smoke-evalrenders four examples (stdenv-hello,rust-ripgrep,python-black,go-hugo) and runsnix flake check --no-buildon each (Determinate Nix onubuntu-latest).make smoke-build(local-only) actuallynix builds the cheap subset (gnu-hello+black) end-to-end.- Real source / cargo / vendor hashes ship in the recipes —
no
sha256-PLACEHOLDERliterals to swap out before install.
- Generated
flake.nixvalidated by Nix as well-formed on every push (matrix: Emacs 30.1 × Linux).
Public Elisp API uses the short pkg- prefix:
pkg-install/pkg-search/pkg-list— Phase 1pkg-definemacro — Phase 2github-fetch/git-fetchsources,rust/python/gobuild systems — Phase 3emacs-packagebuild-system + post-installload-pathaugment +:requirekeyword — Phase 4-A:format(“trivial” / “melpa”) +:native-comptoggle onemacs-package,:asynckeyword onpkg-installwith:on-success/:on-errorcallbacks, andanvil-pkg-import-async-installermigration utility — Phase 4-B:no-auto-depskeyword onpkg-install(opt-out of pre-fetch),pkg-list-generations/pkg-rollback/pkg-history— Phase 4-Canvil-pkg-compat-make-process-async/anvil-pkg-compat-http-getportability primitives (Emacs path shipped; NeLisp signals until Phase 5) — Phase 4-Canvil-pkg-statenamespaced KV with TTL (deps cache + Nix-version cache + generations mirror persist across Emacs sessions),pkg-clear-cachescope arg,:melpa-synth/:melpa-recipe/:melpa-fileskeywords onemacs-package, L18 pre-fetch extended tourl-fetch(tarball header scrape) andgit-fetch(shallow clone scrape),pkg-rollback-packageper-package rollback — Phase 4-Danvil-pkg-emacs-fetch-melpa-recipepublic helper +anvil-pkg-emacs-melpa-upstream-fetchdefcustom for opt-in upstream MELPA recipe lookup (canonical recipe wins over local synth when the defcustom is non-nil and a recipe exists atmelpa/melpa@master/recipes/<pname>); upgraded:melpa-filesdefault from("*.el")to MELPA’s fullpackage-build-default-files-specso subdir / .el.in / .info layouts work without manual configuration — Phase 4-Epkg-installmulti-package dispatch:(pkg-install '(magit dash transient))or(pkg-install '(magit "ripgrep"))renders the flake once and invokesnix profile install/addonce with all flakerefs (atomic across the bulk via Nix’s profile transaction). Mixed registry symbols + nixpkgs strings supported. Async callbacks receive:names NAMESinstead of:name NAME.:requirerejected with list NAME (ambiguous) — Phase 4-F
Long-form aliases (anvil-pkg-install etc.) are provided via
defalias for Emacs-prefix purists.
Phase 5 focuses on the NeLisp async backend that makes
anvil-pkg-compat-make-process-async work outside Emacs and a
NeLisp-native HTTP backend so anvil-pkg-compat-http-get-binary no
longer signals on standalone runtimes.
Claude Code (and similar agents) already speak anvil’s MCP tools
fluently. Adding anvil-pkg-install to that surface means an agent can
provision its own dependencies — install ripgrep, jq, language
servers, anything in nixpkgs — by emitting one Elisp form, without
shelling out to a different package manager per OS.
Nix already handles dependency closures, content-addressed storage,
sandboxed builds, and binary caches. anvil-pkg does not re-implement
any of that. It provides:
- An Elisp DSL (
anvil-pkg-define,anvil-pkg-install, …) that feels native to Emacs / NeLisp users. - A wrapper that translates that DSL to Nix expressions /
nix profilecommands. - A fallback path for Git-host packages not in nixpkgs (private
repos, in-development MCP servers), inspired by
async-installer.
Like async-installer, anvil-pkg prefers setq / defun /
anvil-pkg-define over a baroque use-package-style macro. Reading
your config should not require learning a new macro language.
┌─────────────────────────────────────────────────────────────┐
│ Elisp DSL │
│ (anvil-pkg-install "ripgrep") │
│ (anvil-pkg-define my-tool :src (github "...") :build ...) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ anvil-pkg core (this repo) │
│ - DSL parser / form → backend dispatch │
│ - manifest / generation tracking │
│ - MCP tool surface: anvil_pkg_install / search / list ... │
└─────────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ Nix backend (primary) │ │ Git backend (fallback) │
│ nix profile install ... │ │ inspired by async-installer│
│ nix-eval / flake / nixpkgs │ │ for non-nixpkgs repos │
└─────────────────────────────┘ └─────────────────────────────┘
| Phase | Scope | Status |
|---|---|---|
| 1 | nix profile shell-out wrapper. 3 MCP tools. install/search/list | SHIPPED |
| 2 | pkg-define DSL macro + flake.nix generation + symbol install | SHIPPED |
| 3 | github-fetch / git-fetch sources + rust / python / go bs | SHIPPED |
| 4-A | emacs-package build-system + post-install load-path + :require | SHIPPED |
| 4-B | :format / :native-comp / :async callbacks + importer (L15 def’d) | SHIPPED |
| 4-C | L18 pre-fetch + L19 rollback + L20 Nix 2.34 + L22 compat async | SHIPPED |
| 4-D | melpaBuild recipe synthesis + url/git L18 + per-pkg rollback + state | SHIPPED |
| 4-E | MELPA upstream recipe fetch (opt-in) + better :melpa-files default | SHIPPED |
| 4-F | pkg-install multi-package dispatch (atomic bulk install) | SHIPPED |
| 4-G | private repo: env-var credentials → HTTP / git / nix CLI auth | SHIPPED |
| CI | GitHub Actions — Emacs 30 × Linux, make compile + make test gate | SHIPPED |
| 4-H | Real-Nix smoke (make smoke-eval) + cargoSha256→cargoHash renderer fix | SHIPPED |
| 5+ | NeLisp async backend + HTTP backend (replaces Phase 4-C/4-D stubs) | research |
See docs/design/01-overview.org for the full Phase 1 contract.
git clone https://github.com/zawatton/anvil-pkg ~/anvil-pkgIn your Emacs init:
(add-to-list 'load-path "~/anvil-pkg")
(require 'anvil-pkg)
(require 'anvil-pkg-dsl) ; Phase 2 — pkg-define macro
;; To register pkg-* MCP tools (requires anvil.el loaded):
(anvil-pkg-enable);; Phase 1 — install a package straight from nixpkgs
(pkg-install "ripgrep")
(pkg-search "rust")
(pkg-list)
;; Phase 2 — declare a custom package and install by symbol
(pkg-define my-rg
(version "13.0.0")
(source (url-fetch "https://github.com/BurntSushi/ripgrep/archive/13.0.0.tar.gz"
:sha256 "sha256-..."))
(build-system stdenv)
(inputs (list pkg-config openssl))
(install-phase "make install PREFIX=$out"))
(pkg-install 'my-rg) ; goes through generated flake.nix
;; Phase 3 — github-fetch + rust build-system
(pkg-define my-rust-tool
(version "1.0.0")
(source (github-fetch :owner "user" :repo "tool"
:rev "v1.0.0" :sha256 "sha256-..."))
(build-system (rust :cargo-sha256 "sha256-..."))
(inputs (list openssl)))
;; Phase 3 — git-fetch + go build-system (vendored deps)
(pkg-define my-go-tool
(version "0.3.0" )
(source (git-fetch :url "https://example.com/tool.git"
:rev "v0.3.0" :sha256 "sha256-..."))
(build-system (go))) ; vendorHash = null (vendored)
;; Phase 4-A — emacs-package build-system + post-install require
(pkg-define dash-test
(version "2.20.0")
(source (github-fetch :owner "magnars" :repo "dash.el"
:rev "2.20.0" :sha256 "sha256-..."))
(build-system emacs-package))
(pkg-install 'dash-test :require 'dash)
;; Phase 4-B — :format ("melpa") + :native-comp + explicit deps
(pkg-define magit
(version "3.3.0")
(source (github-fetch :owner "magit" :repo "magit"
:rev "v3.3.0" :sha256 "sha256-..."))
(build-system (emacs-package :format "melpa" :native-comp t))
(depends-on (list dash transient with-editor)))
;; Phase 4-B — :async install with sentinel callbacks
(pkg-install 'magit
:async t
:require 'magit
:on-success (lambda (result)
(message "magit ready: %S" result))
:on-error (lambda (err)
(message "magit install failed: %S" err)))
;; → returns the live process object; sentinel routes the exit
;; status through your callback chain.
;; Phase 4-B — one-shot migration from async-installer-git-list
(require 'anvil-pkg-import)
(anvil-pkg-import-async-installer
:var 'async-installer-git-list
:emit (expand-file-name "imported-pkgs.el" user-emacs-directory))
;; → writes pkg-define forms; load the file from your init when ready.
;; Phase 4-C — auto-derive depends-on (L18 pre-fetch from
;; raw.githubusercontent.com). No explicit (depends-on ...) needed
;; for github-fetch sources; result cached for 30 days per session.
(pkg-define magit
(version "3.3.0")
(source (github-fetch :owner "magit" :repo "magit"
:rev "v3.3.0" :sha256 "sha256-..."))
(build-system (emacs-package :format "melpa")))
(pkg-install 'magit)
;; → pre-fetches magit-pkg.el, derives (dash transient with-editor compat),
;; renders flake.nix, installs. Use :no-auto-deps t to opt out.
;; Phase 4-C — profile generation rollback
(pkg-list-generations)
;; → ((:id 5 :date "..." :packages (ripgrep magit) :active t)
;; (:id 4 :date "..." :packages (ripgrep) :active nil))
(pkg-rollback) ; pop one generation
(pkg-rollback 3) ; jump to generation 3
(pkg-history 'magit) ; → events for magit only
;; Phase 4-C — importer auto-populates depends-on from local clones
(anvil-pkg-import-async-installer
:var 'async-installer-git-list
:emit (expand-file-name "imported-pkgs.el" user-emacs-directory)
:scrape-deps t) ; default; reads ~/.emacs.d/external-packages/<pkg>
; or falls through to L18's HTTP path.
;; Phase 4-D — :format "melpa" auto-synthesises a recipe via postUnpack
;; so upstream repos without recipes/<pname> still build under melpaBuild.
(pkg-define helm
(version "3.9.7")
(source (github-fetch :owner "emacs-helm" :repo "helm"
:rev "v3.9.7" :sha256 "sha256-..."))
(build-system (emacs-package
:format "melpa"
:melpa-synth 'auto ; auto / force / never
:melpa-files '("*.el" "lisp/*.el"))))
;; Override the synth completely with a verbatim recipe:
(pkg-define helm-custom
(version "3.9.7")
(source (github-fetch :owner "emacs-helm" :repo "helm"
:rev "v3.9.7" :sha256 "sha256-..."))
(build-system (emacs-package
:format "melpa"
:melpa-recipe "(helm :fetcher git :url \"https://github.com/emacs-helm/helm.git\" :files (\"*.el\" \"lisp/*.el\"))")))
;; Phase 4-D — auto-derive depends-on now covers url-fetch + git-fetch
(pkg-define dash
(version "2.20.0")
(source (url-fetch :url "https://example.com/dash-2.20.0.tar.gz"
:sha256 "sha256-..."))
(build-system (emacs-package :format "trivial")))
(pkg-install 'dash)
;; → tarball downloaded, foo-pkg.el extracted, deps cached by sha256.
(pkg-define internal
(version "1.0")
(source (git-fetch :url "https://git.example.com/internal.el.git"
:rev "v1.0" :sha256 "sha256-..."))
(build-system (emacs-package :format "trivial")))
(pkg-install 'internal)
;; → shallow git clone, deps scraped, tmpdir cleaned, deps cached by url@rev.
;; Phase 4-D — per-package rollback (drops one entry, keeps the rest)
(pkg-rollback-package 'magit)
;; → re-renders flake.nix from the registry minus magit, runs
;; `nix profile add'. Other installed packages stay.
;; Phase 4-D — clear caches by scope (default = all)
(pkg-clear-cache) ; all anvil-pkg-state namespaces
(pkg-clear-cache 'deps) ; just the Package-Requires lookup cache
(pkg-clear-cache 'nix-version) ; just the Nix-version detection cache
(pkg-clear-cache 'generations) ; just the profile generations mirror
;; Phase 4-E — opt-in MELPA upstream recipe fetch. When enabled,
;; :melpa-synth 'auto first tries
;; raw.githubusercontent.com/melpa/melpa/master/recipes/<pname>;
;; on hit it emits the canonical recipe verbatim instead of the local
;; synth fallback. Cached per package for 7 days in anvil-pkg-state.
(setq anvil-pkg-emacs-melpa-upstream-fetch t)
(pkg-define magit
(version "3.3.0")
(source (github-fetch :owner "magit" :repo "magit"
:rev "v3.3.0" :sha256 "sha256-..."))
(build-system (emacs-package :format "melpa")))
;; → upstream MELPA recipe used when present; falls back to synth on miss.
;; Phase 4-E — :melpa-files default upgraded to MELPA's canonical
;; package-build-default-files-spec. When :melpa-files is omitted, the
;; synth carries ("*.el" "*.el.in" "dir" "*.info" "*.texi" "*.texinfo"
;; "doc/dir" "doc/*.info" ... "lisp/*.el" "lisp/*.el.in" ... :exclude
;; tests / .dir-locals.el). Override only when you need a tighter list.
(pkg-define dash
(version "2.20.0")
(source (github-fetch :owner "magnars" :repo "dash.el"
:rev "2.20.0" :sha256 "sha256-..."))
(build-system (emacs-package :format "melpa")))
;; → synth recipe now covers lisp/ subdir + .el.in + .info layouts.
;; Phase 4-F — multi-package install (atomic bulk).
;; pkg-install dispatches a single nix profile install/add invocation
;; with all flakerefs. Atomic: success or none.
(pkg-install '(magit dash transient)) ; 3 registry symbols
(pkg-install '("ripgrep" "fd" "hyperfine")) ; 3 nixpkgs strings
(pkg-install '(magit "ripgrep")) ; mixed
;; Async with multi-install — :on-success receives :names NAMES, not :name:
(pkg-install '(magit dash) :async t
:on-success (lambda (r)
(message "installed %S" (plist-get r :names))))
;; :require is rejected with a list (which symbol to require?):
;; (pkg-install '(magit dash) :require 'magit)
;; → anvil-pkg-error
;; Phase 4-G — private repo support via env-var credentials.
;; Export a token before invoking pkg-install; the DSL form is
;; identical to a public-repo recipe. No :auth keyword, no
;; on-disk credential storage.
;;
;; $ export GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
(pkg-define my-private-tool
(version "0.1.0")
(source (github-fetch :owner "your-org"
:repo "private-tool"
:rev "v0.1.0"
:sha256 "sha256-..."))
(build-system (rust :cargo-sha256 "sha256-...")))
(pkg-install 'my-private-tool)
;; anvil-pkg auto-injects:
;; - L18 raw.githubusercontent.com Authorization: Bearer header
;; - git -c http.<host>/.extraheader=Authorization: Bearer ...
;; - nix --option extra-access-tokens "github.com=$TOKEN"
;; Default credential alist covers github.com + raw + api +
;; codeload + objects subdomains; gitlab.com (GITLAB_TOKEN);
;; codeberg.org (CODEBERG_TOKEN). See examples/private-github.el.CLI sub-command (anvil pkg install ...) lands in anvil.el via a
separate PR; until then call the Elisp API directly.
examples/ ships copy-pasteable pkg-define recipes for every
supported source + build-system combination:
| File | Source | Build system |
|---|---|---|
examples/stdenv-hello.el | url-fetch | stdenv |
examples/rust-ripgrep.el | github-fetch | rust |
examples/python-black.el | github-fetch | python (pyproject) |
examples/go-hugo.el | github-fetch | go |
examples/emacs-trivial-dash.el | github-fetch | emacs-package trivial |
examples/emacs-melpa-magit.el | github-fetch | emacs-package melpa |
examples/multi-install.el | (mixed) | rust × 3 |
examples/private-github.el | github / git | rust / go |
Each file is self-contained; load with M-x load-file (or
(load-file ...) from your init) and invoke (pkg-install
'<name>). Replace the sha256-PLACEHOLDER literals before
installing — see examples/README.org for the workflow.
Private repository access is opt-in and entirely env-var-driven.
There is no DSL :auth keyword and no on-disk credential
storage — anvil-pkg never writes tokens to anvil-pkg-state or
to any of its own log lines.
- Default credential map (
anvil-pkg-compat-credential-env-alist):Host Env vars (first non-empty wins) github.com / raw.githubusercontent.com / api.github.com / codeload.github.com / objects.githubusercontent.com GITHUB_TOKEN,GH_TOKENgitlab.com GITLAB_TOKENcodeberg.org CODEBERG_TOKEN - Add custom hosts (e.g. corporate GitHub Enterprise) by
add-to-list‘ing intoanvil-pkg-compat-credential-env-alistbefore invokingpkg-install. - Tokens are visible to
ps auxfor the duration ofnix/gitsubprocesses — both tools accept the credential on the CLI and anvil-pkg cannot suppress that. On a single-user machine this is rarely an issue; on a shared host consider Nix daemonaccess-tokens.confor aGIT_ASKPASSshim instead (out of scope for this phase). - Logging through
anvil-pkg-compat-mask-credentialsreplacesBearer .*,extra-access-tokens "host=tok", andx-access-token:tok@patterns with***before anymessage/lwarn/ debug-print call.
make test # 111 ERT tests, no nix binary required (all mocked)
make compile # byte-compile, warnings-as-errors- Emacs 29+ (anvil runtime requirement)
- Nix 2.18+ with flakes enabled (Phase 1 backend)
anvil.elloaded
anvil-pkg follows the existing anvil sub-module pattern (anvil-http,
anvil-state, anvil-defs, anvil-org-index, …). CLI is exposed as a
bin/anvil sub-command (anvil pkg install ...) so the user only ever
has one binary in $PATH.
If the project later outgrows anvil’s ecosystem, a standalone brand rename remains an option. Phase 1-3 do not require it.
GPL-3.0-or-later. Same as anvil.el and NeLisp.