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.
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
- As a skill author, I want to add a new skill by creating a directory with a
SKILL.mdand nothing else, so that adding a skill requires no edit to the flake. - 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.
- 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.
- 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.
- 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.
- 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. - As a project maintainer, I want the committed record of which skills a project uses to be the project's
flake.nixselection plus its pinned input, so that the selection diffs cleanly in git and reproduces exactly on any machine. - As a project maintainer, I want the generated skill symlinks kept out of git, so that machine-specific store paths never get committed.
- 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. - 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.
- 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. - 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.
- 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.
- 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.
- As a skill author, I want the packaging to know nothing about
.claude/skillsor Claude Code, so that when a skill later needs to feed a different agent/tool the content is not welded to one placement convention. - 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.
- As a maintainer, I want
nix flake checkto 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/skillsor anything Claude-Code-specific. - Flake scaffolding uses
flake-utils.lib.eachSystemoverx86_64-linux,aarch64-linux,aarch64-darwin, withnixpkgstracking unstable. An explicit system list is passed rather thaneachDefaultSystem, because that default set includesx86_64-darwin, which recent nixpkgs dropped and whoselegacyPackagesnow throws.
Content tier
- Skills are auto-discovered by recursively walking the
skills/tree; any directory containing aSKILL.mdis a skill, at any depth. Once aSKILL.mdis found, that directory is a skill and its own subfolders are its assets, not further skills. - Directories that do not contain a
SKILL.mdare 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 nopackages.<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 atskills/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):$outcontainsSKILL.mdat 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.mkSkillbuilder 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.mdand 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. bindpackages.<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, alistOf packagewith default[].agentsis deliberately an umbrella namespace (room for futureprograms.agents.<other>), and it has no sharedprograms.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>withsource = <skill derivation>andrecursive = true, gated onprograms.claude-code.enable, readingconfigDirfrom the claude-code module. recursive = trueis 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 oneskills/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-skillhome.filecomposes 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/.gitignorethat 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 pinnedflake.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, theskills-lock.yamllockfile 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) andnix 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: thegitea-axiflake's package-build andchecks/home-manager-module.nixchecks):- Skill-build check — builds every auto-discovered skill derivation. This one check transitively exercises the recursive discovery walk, the
mkSkillbuilder, theSKILL.md-at-$out-root contract, and the name-uniqueness hard error (which fires at eval and so also surfaces here). - Home-manager-module composition check — instantiates
homeModules.defaultunder a sample home-manager configuration that selects a couple of skills withprograms.claude-codeenabled, and builds the resulting home-files derivation. This is the load-bearing check: it proves per-skillhome.file,recursive = true, theclaude-code.enablegate, andconfigDirsourcing compose as intended. Direct analogue of gitea-axi's home-manager-module check. - shellHook assertion — instantiates
lib.mkSkillsShellHookwith sample skills and asserts the produced hook is non-empty and references the expected store paths / skill names.
- Skill-build check — builds every auto-discovered skill derivation. This one check transitively exercises the recursive discovery walk, the
- 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.agentsnamespace 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.agentsis 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-skillhome.filewithrecursive = true, module shape, check style) so the two repos stay consistent and the placement lessons already encoded in gitea-axi's ADRs carry over.