diff options
Diffstat (limited to 'link/skills.sh')
| -rwxr-xr-x | link/skills.sh | 129 |
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 |
