|
| 1 | +#!/usr/bin/env bash |
| 2 | +# sync-skills: build/refresh the per-machine agent skill mirror. Idempotent. |
| 3 | +# |
| 4 | +# Both Claude Code and Codex get a flat per-skill symlink mirror: |
| 5 | +# ~/.claude/skills/<name> -> <canonical skill dir> |
| 6 | +# ~/.codex/skills/<name> -> <canonical skill dir> |
| 7 | +# Claude Code loads only ~/.claude/skills/<name>/SKILL.md (one level deep, |
| 8 | +# per-entry symlinks followed, no recursive scan). Codex scans recursively but |
| 9 | +# Bram's setup is documented as per-skill links too, so no whole-root symlink |
| 10 | +# is created (see README: "Do not replace this with a broad ... symlink"). |
| 11 | +# |
| 12 | +# Sources, in collision priority order: agent-scripts > manager > codex-local. |
| 13 | +# Skills that already live in a mirror root (codex-local extras) stay put and |
| 14 | +# are mirrored into the other root. |
| 15 | +# |
| 16 | +# Usage: sync-skills [-n|--dry-run] [--no-instructions] |
| 17 | +set -eo pipefail |
| 18 | + |
| 19 | +AGENT_SCRIPTS=${AGENT_SCRIPTS_DIR:-$HOME/Projects/agent-scripts} |
| 20 | +AGENT_SKILLS="$AGENT_SCRIPTS/skills" |
| 21 | +MANAGER_SKILLS=${MANAGER_SKILLS_DIR:-$HOME/Projects/manager/skills} |
| 22 | +CODEX_ROOT="$HOME/.codex/skills" |
| 23 | +CLAUDE_ROOT="$HOME/.claude/skills" |
| 24 | +AGENTS_MD="$AGENT_SCRIPTS/AGENTS.MD" |
| 25 | + |
| 26 | +dry_run=0 |
| 27 | +link_instructions=1 |
| 28 | +for arg in "$@"; do |
| 29 | + case $arg in |
| 30 | + -n | --dry-run) dry_run=1 ;; |
| 31 | + --no-instructions) link_instructions=0 ;; |
| 32 | + -h | --help) |
| 33 | + printf 'Usage: sync-skills [-n|--dry-run] [--no-instructions]\n' |
| 34 | + exit 0 |
| 35 | + ;; |
| 36 | + *) |
| 37 | + printf 'sync-skills: unknown argument %s\n' "$arg" >&2 |
| 38 | + exit 2 |
| 39 | + ;; |
| 40 | + esac |
| 41 | +done |
| 42 | + |
| 43 | +changed=0 |
| 44 | +note() { printf '%s\n' "$*"; changed=1; } |
| 45 | +warn() { printf 'WARN: %s\n' "$*" >&2; } |
| 46 | +run() { [ "$dry_run" = 1 ] && return 0; "$@"; } |
| 47 | + |
| 48 | +# canon <dir>: absolute, symlink-free path, so mirrors never chain links. |
| 49 | +canon() { (cd "$1" 2>/dev/null && pwd -P); } |
| 50 | + |
| 51 | +# link <target> <linkpath>: create/retarget symlink, quiet when already right. |
| 52 | +link() { |
| 53 | + [ "$(readlink "$2" 2>/dev/null)" = "$1" ] && return 0 |
| 54 | + if [ -e "$2" ] && [ ! -L "$2" ]; then |
| 55 | + warn "$2 is a real file/dir, not linking (expected -> $1)" |
| 56 | + return 0 |
| 57 | + fi |
| 58 | + run ln -sfn "$1" "$2" |
| 59 | + note "link $2 -> $1" |
| 60 | +} |
| 61 | + |
| 62 | +# --- Claude root must be a real dir; the old layout symlinked the whole dir, |
| 63 | +# which hid manager + codex-local skills. |
| 64 | +if [ -L "$CLAUDE_ROOT" ]; then |
| 65 | + run rm "$CLAUDE_ROOT" |
| 66 | + note "replaced legacy whole-dir symlink $CLAUDE_ROOT with real dir" |
| 67 | +fi |
| 68 | +run mkdir -p "$CLAUDE_ROOT" "$CODEX_ROOT" |
| 69 | + |
| 70 | +# --- Collect desired name -> target (bash 3.2: parallel arrays). |
| 71 | +names=() |
| 72 | +targets=() |
| 73 | +lookup() { |
| 74 | + local i |
| 75 | + for i in "${!names[@]}"; do |
| 76 | + [ "${names[$i]}" = "$1" ] && { printf '%s' "${targets[$i]}"; return 0; } |
| 77 | + done |
| 78 | + return 1 |
| 79 | +} |
| 80 | +claim() { # claim <root> <label>; first root to claim a name wins. |
| 81 | + local root=$1 label=$2 entry name target taken |
| 82 | + [ -d "$root" ] || return 0 |
| 83 | + for entry in "$root"/*/; do |
| 84 | + entry=${entry%/} |
| 85 | + name=$(basename "$entry") |
| 86 | + [ -f "$entry/SKILL.md" ] || continue |
| 87 | + target=$(canon "$entry") || continue |
| 88 | + [ -n "$target" ] || continue |
| 89 | + if taken=$(lookup "$name"); then |
| 90 | + [ "$taken" = "$target" ] || printf 'skip %s (%s): name taken by %s\n' "$name" "$label" "$taken" |
| 91 | + continue |
| 92 | + fi |
| 93 | + names+=("$name") |
| 94 | + targets+=("$target") |
| 95 | + done |
| 96 | +} |
| 97 | +claim "$AGENT_SKILLS" agent-scripts |
| 98 | +claim "$MANAGER_SKILLS" manager |
| 99 | +claim "$CODEX_ROOT" codex-local |
| 100 | +claim "$CLAUDE_ROOT" claude-local |
| 101 | + |
| 102 | +# --- Mirror every claimed skill into both roots. |
| 103 | +mirror() { |
| 104 | + local root=$1 i linkpath |
| 105 | + for i in "${!names[@]}"; do |
| 106 | + linkpath="$root/${names[$i]}" |
| 107 | + # Skill already lives here (codex-local extra): leave it alone. |
| 108 | + [ "${targets[$i]}" = "$(canon "$linkpath" 2>/dev/null)" ] && continue |
| 109 | + link "${targets[$i]}" "$linkpath" |
| 110 | + done |
| 111 | +} |
| 112 | +mirror "$CLAUDE_ROOT" |
| 113 | +mirror "$CODEX_ROOT" |
| 114 | + |
| 115 | +# --- Prune: broken links always; healthy links into managed roots only when |
| 116 | +# no longer desired. Foreign healthy links (user-made) stay. |
| 117 | +prune() { |
| 118 | + local root=$1 entry name target |
| 119 | + for entry in "$root"/* "$root"/.[!.]*; do |
| 120 | + [ -L "$entry" ] || continue |
| 121 | + name=$(basename "$entry") |
| 122 | + target=$(readlink "$entry") |
| 123 | + if [ ! -e "$entry" ]; then |
| 124 | + run rm "$entry" |
| 125 | + note "pruned broken link $name -> $target" |
| 126 | + continue |
| 127 | + fi |
| 128 | + case $target in |
| 129 | + "$AGENT_SKILLS"/* | "$MANAGER_SKILLS"/* | "$CODEX_ROOT"/* | "$CLAUDE_ROOT"/*) |
| 130 | + lookup "$name" >/dev/null || { |
| 131 | + run rm "$entry" |
| 132 | + note "pruned stale link $name -> $target" |
| 133 | + } |
| 134 | + ;; |
| 135 | + esac |
| 136 | + done |
| 137 | +} |
| 138 | +prune "$CLAUDE_ROOT" |
| 139 | +prune "$CODEX_ROOT" |
| 140 | + |
| 141 | +# --- Global instruction pointers; never clobber a real file. |
| 142 | +if [ "$link_instructions" = 1 ]; then |
| 143 | + link "$AGENTS_MD" "$HOME/.claude/CLAUDE.md" |
| 144 | + link "$AGENTS_MD" "$HOME/.claude/AGENTS.md" |
| 145 | + link "$AGENTS_MD" "$HOME/.codex/AGENTS.md" |
| 146 | +fi |
| 147 | + |
| 148 | +[ "$dry_run" = 1 ] && echo "(dry run; no changes written)" |
| 149 | +[ "$changed" = 0 ] && echo "skills mirror up to date (${#names[@]} skills)" |
| 150 | +exit 0 |
0 commit comments