feat(shell): deliver project skills through .agents

This commit is contained in:
2026-07-29 11:54:36 -04:00
parent c6beafc53e
commit 02f2017b40
3 changed files with 54 additions and 32 deletions

View File

@@ -53,68 +53,75 @@ pkgs.runCommandLocal "skills-shell-hook-check"
# The fixture project carries three things the hook must not disturb: a # 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 # 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. # collides with a selected one, and a symlink pointing outside the store.
mkdir -p proj/.claude/skills/handmade mkdir -p proj/.agents/skills/handmade
printf '%s\n' "a hand-authored skill" > proj/.claude/skills/handmade/SKILL.md printf '%s\n' "a hand-authored skill" > proj/.agents/skills/handmade/SKILL.md
mkdir -p proj/.claude/skills/sample-gamma mkdir -p proj/.agents/skills/sample-gamma
printf '%s\n' "a hand-authored gamma" > proj/.claude/skills/sample-gamma/SKILL.md printf '%s\n' "a hand-authored gamma" > proj/.agents/skills/sample-gamma/SKILL.md
ln -s /nonexistent/external-target proj/.claude/skills/external ln -s /nonexistent/external-target proj/.agents/skills/external
cd proj cd proj
echo "first entry: the two free skills link as store symlinks that resolve" echo "first entry: the two free skills link as store symlinks that resolve"
printf '%s\n' "$hookBoth" > ../hook-both.sh printf '%s\n' "$hookBoth" > ../hook-both.sh
. ../hook-both.sh . ../hook-both.sh
for name in sample-alpha sample-beta; do for name in sample-alpha sample-beta; do
[ -L ".claude/skills/$name" ] || fail "$name was not linked as a symlink" [ -L ".agents/skills/$name" ] || fail "$name was not linked as a symlink"
case "$(readlink ".claude/skills/$name")" in case "$(readlink ".agents/skills/$name")" in
${builtins.storeDir}/*) : ;; ${builtins.storeDir}/*) : ;;
*) fail "$name's symlink does not point into the store" ;; *) fail "$name's symlink does not point into the store" ;;
esac esac
test -f ".claude/skills/$name/SKILL.md" \ test -f ".agents/skills/$name/SKILL.md" \
|| fail "$name's link does not resolve to its SKILL.md" || fail "$name's link does not resolve to its SKILL.md"
done done
echo "a selected skill colliding with a hand-authored one leaves the real directory" echo "a selected skill colliding with a hand-authored one leaves the real directory"
{ [ -d .claude/skills/sample-gamma ] && [ ! -L .claude/skills/sample-gamma ]; } \ { [ -d .agents/skills/sample-gamma ] && [ ! -L .agents/skills/sample-gamma ]; } \
|| fail "the colliding hand-authored skill was replaced by a symlink" || fail "the colliding hand-authored skill was replaced by a symlink"
grep -qx "a hand-authored gamma" .claude/skills/sample-gamma/SKILL.md \ grep -qx "a hand-authored gamma" .agents/skills/sample-gamma/SKILL.md \
|| fail "the colliding hand-authored skill's content was overwritten" || fail "the colliding hand-authored skill's content was overwritten"
echo "the non-colliding hand-authored skill is an untouched real directory" echo "the non-colliding hand-authored skill is an untouched real directory"
{ [ -d .claude/skills/handmade ] && [ ! -L .claude/skills/handmade ]; } \ { [ -d .agents/skills/handmade ] && [ ! -L .agents/skills/handmade ]; } \
|| fail "the hand-authored skill was replaced or removed" || fail "the hand-authored skill was replaced or removed"
test -f .claude/skills/handmade/SKILL.md || fail "the hand-authored skill lost its content" test -f .agents/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" 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" [ -L .agents/skills/external ] || fail "the non-store symlink was removed"
echo "Claude Code compatibility points at the canonical skill directory"
[ -L .claude/skills ] || fail ".claude/skills is not a compatibility symlink"
[ "$(readlink .claude/skills)" = "../.agents/skills" ] \
|| fail ".claude/skills points somewhere other than ../.agents/skills"
test -f .claude/skills/sample-alpha/SKILL.md \
|| fail "Claude Code compatibility link does not resolve selected skills"
echo "the .gitignore lists only the skills the hook actually linked, plus itself" 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 '/.gitignore' .agents/skills/.gitignore || fail ".gitignore does not ignore itself"
grep -qx '/sample-alpha' .claude/skills/.gitignore || fail ".gitignore omits sample-alpha" grep -qx '/sample-alpha' .agents/skills/.gitignore || fail ".gitignore omits sample-alpha"
grep -qx '/sample-beta' .claude/skills/.gitignore || fail ".gitignore omits sample-beta" grep -qx '/sample-beta' .agents/skills/.gitignore || fail ".gitignore omits sample-beta"
for tracked in /handmade /sample-gamma /external; do for tracked in /handmade /sample-gamma /external; do
grep -qx "$tracked" .claude/skills/.gitignore \ grep -qx "$tracked" .agents/skills/.gitignore \
&& fail ".gitignore lists $tracked, which would untrack a real path" && fail ".gitignore lists $tracked, which would untrack a real path"
done done
echo "no manifest or state file is written: only the fixtures and links exist" echo "no manifest or state file is written: only the fixtures and links exist"
entries=$(ls -A .claude/skills | sort | tr '\n' ' ') entries=$(ls -A .agents/skills | sort | tr '\n' ' ')
[ "$entries" = ".gitignore external handmade sample-alpha sample-beta sample-gamma " ] \ [ "$entries" = ".gitignore external handmade sample-alpha sample-beta sample-gamma " ] \
|| fail "unexpected entries under .claude/skills: [$entries]" || fail "unexpected entries under .agents/skills: [$entries]"
echo "second entry with sample-beta deselected: its symlink is removed, the rest stay" echo "second entry with sample-beta deselected: its symlink is removed, the rest stay"
printf '%s\n' "$hookAlpha" > ../hook-alpha.sh printf '%s\n' "$hookAlpha" > ../hook-alpha.sh
. ../hook-alpha.sh . ../hook-alpha.sh
test ! -e .claude/skills/sample-beta || fail "the deselected skill's symlink was not removed" test ! -e .agents/skills/sample-beta || fail "the deselected skill's symlink was not removed"
[ -L .claude/skills/sample-alpha ] || fail "the still-selected skill was dropped" [ -L .agents/skills/sample-alpha ] || fail "the still-selected skill was dropped"
{ [ -d .claude/skills/handmade ] && [ ! -L .claude/skills/handmade ]; } \ { [ -d .agents/skills/handmade ] && [ ! -L .agents/skills/handmade ]; } \
|| fail "the hand-authored skill was disturbed on re-entry" || fail "the hand-authored skill was disturbed on re-entry"
{ [ -d .claude/skills/sample-gamma ] && [ ! -L .claude/skills/sample-gamma ]; } \ { [ -d .agents/skills/sample-gamma ] && [ ! -L .agents/skills/sample-gamma ]; } \
|| fail "the colliding hand-authored skill was disturbed on re-entry" || 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" [ -L .agents/skills/external ] || fail "the non-store symlink was disturbed on re-entry"
echo "the .gitignore tracks the new selection" echo "the .gitignore tracks the new selection"
grep -qx '/sample-alpha' .claude/skills/.gitignore || fail ".gitignore lost sample-alpha" grep -qx '/sample-alpha' .agents/skills/.gitignore || fail ".gitignore lost sample-alpha"
grep -qx '/sample-beta' .claude/skills/.gitignore \ grep -qx '/sample-beta' .agents/skills/.gitignore \
&& fail ".gitignore still lists the deselected sample-beta" && fail ".gitignore still lists the deselected sample-beta"
touch "$out" touch "$out"

View File

@@ -34,7 +34,7 @@
{ {
lib = { inherit mkSkill mkSkillsShellHook; }; lib = { inherit mkSkill mkSkillsShellHook; };
# Global placement: an operator selects skills into `~/.claude/skills/` # Global placement: an operator selects skills into enabled agent harnesses
# via `programs.agents.skills`. Not per-system — it is a module function, # via `programs.agents.skills`. Not per-system — it is a module function,
# and the skills it places come from the importing configuration's own # and the skills it places come from the importing configuration's own
# selection rather than this flake's nixpkgs. # selection rather than this flake's nixpkgs.
@@ -95,8 +95,8 @@
# A working shell for developing skills in this repo. # A working shell for developing skills in this repo.
# python3 runs the benchmark skill's deterministic core. # python3 runs the benchmark skill's deterministic core.
# The benchmark-skill is placed into ./.claude/skills/ on entry, so # The benchmark-skill is placed into ./.agents/skills/ on entry, with
# /benchmark-skill is available while working in this repo. # ./.claude/skills as a compatibility symlink.
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
packages = [ pkgs.python3 ]; packages = [ pkgs.python3 ];
shellHook = mkSkillsShellHook [ (skillPackages pkgs).benchmark-skill ]; shellHook = mkSkillsShellHook [ (skillPackages pkgs).benchmark-skill ];

View File

@@ -1,5 +1,6 @@
# Builds a dev-shell shellHook that links a project's selected skills into its # 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. # own `.agents/skills/` as direct-child symlinks into the Nix store, with
# `.claude/skills` as a compatibility symlink when that path is free.
skills: skills:
let let
storeDir = builtins.storeDir; storeDir = builtins.storeDir;
@@ -8,7 +9,7 @@ let
); );
in in
'' ''
# Places this project's Nix-delivered skills into ./.claude/skills/ on shell # Places this project's Nix-delivered skills into ./.agents/skills/ on shell
# entry. # entry.
# Each run first drops every direct-child symlink pointing into the store, # Each run first drops every direct-child symlink pointing into the store,
# then relinks the current selection. # then relinks the current selection.
@@ -16,12 +17,26 @@ in
# skills for free and needs no manifest. # skills for free and needs no manifest.
# A real (non-symlink) skill directory is a hand-authored, project-private # A real (non-symlink) skill directory is a hand-authored, project-private
# skill and is left untouched. # skill and is left untouched.
# When ./.claude/skills is free, it is kept as a compatibility symlink to the
# canonical ./.agents/skills directory so Claude Code sees the same skills.
# The whole hook runs in a subshell so it leaks no names into the interactive # The whole hook runs in a subshell so it leaks no names into the interactive
# shell. # shell.
( (
_mkskills_dir=".claude/skills" _mkskills_dir=".agents/skills"
mkdir -p "$_mkskills_dir" mkdir -p "$_mkskills_dir"
mkdir -p .claude
if [ ! -e .claude/skills ] && [ ! -L .claude/skills ]; then
ln -s ../.agents/skills .claude/skills
elif [ -L .claude/skills ]; then
case "$(readlink .claude/skills)" in
../.agents/skills) : ;;
${storeDir}/*) rm -f .claude/skills && ln -s ../.agents/skills .claude/skills ;;
esac
else
printf 'mkSkillsShellHook: leaving existing non-symlink .claude/skills in place; Claude Code compatibility link not installed\n' >&2
fi
for _mkskills_entry in "$_mkskills_dir"/*; do for _mkskills_entry in "$_mkskills_dir"/*; do
[ -L "$_mkskills_entry" ] || continue [ -L "$_mkskills_entry" ] || continue
case "$(readlink "$_mkskills_entry")" in case "$(readlink "$_mkskills_entry")" in