summaryrefslogtreecommitdiff
path: root/link/skills.sh
blob: 298883fd134fa1dce247e7125cf1baa1eec8d815 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
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