diff --git a/checks/shell-hook.nix b/checks/shell-hook.nix index ad54162..9b7c784 100644 --- a/checks/shell-hook.nix +++ b/checks/shell-hook.nix @@ -53,68 +53,75 @@ pkgs.runCommandLocal "skills-shell-hook-check" # 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 + mkdir -p proj/.agents/skills/handmade + printf '%s\n' "a hand-authored skill" > proj/.agents/skills/handmade/SKILL.md + mkdir -p proj/.agents/skills/sample-gamma + printf '%s\n' "a hand-authored gamma" > proj/.agents/skills/sample-gamma/SKILL.md + ln -s /nonexistent/external-target proj/.agents/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 + [ -L ".agents/skills/$name" ] || fail "$name was not linked as a symlink" + case "$(readlink ".agents/skills/$name")" in ${builtins.storeDir}/*) : ;; *) fail "$name's symlink does not point into the store" ;; 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" 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 ]; } \ + { [ -d .agents/skills/sample-gamma ] && [ ! -L .agents/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 \ + grep -qx "a hand-authored gamma" .agents/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 ]; } \ + { [ -d .agents/skills/handmade ] && [ ! -L .agents/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" + 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" - [ -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" - 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" + grep -qx '/.gitignore' .agents/skills/.gitignore || fail ".gitignore does not ignore itself" + grep -qx '/sample-alpha' .agents/skills/.gitignore || fail ".gitignore omits sample-alpha" + grep -qx '/sample-beta' .agents/skills/.gitignore || fail ".gitignore omits sample-beta" 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" done 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 " ] \ - || 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" 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 ]; } \ + test ! -e .agents/skills/sample-beta || fail "the deselected skill's symlink was not removed" + [ -L .agents/skills/sample-alpha ] || fail "the still-selected skill was dropped" + { [ -d .agents/skills/handmade ] && [ ! -L .agents/skills/handmade ]; } \ || 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" - [ -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" - grep -qx '/sample-alpha' .claude/skills/.gitignore || fail ".gitignore lost sample-alpha" - grep -qx '/sample-beta' .claude/skills/.gitignore \ + grep -qx '/sample-alpha' .agents/skills/.gitignore || fail ".gitignore lost sample-alpha" + grep -qx '/sample-beta' .agents/skills/.gitignore \ && fail ".gitignore still lists the deselected sample-beta" touch "$out" diff --git a/flake.nix b/flake.nix index d4f5f9e..a0ffa42 100644 --- a/flake.nix +++ b/flake.nix @@ -34,7 +34,7 @@ { 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, # and the skills it places come from the importing configuration's own # selection rather than this flake's nixpkgs. @@ -95,8 +95,8 @@ # A working shell for developing skills in this repo. # python3 runs the benchmark skill's deterministic core. - # The benchmark-skill is placed into ./.claude/skills/ on entry, so - # /benchmark-skill is available while working in this repo. + # The benchmark-skill is placed into ./.agents/skills/ on entry, with + # ./.claude/skills as a compatibility symlink. devShells.default = pkgs.mkShell { packages = [ pkgs.python3 ]; shellHook = mkSkillsShellHook [ (skillPackages pkgs).benchmark-skill ]; diff --git a/lib/mk-skills-shell-hook.nix b/lib/mk-skills-shell-hook.nix index 1926158..2e91b0a 100644 --- a/lib/mk-skills-shell-hook.nix +++ b/lib/mk-skills-shell-hook.nix @@ -1,5 +1,6 @@ # 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: let storeDir = builtins.storeDir; @@ -8,7 +9,7 @@ let ); 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. # Each run first drops every direct-child symlink pointing into the store, # then relinks the current selection. @@ -16,12 +17,26 @@ in # skills for free and needs no manifest. # A real (non-symlink) skill directory is a hand-authored, project-private # 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 # shell. ( - _mkskills_dir=".claude/skills" + _mkskills_dir=".agents/skills" 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 [ -L "$_mkskills_entry" ] || continue case "$(readlink "$_mkskills_entry")" in