# 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; links = builtins.concatStringsSep "\n" ( map (skill: " _mkskills_link '${skill.skillName}' '${skill}'") skills ); in '' # 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. # 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. # 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=".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) _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 fi 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 generated # ignore block 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} 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." 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 '.agents/skills/%s\n' "$_mkskills_name" done printf '%s\n' "# END mkSkillsShellHook" } > "$_mkskills_gitignore" rm -f "$_mkskills_gitignore_tmp" ) ''