Files
skills/.claude/spec/nix-skill-packaging.md
alexion 5b6c158d2e feat: package skills as per-skill Nix derivations (task 0001)
Add the content tier: a standalone flake that auto-discovers each skill
(a directory containing a SKILL.md, at any depth) and exposes it as an
individually addressable derivation built by lib.mkSkill. Directories
without a SKILL.md are descended through as cosmetic containers; once a
SKILL.md is found, that directory's subfolders are its assets, not
further skills. Skill names must be globally unique across the tree — a
collision is a hard eval-time error, not a warning.

A fixture-driven skill-build check under `nix flake check` exercises the
recursive walk, the builder, the SKILL.md-at-$out-root contract, the
eval-time name passthru, and the collision error. The repo ships no real
skill content yet, so packages.<system> is empty today.
2026-07-22 16:44:47 -04:00

16 KiB

Problem Statement

I manage a collection of Claude Code agent skills and need a way to select which ones apply globally (on every project) versus per-project. Today that per-project selection is driven by three agent-run skills — setup-skills, update-skills, remove-skills — backed by a .claude/skills-lock.yaml lockfile and a content-hashing script. Setup copies a skill out of a shared library into a project's tree; update re-hashes to detect drift and applies safe upstream changes; remove deletes it. This is a lot of hand-maintained machinery: an LLM chore to run, a lockfile to keep clean, and a hash-based drift classifier to reason about. I want skills packaged and versioned through Nix instead, so that selection is declarative, updates are a pinned-input bump, and the whole copy-and-hash apparatus disappears.

Solution

A standalone Nix flake (alexion/skills) that packages each skill as an individually addressable derivation and exposes optional integration outputs for placing selected skills where Claude Code discovers them.

  • Adding a skill is creating a directory containing a SKILL.md; the flake auto-discovers it.
  • A home-manager config selects skills it wants globally (into ~/.claude/skills/) via a home-manager module.
  • A project's own flake selects skills it wants for that project (into <project>/.claude/skills/) via a dev-shell helper, pinning this flake as an input.
  • "Update" becomes nix flake update; "remove" becomes deleting a line from the selecting config; "setup" becomes adding one.
  • The three management skills, the lockfile, and the hashing script are retired — Nix subsumes all of their responsibilities.

Nix is accepted as a hard requirement: every machine and every project is Nix/dev-shell based, so there is no need to keep skills as portable, committed-into-the-tree files for non-Nix consumers.

User Stories

  1. As a skill author, I want to add a new skill by creating a directory with a SKILL.md and nothing else, so that adding a skill requires no edit to the flake.
  2. As a skill author, I want to organize skills into arbitrarily nested subfolders (e.g. group related workflow skills together), so that the source tree stays browsable as the collection grows.
  3. As a skill author, I want that grouping to be purely cosmetic, so that where a skill's folder sits never changes how it is selected, placed, or named.
  4. As an operator, I want to select a set of skills to be active in every project via my home-manager configuration, so that my always-on skills are declared in one place and installed reproducibly.
  5. As a project maintainer, I want to select a set of skills for a single project via that project's flake, so that only the skills relevant to that project are active there.
  6. As a project maintainer, I want the selected skills to appear under the project's .claude/skills/ automatically when I enter its dev shell, so that I never run a manual install step.
  7. As a project maintainer, I want the committed record of which skills a project uses to be the project's flake.nix selection plus its pinned input, so that the selection diffs cleanly in git and reproduces exactly on any machine.
  8. As a project maintainer, I want the generated skill symlinks kept out of git, so that machine-specific store paths never get committed.
  9. As a project maintainer, I want to keep my own hand-authored, project-private skills in .claude/skills/ alongside the Nix-delivered ones, so that a project can have bespoke skills without adding them to the shared repo.
  10. As a project maintainer, I want the dev shell to only ever touch the skills it manages, so that my hand-authored skills are never removed or clobbered.
  11. As an operator, I want to update all pinned skills with nix flake update, so that picking up upstream skill changes is a single, reviewable, atomic operation.
  12. As an operator, I want a skill I previously selected to disappear when I remove it from my selection and re-enter the shell (or rebuild), so that removal needs no cleanup command.
  13. As an operator, I want to promote a skill from per-project to global (or vice versa) by moving one line between a project flake and my home-manager config, so that changing a skill's reach never moves files.
  14. As a skill author, I want two skills that would resolve to the same name to fail the build with a clear error, so that a silent clobber can never happen.
  15. As a skill author, I want the packaging to know nothing about .claude/skills or Claude Code, so that when a skill later needs to feed a different agent/tool the content is not welded to one placement convention.
  16. As an operator, I want my global skill placement to compose with other modules that write into the same skills directory (e.g. a tool that self-installs its own skill), so that independent sources coexist without collisions.
  17. As a maintainer, I want nix flake check to fail if a skill is malformed, if the home-manager placement regresses, or if the dev-shell helper stops producing a valid hook, so that breakage is caught before it ships.

