Expose lib.mkSkillsShellHook, which takes a list of selected skill derivations and returns a dev-shell shellHook string. On shell entry the hook symlinks each selected skill as a direct child of the project's .claude/skills/, pointing into the store. It is stateless: each entry first removes only store-pointing symlinks (deselected skills included), then relinks the current selection, leaving hand-authored real directories untouched and regenerating a self-ignoring .gitignore of the managed names. Add a nix flake check that sources the produced hook against a fixture project and asserts placement, stale-removal, the collision guard, non-store-symlink survival, and the .gitignore contents.
5.3 KiB
spec, blocked-by
| spec | blocked-by |
|---|---|
| nix-skill-packaging | 0001-content-tier-skill-packaging |
What to build
The per-project placement output: a dev-shell helper that drops a project's selected skills into its own .claude/skills/, plus the check that proves it.
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. Selection is by derivation (skills pulled from packages.<system>), and the hook is a plain shell string relying on ambient POSIX tools — it needs no home-manager context.
Lifecycle is stateless with no manifest or state file: each entry first removes only the symlinks under .claude/skills/ that point into the store (unambiguously "ours"), then recreates the current selection. This gives free stale-removal (deselect a skill → its symlink is gone next entry) and never touches real directories. Real (non-symlink) skill directories under .claude/skills/ — hand-authored, project-private skills — are left untouched. The hook maintains a generated, self-ignoring .claude/skills/.gitignore listing the names it manages (and ignoring 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 selection list plus its pinned flake.lock.
Verified by the shellHook assertion check under nix flake check: it instantiates lib.mkSkillsShellHook with sample skills and asserts the produced hook is non-empty and references the expected store paths / skill names.
Acceptance criteria
lib.mkSkillsShellHookis exposed, takes a list of selected skill derivations, and returns a shellHook string.- The hook is a plain shell string using ambient POSIX tools and needs no home-manager context.
- On entry, each selected skill is symlinked as a direct child of
<project>/.claude/skills/pointing into the store. - Each entry first removes only store-pointing symlinks under
.claude/skills/, then recreates the current selection — deselecting a skill removes its symlink next entry. - Real (non-symlink) skill directories under
.claude/skills/are never touched. - The hook maintains a generated, self-ignoring
.claude/skills/.gitignorelisting the names it manages, keeping store symlinks out of git while hand-authored skills stay tracked. - No manifest or state file is used.
nix flake checkincludes a shellHook assertion that instantiates the helper with sample skills and asserts the hook is non-empty and references the expected store paths / skill names.
Implementation Notes
Files: lib/mk-skills-shell-hook.nix (the helper), checks/shell-hook.nix (the check), and the flake.nix wiring (a mkSkillsShellHook binding in the top-level lib output, plus the check registration).
-
Placement name comes from
skillName. The hook reads each derivation'spassthru.skillName(from task 0001) to name its symlink, so the selection stays by-derivation and the name needs no$outread. -
The whole hook runs in a subshell. It defines helper variables and a function, so wrapping it in
( … )keeps those from leaking into the operator's interactive shell. Verified by a realbashrun: after sourcing,_mkskills_managedand the link function are absent from the parent shell. -
Name collision with a hand-authored skill: the real directory wins. If a selected skill's name already exists as a real (non-symlink) directory, the hook skips it with a stderr warning and leaves the directory in place. A skipped skill is deliberately kept out of the managed set, so it is never added to the
.gitignoreand the hand-authored directory stays tracked. Only skills the hook actually links are listed in the.gitignore. -
Cleanup is precise to store-pointing symlinks. The stale-removal sweep removes a direct-child symlink only when
readlinkshows it targets the Nix store, so a symlink a project points elsewhere is left alone. The check's fixture includes such a non-store symlink and asserts it survives. -
The check executes the hook, beyond the literal testing decision. The task's testing decision only requires asserting the hook string is non-empty and references the expected store paths and names. The check does that, then additionally sources the hook against a fixture project across two successive selections and asserts placement, stateless stale-removal, the collision guard, the non-store-symlink survival, the
.gitignorecontents, and that no state file appears. This is a strict superset that proves the spec-mandated lifecycle (criteria 3–7) rather than trusting a string match, and each added assertion maps to a stated requirement. -
POSIX-tool notes. The hook relies on
readlinkto classify symlinks; it is not in POSIX but is present on every Nix-based machine this targets, and there is no clean substitute. The link step avoids the non-POSIXln -noverwrite flag by removing any residual symlink first, then using a plainln -s. A single quote in a skill name would break the generated shell string; skill names are kebab-case by convention, so hardening against that is left out as out of scope.