#!/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 ":"; 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