feat: add per-project dev-shell skills placement (task 0003)

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.
This commit was merged in pull request #3.
This commit is contained in:
2026-07-22 22:22:38 -04:00
parent b0a763e5ce
commit 49a73c30d1
4 changed files with 248 additions and 1 deletions

View File

@@ -0,0 +1,55 @@
---
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 `<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
- [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 `<project>/.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 37) 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.