Last active
January 22, 2026 05:07
-
-
Save cameroncooke/5f40ea2f51697c1b2806174e7c2232d5 to your computer and use it in GitHub Desktop.
Sync skills
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| source_dirs=( | |
| "/Users/someone/code/some-skills" | |
| "/Users/someone/code/sentry_skills" | |
| "/Users/someone/code/my-skills" | |
| ) | |
| codex_dest="$HOME/.codex/skills/public" | |
| claude_skills_dest="$HOME/.claude/skills" | |
| codex_union="$(mktemp -d)" | |
| claude_union="$(mktemp -d)" | |
| color_reset=$'\033[0m' | |
| color_dim=$'\033[2m' | |
| color_bold=$'\033[1m' | |
| color_cyan=$'\033[36m' | |
| color_green=$'\033[32m' | |
| color_yellow=$'\033[33m' | |
| color_purple=$'\033[35m' | |
| term_width=$(tput cols 2>/dev/null || echo 60) | |
| box_width=$((term_width > 70 ? 60 : term_width - 10)) | |
| print_header() { | |
| local text="$1" | |
| local pad=$(( (box_width - ${#text} - 2) / 2 )) | |
| local pad_r=$(( box_width - ${#text} - 2 - pad )) | |
| printf "\n${color_cyan}${color_bold}" | |
| printf "╭%*s╮\n" "$box_width" "" | tr ' ' '─' | |
| printf "│%*s %s %*s│\n" "$pad" "" "$text" "$pad_r" "" | |
| printf "╰%*s╯\n" "$box_width" "" | tr ' ' '─' | |
| printf "${color_reset}" | |
| } | |
| log_section() { | |
| local icon="$1" | |
| local title="$2" | |
| printf "\n${color_purple}${color_bold} %s %s${color_reset}\n" "$icon" "$title" | |
| printf "${color_dim} %*s${color_reset}\n" "${#title}" "" | tr ' ' '─' | |
| } | |
| log_step() { | |
| printf " ${color_cyan}●${color_reset} %s" "$1" | |
| } | |
| log_ok() { | |
| printf "\r ${color_green}✔${color_reset} %s\n" "$1" | |
| } | |
| trap 'rm -rf "$codex_union" "$claude_union"' EXIT | |
| total_plugins=0 | |
| is_marketplace() { | |
| [[ -f "$1/.claude-plugin/marketplace.json" ]] | |
| } | |
| count_dirs() { | |
| local path="$1" | |
| find "$path" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l | tr -d ' ' | |
| } | |
| get_orphaned_dirs() { | |
| local source="$1" | |
| local dest="$2" | |
| local orphans=() | |
| if [[ ! -d "$dest" ]]; then | |
| return | |
| fi | |
| shopt -s nullglob | |
| for dest_dir in "$dest"/*/; do | |
| dir_name=$(basename "$dest_dir") | |
| if [[ ! -d "$source/$dir_name" ]]; then | |
| orphans+=("$dir_name") | |
| fi | |
| done | |
| shopt -u nullglob | |
| printf '%s\n' "${orphans[@]}" | |
| } | |
| prompt_deploy_mode() { | |
| local codex_orphans claude_orphans | |
| codex_orphans=$(get_orphaned_dirs "$codex_union" "$codex_dest") | |
| claude_orphans=$(get_orphaned_dirs "$claude_union" "$claude_skills_dest") | |
| if [[ -z "$codex_orphans" && -z "$claude_orphans" ]]; then | |
| deploy_mode="replace" | |
| return | |
| fi | |
| printf "\n${color_yellow}${color_bold} ⚠ Existing skills detected${color_reset}\n" | |
| printf "${color_dim} The following skills exist at the destination but not in sources:${color_reset}\n\n" | |
| if [[ -n "$codex_orphans" ]]; then | |
| printf " ${color_bold}Codex:${color_reset}\n" | |
| while IFS= read -r orphan; do | |
| [[ -n "$orphan" ]] && printf " ${color_dim}•${color_reset} %s\n" "$orphan" | |
| done <<< "$codex_orphans" | |
| echo | |
| fi | |
| if [[ -n "$claude_orphans" ]]; then | |
| printf " ${color_bold}Claude:${color_reset}\n" | |
| while IFS= read -r orphan; do | |
| [[ -n "$orphan" ]] && printf " ${color_dim}•${color_reset} %s\n" "$orphan" | |
| done <<< "$claude_orphans" | |
| echo | |
| fi | |
| printf " ${color_cyan}[R]${color_reset} Replace all ${color_dim}(remove unlisted skills)${color_reset}\n" | |
| printf " ${color_cyan}[M]${color_reset} Merge ${color_dim}(keep existing, add new)${color_reset}\n" | |
| printf " ${color_cyan}[C]${color_reset} Cancel\n\n" | |
| while true; do | |
| printf " Choice ${color_dim}[R/M/C]${color_reset}: " | |
| read -r choice | |
| case "${choice,,}" in | |
| r|replace) | |
| deploy_mode="replace" | |
| break | |
| ;; | |
| m|merge) | |
| deploy_mode="merge" | |
| break | |
| ;; | |
| c|cancel) | |
| printf "\n${color_yellow} ✗ Cancelled${color_reset}\n\n" | |
| exit 0 | |
| ;; | |
| *) | |
| printf " ${color_yellow}Invalid choice. Please enter R, M, or C.${color_reset}\n" | |
| ;; | |
| esac | |
| done | |
| } | |
| sync_skill_dirs() { | |
| local skills_path="$1" | |
| local dest="$2" | |
| shopt -s nullglob | |
| local dirs=("$skills_path"/*/) | |
| shopt -u nullglob | |
| for dir in "${dirs[@]}"; do | |
| if [[ -f "${dir}SKILL.md" ]]; then | |
| rsync -a --exclude ".git" "${dir%/}" "$dest/" | |
| fi | |
| done | |
| } | |
| print_header "Skills Updater" | |
| log_section "📥" "Fetching Updates" | |
| required_tools=(git rsync jq claude) | |
| missing_tools=() | |
| for tool in "${required_tools[@]}"; do | |
| if ! command -v "$tool" >/dev/null 2>&1; then | |
| missing_tools+=("$tool") | |
| fi | |
| done | |
| if ((${#missing_tools[@]} > 0)); then | |
| printf " ${color_yellow}⚠${color_reset} Missing required tools: %s\n" "${missing_tools[*]}" >&2 | |
| exit 1 | |
| fi | |
| for source_dir in "${source_dirs[@]}"; do | |
| if [[ ! -d "$source_dir" ]]; then | |
| printf " ${color_yellow}⚠${color_reset} Missing: %s ${color_dim}(skipping)${color_reset}\n" "$source_dir" >&2 | |
| continue | |
| fi | |
| repo_root="$(git -C "$source_dir" rev-parse --show-toplevel 2>/dev/null || true)" | |
| if [[ -z "$repo_root" || ! -d "$repo_root/.git" ]]; then | |
| printf " ${color_yellow}⚠${color_reset} No git repo: %s\n" "$source_dir" >&2 | |
| exit 1 | |
| fi | |
| repo_name=$(basename "$repo_root") | |
| log_step "$repo_name" | |
| output=$(git -C "$repo_root" pull --ff-only 2>&1) | |
| if [[ "$output" == *"Already up to date"* ]]; then | |
| printf "\r ${color_green}✔${color_reset} %s ${color_dim}(up to date)${color_reset}\n" "$repo_name" | |
| else | |
| printf "\r ${color_green}✔${color_reset} %s ${color_green}(updated)${color_reset}\n" "$repo_name" | |
| fi | |
| done | |
| log_section "📦" "Installing Skills" | |
| for source_dir in "${source_dirs[@]}"; do | |
| dir_name=$(basename "$source_dir") | |
| if [[ ! -d "$source_dir" ]]; then | |
| printf "\n ${color_yellow}⚠${color_reset} Missing: %s ${color_dim}(skipping)${color_reset}\n" "$source_dir" | |
| continue | |
| fi | |
| if is_marketplace "$source_dir"; then | |
| printf "\n ${color_cyan}▸${color_reset} ${color_bold}%s${color_reset} ${color_dim}(plugin source)${color_reset}\n" "$dir_name" | |
| while IFS= read -r plugin_name; do | |
| log_step "Installing $plugin_name" | |
| claude plugin install --plugin-dir="$source_dir" "$plugin_name" > /dev/null 2>&1 | |
| log_ok "Plugin → Claude ${color_dim}($plugin_name)${color_reset}" | |
| total_plugins=$((total_plugins + 1)) | |
| plugin_source=$(jq -r --arg name "$plugin_name" '.plugins[] | select(.name == $name) | .source' "$source_dir/.claude-plugin/marketplace.json") | |
| plugin_path="$source_dir/$plugin_source" | |
| if [[ -d "$plugin_path/skills" ]]; then | |
| skill_count=$(find "$plugin_path/skills" -mindepth 1 -maxdepth 1 -type d -exec test -f '{}/SKILL.md' \; -print 2>/dev/null | wc -l | tr -d ' ') | |
| sync_skill_dirs "$plugin_path/skills" "$codex_union" | |
| [[ "$skill_count" -gt 0 ]] && printf " ${color_green}✔${color_reset} Skills → Codex ${color_dim}(%s)${color_reset}\n" "$skill_count" | |
| fi | |
| if [[ -d "$plugin_path/agents" ]]; then | |
| for agent_file in "$plugin_path/agents"/*.md; do | |
| [[ -f "$agent_file" ]] || continue | |
| agent_name=$(basename "$agent_file" .md) | |
| [[ "$agent_name" == "README" ]] && continue | |
| mkdir -p "$codex_union/$agent_name" | |
| sed -n '/^---$/,$p' "$agent_file" > "$codex_union/$agent_name/SKILL.md" | |
| printf " ${color_green}✔${color_reset} Agent → Codex ${color_dim}(%s)${color_reset}\n" "$agent_name" | |
| done | |
| fi | |
| done < <(jq -r '.plugins[].name' "$source_dir/.claude-plugin/marketplace.json") | |
| else | |
| skill_count=$(find "$source_dir" -mindepth 1 -maxdepth 1 -type d -exec test -f '{}/SKILL.md' \; -print 2>/dev/null | wc -l | tr -d ' ') | |
| printf "\n ${color_cyan}▸${color_reset} ${color_bold}%s${color_reset} ${color_dim}(%s skills)${color_reset}\n" "$dir_name" "$skill_count" | |
| sync_skill_dirs "$source_dir" "$codex_union" | |
| printf " ${color_green}✔${color_reset} Skills → Codex\n" | |
| sync_skill_dirs "$source_dir" "$claude_union" | |
| printf " ${color_green}✔${color_reset} Skills → Claude\n" | |
| fi | |
| done | |
| deploy_mode="" | |
| prompt_deploy_mode | |
| log_section "🚀" "Deploying" | |
| deploy_to() { | |
| local src="$1" dest="$2" name="$3" | |
| local rsync_opts=(-a) | |
| [[ "$deploy_mode" == "replace" ]] && rsync_opts+=(--delete) | |
| mkdir -p "$dest" | |
| rsync "${rsync_opts[@]}" "$src/" "$dest/" | |
| printf " ${color_green}✔${color_reset} %s: %s ${color_dim}(%s)${color_reset}\n" "$name" "$dest" "$deploy_mode" | |
| } | |
| deploy_to "$codex_union" "$codex_dest" "Codex" | |
| deploy_to "$claude_union" "$claude_skills_dest" "Claude" | |
| codex_count=$(count_dirs "$codex_union") | |
| claude_count=$(count_dirs "$claude_union") | |
| printf "\n${color_green}${color_bold} ✓ All done!${color_reset}\n" | |
| printf "${color_dim} Codex: %s skills │ Claude: %s skills, %s plugins${color_reset}\n\n" "$codex_count" "$claude_count" "$total_plugins" |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Skills Updater
Keep Codex and Claude Code skills in sync with multiple sources. The script pulls your sources, builds clean unified skill sets, installs Claude plugins when needed, and makes plugin agents available to Codex as skills.
What it does
Strategies
Configure your sources
Edit the source list at the top of the script:
Usage