feat: add per-project dev-shell skills placement (task 0003) #3
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.
|
||||||
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"
|
||||||
|
''
|
||||||
10
flake.nix
10
flake.nix
@@ -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;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
63
lib/mk-skills-shell-hook.nix
Normal file
63
lib/mk-skills-shell-hook.nix
Normal 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"
|
||||||
|
)
|
||||||
|
''
|
||||||
Reference in New Issue
Block a user