--- spec: nix-skill-packaging blocked-by: 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 `/.claude/skills/`, pointing into the Nix store. Selection is by derivation (skills pulled from `packages.`), 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 - [x] `lib.mkSkillsShellHook` is exposed, takes a list of selected skill derivations, and returns a shellHook string. - [x] The hook is a plain shell string using ambient POSIX tools and needs no home-manager context. - [x] On entry, each selected skill is symlinked as a direct child of `/.claude/skills/` pointing into the store. - [x] Each entry first removes only store-pointing symlinks under `.claude/skills/`, then recreates the current selection — deselecting a skill removes its symlink next entry. - [x] Real (non-symlink) skill directories under `.claude/skills/` are never touched. - [x] The hook maintains a generated, self-ignoring `.claude/skills/.gitignore` listing the names it manages, keeping store symlinks out of git while hand-authored skills stay tracked. - [x] No manifest or state file is used. - [x] `nix flake check` includes 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's `passthru.skillName` (from task 0001) to name its symlink, so the selection stays by-derivation and the name needs no `$out` read. - **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 real `bash` run: after sourcing, `_mkskills_managed` and 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 `.gitignore` and 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 `readlink` shows 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 `.gitignore` contents, 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 `readlink` to 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-POSIX `ln -n` overwrite flag by removing any residual symlink first, then using a plain `ln -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.