Implementation Decisions

Overall structure

  • The flake has two clearly separated tiers: a content tier (tool-agnostic skill packaging) and an integration tier (optional, Claude-Code-specific placement). A skill derivation in the content tier must never reference .claude/skills or anything Claude-Code-specific.
  • Flake scaffolding uses flake-utils.lib.eachSystem over x86_64-linux, aarch64-linux, aarch64-darwin, with nixpkgs tracking unstable. An explicit system list is passed rather than eachDefaultSystem, because that default set includes x86_64-darwin, which recent nixpkgs dropped and whose legacyPackages now throws.

Content tier

  • Skills are auto-discovered by recursively walking the skills/ tree; any directory containing a SKILL.md is a skill, at any depth. Once a SKILL.md is found, that directory is a skill and its own subfolders are its assets, not further skills.
  • Directories that do not contain a SKILL.md are organizational containers only; the walk descends through them. This nesting is cosmetic: it never reaches the placement location and is not a selectable unit (no "select a whole group" — that is a purely additive future change if ever wanted).
  • Each skill becomes an individually addressable derivation, exposed at packages.<system>.<skill-name>. There is no packages.<system>.default — a catalog has no single default skill.
  • A skill is addressed by its name (leaf directory / frontmatter name), independent of its source path. Placement flattens the source path away — a skill nested at skills/workflow/to-spec/ is placed as a direct child .../skills/to-spec/, because Claude Code only discovers direct children of the skills root.
  • Skill-derivation contract (the interchange primitive, shared with self-packaging tool repos like gitea-axi):
    • $out contains SKILL.md at its root, plus any assets.
    • The derivation carries its name as an eval-time attribute (e.g. pname / a passthru), so placement can form .../skills/<name> without reading $out (no import-from-derivation).
  • Names must be globally unique across the whole tree. A collision detected during the discovery walk is a hard build error with a clear message, not a warning or a lint.
  • A lib.mkSkill builder is exposed (turns a skill source directory into a conforming derivation). Exposing it costs nothing and lets a tool repo reuse it, even though tool repos are expected to self-package today.

Cross-skill references

  • There is no dependency/closure machinery. Skills interact by name-based invocation (the Skill tool), not by reading each other's files. A skill that references another skill relies on that skill being loaded (globally or in the same project), not on any relative path resolving on disk.
  • Design convention, enforced by convention only (no lint): skills are self-contained — a skill never reaches into another skill's files; anything it needs at a path, it carries itself. The retired management skills (which read siblings' LOCKFILE.md and executed a sibling's script) are exactly the anti-pattern this convention forbids.

Integration tier — selection

  • Selection is by derivation, not by name string. Consumers pass skill derivations pulled from packages.<system> (e.g. bind packages.<system> to a short local name and list the skills off it).
  • Rationale: skills are derivations in this ecosystem, so a derivation-based API composes with any skill satisfying the contract, from any repo, and keeps this flake ignorant of skills it does not own (respecting the boundary that tool-specific skills live in and are packaged by their own repos, not registered here).

Integration tier — global placement (home-manager)

  • Exposed as homeModules.default.
  • The operator-facing option is programs.agents.skills, a listOf package with default []. agents is deliberately an umbrella namespace (room for future programs.agents.<other>), and it has no shared programs.agents.enable — each sub-feature self-gates, so the namespace stays a clean, mergeable surface a different repo could also extend without an ownership conflict. An empty list is a no-op.
  • For each selected skill the module writes an individual home file at ${claude-code.configDir}/skills/<name> with source = <skill derivation> and recursive = true, gated on programs.claude-code.enable, reading configDir from the claude-code module.
  • recursive = true is a hard requirement, not a style choice: it forces home-manager to materialize .../skills/<name>/ as a real directory of per-file symlinks rather than claiming the directory as one opaque symlink. That is what allows this module, the operator's own skills declarations, and self-placing tool modules to coexist under one skills/ tree.
  • The module deliberately does not feed programs.claude-code.skills. That option is single-valued and would collide with an operator already setting it; per-skill home.file composes where the option does not.

