@vikasagarwal101/skill-sync 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +206 -0
- package/bin/skill-sync.js +38 -0
- package/bin/skill-sync.sh +1250 -0
- package/install.sh +516 -0
- package/lib/authoring.sh +200 -0
- package/lib/core.sh +221 -0
- package/lib/health.sh +136 -0
- package/lib/inspect.sh +210 -0
- package/lib/sync.sh +281 -0
- package/lib/targets.sh +73 -0
- package/package.json +42 -0
- package/rules/skill-sync.md +96 -0
- package/scripts/bundle.sh +28 -0
- package/skill-sync +131 -0
|
@@ -0,0 +1,1250 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# skill-sync (bundled by scripts/bundle.sh)
|
|
5
|
+
# Source: https://github.com/vikasagarwal101/skill-sync
|
|
6
|
+
|
|
7
|
+
# -- Configuration --
|
|
8
|
+
|
|
9
|
+
CONFIG_DIR="${SKILLSYNC_CONFIG_DIR:-$HOME/.config/skill-sync}"
|
|
10
|
+
MANIFEST="$CONFIG_DIR/manifest"
|
|
11
|
+
CONFIG_FILE="$CONFIG_DIR/config"
|
|
12
|
+
|
|
13
|
+
# Defaults (overridden by config file if present)
|
|
14
|
+
declare -A SOURCES=(
|
|
15
|
+
[agents]="${SKILLSYNC_AGENTS_DIR:-$HOME/.agents/skills}"
|
|
16
|
+
[opencode]="${SKILLSYNC_OPENCODE_DIR:-$HOME/.config/opencode/skills}"
|
|
17
|
+
[codex]="${SKILLSYNC_CODEX_DIR:-$HOME/.codex/skills}"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
declare -A TARGETS=(
|
|
21
|
+
[claude]="${SKILLSYNC_CLAUDE_DIR:-$HOME/.claude/skills}"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
SOURCE_PRIORITY=("agents" "opencode" "codex")
|
|
25
|
+
|
|
26
|
+
# Load config file if it exists (overrides defaults)
|
|
27
|
+
[ -f "$CONFIG_FILE" ] && source "$CONFIG_FILE"
|
|
28
|
+
|
|
29
|
+
# Global flags
|
|
30
|
+
DRY_RUN=0
|
|
31
|
+
|
|
32
|
+
# -- Helpers --
|
|
33
|
+
|
|
34
|
+
# Safe operations that respect --dry-run
|
|
35
|
+
_safe_cp() { [ $DRY_RUN -eq 1 ] && echo " [dry-run] cp -r $1 $2" || cp -r "$1" "$2"; }
|
|
36
|
+
_safe_rm() { [ $DRY_RUN -eq 1 ] && echo " [dry-run] rm -rf $1" || rm -rf "$1"; }
|
|
37
|
+
_safe_mv() { [ $DRY_RUN -eq 1 ] && echo " [dry-run] mv $1 $2" || mv "$1" "$2"; }
|
|
38
|
+
|
|
39
|
+
md5_content() {
|
|
40
|
+
find "$1" -type f -exec md5sum {} \; 2>/dev/null | awk '{print $1}' | sort | md5sum | cut -d' ' -f1
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
dir_exists() { [ -d "$1" ]; }
|
|
44
|
+
|
|
45
|
+
source_for_skill() {
|
|
46
|
+
local name="$1"
|
|
47
|
+
for label in "${SOURCE_PRIORITY[@]}"; do
|
|
48
|
+
local dir="${SOURCES[$label]}"
|
|
49
|
+
if [ -f "$dir/$name/SKILL.md" ] || [ -d "$dir/$name" ]; then
|
|
50
|
+
echo "$label"
|
|
51
|
+
return 0
|
|
52
|
+
fi
|
|
53
|
+
done
|
|
54
|
+
return 1
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
source_path_for_skill() {
|
|
58
|
+
local name="$1"
|
|
59
|
+
for label in "${SOURCE_PRIORITY[@]}"; do
|
|
60
|
+
local dir="${SOURCES[$label]}"
|
|
61
|
+
if [ -f "$dir/$name/SKILL.md" ] || [ -d "$dir/$name" ]; then
|
|
62
|
+
echo "$dir/$name"
|
|
63
|
+
return 0
|
|
64
|
+
fi
|
|
65
|
+
done
|
|
66
|
+
return 1
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
all_skill_names() {
|
|
70
|
+
declare -A seen
|
|
71
|
+
for label in "${SOURCE_PRIORITY[@]}"; do
|
|
72
|
+
local dir="${SOURCES[$label]}"
|
|
73
|
+
dir_exists "$dir" || continue
|
|
74
|
+
for d in "$dir"/*/; do
|
|
75
|
+
[ -d "$d" ] || continue
|
|
76
|
+
local n
|
|
77
|
+
n=$(basename "$d")
|
|
78
|
+
[ "$n" = ".system" ] && continue
|
|
79
|
+
if [ -f "$d/SKILL.md" ]; then
|
|
80
|
+
[ -n "${seen[$n]:-}" ] && continue
|
|
81
|
+
seen[$n]=1
|
|
82
|
+
echo "$n"
|
|
83
|
+
else
|
|
84
|
+
for sub in "$d"*/; do
|
|
85
|
+
[ -d "$sub" ] || continue
|
|
86
|
+
[ -f "$sub/SKILL.md" ] || continue
|
|
87
|
+
local sn nested
|
|
88
|
+
sn=$(basename "$sub")
|
|
89
|
+
nested="$n/$sn"
|
|
90
|
+
[ -n "${seen[$nested]:-}" ] && continue
|
|
91
|
+
seen[$nested]=1
|
|
92
|
+
echo "$nested"
|
|
93
|
+
done
|
|
94
|
+
fi
|
|
95
|
+
done
|
|
96
|
+
done
|
|
97
|
+
for label in "${!TARGETS[@]}"; do
|
|
98
|
+
local dir="${TARGETS[$label]}"
|
|
99
|
+
dir_exists "$dir" || continue
|
|
100
|
+
for d in "$dir"/*/; do
|
|
101
|
+
[ -d "$d" ] || continue
|
|
102
|
+
local n
|
|
103
|
+
n=$(basename "$d")
|
|
104
|
+
if [ -f "$d/SKILL.md" ]; then
|
|
105
|
+
[ -n "${seen[$n]:-}" ] && continue
|
|
106
|
+
seen[$n]=1
|
|
107
|
+
echo "$n"
|
|
108
|
+
else
|
|
109
|
+
for sub in "$d"*/; do
|
|
110
|
+
[ -d "$sub" ] || continue
|
|
111
|
+
[ -f "$sub/SKILL.md" ] || continue
|
|
112
|
+
local sn nested
|
|
113
|
+
sn=$(basename "$sub")
|
|
114
|
+
nested="$n/$sn"
|
|
115
|
+
[ -n "${seen[$nested]:-}" ] && continue
|
|
116
|
+
seen[$nested]=1
|
|
117
|
+
echo "$nested"
|
|
118
|
+
done
|
|
119
|
+
fi
|
|
120
|
+
done
|
|
121
|
+
done
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
extract_frontmatter_field() {
|
|
125
|
+
local file="$1"
|
|
126
|
+
local field="$2"
|
|
127
|
+
[ -f "$file" ] || return 1
|
|
128
|
+
awk -v field="$field" '
|
|
129
|
+
BEGIN { found=0; block=""; is_block=0 }
|
|
130
|
+
/^---$/ {
|
|
131
|
+
if (found && is_block) { print block; exit }
|
|
132
|
+
in_fm = !in_fm; next
|
|
133
|
+
}
|
|
134
|
+
in_fm && !found && $0 ~ "^"field":" {
|
|
135
|
+
val = $0
|
|
136
|
+
sub("^"field":\\s*", "", val)
|
|
137
|
+
if (val == ">" || val == ">-" || val == "|" || val == "|-") {
|
|
138
|
+
is_block = 1; found = 1; next
|
|
139
|
+
}
|
|
140
|
+
gsub(/^["'\'']|["'\'']$/, "", val)
|
|
141
|
+
if (val == ">") val = ""
|
|
142
|
+
print val
|
|
143
|
+
exit
|
|
144
|
+
}
|
|
145
|
+
found && is_block && /^[ \t]/ {
|
|
146
|
+
line = $0; sub(/^[ \t]+/, "", line)
|
|
147
|
+
if (block == "") block = line
|
|
148
|
+
else block = block " " line
|
|
149
|
+
next
|
|
150
|
+
}
|
|
151
|
+
found && is_block && /^[^ \t]/ { print block; exit }
|
|
152
|
+
END { if (found && is_block) print block }
|
|
153
|
+
' "$file"
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
has_frontmatter() {
|
|
157
|
+
local file="$1"
|
|
158
|
+
[ -f "$file" ] || return 1
|
|
159
|
+
head -1 "$file" 2>/dev/null | grep -q '^---$'
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# -- Manifest --
|
|
163
|
+
|
|
164
|
+
ensure_manifest() {
|
|
165
|
+
mkdir -p "$CONFIG_DIR"
|
|
166
|
+
if [ ! -f "$MANIFEST" ]; then
|
|
167
|
+
cmd_init
|
|
168
|
+
fi
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
manifest_get_field() {
|
|
172
|
+
local name="$1" field="$2"
|
|
173
|
+
[ -f "$MANIFEST" ] || return 1
|
|
174
|
+
awk -F'|' -v name="$name" -v field="$field" '
|
|
175
|
+
NR <= 4 { next }
|
|
176
|
+
{ gsub(/[ \t]+/, "", $1); gsub(/[ \t]+/, "", $2); gsub(/[ \t]+/, "", $3) }
|
|
177
|
+
$1 == name {
|
|
178
|
+
if (field == "source") print $2
|
|
179
|
+
else if (field == "targets") print $3
|
|
180
|
+
exit
|
|
181
|
+
}
|
|
182
|
+
' "$MANIFEST"
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
manifest_has() {
|
|
186
|
+
local name="$1"
|
|
187
|
+
[ -f "$MANIFEST" ] || return 1
|
|
188
|
+
awk -F'|' -v name="$name" '
|
|
189
|
+
NR <= 4 { next }
|
|
190
|
+
{ gsub(/[ \t]+/, "", $1) }
|
|
191
|
+
$1 == name { found=1; exit }
|
|
192
|
+
END { exit !found }
|
|
193
|
+
' "$MANIFEST"
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
manifest_add() {
|
|
197
|
+
local name="$1" source="$2" targets="$3"
|
|
198
|
+
ensure_manifest
|
|
199
|
+
if manifest_has "$name"; then
|
|
200
|
+
local tmp
|
|
201
|
+
tmp=$(mktemp)
|
|
202
|
+
awk -F'|' -v name="$name" -v src="$source" -v tgt="$targets" '
|
|
203
|
+
NR <= 4 { print; next }
|
|
204
|
+
{ gsub(/[ \t]+/, "", $1) }
|
|
205
|
+
$1 == name { printf "%-30s | %-10s | %s\n", name, src, tgt; next }
|
|
206
|
+
{ print }
|
|
207
|
+
' "$MANIFEST" > "$tmp"
|
|
208
|
+
mv "$tmp" "$MANIFEST"
|
|
209
|
+
else
|
|
210
|
+
printf "%-30s | %-10s | %s\n" "$name" "$source" "$targets" >> "$MANIFEST"
|
|
211
|
+
fi
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
manifest_remove() {
|
|
215
|
+
local name="$1"
|
|
216
|
+
[ -f "$MANIFEST" ] || return 0
|
|
217
|
+
local tmp
|
|
218
|
+
tmp=$(mktemp)
|
|
219
|
+
awk -F'|' -v name="$name" '
|
|
220
|
+
NR <= 4 { print; next }
|
|
221
|
+
{ gsub(/[ \t]+/, "", $1) }
|
|
222
|
+
$1 != name { print }
|
|
223
|
+
' "$MANIFEST" > "$tmp"
|
|
224
|
+
mv "$tmp" "$MANIFEST"
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
cmd_init() {
|
|
230
|
+
mkdir -p "$CONFIG_DIR"
|
|
231
|
+
echo "# skill-sync manifest" > "$MANIFEST"
|
|
232
|
+
echo "# Format: skill_name | source | targets" >> "$MANIFEST"
|
|
233
|
+
echo "# Sources: agents, opencode, codex | Targets: claude (or - for canonical-only)" >> "$MANIFEST"
|
|
234
|
+
echo "#" >> "$MANIFEST"
|
|
235
|
+
|
|
236
|
+
# Build from current state
|
|
237
|
+
local added=0
|
|
238
|
+
for name in $(all_skill_names | sort); do
|
|
239
|
+
local src
|
|
240
|
+
src=$(source_for_skill "$name" 2>/dev/null || echo "orphan")
|
|
241
|
+
|
|
242
|
+
# Determine targets: which target dirs contain this skill
|
|
243
|
+
local tgt_list="-"
|
|
244
|
+
for tlabel in "${!TARGETS[@]}"; do
|
|
245
|
+
local tdir="${TARGETS[$tlabel]}"
|
|
246
|
+
if dir_exists "$tdir/$name"; then
|
|
247
|
+
if [ "$tgt_list" = "-" ]; then
|
|
248
|
+
tgt_list="$tlabel"
|
|
249
|
+
else
|
|
250
|
+
tgt_list="$tgt_list,$tlabel"
|
|
251
|
+
fi
|
|
252
|
+
fi
|
|
253
|
+
done
|
|
254
|
+
|
|
255
|
+
# Skip codex system skills
|
|
256
|
+
[ "$src" = "codex" ] && {
|
|
257
|
+
local cdir="${SOURCES[codex]}"
|
|
258
|
+
[ -d "$cdir/.system/$name" ] && continue
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
printf "%-30s | %-10s | %s\n" "$name" "$src" "$tgt_list" >> "$MANIFEST"
|
|
262
|
+
added=$((added + 1))
|
|
263
|
+
done
|
|
264
|
+
|
|
265
|
+
echo "Manifest generated: $MANIFEST"
|
|
266
|
+
echo " $added skills indexed"
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
cmd_sync_all() {
|
|
270
|
+
ensure_manifest
|
|
271
|
+
local total=0 synced=0 updated=0 failed=0
|
|
272
|
+
|
|
273
|
+
echo "Syncing all manifest skills..."
|
|
274
|
+
echo ""
|
|
275
|
+
|
|
276
|
+
while IFS='|' read -r name source targets; do
|
|
277
|
+
# Skip comments/headers
|
|
278
|
+
name=$(echo "$name" | xargs)
|
|
279
|
+
[[ "$name" =~ ^# ]] && continue
|
|
280
|
+
[ -z "$name" ] && continue
|
|
281
|
+
source=$(echo "$source" | xargs)
|
|
282
|
+
targets=$(echo "$targets" | xargs)
|
|
283
|
+
[ "$targets" = "-" ] && continue
|
|
284
|
+
|
|
285
|
+
total=$((total + 1))
|
|
286
|
+
|
|
287
|
+
local src_path
|
|
288
|
+
src_path=$(source_path_for_skill "$name") || {
|
|
289
|
+
echo " ⚠️ $name — source not found"
|
|
290
|
+
failed=$((failed + 1))
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
# Sync to each target
|
|
295
|
+
IFS=',' read -ra tgt_arr <<< "$targets"
|
|
296
|
+
for tlabel in "${tgt_arr[@]}"; do
|
|
297
|
+
tlabel=$(echo "$tlabel" | xargs)
|
|
298
|
+
local tdir="${TARGETS[$tlabel]}"
|
|
299
|
+
[ -d "$tdir" ] || mkdir -p "$tdir"
|
|
300
|
+
|
|
301
|
+
local src_hash tgt_hash
|
|
302
|
+
src_hash=$(md5_content "$src_path")
|
|
303
|
+
if [ -d "$tdir/$name" ]; then
|
|
304
|
+
tgt_hash=$(md5_content "$tdir/$name")
|
|
305
|
+
else
|
|
306
|
+
tgt_hash="none"
|
|
307
|
+
fi
|
|
308
|
+
|
|
309
|
+
if [ "$src_hash" = "$tgt_hash" ]; then
|
|
310
|
+
echo " ✅ $name → $tlabel (in sync)"
|
|
311
|
+
synced=$((synced + 1))
|
|
312
|
+
else
|
|
313
|
+
_safe_rm "$tdir/$name"
|
|
314
|
+
_safe_cp "$src_path" "$tdir/$name"
|
|
315
|
+
echo " 🔄 $name → $tlabel (updated)"
|
|
316
|
+
updated=$((updated + 1))
|
|
317
|
+
fi
|
|
318
|
+
done
|
|
319
|
+
done < "$MANIFEST"
|
|
320
|
+
|
|
321
|
+
echo ""
|
|
322
|
+
echo "Done: $total skills, $updated updated, $synced already in sync, $failed failed"
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
cmd_sync_one() {
|
|
326
|
+
local name="$1"
|
|
327
|
+
ensure_manifest
|
|
328
|
+
|
|
329
|
+
local targets
|
|
330
|
+
targets=$(manifest_get_field "$name" "targets")
|
|
331
|
+
[ -z "$targets" ] && {
|
|
332
|
+
echo "'$name' not in manifest. Use: skill-sync add $name"
|
|
333
|
+
return 1
|
|
334
|
+
}
|
|
335
|
+
[ "$targets" = "-" ] && {
|
|
336
|
+
echo "'$name' has no sync targets (canonical-only)."
|
|
337
|
+
return 0
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
local src_path
|
|
341
|
+
src_path=$(source_path_for_skill "$name") || {
|
|
342
|
+
echo "Source not found for '$name'"
|
|
343
|
+
return 1
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
IFS=',' read -ra tgt_arr <<< "$targets"
|
|
347
|
+
for tlabel in "${tgt_arr[@]}"; do
|
|
348
|
+
tlabel=$(echo "$tlabel" | xargs)
|
|
349
|
+
local tdir="${TARGETS[$tlabel]}"
|
|
350
|
+
[ -d "$tdir" ] || mkdir -p "$tdir"
|
|
351
|
+
|
|
352
|
+
if [ -d "$tdir/$name" ] && [ "$(md5_content "$src_path")" = "$(md5_content "$tdir/$name")" ]; then
|
|
353
|
+
echo "✅ $name → $tlabel (already in sync)"
|
|
354
|
+
else
|
|
355
|
+
_safe_rm "$tdir/$name"
|
|
356
|
+
_safe_cp "$src_path" "$tdir/$name"
|
|
357
|
+
echo "🔄 $name → $tlabel (updated)"
|
|
358
|
+
fi
|
|
359
|
+
done
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
cmd_add() {
|
|
363
|
+
local name="$1"
|
|
364
|
+
shift || true
|
|
365
|
+
local to_target="claude"
|
|
366
|
+
|
|
367
|
+
while [[ $# -gt 0 ]]; do
|
|
368
|
+
case "$1" in
|
|
369
|
+
--to) to_target="$2"; shift 2 ;;
|
|
370
|
+
*) shift ;;
|
|
371
|
+
esac
|
|
372
|
+
done
|
|
373
|
+
|
|
374
|
+
local src_label
|
|
375
|
+
src_label=$(source_for_skill "$name" 2>/dev/null) || {
|
|
376
|
+
echo "Skill '$name' not found in any source:"
|
|
377
|
+
for l in "${SOURCE_PRIORITY[@]}"; do echo " ${SOURCES[$l]}/$name/"; done
|
|
378
|
+
return 1
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
local src_path
|
|
382
|
+
src_path=$(source_path_for_skill "$name")
|
|
383
|
+
|
|
384
|
+
local tdir="${TARGETS[$to_target]}"
|
|
385
|
+
[ -d "$tdir" ] || mkdir -p "$tdir"
|
|
386
|
+
|
|
387
|
+
_safe_rm "$tdir/$name"
|
|
388
|
+
_safe_cp "$src_path" "$tdir/$name"
|
|
389
|
+
|
|
390
|
+
manifest_add "$name" "$src_label" "$to_target"
|
|
391
|
+
echo "✅ '$name' added → $to_target (from $src_label)"
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
cmd_remove() {
|
|
395
|
+
local name="$1"
|
|
396
|
+
shift || true
|
|
397
|
+
local from_target="" purge=0
|
|
398
|
+
|
|
399
|
+
while [[ $# -gt 0 ]]; do
|
|
400
|
+
case "$1" in
|
|
401
|
+
--from) from_target="$2"; shift 2 ;;
|
|
402
|
+
--purge) purge=1; shift ;;
|
|
403
|
+
*) shift ;;
|
|
404
|
+
esac
|
|
405
|
+
done
|
|
406
|
+
|
|
407
|
+
# Safety: resolve the skill and show where it currently exists
|
|
408
|
+
local src_label="" src_path=""
|
|
409
|
+
src_label=$(source_for_skill "$name" 2>/dev/null) && src_path=$(source_path_for_skill "$name")
|
|
410
|
+
|
|
411
|
+
echo "Removing: $name"
|
|
412
|
+
echo ""
|
|
413
|
+
|
|
414
|
+
# Show current state
|
|
415
|
+
if [ -n "$src_path" ]; then
|
|
416
|
+
echo " Source: $src_label ($src_path)"
|
|
417
|
+
else
|
|
418
|
+
echo " Source: — (no canonical source)"
|
|
419
|
+
fi
|
|
420
|
+
|
|
421
|
+
local target_hits=""
|
|
422
|
+
for tlabel in "${!TARGETS[@]}"; do
|
|
423
|
+
if [ -d "${TARGETS[$tlabel]}/$name" ]; then
|
|
424
|
+
target_hits="$target_hits $tlabel"
|
|
425
|
+
fi
|
|
426
|
+
done
|
|
427
|
+
if [ -n "$target_hits" ]; then
|
|
428
|
+
echo " Targets:$target_hits"
|
|
429
|
+
else
|
|
430
|
+
echo " Targets: — (not deployed)"
|
|
431
|
+
fi
|
|
432
|
+
echo ""
|
|
433
|
+
|
|
434
|
+
# If skill doesn't exist anywhere
|
|
435
|
+
if [ -z "$src_path" ] && [ -z "$target_hits" ]; then
|
|
436
|
+
echo "Skill '$name' not found anywhere."
|
|
437
|
+
manifest_remove "$name"
|
|
438
|
+
return 0
|
|
439
|
+
fi
|
|
440
|
+
|
|
441
|
+
# Phase 1: Remove from targets
|
|
442
|
+
local removed_any=0
|
|
443
|
+
|
|
444
|
+
if [ -n "$from_target" ]; then
|
|
445
|
+
# Remove from specific target only
|
|
446
|
+
local tdir="${TARGETS[$from_target]:-}"
|
|
447
|
+
if [ -z "$tdir" ]; then
|
|
448
|
+
echo "Unknown target '$from_target'"
|
|
449
|
+
return 1
|
|
450
|
+
fi
|
|
451
|
+
if [ -d "$tdir/$name" ]; then
|
|
452
|
+
_safe_rm "$tdir/$name"
|
|
453
|
+
echo " 🗑️ Removed from $from_target"
|
|
454
|
+
removed_any=1
|
|
455
|
+
else
|
|
456
|
+
echo " '$name' was not in $from_target"
|
|
457
|
+
fi
|
|
458
|
+
|
|
459
|
+
# Update manifest: remove this target from the list
|
|
460
|
+
local current_targets
|
|
461
|
+
current_targets=$(manifest_get_field "$name" "targets")
|
|
462
|
+
local new_targets=""
|
|
463
|
+
IFS=',' read -ra remain <<< "$current_targets"
|
|
464
|
+
for t in "${remain[@]}"; do
|
|
465
|
+
t=$(echo "$t" | xargs)
|
|
466
|
+
[ "$t" = "$from_target" ] && continue
|
|
467
|
+
if [ -z "$new_targets" ]; then new_targets="$t"; else new_targets="$new_targets,$t"; fi
|
|
468
|
+
done
|
|
469
|
+
[ -z "$new_targets" ] && new_targets="-"
|
|
470
|
+
[ -n "$src_label" ] && manifest_add "$name" "$src_label" "$new_targets"
|
|
471
|
+
else
|
|
472
|
+
# Remove from ALL targets
|
|
473
|
+
for tlabel in "${!TARGETS[@]}"; do
|
|
474
|
+
local tdir="${TARGETS[$tlabel]}"
|
|
475
|
+
if [ -d "$tdir/$name" ]; then
|
|
476
|
+
_safe_rm "$tdir/$name"
|
|
477
|
+
echo " 🗑️ Removed from $tlabel"
|
|
478
|
+
removed_any=1
|
|
479
|
+
fi
|
|
480
|
+
done
|
|
481
|
+
[ $removed_any -eq 0 ] && echo " (was not deployed to any target)"
|
|
482
|
+
|
|
483
|
+
# Update manifest: set targets to -
|
|
484
|
+
[ -n "$src_label" ] && manifest_add "$name" "$src_label" "-"
|
|
485
|
+
fi
|
|
486
|
+
|
|
487
|
+
# Phase 2: Purge from canonical source
|
|
488
|
+
if [ $purge -eq 1 ]; then
|
|
489
|
+
if [ -n "$src_path" ] && [ -d "$src_path" ]; then
|
|
490
|
+
_safe_rm "$src_path"
|
|
491
|
+
echo " 💀 Purged from $src_label source"
|
|
492
|
+
manifest_remove "$name"
|
|
493
|
+
elif [ -z "$src_path" ]; then
|
|
494
|
+
echo " (no canonical source to purge)"
|
|
495
|
+
fi
|
|
496
|
+
fi
|
|
497
|
+
|
|
498
|
+
echo ""
|
|
499
|
+
echo "Done."
|
|
500
|
+
|
|
501
|
+
# Show what remains
|
|
502
|
+
if [ $purge -eq 0 ] && [ -n "$src_label" ]; then
|
|
503
|
+
echo " Skill still exists in source: $src_label"
|
|
504
|
+
echo " Re-deploy with: skill-sync add $name"
|
|
505
|
+
elif [ $purge -eq 1 ]; then
|
|
506
|
+
echo " Skill fully removed from all locations."
|
|
507
|
+
fi
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
cmd_create() {
|
|
512
|
+
local name="$1"
|
|
513
|
+
shift || true
|
|
514
|
+
local scope="agents"
|
|
515
|
+
|
|
516
|
+
while [[ $# -gt 0 ]]; do
|
|
517
|
+
case "$1" in
|
|
518
|
+
--scope) scope="$2"; shift 2 ;;
|
|
519
|
+
--to)
|
|
520
|
+
local to_target="$2"; shift 2
|
|
521
|
+
;;
|
|
522
|
+
*) shift ;;
|
|
523
|
+
esac
|
|
524
|
+
done
|
|
525
|
+
|
|
526
|
+
# Validate scope
|
|
527
|
+
[ -z "${SOURCES[$scope]:-}" ] && {
|
|
528
|
+
echo "Unknown scope '$scope'. Use: agents, opencode, codex"
|
|
529
|
+
return 1
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
local dir="${SOURCES[$scope]}/$name"
|
|
533
|
+
[ -d "$dir" ] && {
|
|
534
|
+
echo "'$name' already exists at $dir"
|
|
535
|
+
return 1
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
mkdir -p "$dir"
|
|
539
|
+
cat > "$dir/SKILL.md" << EOF
|
|
540
|
+
---
|
|
541
|
+
name: $name
|
|
542
|
+
description: TODO — describe what this skill does and when to trigger it. Include specific use cases and example phrases the user might say.
|
|
543
|
+
---
|
|
544
|
+
|
|
545
|
+
# $(echo "$name" | sed 's/-/ /g' | awk '{for(i=1;i<=NF;i++)$i=toupper(substr($i,1,1))substr($i,2)}1')
|
|
546
|
+
|
|
547
|
+
## When to Use
|
|
548
|
+
|
|
549
|
+
TODO — Describe the specific scenarios where this skill should be triggered.
|
|
550
|
+
|
|
551
|
+
## Workflow
|
|
552
|
+
|
|
553
|
+
TODO — Describe the step-by-step process.
|
|
554
|
+
|
|
555
|
+
## Key Rules
|
|
556
|
+
|
|
557
|
+
TODO — List any non-negotiable rules or constraints.
|
|
558
|
+
EOF
|
|
559
|
+
|
|
560
|
+
manifest_add "$name" "$scope" "-"
|
|
561
|
+
echo "✅ Created '$name' in $scope"
|
|
562
|
+
echo " $dir/SKILL.md"
|
|
563
|
+
echo ""
|
|
564
|
+
echo " Edit the SKILL.md, then deploy with:"
|
|
565
|
+
echo " skill-sync add $name --to claude"
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
cmd_edit() {
|
|
569
|
+
local name="$1"
|
|
570
|
+
local src_path
|
|
571
|
+
src_path=$(source_path_for_skill "$name") || {
|
|
572
|
+
echo "'$name' not found in any canonical source"
|
|
573
|
+
return 1
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
local skill_md="$src_path/SKILL.md"
|
|
577
|
+
[ -f "$skill_md" ] || {
|
|
578
|
+
echo "No SKILL.md at $src_path"
|
|
579
|
+
return 1
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
local editor="${EDITOR:-vi}"
|
|
583
|
+
echo "Opening $skill_md with $editor..."
|
|
584
|
+
"$editor" "$skill_md"
|
|
585
|
+
|
|
586
|
+
# After editor exits, offer to sync if skill has targets
|
|
587
|
+
local targets
|
|
588
|
+
targets=$(manifest_get_field "$name" "targets" 2>/dev/null)
|
|
589
|
+
if [ -n "$targets" ] && [ "$targets" != "-" ]; then
|
|
590
|
+
echo ""
|
|
591
|
+
echo "Skill '$name' has targets: $targets"
|
|
592
|
+
echo "Run 'skill-sync sync $name' to push changes."
|
|
593
|
+
fi
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
cmd_pull() {
|
|
597
|
+
local name="$1"
|
|
598
|
+
shift || true
|
|
599
|
+
local from_target=""
|
|
600
|
+
|
|
601
|
+
while [[ $# -gt 0 ]]; do
|
|
602
|
+
case "$1" in
|
|
603
|
+
--from) from_target="$2"; shift 2 ;;
|
|
604
|
+
*) shift ;;
|
|
605
|
+
esac
|
|
606
|
+
done
|
|
607
|
+
|
|
608
|
+
local src_path
|
|
609
|
+
src_path=$(source_path_for_skill "$name") || {
|
|
610
|
+
echo "Canonical source for '$name' not found"
|
|
611
|
+
return 1
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
# Find which target to pull from
|
|
615
|
+
local tlabel tdir
|
|
616
|
+
if [ -n "$from_target" ]; then
|
|
617
|
+
tlabel="$from_target"
|
|
618
|
+
tdir="${TARGETS[$tlabel]:-}"
|
|
619
|
+
[ -z "$tdir" ] && { echo "Unknown target '$tlabel'"; return 1; }
|
|
620
|
+
else
|
|
621
|
+
# Auto-detect: find first target that has this skill
|
|
622
|
+
for tl in "${!TARGETS[@]}"; do
|
|
623
|
+
if [ -d "${TARGETS[$tl]}/$name" ]; then
|
|
624
|
+
tlabel="$tl"
|
|
625
|
+
tdir="${TARGETS[$tl]}"
|
|
626
|
+
break
|
|
627
|
+
fi
|
|
628
|
+
done
|
|
629
|
+
fi
|
|
630
|
+
|
|
631
|
+
[ -z "${tlabel:-}" ] && { echo "'$name' not found in any target"; return 1; }
|
|
632
|
+
|
|
633
|
+
if [ ! -d "$tdir/$name" ]; then
|
|
634
|
+
echo "'$name' not in $tlabel"
|
|
635
|
+
return 1
|
|
636
|
+
fi
|
|
637
|
+
|
|
638
|
+
local src_hash tgt_hash
|
|
639
|
+
src_hash=$(md5_content "$src_path")
|
|
640
|
+
tgt_hash=$(md5_content "$tdir/$name")
|
|
641
|
+
|
|
642
|
+
if [ "$src_hash" = "$tgt_hash" ]; then
|
|
643
|
+
echo "✅ Already in sync — no changes to pull"
|
|
644
|
+
return 0
|
|
645
|
+
fi
|
|
646
|
+
|
|
647
|
+
echo "Pulling '$name' from $tlabel → canonical source..."
|
|
648
|
+
_safe_rm "$src_path"
|
|
649
|
+
_safe_cp "$tdir/$name" "$src_path"
|
|
650
|
+
echo "✅ Pulled. Canonical source updated from $tlabel copy."
|
|
651
|
+
echo " Run 'skill-sync sync' to push to other targets."
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
cmd_rename() {
|
|
655
|
+
local old_name="$1"
|
|
656
|
+
local new_name="$2"
|
|
657
|
+
|
|
658
|
+
[ -z "$new_name" ] && { echo "Usage: skill-sync rename <old> <new>"; return 1; }
|
|
659
|
+
|
|
660
|
+
# Validate new name
|
|
661
|
+
[[ "$new_name" =~ ^[a-z0-9][-a-z0-9/]*$ ]] || {
|
|
662
|
+
echo "Invalid name '$new_name' — use lowercase, hyphens, digits only"
|
|
663
|
+
return 1
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
local src_label src_path
|
|
667
|
+
src_label=$(source_for_skill "$old_name" 2>/dev/null) || {
|
|
668
|
+
echo "'$old_name' not found in any source"
|
|
669
|
+
return 1
|
|
670
|
+
}
|
|
671
|
+
src_path=$(source_path_for_skill "$old_name")
|
|
672
|
+
|
|
673
|
+
# Check new name doesn't already exist
|
|
674
|
+
if source_for_skill "$new_name" >/dev/null 2>&1; then
|
|
675
|
+
echo "'$new_name' already exists"
|
|
676
|
+
return 1
|
|
677
|
+
fi
|
|
678
|
+
|
|
679
|
+
echo "Renaming: $old_name → $new_name"
|
|
680
|
+
echo ""
|
|
681
|
+
|
|
682
|
+
# 1. Rename canonical source
|
|
683
|
+
local src_parent
|
|
684
|
+
src_parent=$(dirname "$src_path")
|
|
685
|
+
_safe_mv "$src_path" "$src_parent/$new_name"
|
|
686
|
+
echo " 📝 Renamed in $src_label source"
|
|
687
|
+
|
|
688
|
+
# 2. Rename in all targets
|
|
689
|
+
for tlabel in "${!TARGETS[@]}"; do
|
|
690
|
+
local tdir="${TARGETS[$tlabel]}"
|
|
691
|
+
if [ -d "$tdir/$old_name" ]; then
|
|
692
|
+
_safe_rm "$tdir/$old_name"
|
|
693
|
+
_safe_cp "$src_parent/$new_name" "$tdir/$new_name"
|
|
694
|
+
echo " 🔄 Updated in $tlabel"
|
|
695
|
+
fi
|
|
696
|
+
done
|
|
697
|
+
|
|
698
|
+
# 3. Update manifest
|
|
699
|
+
manifest_remove "$old_name"
|
|
700
|
+
local targets="-"
|
|
701
|
+
for tlabel in "${!TARGETS[@]}"; do
|
|
702
|
+
if [ -d "${TARGETS[$tlabel]}/$new_name" ]; then
|
|
703
|
+
[ "$targets" = "-" ] && targets="$tlabel" || targets="$targets,$tlabel"
|
|
704
|
+
fi
|
|
705
|
+
done
|
|
706
|
+
manifest_add "$new_name" "$src_label" "$targets"
|
|
707
|
+
|
|
708
|
+
echo ""
|
|
709
|
+
echo "✅ Renamed $old_name → $new_name across all locations"
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
cmd_info() {
|
|
713
|
+
local name="$1"
|
|
714
|
+
|
|
715
|
+
local src_label
|
|
716
|
+
src_label=$(source_for_skill "$name" 2>/dev/null) || {
|
|
717
|
+
echo "'$name' not found in any canonical source"
|
|
718
|
+
# Check if it's orphaned in a target
|
|
719
|
+
for tlabel in "${!TARGETS[@]}"; do
|
|
720
|
+
if [ -d "${TARGETS[$tlabel]}/$name" ]; then
|
|
721
|
+
echo " ⚠️ Found orphaned copy in $tlabel (no canonical source)"
|
|
722
|
+
fi
|
|
723
|
+
done
|
|
724
|
+
return 1
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
local src_path
|
|
728
|
+
src_path=$(source_path_for_skill "$name")
|
|
729
|
+
local desc
|
|
730
|
+
desc=$(extract_frontmatter_field "$src_path/SKILL.md" "description")
|
|
731
|
+
[ -z "$desc" ] && desc="(no description)"
|
|
732
|
+
|
|
733
|
+
echo "$name"
|
|
734
|
+
echo " Source: $src_label ($src_path)"
|
|
735
|
+
echo " Description: $desc"
|
|
736
|
+
|
|
737
|
+
# Size and files
|
|
738
|
+
local size files
|
|
739
|
+
size=$(du -sh "$src_path" 2>/dev/null | cut -f1)
|
|
740
|
+
files=$(find "$src_path" -type f | wc -l)
|
|
741
|
+
echo " Files: $files ($size)"
|
|
742
|
+
|
|
743
|
+
# Frontmatter health
|
|
744
|
+
if has_frontmatter "$src_path/SKILL.md"; then
|
|
745
|
+
echo " Frontmatter: ✅ present"
|
|
746
|
+
else
|
|
747
|
+
echo " Frontmatter: ❌ MISSING"
|
|
748
|
+
fi
|
|
749
|
+
|
|
750
|
+
# Targets / sync state
|
|
751
|
+
local manifest_targets="-"
|
|
752
|
+
[ -f "$MANIFEST" ] && manifest_targets=$(manifest_get_field "$name" "targets")
|
|
753
|
+
[ -z "$manifest_targets" ] && manifest_targets="(not in manifest)"
|
|
754
|
+
echo " Manifest: $manifest_targets"
|
|
755
|
+
|
|
756
|
+
echo " Copies:"
|
|
757
|
+
for tlabel in "${!TARGETS[@]}"; do
|
|
758
|
+
local tdir="${TARGETS[$tlabel]}"
|
|
759
|
+
if [ -d "$tdir/$name" ]; then
|
|
760
|
+
local sh th
|
|
761
|
+
sh=$(md5_content "$src_path")
|
|
762
|
+
th=$(md5_content "$tdir/$name")
|
|
763
|
+
if [ "$sh" = "$th" ]; then
|
|
764
|
+
echo " $tlabel: ✅ in sync"
|
|
765
|
+
else
|
|
766
|
+
echo " $tlabel: 🔄 DRIFTED"
|
|
767
|
+
fi
|
|
768
|
+
else
|
|
769
|
+
echo " $tlabel: — (not deployed)"
|
|
770
|
+
fi
|
|
771
|
+
done
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
cmd_status() {
|
|
775
|
+
ensure_manifest
|
|
776
|
+
printf "%-30s %-10s %-15s %s\n" "SKILL" "SOURCE" "TARGETS" "STATE"
|
|
777
|
+
printf "%-30s %-10s %-15s %s\n" "$(printf '%.0s-' {1..30})" "$(printf '%.0s-' {1..10})" "$(printf '%.0s-' {1..15})" "$(printf '%.0s-' {1..5})"
|
|
778
|
+
|
|
779
|
+
local all_ok=1
|
|
780
|
+
|
|
781
|
+
while IFS='|' read -r name source targets; do
|
|
782
|
+
name=$(echo "$name" | xargs)
|
|
783
|
+
[[ "$name" =~ ^# ]] && continue
|
|
784
|
+
[ -z "$name" ] && continue
|
|
785
|
+
source=$(echo "$source" | xargs)
|
|
786
|
+
targets=$(echo "$targets" | xargs)
|
|
787
|
+
|
|
788
|
+
local state=""
|
|
789
|
+
if [ "$targets" = "-" ]; then
|
|
790
|
+
state="canonical"
|
|
791
|
+
else
|
|
792
|
+
IFS=',' read -ra tgt_arr <<< "$targets"
|
|
793
|
+
for tlabel in "${tgt_arr[@]}"; do
|
|
794
|
+
tlabel=$(echo "$tlabel" | xargs)
|
|
795
|
+
local tdir="${TARGETS[$tlabel]}"
|
|
796
|
+
local src_path
|
|
797
|
+
src_path=$(source_path_for_skill "$name" 2>/dev/null)
|
|
798
|
+
if [ -z "$src_path" ]; then
|
|
799
|
+
state="$state ⚠️"
|
|
800
|
+
all_ok=0
|
|
801
|
+
elif [ -d "$tdir/$name" ]; then
|
|
802
|
+
if [ "$(md5_content "$src_path")" = "$(md5_content "$tdir/$name")" ]; then
|
|
803
|
+
state="$state ✅"
|
|
804
|
+
else
|
|
805
|
+
state="$state 🔄"
|
|
806
|
+
all_ok=0
|
|
807
|
+
fi
|
|
808
|
+
else
|
|
809
|
+
state="$state ❌"
|
|
810
|
+
all_ok=0
|
|
811
|
+
fi
|
|
812
|
+
done
|
|
813
|
+
fi
|
|
814
|
+
|
|
815
|
+
printf "%-30s %-10s %-15s %s\n" "$name" "$source" "$targets" "$state"
|
|
816
|
+
done < "$MANIFEST"
|
|
817
|
+
|
|
818
|
+
echo ""
|
|
819
|
+
[ $all_ok -eq 1 ] && echo "All synced ✅" || echo "Issues found — run 'skill-sync sync' to fix"
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
cmd_list() {
|
|
823
|
+
local show_all="${1:-}"
|
|
824
|
+
|
|
825
|
+
if [ "$show_all" != "--all" ]; then
|
|
826
|
+
# List manifest skills only
|
|
827
|
+
ensure_manifest
|
|
828
|
+
printf "%-30s %-10s %s\n" "SKILL" "SOURCE" "TARGETS"
|
|
829
|
+
printf "%-30s %-10s %s\n" "-----" "------" "-------"
|
|
830
|
+
while IFS='|' read -r name source targets; do
|
|
831
|
+
name=$(echo "$name" | xargs)
|
|
832
|
+
[[ "$name" =~ ^# ]] && continue
|
|
833
|
+
[ -z "$name" ] && continue
|
|
834
|
+
source=$(echo "$source" | xargs)
|
|
835
|
+
targets=$(echo "$targets" | xargs)
|
|
836
|
+
[ "$targets" != "-" ] && printf "%-30s %-10s %s\n" "$name" "$source" "$targets"
|
|
837
|
+
done < "$MANIFEST"
|
|
838
|
+
return
|
|
839
|
+
fi
|
|
840
|
+
|
|
841
|
+
# List ALL skills from all locations
|
|
842
|
+
printf "%-30s %-10s %-10s %s\n" "SKILL" "SOURCE" "COPIES" "DESCRIPTION"
|
|
843
|
+
printf "%-30s %-10s %-10s %s\n" "-----" "------" "------" "-----------"
|
|
844
|
+
|
|
845
|
+
for name in $(all_skill_names | sort); do
|
|
846
|
+
local src_label src_path copies=""
|
|
847
|
+
src_label=$(source_for_skill "$name" 2>/dev/null) || src_label="orphan"
|
|
848
|
+
src_path=$(source_path_for_skill "$name" 2>/dev/null) || src_path=""
|
|
849
|
+
|
|
850
|
+
for tlabel in "${!TARGETS[@]}"; do
|
|
851
|
+
[ -d "${TARGETS[$tlabel]}/$name" ] && copies="$copies $tlabel"
|
|
852
|
+
done
|
|
853
|
+
[ -z "$copies" ] && copies="-"
|
|
854
|
+
|
|
855
|
+
local desc=""
|
|
856
|
+
if [ -n "$src_path" ] && [ -f "$src_path/SKILL.md" ]; then
|
|
857
|
+
desc=$(extract_frontmatter_field "$src_path/SKILL.md" "description" 2>/dev/null)
|
|
858
|
+
fi
|
|
859
|
+
[ -z "$desc" ] && desc="—"
|
|
860
|
+
|
|
861
|
+
printf "%-30s %-10s %-10s %.60s\n" "$name" "$src_label" "$copies" "$desc"
|
|
862
|
+
done
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
cmd_search() {
|
|
866
|
+
local query="$*"
|
|
867
|
+
[ -z "$query" ] && { echo "Usage: skill-sync search <query>"; return 1; }
|
|
868
|
+
query=$(echo "$query" | tr '[:upper:]' '[:lower:]')
|
|
869
|
+
|
|
870
|
+
printf "%-30s %-10s %.70s\n" "SKILL" "SOURCE" "DESCRIPTION"
|
|
871
|
+
printf "%-30s %-10s %.70s\n" "-----" "------" "-----------"
|
|
872
|
+
|
|
873
|
+
local found=0
|
|
874
|
+
for name in $(all_skill_names | sort); do
|
|
875
|
+
local src_path desc lname
|
|
876
|
+
src_path=$(source_path_for_skill "$name" 2>/dev/null) || continue
|
|
877
|
+
desc=$(extract_frontmatter_field "$src_path/SKILL.md" "description" 2>/dev/null)
|
|
878
|
+
[ -z "$desc" ] && desc="—"
|
|
879
|
+
lname=$(echo "$name $desc" | tr '[:upper:]' '[:lower:]')
|
|
880
|
+
|
|
881
|
+
if [[ "$lname" == *"$query"* ]]; then
|
|
882
|
+
local src_label
|
|
883
|
+
src_label=$(source_for_skill "$name")
|
|
884
|
+
printf "%-30s %-10s %.70s\n" "$name" "$src_label" "$desc"
|
|
885
|
+
found=$((found + 1))
|
|
886
|
+
fi
|
|
887
|
+
done
|
|
888
|
+
|
|
889
|
+
echo ""
|
|
890
|
+
[ $found -eq 0 ] && echo "No matches for '$query'" || echo "$found skill(s) found"
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
cmd_diff() {
|
|
894
|
+
local name="$1"
|
|
895
|
+
|
|
896
|
+
local src_path
|
|
897
|
+
src_path=$(source_path_for_skill "$name") || {
|
|
898
|
+
echo "'$name' not found in any source"
|
|
899
|
+
return 1
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
local has_targets=0
|
|
903
|
+
for tlabel in "${!TARGETS[@]}"; do
|
|
904
|
+
if [ -d "${TARGETS[$tlabel]}/$name" ]; then
|
|
905
|
+
has_targets=1
|
|
906
|
+
echo "── diff: $name (source → $tlabel) ──"
|
|
907
|
+
diff -rq "$src_path" "${TARGETS[$tlabel]}/$name" 2>/dev/null || true
|
|
908
|
+
echo ""
|
|
909
|
+
|
|
910
|
+
# Show SKILL.md content diff if different
|
|
911
|
+
if ! diff -q "$src_path/SKILL.md" "${TARGETS[$tlabel]}/$name/SKILL.md" >/dev/null 2>&1; then
|
|
912
|
+
echo "── SKILL.md content diff ──"
|
|
913
|
+
diff -u "${TARGETS[$tlabel]}/$name/SKILL.md" "$src_path/SKILL.md" 2>/dev/null || true
|
|
914
|
+
echo ""
|
|
915
|
+
fi
|
|
916
|
+
fi
|
|
917
|
+
done
|
|
918
|
+
|
|
919
|
+
[ $has_targets -eq 0 ] && echo "'$name' has no target copies to diff against"
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
cmd_validate() {
|
|
924
|
+
local name="${1:-}"
|
|
925
|
+
local errors=0
|
|
926
|
+
|
|
927
|
+
if [ -n "$name" ]; then
|
|
928
|
+
local src_path
|
|
929
|
+
src_path=$(source_path_for_skill "$name" 2>/dev/null) || {
|
|
930
|
+
echo "❌ '$name' not found"
|
|
931
|
+
return 1
|
|
932
|
+
}
|
|
933
|
+
_validate_skill "$name" "$src_path"
|
|
934
|
+
return $?
|
|
935
|
+
fi
|
|
936
|
+
|
|
937
|
+
# Validate all
|
|
938
|
+
for n in $(all_skill_names | sort); do
|
|
939
|
+
local sp
|
|
940
|
+
sp=$(source_path_for_skill "$n" 2>/dev/null) || continue
|
|
941
|
+
_validate_skill "$n" "$sp" || errors=$((errors + 1))
|
|
942
|
+
done
|
|
943
|
+
echo ""
|
|
944
|
+
[ $errors -eq 0 ] && echo "All skills valid ✅" || echo "$errors skills have issues"
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
_validate_skill() {
|
|
948
|
+
local name="$1" path="$2"
|
|
949
|
+
local file="$path/SKILL.md"
|
|
950
|
+
local ok=1
|
|
951
|
+
|
|
952
|
+
[ -f "$file" ] || { echo "❌ $name — no SKILL.md"; return 1; }
|
|
953
|
+
|
|
954
|
+
has_frontmatter "$file" || { echo "❌ $name — missing YAML frontmatter (---)"; return 1; }
|
|
955
|
+
|
|
956
|
+
local fm_name fm_desc
|
|
957
|
+
fm_name=$(extract_frontmatter_field "$file" "name")
|
|
958
|
+
fm_desc=$(extract_frontmatter_field "$file" "description")
|
|
959
|
+
|
|
960
|
+
[ -z "$fm_name" ] && { echo "❌ $name — missing 'name' in frontmatter"; ok=0; }
|
|
961
|
+
[ -z "$fm_desc" ] && { echo "❌ $name — missing 'description' in frontmatter"; ok=0; }
|
|
962
|
+
|
|
963
|
+
# Check naming: lowercase, hyphens, digits only
|
|
964
|
+
[[ "$name" =~ ^[a-z0-9][-a-z0-9/]*$ ]] || { echo "⚠️ $name — name should be lowercase-hyphenated"; ok=0; }
|
|
965
|
+
|
|
966
|
+
# Check description length
|
|
967
|
+
if [ -n "$fm_desc" ]; then
|
|
968
|
+
local dlen=${#fm_desc}
|
|
969
|
+
[ $dlen -lt 20 ] && { echo "⚠️ $name — description very short ($dlen chars)"; ok=0; }
|
|
970
|
+
fi
|
|
971
|
+
|
|
972
|
+
[ $ok -eq 1 ] && echo "✅ $name"
|
|
973
|
+
return $(( 1 - ok ))
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
cmd_doctor() {
|
|
977
|
+
echo "Skill system health check"
|
|
978
|
+
echo ""
|
|
979
|
+
local issues=0
|
|
980
|
+
|
|
981
|
+
echo "── Frontmatter & naming ──"
|
|
982
|
+
cmd_validate || issues=$((issues + 1))
|
|
983
|
+
|
|
984
|
+
echo ""
|
|
985
|
+
echo "── Orphaned copies (in targets but no source) ──"
|
|
986
|
+
for tlabel in "${!TARGETS[@]}"; do
|
|
987
|
+
local tdir="${TARGETS[$tlabel]}"
|
|
988
|
+
dir_exists "$tdir" || continue
|
|
989
|
+
for d in "$tdir"/*/; do
|
|
990
|
+
[ -d "$d" ] || continue
|
|
991
|
+
local n
|
|
992
|
+
n=$(basename "$d")
|
|
993
|
+
if [ -f "$d/SKILL.md" ]; then
|
|
994
|
+
if ! source_for_skill "$n" >/dev/null 2>&1; then
|
|
995
|
+
echo " ⚠️ $n — in $tlabel but no canonical source"
|
|
996
|
+
issues=$((issues + 1))
|
|
997
|
+
fi
|
|
998
|
+
else
|
|
999
|
+
for sub in "$d"*/; do
|
|
1000
|
+
[ -d "$sub" ] || continue
|
|
1001
|
+
[ -f "$sub/SKILL.md" ] || continue
|
|
1002
|
+
local sn nested
|
|
1003
|
+
sn=$(basename "$sub")
|
|
1004
|
+
nested="$n/$sn"
|
|
1005
|
+
if ! source_for_skill "$nested" >/dev/null 2>&1; then
|
|
1006
|
+
echo " ⚠️ $nested — in $tlabel but no canonical source"
|
|
1007
|
+
issues=$((issues + 1))
|
|
1008
|
+
fi
|
|
1009
|
+
done
|
|
1010
|
+
fi
|
|
1011
|
+
done
|
|
1012
|
+
done
|
|
1013
|
+
[ $issues -eq 0 ] && echo " None ✅"
|
|
1014
|
+
|
|
1015
|
+
echo ""
|
|
1016
|
+
echo "── Sync drift ──"
|
|
1017
|
+
local drift=0
|
|
1018
|
+
for tlabel in "${!TARGETS[@]}"; do
|
|
1019
|
+
local tdir="${TARGETS[$tlabel]}"
|
|
1020
|
+
dir_exists "$tdir" || continue
|
|
1021
|
+
for d in "$tdir"/*/; do
|
|
1022
|
+
[ -d "$d" ] || continue
|
|
1023
|
+
local n
|
|
1024
|
+
n=$(basename "$d")
|
|
1025
|
+
local src_path
|
|
1026
|
+
src_path=$(source_path_for_skill "$n" 2>/dev/null) || continue
|
|
1027
|
+
if [ "$(md5_content "$src_path")" != "$(md5_content "$d")" ]; then
|
|
1028
|
+
echo " 🔄 $n — drifted in $tlabel"
|
|
1029
|
+
drift=$((drift + 1))
|
|
1030
|
+
fi
|
|
1031
|
+
done
|
|
1032
|
+
done
|
|
1033
|
+
[ $drift -eq 0 ] && echo " All in sync ✅"
|
|
1034
|
+
|
|
1035
|
+
echo ""
|
|
1036
|
+
echo "── Location summary ──"
|
|
1037
|
+
for label in "${SOURCE_PRIORITY[@]}"; do
|
|
1038
|
+
local dir="${SOURCES[$label]}"
|
|
1039
|
+
if dir_exists "$dir"; then
|
|
1040
|
+
local count
|
|
1041
|
+
count=$(find "$dir" -maxdepth 1 -mindepth 1 -type d ! -name ".system" | wc -l)
|
|
1042
|
+
printf " %-12s %2d skills %s\n" "$label" "$count" "$dir"
|
|
1043
|
+
fi
|
|
1044
|
+
done
|
|
1045
|
+
for tlabel in "${!TARGETS[@]}"; do
|
|
1046
|
+
local tdir="${TARGETS[$tlabel]}"
|
|
1047
|
+
if dir_exists "$tdir"; then
|
|
1048
|
+
local count
|
|
1049
|
+
count=$(find "$tdir" -maxdepth 1 -mindepth 1 -type d | wc -l)
|
|
1050
|
+
printf " %-12s %2d skills %s\n" "$tlabel" "$count" "$tdir"
|
|
1051
|
+
fi
|
|
1052
|
+
done
|
|
1053
|
+
|
|
1054
|
+
echo ""
|
|
1055
|
+
local total_issues=$((issues + drift))
|
|
1056
|
+
[ $total_issues -eq 0 ] && echo "All healthy ✅" || echo "$total_issues total issues found"
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
# ── Config file writer ─────────────────────────────────────────
|
|
1061
|
+
|
|
1062
|
+
_save_config() {
|
|
1063
|
+
mkdir -p "$CONFIG_DIR"
|
|
1064
|
+
{
|
|
1065
|
+
echo "# Auto-generated by skill-sync."
|
|
1066
|
+
echo "# This file is sourced at startup. Edit manually or use target commands."
|
|
1067
|
+
echo ""
|
|
1068
|
+
declare -p SOURCES
|
|
1069
|
+
declare -p TARGETS
|
|
1070
|
+
declare -p SOURCE_PRIORITY
|
|
1071
|
+
} > "$CONFIG_FILE"
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
# ── New commands ───────────────────────────────────────────────
|
|
1075
|
+
|
|
1076
|
+
cmd_target() {
|
|
1077
|
+
local action="${1:-list}"
|
|
1078
|
+
|
|
1079
|
+
case "$action" in
|
|
1080
|
+
list)
|
|
1081
|
+
echo "Sync targets:"
|
|
1082
|
+
echo ""
|
|
1083
|
+
printf "%-15s %s\n" "LABEL" "PATH"
|
|
1084
|
+
printf "%-15s %s\n" "-----" "----"
|
|
1085
|
+
for tlabel in "${!TARGETS[@]}"; do
|
|
1086
|
+
printf "%-15s %s\n" "$tlabel" "${TARGETS[$tlabel]}"
|
|
1087
|
+
done
|
|
1088
|
+
echo ""
|
|
1089
|
+
echo "Sources:"
|
|
1090
|
+
printf "%-15s %s\n" "LABEL" "PATH"
|
|
1091
|
+
printf "%-15s %s\n" "-----" "----"
|
|
1092
|
+
for slabel in "${SOURCE_PRIORITY[@]}"; do
|
|
1093
|
+
printf "%-15s %s\n" "$slabel" "${SOURCES[$slabel]}"
|
|
1094
|
+
done
|
|
1095
|
+
;;
|
|
1096
|
+
add)
|
|
1097
|
+
local label="$2" path="$3"
|
|
1098
|
+
[ -z "$label" ] || [ -z "$path" ] && {
|
|
1099
|
+
echo "Usage: skill-sync target add <label> <path>"
|
|
1100
|
+
return 1
|
|
1101
|
+
}
|
|
1102
|
+
# Expand ~ in path
|
|
1103
|
+
path="${path/#\~/$HOME}"
|
|
1104
|
+
[ -n "${TARGETS[$label]:-}" ] && {
|
|
1105
|
+
echo "Target '$label' already exists: ${TARGETS[$label]}"
|
|
1106
|
+
return 1
|
|
1107
|
+
}
|
|
1108
|
+
TARGETS["$label"]="$path"
|
|
1109
|
+
mkdir -p "$path"
|
|
1110
|
+
_save_config
|
|
1111
|
+
echo "✅ Added target '$label' → $path"
|
|
1112
|
+
echo " Config saved to $CONFIG_FILE"
|
|
1113
|
+
;;
|
|
1114
|
+
remove)
|
|
1115
|
+
local label="$2"
|
|
1116
|
+
[ -z "$label" ] && { echo "Usage: skill-sync target remove <label>"; return 1; }
|
|
1117
|
+
[ -z "${TARGETS[$label]:-}" ] && {
|
|
1118
|
+
echo "Target '$label' not found"
|
|
1119
|
+
return 1
|
|
1120
|
+
}
|
|
1121
|
+
unset "TARGETS[$label]"
|
|
1122
|
+
_save_config
|
|
1123
|
+
echo "🗑️ Removed target '$label'"
|
|
1124
|
+
echo " (skill directories untouched, config updated)"
|
|
1125
|
+
;;
|
|
1126
|
+
*)
|
|
1127
|
+
echo "Usage: skill-sync target [list|add|remove]"
|
|
1128
|
+
return 1
|
|
1129
|
+
;;
|
|
1130
|
+
esac
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
# ── Main dispatch ──────────────────────────────────────────────
|
|
1135
|
+
|
|
1136
|
+
# Parse global flags
|
|
1137
|
+
local_args=()
|
|
1138
|
+
for a in "$@"; do
|
|
1139
|
+
case "$a" in
|
|
1140
|
+
--dry-run) DRY_RUN=1 ;;
|
|
1141
|
+
*) local_args+=("$a") ;;
|
|
1142
|
+
esac
|
|
1143
|
+
done
|
|
1144
|
+
set -- "${local_args[@]}" 2>/dev/null || true
|
|
1145
|
+
[ $DRY_RUN -eq 1 ] && echo "[dry-run mode — no changes will be made]"
|
|
1146
|
+
|
|
1147
|
+
cmd="${1:-help}"
|
|
1148
|
+
shift || true
|
|
1149
|
+
|
|
1150
|
+
case "$cmd" in
|
|
1151
|
+
sync|"")
|
|
1152
|
+
[ -n "${1:-}" ] && cmd_sync_one "$1" || cmd_sync_all
|
|
1153
|
+
;;
|
|
1154
|
+
add)
|
|
1155
|
+
[ -z "${1:-}" ] && { echo "Usage: skill-sync add <name> [--to claude]"; exit 1; }
|
|
1156
|
+
cmd_add "$@"
|
|
1157
|
+
;;
|
|
1158
|
+
remove|rm)
|
|
1159
|
+
[ -z "${1:-}" ] && { echo "Usage: skill-sync remove <name> [--from T] [--purge]"; exit 1; }
|
|
1160
|
+
cmd_remove "$@"
|
|
1161
|
+
;;
|
|
1162
|
+
create|new)
|
|
1163
|
+
[ -z "${1:-}" ] && { echo "Usage: skill-sync create <name> [--scope agents|opencode]"; exit 1; }
|
|
1164
|
+
cmd_create "$@"
|
|
1165
|
+
;;
|
|
1166
|
+
edit)
|
|
1167
|
+
[ -z "${1:-}" ] && { echo "Usage: skill-sync edit <name>"; exit 1; }
|
|
1168
|
+
cmd_edit "$1"
|
|
1169
|
+
;;
|
|
1170
|
+
pull)
|
|
1171
|
+
[ -z "${1:-}" ] && { echo "Usage: skill-sync pull <name> [--from T]"; exit 1; }
|
|
1172
|
+
cmd_pull "$@"
|
|
1173
|
+
;;
|
|
1174
|
+
search)
|
|
1175
|
+
[ -z "${1:-}" ] && { echo "Usage: skill-sync search <query>"; exit 1; }
|
|
1176
|
+
cmd_search "$@"
|
|
1177
|
+
;;
|
|
1178
|
+
rename)
|
|
1179
|
+
[ -z "${2:-}" ] && { echo "Usage: skill-sync rename <old> <new>"; exit 1; }
|
|
1180
|
+
cmd_rename "$1" "$2"
|
|
1181
|
+
;;
|
|
1182
|
+
diff)
|
|
1183
|
+
[ -z "${1:-}" ] && { echo "Usage: skill-sync diff <name>"; exit 1; }
|
|
1184
|
+
cmd_diff "$1"
|
|
1185
|
+
;;
|
|
1186
|
+
target)
|
|
1187
|
+
cmd_target "${1:-list}" "${2:-}" "${3:-}"
|
|
1188
|
+
;;
|
|
1189
|
+
info)
|
|
1190
|
+
[ -z "${1:-}" ] && { echo "Usage: skill-sync info <name>"; exit 1; }
|
|
1191
|
+
cmd_info "$1"
|
|
1192
|
+
;;
|
|
1193
|
+
status)
|
|
1194
|
+
cmd_status
|
|
1195
|
+
;;
|
|
1196
|
+
list|ls)
|
|
1197
|
+
cmd_list "${1:-}"
|
|
1198
|
+
;;
|
|
1199
|
+
validate|check)
|
|
1200
|
+
cmd_validate "${1:-}"
|
|
1201
|
+
;;
|
|
1202
|
+
doctor)
|
|
1203
|
+
cmd_doctor
|
|
1204
|
+
;;
|
|
1205
|
+
init)
|
|
1206
|
+
cmd_init
|
|
1207
|
+
;;
|
|
1208
|
+
help|--help|-h)
|
|
1209
|
+
cat << 'HELPEOF'
|
|
1210
|
+
skill-sync — manage skills across all AI agent directories
|
|
1211
|
+
|
|
1212
|
+
Commands:
|
|
1213
|
+
sync [name] Sync all manifest skills, or just one
|
|
1214
|
+
add <name> [--to T] Deploy a skill to a target (default: claude)
|
|
1215
|
+
remove <name> Remove from targets (--from T) or --purge source
|
|
1216
|
+
create <name> [--scope S] Scaffold new skill (default scope: agents)
|
|
1217
|
+
edit <name> Open SKILL.md in $EDITOR, then offer to sync
|
|
1218
|
+
pull <name> [--from T] Reverse sync: copy target edits back to source
|
|
1219
|
+
search <query> Find skills by keyword in name or description
|
|
1220
|
+
rename <old> <new> Rename a skill across all locations
|
|
1221
|
+
diff <name> Show content diff between source and target copies
|
|
1222
|
+
info <name> Show details, sync state, and health
|
|
1223
|
+
status Show sync status for all tracked skills
|
|
1224
|
+
list [--all] List synced skills, or --all for every skill
|
|
1225
|
+
validate [name] Check frontmatter + naming (one or all)
|
|
1226
|
+
doctor Full health check across all locations
|
|
1227
|
+
target [list|add|remove] Manage sync targets dynamically
|
|
1228
|
+
init Generate manifest from current state
|
|
1229
|
+
|
|
1230
|
+
Global flags:
|
|
1231
|
+
--dry-run Preview changes without executing
|
|
1232
|
+
|
|
1233
|
+
Sources (canonical, auto-discovered):
|
|
1234
|
+
agents ~/.agents/skills/
|
|
1235
|
+
opencode ~/.config/opencode/skills/
|
|
1236
|
+
codex ~/.codex/skills/
|
|
1237
|
+
|
|
1238
|
+
Targets (real copies, synced from sources):
|
|
1239
|
+
claude ~/.claude/skills/
|
|
1240
|
+
|
|
1241
|
+
Config: ~/.config/skill-sync/config
|
|
1242
|
+
Manifest: ~/.config/skill-sync/manifest
|
|
1243
|
+
HELPEOF
|
|
1244
|
+
;;
|
|
1245
|
+
*)
|
|
1246
|
+
echo "Unknown command: $cmd"
|
|
1247
|
+
echo "Run 'skill-sync help' for usage"
|
|
1248
|
+
exit 1
|
|
1249
|
+
;;
|
|
1250
|
+
esac
|