diff --git a/checks/shell-hook.nix b/checks/shell-hook.nix index 9b7c784..fdfd6d6 100644 --- a/checks/shell-hook.nix +++ b/checks/shell-hook.nix @@ -55,6 +55,7 @@ pkgs.runCommandLocal "skills-shell-hook-check" # collides with a selected one, and a symlink pointing outside the store. mkdir -p proj/.agents/skills/handmade printf '%s\n' "a hand-authored skill" > proj/.agents/skills/handmade/SKILL.md + printf '%s\n' "# fixture ignore" "result" > proj/.gitignore 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 @@ -94,18 +95,23 @@ pkgs.runCommandLocal "skills-shell-hook-check" 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' .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" .agents/skills/.gitignore \ + echo "the project .gitignore lists only generated links and keeps skill scanners clear" + test ! -e .agents/skills/.gitignore || fail "the hook wrote an ignore file where skill scanners read it" + grep -qx '# fixture ignore' .gitignore || fail "the existing .gitignore content was lost" + grep -qx 'result' .gitignore || fail "the existing .gitignore pattern was lost" + grep -qx '# BEGIN mkSkillsShellHook' .gitignore || fail ".gitignore lacks the managed block start" + grep -qx '# END mkSkillsShellHook' .gitignore || fail ".gitignore lacks the managed block end" + grep -qx '.claude/skills' .gitignore || fail ".gitignore omits the compatibility symlink" + grep -qx '.agents/skills/sample-alpha' .gitignore || fail ".gitignore omits sample-alpha" + grep -qx '.agents/skills/sample-beta' .gitignore || fail ".gitignore omits sample-beta" + for tracked in .agents/skills/handmade .agents/skills/sample-gamma .agents/skills/external; do + grep -qx "$tracked" .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 .agents/skills | sort | tr '\n' ' ') - [ "$entries" = ".gitignore external handmade sample-alpha sample-beta sample-gamma " ] \ + [ "$entries" = "external handmade sample-alpha sample-beta sample-gamma " ] \ || fail "unexpected entries under .agents/skills: [$entries]" echo "second entry with sample-beta deselected: its symlink is removed, the rest stay" @@ -119,10 +125,12 @@ pkgs.runCommandLocal "skills-shell-hook-check" || fail "the colliding hand-authored skill 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' .agents/skills/.gitignore || fail ".gitignore lost sample-alpha" - grep -qx '/sample-beta' .agents/skills/.gitignore \ + echo "the .gitignore tracks the new selection without duplicating the managed block" + grep -qx '.agents/skills/sample-alpha' .gitignore || fail ".gitignore lost sample-alpha" + grep -qx '.agents/skills/sample-beta' .gitignore \ && fail ".gitignore still lists the deselected sample-beta" + [ "$(grep -xc '# BEGIN mkSkillsShellHook' .gitignore)" = 1 ] \ + || fail ".gitignore has duplicate managed blocks" touch "$out" '' diff --git a/lib/mk-skills-shell-hook.nix b/lib/mk-skills-shell-hook.nix index 2e91b0a..36ce207 100644 --- a/lib/mk-skills-shell-hook.nix +++ b/lib/mk-skills-shell-hook.nix @@ -1,6 +1,8 @@ # Builds a dev-shell shellHook that links a project's selected skills into its # own `.agents/skills/` as direct-child symlinks into the Nix store, with # `.claude/skills` as a compatibility symlink when that path is free. +# Generated ignores live in the project root so agent skill scanners still see +# the linked skills. skills: let storeDir = builtins.storeDir; @@ -25,13 +27,15 @@ in _mkskills_dir=".agents/skills" mkdir -p "$_mkskills_dir" + _mkskills_claude_skills_managed=0 mkdir -p .claude if [ ! -e .claude/skills ] && [ ! -L .claude/skills ]; then ln -s ../.agents/skills .claude/skills + _mkskills_claude_skills_managed=1 elif [ -L .claude/skills ]; then case "$(readlink .claude/skills)" in - ../.agents/skills) : ;; - ${storeDir}/*) rm -f .claude/skills && ln -s ../.agents/skills .claude/skills ;; + ../.agents/skills) _mkskills_claude_skills_managed=1 ;; + ${storeDir}/*) rm -f .claude/skills && ln -s ../.agents/skills .claude/skills && _mkskills_claude_skills_managed=1 ;; esac else printf 'mkSkillsShellHook: leaving existing non-symlink .claude/skills in place; Claude Code compatibility link not installed\n' >&2 @@ -45,8 +49,9 @@ in 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. + # It is left in place and left out of the managed set, so the generated + # ignore block keeps tracking it rather than the skill that would have been + # linked. _mkskills_managed="" _mkskills_link() { _mkskills_dest="$_mkskills_dir/$1" @@ -62,17 +67,46 @@ in } ${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. + if [ -f "$_mkskills_dir/.gitignore" ] \ + && grep -qx '# Generated by mkSkillsShellHook. Nix-delivered skill symlinks, kept out of git.' "$_mkskills_dir/.gitignore"; then + rm -f "$_mkskills_dir/.gitignore" + fi + + # Regenerate the project-root ignore block from the current selection. + # Skill scanners read ignore files under .agents/skills, so the block must + # live above the scanned tree. + _mkskills_gitignore=".gitignore" + _mkskills_gitignore_tmp=".gitignore.mkskills.$$" + if [ -f "$_mkskills_gitignore" ]; then + awk ' + $0 == "# BEGIN mkSkillsShellHook" { skip = 1; next } + $0 == "# END mkSkillsShellHook" { skip = 0; next } + !skip { lines[++n] = $0 } + END { + while (n > 0 && lines[n] == "") n-- + for (i = 1; i <= n; i++) print lines[i] + } + ' "$_mkskills_gitignore" > "$_mkskills_gitignore_tmp" + else + : > "$_mkskills_gitignore_tmp" + fi { + if [ -s "$_mkskills_gitignore_tmp" ]; then + cat "$_mkskills_gitignore_tmp" + printf '\n' + fi + printf '%s\n' "# BEGIN mkSkillsShellHook" printf '%s\n' "# Generated by mkSkillsShellHook. Nix-delivered skill symlinks, kept out of git." - printf '%s\n' "/.gitignore" + if [ "$_mkskills_claude_skills_managed" = 1 ]; then + printf '%s\n' ".claude/skills" + fi # 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" + printf '.agents/skills/%s\n' "$_mkskills_name" done - } > "$_mkskills_dir/.gitignore" + printf '%s\n' "# END mkSkillsShellHook" + } > "$_mkskills_gitignore" + rm -f "$_mkskills_gitignore_tmp" ) ''