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

Merged
alexion merged 1 commits from task-0003-per-project-placement-dev-shell into main 2026-07-22 22:32:34 -04:00
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.

121
checks/shell-hook.nix Normal file
View File

@@ -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"
''

View File

@@ -17,6 +17,7 @@
{ self, nixpkgs, flake-utils, home-manager }: { self, nixpkgs, flake-utils, home-manager }:
let let
mkSkill = import ./lib/mk-skill.nix; mkSkill = import ./lib/mk-skill.nix;
mkSkillsShellHook = import ./lib/mk-skills-shell-hook.nix;
discoverSkills = import ./lib/discover.nix nixpkgs.lib; discoverSkills = import ./lib/discover.nix nixpkgs.lib;
discoveredSkills = discoverSkills ./skills; discoveredSkills = discoverSkills ./skills;
@@ -31,7 +32,7 @@
); );
in in
{ {
lib = { inherit mkSkill; }; lib = { inherit mkSkill mkSkillsShellHook; };
# Global placement: an operator selects skills into `~/.claude/skills/` # Global placement: an operator selects skills into `~/.claude/skills/`
# via `programs.agents.skills`. Not per-system — it is a module function, # via `programs.agents.skills`. Not per-system — it is a module function,
@@ -76,6 +77,13 @@
inherit pkgs home-manager mkSkill; inherit pkgs home-manager mkSkill;
module = self.homeModules.default; 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;
};
}; };
} }
); );

View File

@@ -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"
)
''