-
-
Save MaTriXy/c7c6f6bb15fb16c98265ea76e3b4d29e 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
Pulls the latest changes from each configured source repo.
Builds a Codex-ready skill set and a Claude-ready skill set.
Installs Claude plugins directly instead of copying their skills into Claude.
Extracts plugin skills and agents, converts agents into Codex skills, and copies them into Codex.
Strategies
Union build: sources are merged into temporary “union” directories before deploying to Codex and Claude. This avoids partial updates.
Plugin-aware flow: plugin repos are treated differently from plain skill repos, so Claude stays plugin-first while Codex can still use plugin content.
Deploy modes: if destination skills don’t exist in sources, you can choose merge (keep extras) or replace (remove unlisted).
Configure your sources
Edit the source list at the top of the script:
source_dirs=(
"/path/to/your/skills-repo"
"/path/to/your/claude-plugin-repo"
)
Usage
./update_skills.sh