summaryrefslogtreecommitdiff
path: root/link/skills.sh
diff options
context:
space:
mode:
Diffstat (limited to 'link/skills.sh')
-rwxr-xr-xlink/skills.sh129
1 files changed, 129 insertions, 0 deletions
diff --git a/link/skills.sh b/link/skills.sh
new file mode 100755
index 0000000..298883f
--- /dev/null
+++ b/link/skills.sh
@@ -0,0 +1,129 @@
+#!/bin/bash
+
+# Agent skills, into every installed agent. Dry run by default; pass `yes` to
+# apply.
+
+set -euo pipefail
+
+# shellcheck source-path=SCRIPTDIR
+# shellcheck source=lib.sh
+source "$(dirname "$0")/lib.sh" "$@"
+
+# Agent skills (portable SKILL.md format), linked per skill into every installed
+# agent's skills dir, so skills from plugins or other tooling are left alone.
+# Two sources: this repo's skills/, and ~/.config/agent-skills.d/, where another
+# layer links skills of its own.
+# Agents that copy skills rather than follow links are not covered.
+#
+# Each entry is "<agent dir>:<skills dir>"; only where the agent dir already
+# exists, so machines without a given agent stay clean.
+#
+# Guards, because link() starts with `rm -rf`:
+# - a real file or directory in an agent's skills dir (Claude Code keeps its
+# own `synced/` there) is never replaced; the skill is skipped, loudly.
+# - a skill name defined in both sources is linked from neither, and said so
+# once, rather than one silently winning.
+# Pruned: dangling links in the hook dir, and dangling links in an agent's
+# skills dir that point into either source -- a skill removed from a layer goes.
+# Nothing else is touched.
+hook_dir="$xdg_config_dir/agent-skills.d"
+own_dir="$dotfiles_dir/skills"
+
+# Remove $1 if it is a symlink whose target is gone and whose recorded target
+# starts with one of the remaining arguments.
+prune_dangling() {
+ local entry="$1" prefix target
+ shift
+ [[ -L "$entry" && ! -e "$entry" ]] || return 0
+ target="$(readlink "$entry")"
+ for prefix in "$@"
+ do
+ if [[ "$target" == "$prefix"/* ]]
+ then
+ if [[ -n "${is_dry_run}" ]]
+ then
+ yellow "$entry dangles and will be removed"
+ else
+ rm "$entry"
+ fi
+ return 0
+ fi
+ done
+}
+
+if [[ -d "$hook_dir" ]]
+then
+ for entry in "$hook_dir"/*
+ do
+ [[ -L "$entry" && ! -e "$entry" ]] || continue
+ if [[ -n "${is_dry_run}" ]]
+ then
+ yellow "$entry dangles and will be removed"
+ else
+ rm "$entry"
+ fi
+ done
+fi
+
+# Names defined in both sources, reported once and skipped for every agent.
+duplicates=" "
+if [[ -d "$hook_dir" && -d "$own_dir" ]]
+then
+ for skill in "$hook_dir"/*
+ do
+ [[ -e "$skill" ]] || continue
+ name="$(basename "$skill")"
+ if [[ -e "$own_dir/$name" ]]
+ then
+ red "skill $name is defined in both $own_dir and $hook_dir; linking neither"
+ duplicates="$duplicates$name "
+ fi
+ done
+fi
+
+kimi_home="${KIMI_CODE_HOME:-$HOME/.local/share/kimi}"
+[[ -d "$kimi_home" ]] || kimi_home="$HOME/.kimi-code"
+for entry in \
+ "$HOME/.claude:$HOME/.claude/skills" \
+ "$HOME/.pi/agent:$HOME/.pi/agent/skills" \
+ "$HOME/.codex:$HOME/.codex/skills" \
+ "$HOME/.config/opencode:$HOME/.config/opencode/skills" \
+ "$HOME/.cursor:$HOME/.cursor/skills" \
+ "$HOME/.gemini:$HOME/.gemini/skills" \
+ "$HOME/.copilot:$HOME/.copilot/skills" \
+ "$HOME/.grok:$HOME/.grok/skills" \
+ "$kimi_home:$kimi_home/skills"
+do
+ agent_dir="${entry%%:*}"
+ skills_dir="${entry#*:}"
+ [[ -d "$agent_dir" ]] || continue
+
+ if [[ -d "$skills_dir" ]]
+ then
+ for existing in "$skills_dir"/*
+ do
+ prune_dangling "$existing" "$own_dir" "$hook_dir"
+ done
+ fi
+
+ for source_dir in "$own_dir" "$hook_dir"
+ do
+ [[ -d "$source_dir" ]] || continue
+ for skill in "$source_dir"/*
+ do
+ [[ -e "$skill" ]] || continue
+ name="$(basename "$skill")"
+ if [[ "$duplicates" == *" $name "* ]]
+ then
+ continue
+ fi
+ dest="$skills_dir/$name"
+ if [[ -e "$dest" && ! -L "$dest" ]]
+ then
+ red "$dest is not a link; leaving it, skill $name not linked"
+ continue
+ fi
+ link "$skill" "$dest"
+ done
+ done
+done