diff --git a/.claude/tasks/0003-per-project-placement-dev-shell.md b/.claude/tasks/0003-per-project-placement-dev-shell.md new file mode 100644 index 0000000..68a8dd5 --- /dev/null +++ b/.claude/tasks/0003-per-project-placement-dev-shell.md @@ -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 `/.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. diff --git a/checks/shell-hook.nix b/checks/shell-hook.nix new file mode 100644 index 0000000..ad54162 --- /dev/null +++ b/checks/shell-hook.nix @@ -0,0 +1,121 @@ +# Runs the dev-shell helper's hook against a fixture project and asserts the +# placement, stateless stale-removal, and hand-authored-skill safety it promises. +{ + pkgs, + mkSkill, + mkSkillsShellHook, +}: +let + # Sample skills built through the flake's own `mkSkill`, so they carry the + # `skillName` passthru the hook reads and land at real store paths the hook + # can link to. + mkSampleSkill = + name: + mkSkill { + inherit pkgs name; + src = pkgs.runCommandLocal "${name}-src" { } '' + mkdir -p "$out" + printf '%s\n' "sample skill ${name}" > "$out/SKILL.md" + ''; + }; + + skillAlpha = mkSampleSkill "sample-alpha"; + skillBeta = mkSampleSkill "sample-beta"; + # Selected, but a hand-authored real directory of the same name already exists + # in the fixture, so the hook must skip it. + skillGamma = mkSampleSkill "sample-gamma"; + + # Two hooks for two successive selections: the second drops sample-beta, so + # sourcing it after the first proves a deselected skill's symlink is removed. + hookBoth = mkSkillsShellHook [ + skillAlpha + skillBeta + skillGamma + ]; + hookAlpha = mkSkillsShellHook [ skillAlpha ]; +in +pkgs.runCommandLocal "skills-shell-hook-check" + { + inherit hookBoth hookAlpha; + alphaPath = "${skillAlpha}"; + betaPath = "${skillBeta}"; + } + '' + fail() { echo "FAIL: $1" >&2; exit 1; } + + echo "the hook is a non-empty string referencing each skill's store path and name" + [ -n "$hookBoth" ] || fail "the produced hook is empty" + for token in "$alphaPath" "$betaPath" sample-alpha sample-beta; do + printf '%s' "$hookBoth" | grep -qF "$token" \ + || fail "the hook does not reference $token" + done + + # The fixture project carries three things the hook must not disturb: a + # hand-authored skill under its own name, a hand-authored skill whose name + # collides with a selected one, and a symlink pointing outside the store. + mkdir -p proj/.claude/skills/handmade + printf '%s\n' "a hand-authored skill" > proj/.claude/skills/handmade/SKILL.md + mkdir -p proj/.claude/skills/sample-gamma + printf '%s\n' "a hand-authored gamma" > proj/.claude/skills/sample-gamma/SKILL.md + ln -s /nonexistent/external-target proj/.claude/skills/external + cd proj + + echo "first entry: the two free skills link as store symlinks that resolve" + printf '%s\n' "$hookBoth" > ../hook-both.sh + . ../hook-both.sh + for name in sample-alpha sample-beta; do + [ -L ".claude/skills/$name" ] || fail "$name was not linked as a symlink" + case "$(readlink ".claude/skills/$name")" in + ${builtins.storeDir}/*) : ;; + *) fail "$name's symlink does not point into the store" ;; + esac + test -f ".claude/skills/$name/SKILL.md" \ + || fail "$name's link does not resolve to its SKILL.md" + done + + echo "a selected skill colliding with a hand-authored one leaves the real directory" + { [ -d .claude/skills/sample-gamma ] && [ ! -L .claude/skills/sample-gamma ]; } \ + || fail "the colliding hand-authored skill was replaced by a symlink" + grep -qx "a hand-authored gamma" .claude/skills/sample-gamma/SKILL.md \ + || fail "the colliding hand-authored skill's content was overwritten" + + echo "the non-colliding hand-authored skill is an untouched real directory" + { [ -d .claude/skills/handmade ] && [ ! -L .claude/skills/handmade ]; } \ + || fail "the hand-authored skill was replaced or removed" + test -f .claude/skills/handmade/SKILL.md || fail "the hand-authored skill lost its content" + + echo "a symlink pointing outside the store is not ours, so it is left alone" + [ -L .claude/skills/external ] || fail "the non-store symlink was removed" + + echo "the .gitignore lists only the skills the hook actually linked, plus itself" + grep -qx '/.gitignore' .claude/skills/.gitignore || fail ".gitignore does not ignore itself" + grep -qx '/sample-alpha' .claude/skills/.gitignore || fail ".gitignore omits sample-alpha" + grep -qx '/sample-beta' .claude/skills/.gitignore || fail ".gitignore omits sample-beta" + for tracked in /handmade /sample-gamma /external; do + grep -qx "$tracked" .claude/skills/.gitignore \ + && fail ".gitignore lists $tracked, which would untrack a real path" + done + + echo "no manifest or state file is written: only the fixtures and links exist" + entries=$(ls -A .claude/skills | sort | tr '\n' ' ') + [ "$entries" = ".gitignore external handmade sample-alpha sample-beta sample-gamma " ] \ + || fail "unexpected entries under .claude/skills: [$entries]" + + echo "second entry with sample-beta deselected: its symlink is removed, the rest stay" + printf '%s\n' "$hookAlpha" > ../hook-alpha.sh + . ../hook-alpha.sh + test ! -e .claude/skills/sample-beta || fail "the deselected skill's symlink was not removed" + [ -L .claude/skills/sample-alpha ] || fail "the still-selected skill was dropped" + { [ -d .claude/skills/handmade ] && [ ! -L .claude/skills/handmade ]; } \ + || fail "the hand-authored skill was disturbed on re-entry" + { [ -d .claude/skills/sample-gamma ] && [ ! -L .claude/skills/sample-gamma ]; } \ + || fail "the colliding hand-authored skill was disturbed on re-entry" + [ -L .claude/skills/external ] || fail "the non-store symlink was disturbed on re-entry" + + echo "the .gitignore tracks the new selection" + grep -qx '/sample-alpha' .claude/skills/.gitignore || fail ".gitignore lost sample-alpha" + grep -qx '/sample-beta' .claude/skills/.gitignore \ + && fail ".gitignore still lists the deselected sample-beta" + + touch "$out" + '' diff --git a/flake.nix b/flake.nix index 7fdf076..9cb18e4 100644 --- a/flake.nix +++ b/flake.nix @@ -17,6 +17,7 @@ { self, nixpkgs, flake-utils, home-manager }: let mkSkill = import ./lib/mk-skill.nix; + mkSkillsShellHook = import ./lib/mk-skills-shell-hook.nix; discoverSkills = import ./lib/discover.nix nixpkgs.lib; discoveredSkills = discoverSkills ./skills; @@ -31,7 +32,7 @@ ); in { - lib = { inherit mkSkill; }; + lib = { inherit mkSkill mkSkillsShellHook; }; # Global placement: an operator selects skills into `~/.claude/skills/` # via `programs.agents.skills`. Not per-system — it is a module function, @@ -76,6 +77,13 @@ inherit pkgs home-manager mkSkill; module = self.homeModules.default; }; + + # Proves the per-project dev-shell helper: it runs the produced hook + # against a fixture project and asserts placement, stateless + # stale-removal, and that hand-authored skills stay untouched. + shell-hook = import ./checks/shell-hook.nix { + inherit pkgs mkSkill mkSkillsShellHook; + }; }; } ); diff --git a/lib/mk-skills-shell-hook.nix b/lib/mk-skills-shell-hook.nix new file mode 100644 index 0000000..1926158 --- /dev/null +++ b/lib/mk-skills-shell-hook.nix @@ -0,0 +1,63 @@ +# Builds a dev-shell shellHook that links a project's selected skills into its +# own `.claude/skills/` as direct-child symlinks into the Nix store. +skills: +let + storeDir = builtins.storeDir; + links = builtins.concatStringsSep "\n" ( + map (skill: " _mkskills_link '${skill.skillName}' '${skill}'") skills + ); +in +'' + # Places this project's Nix-delivered skills into ./.claude/skills/ on shell + # entry. + # Each run first drops every direct-child symlink pointing into the store, + # then relinks the current selection. + # Those store symlinks are unambiguously ours, so this removes deselected + # skills for free and needs no manifest. + # A real (non-symlink) skill directory is a hand-authored, project-private + # skill and is left untouched. + # The whole hook runs in a subshell so it leaks no names into the interactive + # shell. + ( + _mkskills_dir=".claude/skills" + mkdir -p "$_mkskills_dir" + + for _mkskills_entry in "$_mkskills_dir"/*; do + [ -L "$_mkskills_entry" ] || continue + case "$(readlink "$_mkskills_entry")" in + ${storeDir}/*) rm -f "$_mkskills_entry" ;; + esac + done + + # A real path at a selected skill's name is a hand-authored skill. + # It is left in place and left out of the managed set, so the .gitignore + # below keeps tracking it rather than the skill that would have been linked. + _mkskills_managed="" + _mkskills_link() { + _mkskills_dest="$_mkskills_dir/$1" + if [ -e "$_mkskills_dest" ] && [ ! -L "$_mkskills_dest" ]; then + printf 'mkSkillsShellHook: skipping %s: a non-symlink path already exists\n' "$1" >&2 + else + # At most a non-store symlink remains at this name, so removing it first + # keeps the link step to a plain POSIX `ln -s` with no overwrite flag. + rm -f "$_mkskills_dest" + ln -s "$2" "$_mkskills_dest" + _mkskills_managed="$_mkskills_managed $1" + fi + } +${links} + + # Regenerate the self-ignoring .gitignore from the current selection, so the + # store symlinks stay out of git while hand-authored skills, absent from this + # list, remain tracked. + { + printf '%s\n' "# Generated by mkSkillsShellHook. Nix-delivered skill symlinks, kept out of git." + printf '%s\n' "/.gitignore" + # Disable pathname expansion so a managed name is never glob-expanded. + set -f + for _mkskills_name in $_mkskills_managed; do + printf '/%s\n' "$_mkskills_name" + done + } > "$_mkskills_dir/.gitignore" + ) +''