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:
121
checks/shell-hook.nix
Normal file
121
checks/shell-hook.nix
Normal 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"
|
||||
''
|
||||
Reference in New Issue
Block a user