Files
skills/.claude/tasks/0003-per-project-placement-dev-shell.md
alexion 49a73c30d1 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.
2026-07-22 22:22:38 -04:00

5.3 KiB
Raw Blame History

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.mkSkillsShellHook is 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/.gitignore listing 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 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.