Objective
Replace the imperative home.activation.installUvTools flow in nix/home-manager/modules/uvx.nix with declarative uv2nix-built derivations for local Python projects, and consolidate uv, uvx, and Python version concerns into a single python.nix module so wheels live in the Nix store and hm switch becomes a no-op when nothing changed.
Result: shipped on 2026-05-02 in commit 380a3b5. ADR 0003 (docs/decisions/0003-uv2nix-for-local-python-tools.md) documents the decision and alternatives considered.
Context
The pre-migration uvx.nix (~200 lines) installed local Python tools imperatively from their source paths via uv tool install --force --reinstall <path>. The --force --reinstall was unconditional because uv would otherwise see a matching version string and skip the reinstall, leaving stale code on disk after a source edit. Result: every activation reinstalled every local tool, even when nothing changed. PyPI tools (jupyterlab, comfy-cli, etc.) had a similar but milder issue: uv was mostly idempotent there but still ran a network-touching check each time.
uv.nix was a 22-line settings-only sibling that split the toolchain across two modules for no real reason.
Approach taken
One module, nix/home-manager/modules/python.nix, owns the full uv-driven Python toolchain:
- uv settings:
exclude-newer = "3 days"supply-chain policy, plus the existing Darwincctoolswrapping foruvitself. - Managed interpreters:
uv python install 3.10..3.15activation, hash-gated on a marker file at$XDG_STATE_HOME/uvx/python-versions.specso unchanged interpreter sets are a no-op. (Was reinstalling 3.15.0aN every activation under the imperative flow because uv re-resolves pre-release specifiers.) - Local tools (declarative): each project built via
uvLib.mkUvProject ./path/to/project { }exposed on_module.args.uvLib. Loads the workspace frompyproject.toml/uv.lock, applies any required build-system overrides, emits a derivation, then wraps to expose only entry-point binaries from[project.scripts]. Wraps becausemkVirtualEnvoutputs collide onbin/python3,bin/dotenv, etc. when two end up in the same user profile. - PyPI tools-as-applications (imperative, hash-gated): same
uvxToolsattrset as before, but the activation hashesname + python + withDeps + withExecutablesFromper tool and persists markers under$XDG_STATE_HOME/uvx/<name>.spec. Skipsuv tool installwhen the marker matches anduv tool listshows the tool present. - Explicit upgrades:
just uv-upgraderunsuv python upgradethenuv tool upgrade --all. Decouples routine activation from version churn. The existingupdatewrapper script already callsjust uv-upgrade, so no further changes there.
Helper exposure uses _module.args.uvLib from python.nix, mirroring the existing _module.args.{base16, signoz-src, signoz-dashboards-src} pattern at nix/flake.nix:90-95.
What got migrated
| Tool | Owning module | Entry points |
|---|---|---|
permission-suggestion | claude.nix | 5 (-evals, -optimize, -replay, -results, base) |
bash-command-validator | claude.nix | 1 |
gh-review-preview | shell.nix | 1 |
claude-ops | shell.nix | 1 |
Each consumer now declares home.packages = [ (uvLib.mkUvProject ../../files/scripts/<name> { }) ]; and the home.file.".local/share/<name>" deploys are gone. mkUvProject auto-detects entry points from [project.scripts] so no per-tool binary list is needed.
The previous shell-script shims in claude.nix that exec’d $HOME/.local/bin/permission-suggestion and $HOME/.local/bin/bash-command-validator now reference ${permissionSuggestionPkg}/bin/permission-suggestion directly via Nix-store paths (hermetic, no PATH dependency).
shell.nix:589 generateClaudeOpsCompletion activation hook moved from entryAfter [ "installPackages" "installUvTools" ] to entryAfter [ "installPackages" ] and now invokes ${claudeOpsPkg}/bin/claude-ops directly.
Build-system overrides needed
One: clickhouse-sqlalchemy.setuptools = [ ]. The package’s sdist omits the setuptools declaration in [build-system].requires, so uv2nix needs the override pattern from pyproject-nix’s overriding-build-systems docs. The override map lives inline in python.nix and can be extended as new sdist packages surface.
Verification
End-to-end checks performed during the migration:
nix flake checkpasses after adding the three flake inputs (uv2nix,pyproject-nix,pyproject-build-systems).command -vresolves all eight binaries to~/.nix-profile/bin/(4 base tools + 4permission-suggestion-*variants).~/.local/share/{permission-suggestion,bash-command-validator,gh-review-preview,claude-ops}no longer exist.uv tool listno longer tracks the four migrated tools (uninstalled viauv tool uninstall <name>once during cutover).- All four tools functional:
gh-review-preview --helpandclaude-ops --helpshow CLI help; the two Claude Code hook tools (bash-command-validator,permission-suggestion) load and reachmain(theJSONDecodeErrorthey surface on stdin-less invocation is expected hook behavior). - Re-running
hm switchimmediately after a successful run reportsPython versions up to date (spec hash unchanged)and<tool> up to date (spec hash unchanged)for every PyPI bucket entry, no reinstall work. just checkpasses (nixpkgs-fmt, statix, deadnix, mdformat, secret detection).
Files changed
| Path | Action |
|---|---|
nix/flake.nix | Added uv2nix, pyproject-nix, pyproject-build-systems inputs; exposed via _module.args alongside existing entries |
nix/flake.lock | New input pins |
nix/home-manager/modules/python.nix | Created: consolidates uv.nix + uvx.nix; defines mkUvProject with auto-detected entry points and build-system overrides; hash-gated activations |
nix/home-manager/home.nix | Replaced ./modules/uv.nix and ./modules/uvx.nix imports with ./modules/python.nix |
nix/home-manager/modules/uv.nix | Deleted |
nix/home-manager/modules/uvx.nix | Deleted |
nix/home-manager/modules/shell.nix | Dropped programs.uvx.localTools block; added home.packages entries; removed home.file.".local/share/{gh-review-preview,claude-ops}" deploys; fixed generateClaudeOpsCompletion activation deps |
nix/home-manager/modules/coding-agents/claude/claude.nix | Same shape as shell.nix; hook shims switched to Nix-store paths |
justfile | uv-upgrade recipe now runs uv python upgrade before uv tool upgrade --all |
docs/decisions/0003-uv2nix-for-local-python-tools.md | New ADR documenting the decision |
Lessons / things to watch
- Pre-release Python specifiers re-install on every activation. uv treats
"3.15"as a moving target, re-resolving to3.15.0aNand re-running the install path each time. Hash-gating sidesteps this entirely (skip theuv python installcall when the spec list is unchanged), andjust uv-upgradeis the explicit pull lever. If you ever want auto-updates back, drop the gate and accept the alpha churn. - mkVirtualEnv outputs collide between projects. Two projects’ venvs both ship
bin/python3and any shared transitive console scripts (e.g.,dotenvvia python-dotenv). The wrapper inmkUvProjectsolves this by linking only[project.scripts]entries from each project’s pyproject.toml. Multi-entry-point projects (permission-suggestionhas 5) work because the helper reads the TOML at eval time. - Build-system override list is open-ended. Each new sdist that omits its
setuptools(or other) build-system declaration adds an entry to the override map.clickhouse-sqlalchemyis the only one needed today; expect more as the dep surface grows. home.filedeploys to~/.local/share/<name>were the only consumers of the runtime source path. After the migration nothing references those paths, so removing the deploys was a clean win. Verified by grepping the repo for the old paths.auto-updatescript wiring is unchanged. Theupdatewrapper already invokedjust uv-upgrade; extending the recipe was sufficient to cover the new “pull alphas” responsibility without touching the orchestration script.
Resources
- uv2nix repo
- uv2nix PEP 723 inline-metadata usage (referenced for the helper shape, though all four tools are full projects, not PEP 723)
- uv2nix overriding build-systems pattern
- uv issue #12533 (declarative tool manifest)
- ADR 0003:
docs/decisions/0003-uv2nix-for-local-python-tools.md - Plan file:
~/.claude/plans/let-s-go-ahead-and-humble-puffin.md
Related Projects
- Dotfiles Host Profiles via Private Flake — flake-input changes here landed first, smoothing future host-profile work
- MacBook M4 2025 Migration — uv tool reinstall churn was visible during that migration; this resolves it