Skip to content

Instantly share code, notes, and snippets.

@jbn
Created March 6, 2026 20:30
Show Gist options
  • Select an option

  • Save jbn/1dea43d292c3cbc300f298a1d7e8ba6d to your computer and use it in GitHub Desktop.

Select an option

Save jbn/1dea43d292c3cbc300f298a1d7e8ba6d to your computer and use it in GitHub Desktop.
Keep .agents/skills/ and .claude/skills/ in mirror sync
#!/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