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 Darwin cctools wrapping for uv itself.
  • Managed interpreters: uv python install 3.10..3.15 activation, hash-gated on a marker file at $XDG_STATE_HOME/uvx/python-versions.spec so 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 from pyproject.toml/uv.lock, applies any required build-system overrides, emits a derivation, then wraps to expose only entry-point binaries from [project.scripts]. Wraps because mkVirtualEnv outputs collide on bin/python3, bin/dotenv, etc. when two end up in the same user profile.
  • PyPI tools-as-applications (imperative, hash-gated): same uvxTools attrset as before, but the activation hashes name + python + withDeps + withExecutablesFrom per tool and persists markers under $XDG_STATE_HOME/uvx/<name>.spec. Skips uv tool install when the marker matches and uv tool list shows the tool present.
  • Explicit upgrades: just uv-upgrade runs uv python upgrade then uv tool upgrade --all. Decouples routine activation from version churn. The existing update wrapper script already calls just 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

ToolOwning moduleEntry points
permission-suggestionclaude.nix5 (-evals, -optimize, -replay, -results, base)
bash-command-validatorclaude.nix1
gh-review-previewshell.nix1
claude-opsshell.nix1

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 check passes after adding the three flake inputs (uv2nix, pyproject-nix, pyproject-build-systems).
  • command -v resolves all eight binaries to ~/.nix-profile/bin/ (4 base tools + 4 permission-suggestion-* variants).
  • ~/.local/share/{permission-suggestion,bash-command-validator,gh-review-preview,claude-ops} no longer exist.
  • uv tool list no longer tracks the four migrated tools (uninstalled via uv tool uninstall <name> once during cutover).
  • All four tools functional: gh-review-preview --help and claude-ops --help show CLI help; the two Claude Code hook tools (bash-command-validator, permission-suggestion) load and reach main (the JSONDecodeError they surface on stdin-less invocation is expected hook behavior).
  • Re-running hm switch immediately after a successful run reports Python versions up to date (spec hash unchanged) and <tool> up to date (spec hash unchanged) for every PyPI bucket entry, no reinstall work.
  • just check passes (nixpkgs-fmt, statix, deadnix, mdformat, secret detection).

Files changed

PathAction
nix/flake.nixAdded uv2nix, pyproject-nix, pyproject-build-systems inputs; exposed via _module.args alongside existing entries
nix/flake.lockNew input pins
nix/home-manager/modules/python.nixCreated: consolidates uv.nix + uvx.nix; defines mkUvProject with auto-detected entry points and build-system overrides; hash-gated activations
nix/home-manager/home.nixReplaced ./modules/uv.nix and ./modules/uvx.nix imports with ./modules/python.nix
nix/home-manager/modules/uv.nixDeleted
nix/home-manager/modules/uvx.nixDeleted
nix/home-manager/modules/shell.nixDropped 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.nixSame shape as shell.nix; hook shims switched to Nix-store paths
justfileuv-upgrade recipe now runs uv python upgrade before uv tool upgrade --all
docs/decisions/0003-uv2nix-for-local-python-tools.mdNew 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 to 3.15.0aN and re-running the install path each time. Hash-gating sidesteps this entirely (skip the uv python install call when the spec list is unchanged), and just uv-upgrade is 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/python3 and any shared transitive console scripts (e.g., dotenv via python-dotenv). The wrapper in mkUvProject solves this by linking only [project.scripts] entries from each project’s pyproject.toml. Multi-entry-point projects (permission-suggestion has 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-sqlalchemy is the only one needed today; expect more as the dep surface grows.
  • home.file deploys 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-update script wiring is unchanged. The update wrapper already invoked just uv-upgrade; extending the recipe was sufficient to cover the new “pull alphas” responsibility without touching the orchestration script.

Resources