Integration tier — per-project placement (dev shell)

  • Exposed as lib.mkSkillsShellHook, which takes a list of selected skill derivations and returns a shellHook string a project drops into its dev shell.
  • On shell entry the hook symlinks each selected skill as a direct child of <project>/.claude/skills/, pointing into the Nix store.
  • Stateless lifecycle: each entry first removes only the symlinks under .claude/skills/ that point into the store (unambiguously "ours"), then recreates the current selection. This yields free stale-removal (deselect a skill → its symlink is gone next entry) and never touches real directories. No manifest or state file.
  • Coexistence with hand-authored skills: real (non-symlink) skill directories under .claude/skills/ are left untouched. The hook maintains a generated, self-ignoring .claude/skills/.gitignore that lists the names it manages (and ignores itself), so Nix-delivered symlinks stay out of git while hand-authored skills remain tracked.
  • The committed record of a project's selection is its flake.nix (the selection list) plus its pinned flake.lock; the generated symlinks are gitignored.
  • The hook is a plain shell string relying on ambient POSIX tools; it does not need a home-manager context (projects are not home-manager-managed).

Retired

  • setup-skills, update-skills, remove-skills, the skills-lock.yaml lockfile concept, and the directory-hashing script are all replaced by Nix and are not carried into this repo. Their responsibilities map to: selection in a flake (setup / remove) and nix flake update (update).

Testing Decisions

  • A good test here exercises the flake's public outputs as a consumer would observe them — a built skill, an evaluated-and-built module, an instantiated helper — not the internal shape of the discovery walk or builder. The outputs are the surface; there is no separate production code path needing its own seam.
  • The single seam is nix flake check, with three focused checks beneath it (prior art: the gitea-axi flake's package-build and checks/home-manager-module.nix checks):
    1. Skill-build check — builds every auto-discovered skill derivation. This one check transitively exercises the recursive discovery walk, the mkSkill builder, the SKILL.md-at-$out-root contract, and the name-uniqueness hard error (which fires at eval and so also surfaces here).
    2. Home-manager-module composition check — instantiates homeModules.default under a sample home-manager configuration that selects a couple of skills with programs.claude-code enabled, and builds the resulting home-files derivation. This is the load-bearing check: it proves per-skill home.file, recursive = true, the claude-code.enable gate, and configDir sourcing compose as intended. Direct analogue of gitea-axi's home-manager-module check.
    3. shellHook assertion — instantiates lib.mkSkillsShellHook with sample skills and asserts the produced hook is non-empty and references the expected store paths / skill names.
  • Prefer the highest seam: none of these introduce a bespoke test hook into production logic; they evaluate/realize the flake outputs directly.

Out of Scope

  • Dotfiles and any migration of existing skills. This repo owns only the packaging and integration outputs. Moving current skills out of the dotfiles tree, rewiring the dotfiles home-manager configuration to consume this flake, deleting the retired management skills from wherever they currently live, and reviewing/rewriting individual skills are all handled separately and by hand. The repo starts with flake machinery and no skill content; skills are added incrementally, "as needed."
  • Integration with tool-specific skills. Skills that ship inside a tool's own repo (e.g. gitea-axi) self-package and self-place; they are not listed in or delivered by this flake. This flake stays ignorant of them. The shared skill-derivation contract is the only thing in common.
  • Selectable groups. Nested folders are cosmetic; selecting a whole group as a unit is not built (additive later if wanted).
  • A formal cross-skill dependency system. Not built; name-based invocation plus the self-containment convention is the whole mechanism.
  • Non-Nix portability. Skills are not required to work on a machine without Nix; there is no committed-into-the-tree copy for non-Nix consumers.
  • Multiple harnesses. Placement targets Claude Code only. The programs.agents namespace is chosen to leave room for other harnesses later, but no second harness is implemented now.

Further Notes

  • The global-vs-per-project distinction is no longer a directory (library/ is gone) or a file-copy state; it is purely which config selects a skill. The same per-skill derivation is selected by home-manager for the global set and by a project's dev shell for that project's set.
  • The two integration outputs place skills through different mechanisms because they run in different contexts: home-manager (home.file, recursive = true) for the global set, and a dev-shell shellHook (store symlinks) for the per-project set. Both operate on the same by-derivation selection and the same skill-derivation contract.
  • programs.agents is a generic namespace; if this repo is ever made public it is a mild land-grab worth revisiting, but it is appropriate for a personal ecosystem.
  • The design intentionally mirrors gitea-axi (flake scaffolding, per-skill home.file with recursive = true, module shape, check style) so the two repos stay consistent and the placement lessons already encoded in gitea-axi's ADRs carry over.