Created
March 6, 2026 20:30
-
-
Save jbn/1dea43d292c3cbc300f298a1d7e8ba6d to your computer and use it in GitHub Desktop.
Keep .agents/skills/ and .claude/skills/ in mirror sync
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 | |
| # ============================================================================= | |
| # skills_sync.sh — Keep .agents/skills/ and .claude/skills/ in mirror sync | |
| # ============================================================================= | |
| # | |
| # THE PROBLEM | |
| # ----------- | |
| # The Agent Skills open standard (SKILL.md) is supported by multiple coding | |
| # agents, but each tool looks for skills in a different directory: | |
| # | |
| # .agents/skills/ — Read natively by Pi and OpenAI Codex CLI | |
| # .claude/skills/ — Read natively by Claude Code | |
| # | |
| # Both directories must contain identical skill folders. If they drift apart, | |
| # one tool silently loses access to skills the other can see. | |
| # | |
| # HOW THIS SCRIPT WORKS | |
| # --------------------- | |
| # It has two modes: | |
| # | |
| # 1. CHECK MODE (default, used as a pre-commit hook): | |
| # Compares the two directories. If they differ, the commit is blocked | |
| # with instructions on how to fix it. | |
| # | |
| # 2. FIX MODE (--fix): | |
| # Looks at what's staged in git to determine which side was edited, | |
| # then mirrors those changes to the other side and stages the result. | |
| # | |
| # - If only .agents/skills/ has staged changes → copies to .claude/skills/ | |
| # - If only .claude/skills/ has staged changes → copies to .agents/skills/ | |
| # - If BOTH have staged changes with conflicting content → aborts with | |
| # an error; you must resolve manually. | |
| # - If neither has staged changes but dirs differ → copies from | |
| # .agents/skills/ (treated as canonical) to .claude/skills/ | |
| # | |
| # INSTALLATION AS A GIT HOOK | |
| # -------------------------- | |
| # cp skills_sync.sh .git/hooks/pre-commit | |
| # chmod +x .git/hooks/pre-commit | |
| # | |
| # Or, if you use a hooks manager (husky, lefthook, etc.), call this script | |
| # from your pre-commit configuration. | |
| # | |
| # IF THE HOOK FAILS | |
| # ----------------- | |
| # Run: | |
| # ./skills_sync.sh --fix | |
| # | |
| # Then re-stage and commit. If --fix itself fails (e.g., both sides were | |
| # edited with conflicting content), ask your LLM to read this script for | |
| # context, then describe what you changed. It will understand the problem | |
| # and can help you resolve it. | |
| # | |
| # FOR LLMs READING THIS SCRIPT | |
| # ---------------------------- | |
| # The user maintains skills in two directories that must be mirrors of each | |
| # other. .agents/skills/ is the canonical source for Pi and Codex. .claude/ | |
| # skills/ is the mirror for Claude Code. The directories must be byte-for- | |
| # byte identical in their contents. When helping the user fix sync issues: | |
| # 1. Determine which directory has the "intended" version of each skill | |
| # 2. Copy that version to the other directory | |
| # 3. Stage both paths with `git add` | |
| # 4. The user can then commit normally | |
| # ============================================================================= | |
| set -euo pipefail | |
| AGENTS_DIR=".agents/skills" | |
| CLAUDE_DIR=".claude/skills" | |
| # --- Colors (suppressed if not a terminal) ----------------------------------- | |
| if [ -t 1 ]; then | |
| RED='\033[0;31m' | |
| GREEN='\033[0;32m' | |
| YELLOW='\033[0;33m' | |
| BOLD='\033[1m' | |
| RESET='\033[0m' | |
| else | |
| RED='' GREEN='' YELLOW='' BOLD='' RESET='' | |
| fi | |
| # --- Helpers ------------------------------------------------------------------ | |
| die() { echo -e "${RED}error:${RESET} $*" >&2; exit 1; } | |
| info() { echo -e "${GREEN}✓${RESET} $*"; } | |
| warn() { echo -e "${YELLOW}⚠${RESET} $*"; } | |
| # Find the repo root so this works from any subdirectory | |
| REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" \ | |
| || die "Not inside a git repository." | |
| cd "$REPO_ROOT" | |
| # --- Directory existence ------------------------------------------------------ | |
| ensure_dirs_exist() { | |
| # If neither directory exists, there's nothing to sync. | |
| if [ ! -d "$AGENTS_DIR" ] && [ ! -d "$CLAUDE_DIR" ]; then | |
| exit 0 | |
| fi | |
| # If only one exists, that's a sync problem (unless it's empty). | |
| if [ -d "$AGENTS_DIR" ] && [ ! -d "$CLAUDE_DIR" ]; then | |
| if [ -z "$(ls -A "$AGENTS_DIR" 2>/dev/null)" ]; then | |
| exit 0 # both effectively empty | |
| fi | |
| return 1 # agents exists with content, claude missing | |
| fi | |
| if [ ! -d "$AGENTS_DIR" ] && [ -d "$CLAUDE_DIR" ]; then | |
| if [ -z "$(ls -A "$CLAUDE_DIR" 2>/dev/null)" ]; then | |
| exit 0 | |
| fi | |
| return 1 | |
| fi | |
| return 0 | |
| } | |
| # --- Diff logic --------------------------------------------------------------- | |
| # Returns 0 if directories are in sync, 1 if they differ. | |
| # Uses diff on directory contents, ignoring .git metadata or OS junk. | |
| dirs_are_in_sync() { | |
| diff -rq \ | |
| --exclude='.DS_Store' \ | |
| --exclude='Thumbs.db' \ | |
| "$AGENTS_DIR" "$CLAUDE_DIR" \ | |
| >/dev/null 2>&1 | |
| } | |
| # Produce a human-readable summary of differences | |
| diff_summary() { | |
| diff -rq \ | |
| --exclude='.DS_Store' \ | |
| --exclude='Thumbs.db' \ | |
| "$AGENTS_DIR" "$CLAUDE_DIR" \ | |
| 2>/dev/null \ | |
| | head -20 \ | |
| | sed "s|$REPO_ROOT/||g" | |
| } | |
| # --- Staged-changes detection ------------------------------------------------- | |
| # Returns 0 if there are staged changes under the given path | |
| has_staged_changes() { | |
| local path="$1" | |
| git diff --cached --name-only -- "$path" 2>/dev/null | grep -q . | |
| } | |
| # Returns 0 if staged content under $path differs from HEAD. | |
| # This distinguishes "user edited this side" from "this side was only | |
| # staged by a previous --fix mirror." | |
| staged_differs_from_head() { | |
| local path="$1" | |
| # If there's no HEAD yet (initial commit), any staged content counts | |
| if ! git rev-parse HEAD >/dev/null 2>&1; then | |
| has_staged_changes "$path" | |
| return $? | |
| fi | |
| git diff --cached --name-only HEAD -- "$path" 2>/dev/null | grep -q . | |
| } | |
| # --- Fix logic ---------------------------------------------------------------- | |
| mirror_dir() { | |
| local src="$1" | |
| local dst="$2" | |
| info "Mirroring ${BOLD}$src${RESET} → ${BOLD}$dst${RESET}" | |
| # Wipe destination and do a clean copy to ensure exact mirror. | |
| # This is simpler and more portable than rsync. | |
| rm -rf "$dst" | |
| mkdir -p "$dst" | |
| if [ -n "$(ls -A "$src" 2>/dev/null)" ]; then | |
| cp -a "$src/." "$dst/" | |
| fi | |
| # Clean OS junk from the copy | |
| find "$dst" \( -name '.DS_Store' -o -name 'Thumbs.db' \) -delete 2>/dev/null || true | |
| git add "$dst" | |
| info "Staged $dst" | |
| } | |
| mirror_to_claude() { mirror_dir "$AGENTS_DIR" "$CLAUDE_DIR"; } | |
| mirror_to_agents() { mirror_dir "$CLAUDE_DIR" "$AGENTS_DIR"; } | |
| do_fix() { | |
| if ! ensure_dirs_exist; then | |
| # One directory exists, the other doesn't | |
| if [ -d "$AGENTS_DIR" ]; then | |
| mirror_to_claude | |
| else | |
| mirror_to_agents | |
| fi | |
| info "Fix complete." | |
| return 0 | |
| fi | |
| # Both directories exist — check if already in sync | |
| if dirs_are_in_sync; then | |
| info "Already in sync. Nothing to fix." | |
| return 0 | |
| fi | |
| local agents_changed=false | |
| local claude_changed=false | |
| staged_differs_from_head "$AGENTS_DIR" && agents_changed=true | |
| staged_differs_from_head "$CLAUDE_DIR" && claude_changed=true | |
| if $agents_changed && $claude_changed; then | |
| # Both sides have real edits relative to last commit. | |
| if dirs_are_in_sync; then | |
| info "Both sides were edited but content already matches. Nothing to fix." | |
| return 0 | |
| fi | |
| echo "" | |
| die "Both ${BOLD}$AGENTS_DIR${RESET} and ${BOLD}$CLAUDE_DIR${RESET} have been edited with different content. | |
| Cannot auto-fix: unclear which side is authoritative. | |
| To resolve manually: | |
| 1. Decide which directory has the correct version of each skill | |
| 2. Copy the correct version to the other directory | |
| 3. git add both directories | |
| 4. Commit normally | |
| Or ask your LLM to read this script (skills_sync.sh) for context, | |
| then describe what you changed. It will understand the problem | |
| and can help you resolve it." | |
| fi | |
| if $agents_changed; then | |
| mirror_to_claude | |
| elif $claude_changed; then | |
| mirror_to_agents | |
| else | |
| # Neither side differs from HEAD, but dirs differ. | |
| # This happens on initial setup or working-tree drift. | |
| # Default to .agents/skills/ as canonical. | |
| warn "No staged skill edits detected, but directories differ." | |
| warn "Defaulting to ${BOLD}$AGENTS_DIR${RESET} as canonical source." | |
| mirror_to_claude | |
| fi | |
| info "Fix complete. Review staged changes, then commit." | |
| } | |
| # --- Check logic (pre-commit hook) -------------------------------------------- | |
| do_check() { | |
| if ! ensure_dirs_exist; then | |
| echo "" | |
| echo -e "${RED}${BOLD}Skills directories are out of sync.${RESET}" | |
| echo "" | |
| if [ -d "$AGENTS_DIR" ]; then | |
| echo " $AGENTS_DIR exists but $CLAUDE_DIR is missing." | |
| else | |
| echo " $CLAUDE_DIR exists but $AGENTS_DIR is missing." | |
| fi | |
| echo "" | |
| echo -e "Run ${BOLD}./skills_sync.sh --fix${RESET} to mirror, then re-commit." | |
| echo "" | |
| echo "If that doesn't work, ask your LLM to read skills_sync.sh for context." | |
| exit 1 | |
| fi | |
| # If neither directory exists, nothing to check | |
| if [ ! -d "$AGENTS_DIR" ] && [ ! -d "$CLAUDE_DIR" ]; then | |
| exit 0 | |
| fi | |
| # Only check if skills are part of this commit | |
| local has_skill_changes=false | |
| has_staged_changes "$AGENTS_DIR" && has_skill_changes=true | |
| has_staged_changes "$CLAUDE_DIR" && has_skill_changes=true | |
| if ! $has_skill_changes; then | |
| # No skill files are being committed — skip the check. | |
| # (We don't want to block unrelated commits if dirs drifted in the | |
| # working tree but the user isn't touching skills right now.) | |
| exit 0 | |
| fi | |
| if dirs_are_in_sync; then | |
| exit 0 | |
| fi | |
| echo "" | |
| echo -e "${RED}${BOLD}Skills directories are out of sync.${RESET}" | |
| echo "" | |
| echo "You have staged changes to skills, but the following differences exist" | |
| echo "between $AGENTS_DIR and $CLAUDE_DIR:" | |
| echo "" | |
| diff_summary | while IFS= read -r line; do | |
| echo " $line" | |
| done | |
| echo "" | |
| echo -e "Run ${BOLD}./skills_sync.sh --fix${RESET} to auto-mirror, then re-commit." | |
| echo "" | |
| echo "If --fix fails (e.g., conflicting edits in both directories), ask your" | |
| echo "LLM to read skills_sync.sh for context. It has instructions for resolving" | |
| echo "the sync manually." | |
| echo "" | |
| exit 1 | |
| } | |
| # --- Main --------------------------------------------------------------------- | |
| case "${1:-}" in | |
| --fix) | |
| do_fix | |
| ;; | |
| --help|-h) | |
| # Print the header comment as help | |
| sed -n '2,/^# ===.*===$/{/^# ===.*===$/!s/^# \?//p}' "$0" | |
| ;; | |
| *) | |
| do_check | |
| ;; | |
| esac |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment