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:
55
.claude/tasks/0003-per-project-placement-dev-shell.md
Normal file
55
.claude/tasks/0003-per-project-placement-dev-shell.md
Normal 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 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.
|
||||
Reference in New Issue
Block a user