Skip to content

zawatton/anvil-pkg

Repository files navigation

anvil-pkg — Elisp DSL package manager for anvil, backed by Nix store

https://github.com/zawatton/anvil-pkg/actions/workflows/test.yml/badge.svg https://github.com/zawatton/anvil-pkg/actions/workflows/smoke.yml/badge.svg

What is anvil-pkg?

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.

Status

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-eval renders four examples (stdenv-hello, rust-ripgrep, python-black, go-hugo) and runs nix flake check --no-build on each (Determinate Nix on ubuntu-latest).
    • make smoke-build (local-only) actually nix build s the cheap subset (gnu-hello + black) end-to-end.
    • Real source / cargo / vendor hashes ship in the recipes — no sha256-PLACEHOLDER literals to swap out before install.
  • Generated flake.nix validated 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 1
  • pkg-define macro — Phase 2
  • github-fetch / git-fetch sources, rust / python / go build systems — Phase 3
  • emacs-package build-system + post-install load-path augment + :require keyword — Phase 4-A
  • :format (“trivial” / “melpa”) + :native-comp toggle on emacs-package, :async keyword on pkg-install with :on-success / :on-error callbacks, and anvil-pkg-import-async-installer migration utility — Phase 4-B
  • :no-auto-deps keyword on pkg-install (opt-out of pre-fetch), pkg-list-generations / pkg-rollback / pkg-history — Phase 4-C
  • anvil-pkg-compat-make-process-async / anvil-pkg-compat-http-get portability primitives (Emacs path shipped; NeLisp signals until Phase 5) — Phase 4-C
  • anvil-pkg-state namespaced KV with TTL (deps cache + Nix-version cache + generations mirror persist across Emacs sessions), pkg-clear-cache scope arg, :melpa-synth / :melpa-recipe / :melpa-files keywords on emacs-package, L18 pre-fetch extended to url-fetch (tarball header scrape) and git-fetch (shallow clone scrape), pkg-rollback-package per-package rollback — Phase 4-D
  • anvil-pkg-emacs-fetch-melpa-recipe public helper + anvil-pkg-emacs-melpa-upstream-fetch defcustom for opt-in upstream MELPA recipe lookup (canonical recipe wins over local synth when the defcustom is non-nil and a recipe exists at melpa/melpa@master/recipes/<pname>); upgraded :melpa-files default from ("*.el") to MELPA’s full package-build-default-files-spec so subdir / .el.in / .info layouts work without manual configuration — Phase 4-E
  • pkg-install multi-package dispatch: (pkg-install '(magit dash transient)) or (pkg-install '(magit "ripgrep")) renders the flake once and invokes nix profile install/add once with all flakerefs (atomic across the bulk via Nix’s profile transaction). Mixed registry symbols + nixpkgs strings supported. Async callbacks receive :names NAMES instead of :name NAME. :require rejected 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.

Why?

AI agents need a single DSL for tool installation

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 solves the hard problems; we add the DSL

Nix already handles dependency closures, content-addressed storage, sandboxed builds, and binary caches. anvil-pkg does not re-implement any of that. It provides:

  1. An Elisp DSL (anvil-pkg-define, anvil-pkg-install, …) that feels native to Emacs / NeLisp users.
  2. A wrapper that translates that DSL to Nix expressions / nix profile commands.
  3. A fallback path for Git-host packages not in nixpkgs (private repos, in-development MCP servers), inspired by async-installer.

Plain Elisp, no macro DSL forced on you

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.

Design overview

┌─────────────────────────────────────────────────────────────┐
│  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 plan

PhaseScopeStatus
1nix profile shell-out wrapper. 3 MCP tools. install/search/listSHIPPED
2pkg-define DSL macro + flake.nix generation + symbol installSHIPPED
3github-fetch / git-fetch sources + rust / python / go bsSHIPPED
4-Aemacs-package build-system + post-install load-path + :requireSHIPPED
4-B:format / :native-comp / :async callbacks + importer (L15 def’d)SHIPPED
4-CL18 pre-fetch + L19 rollback + L20 Nix 2.34 + L22 compat asyncSHIPPED
4-DmelpaBuild recipe synthesis + url/git L18 + per-pkg rollback + stateSHIPPED
4-EMELPA upstream recipe fetch (opt-in) + better :melpa-files defaultSHIPPED
4-Fpkg-install multi-package dispatch (atomic bulk install)SHIPPED
4-Gprivate repo: env-var credentials → HTTP / git / nix CLI authSHIPPED
CIGitHub Actions — Emacs 30 × Linux, make compile + make test gateSHIPPED
4-HReal-Nix smoke (make smoke-eval) + cargoSha256→cargoHash renderer fixSHIPPED
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.

Install

git clone https://github.com/zawatton/anvil-pkg ~/anvil-pkg

In 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)

Examples

;; 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 directory

examples/ ships copy-pasteable pkg-define recipes for every supported source + build-system combination:

FileSourceBuild system
examples/stdenv-hello.elurl-fetchstdenv
examples/rust-ripgrep.elgithub-fetchrust
examples/python-black.elgithub-fetchpython (pyproject)
examples/go-hugo.elgithub-fetchgo
examples/emacs-trivial-dash.elgithub-fetchemacs-package trivial
examples/emacs-melpa-magit.elgithub-fetchemacs-package melpa
examples/multi-install.el(mixed)rust × 3
examples/private-github.elgithub / gitrust / 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.

Security notes (Phase 4-G credentials)

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):
    HostEnv vars (first non-empty wins)
    github.com / raw.githubusercontent.com / api.github.com / codeload.github.com / objects.githubusercontent.comGITHUB_TOKEN, GH_TOKEN
    gitlab.comGITLAB_TOKEN
    codeberg.orgCODEBERG_TOKEN
  • Add custom hosts (e.g. corporate GitHub Enterprise) by add-to-list‘ing into anvil-pkg-compat-credential-env-alist before invoking pkg-install.
  • Tokens are visible to ps aux for the duration of nix / git subprocesses — 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 daemon access-tokens.conf or a GIT_ASKPASS shim instead (out of scope for this phase).
  • Logging through anvil-pkg-compat-mask-credentials replaces Bearer .*, extra-access-tokens "host=tok", and x-access-token:tok@ patterns with *** before any message / lwarn / debug-print call.

Run the test suite

make test       # 111 ERT tests, no nix binary required (all mocked)
make compile    # byte-compile, warnings-as-errors

Requirements (planned)

  • Emacs 29+ (anvil runtime requirement)
  • Nix 2.18+ with flakes enabled (Phase 1 backend)
  • anvil.el loaded

Naming

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.

License

GPL-3.0-or-later. Same as anvil.el and NeLisp.

About

Elisp DSL package manager backed by the Nix store. AI-native MCP surface, anvil ecosystem sub-module.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors