Files
skills/lib/mk-skills-shell-hook.nix
alexion 49a73c30d1 feat: add per-project dev-shell skills placement (task 0003)
Expose lib.mkSkillsShellHook, which takes a list of selected skill derivations
and returns a dev-shell shellHook string. On shell entry the hook symlinks each
selected skill as a direct child of the project's .claude/skills/, pointing into
the store. It is stateless: each entry first removes only store-pointing symlinks
(deselected skills included), then relinks the current selection, leaving
hand-authored real directories untouched and regenerating a self-ignoring
.gitignore of the managed names. Add a nix flake check that sources the produced
hook against a fixture project and asserts placement, stale-removal, the
collision guard, non-store-symlink survival, and the .gitignore contents.
2026-07-22 22:22:38 -04:00

64 lines
2.4 KiB
Nix

# 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"
)
''