#!/usr/bin/env bash # Per-project bundle freshness check, designed to run at startup. # # Covers two kinds of bundle copied into a project's .claude/: # - LANGUAGE bundles (languages//) — generic rules + language rules + # hooks + githooks, with settings.json surfaced (not auto-fixed). # - TEAM overlays (teams//) — only the overlay's own rule file(s); # no generic rules, hooks, githooks, or settings. # # Detection is by fingerprint (no marker file): a project "has" a bundle iff # one of that bundle's own rule files is present in the project's # .claude/rules/. For each detected bundle it reconciles against the canonical # rulesets source: # - AUTO-FIX (rulesets-owned, safe to overwrite): .claude/rules/*.md, and # for language bundles also .claude/hooks/* and githooks/* # - SURFACE (project may customize — reported, never written): settings.json # CLAUDE.md is intentionally not tracked: it is seed-only in install-lang # and project-owned afterward (diff-lang skips it for the same reason). # # Usage: sync-language-bundle.sh [project-path] (default: $PWD) # # Exit: 0 no bundle, or clean / rules+hooks auto-fixed (resolved) # 3 manual action recommended (settings.json drift) # 1 usage / path error # # Quiet when there is nothing to report. Resolves the canonical source # relative to its own location, so it always reads the current checkout. set -u PROJECT="${1:-$PWD}" REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" LANG_ROOT="$REPO_ROOT/languages" TEAM_ROOT="$REPO_ROOT/teams" GENERIC_RULES="$REPO_ROOT/claude-rules" if [ ! -d "$PROJECT" ]; then echo "ERROR: project path does not exist: $PROJECT" >&2 exit 1 fi PROJECT="$(cd "$PROJECT" && pwd)" OUT="" # buffered report, printed once at the end FIXED=0 MANUAL=0 HEADER_DONE=0 CUR_HEADER="" ensure_header() { if [ "$HEADER_DONE" -eq 0 ]; then OUT+="$CUR_HEADER"$'\n' HEADER_DONE=1 fi } # fix SRC DST [exec] — overwrite DST from SRC if missing or differing. fix() { local src="$1" dst="$2" want_exec="${3:-}" [ -f "$src" ] || return 0 if [ ! -f "$dst" ] || ! diff -q "$src" "$dst" >/dev/null 2>&1; then mkdir -p "$(dirname "$dst")" cp "$src" "$dst" [ "$want_exec" = exec ] && chmod +x "$dst" ensure_header OUT+=" fixed ${dst#"$PROJECT"/}"$'\n' FIXED=$((FIXED + 1)) fi } # surface SRC DST — report DST drift without writing. surface() { local src="$1" dst="$2" reason="" [ -f "$src" ] || return 0 if [ ! -f "$dst" ]; then reason="missing" elif ! diff -q "$src" "$dst" >/dev/null 2>&1; then reason="differs" else return 0 fi ensure_header OUT+=" drift ${dst#"$PROJECT"/} ($reason — not auto-fixed)"$'\n' MANUAL=$((MANUAL + 1)) } # inbox_drop SRC BASENAME — offer a project-owned file via the .ai/ inbox. # Only acts when the project uses the .ai/ inbox convention (so a bundle target # without .ai/ is never given an empty one). No-op once the project has already # adopted the file at its root or has a copy waiting in the inbox. inbox_drop() { local src="$1" base="$2" inbox="$PROJECT/.ai/inbox" [ -f "$src" ] || return 0 [ -d "$inbox" ] || return 0 [ -f "$PROJECT/$base" ] && return 0 # already adopted at project root ls "$inbox"/*"$base" >/dev/null 2>&1 && return 0 # already waiting in inbox cp "$src" "$inbox/from-rulesets-$base" ensure_header OUT+=" inbox .ai/inbox/from-rulesets-$base (project-owned — adopt deliberately)"$'\n' FIXED=$((FIXED + 1)) } # process_bundle KIND SRC_DIR — reconcile one bundle (KIND is language|team). # A team overlay owns only its own rule files; a language bundle also owns the # shared generic rules, its hooks/githooks, and surfaces settings.json. process_bundle() { local kind="$1" src="${2%/}" local name rules rf f rel manual_before name="$(basename "$src")" rules="$src/claude/rules" [ -d "$rules" ] || return 0 # Fingerprint: project has this bundle iff one of its own rule files is # present in .claude/rules/. local detected=0 for rf in "$rules"/*.md; do [ -f "$rf" ] || continue if [ -f "$PROJECT/.claude/rules/$(basename "$rf")" ]; then detected=1 break fi done [ "$detected" -eq 1 ] || return 0 CUR_HEADER="$kind bundle '$name' — $PROJECT:" HEADER_DONE=0 manual_before=$MANUAL # AUTO-FIX: language bundles carry the shared generic rules; team overlays # carry only their own rule(s). if [ "$kind" = language ]; then for f in "$GENERIC_RULES"/*.md; do [ -f "$f" ] || continue fix "$f" "$PROJECT/.claude/rules/$(basename "$f")" done fi for f in "$rules"/*.md; do [ -f "$f" ] || continue fix "$f" "$PROJECT/.claude/rules/$(basename "$f")" done # Language bundles also own hooks, githooks, and settings; teams don't. if [ "$kind" = language ]; then if [ -d "$src/claude/hooks" ]; then while IFS= read -r f; do rel="${f#"$src"/claude/}" fix "$f" "$PROJECT/.claude/$rel" exec done < <(find "$src/claude/hooks" -type f) fi if [ -d "$src/claude/scripts" ]; then while IFS= read -r f; do rel="${f#"$src"/claude/}" fix "$f" "$PROJECT/.claude/$rel" done < <(find "$src/claude/scripts" -type f) fi if [ -d "$src/githooks" ]; then while IFS= read -r f; do rel="${f#"$src"/githooks/}" fix "$f" "$PROJECT/githooks/$rel" exec done < <(find "$src/githooks" -type f) fi surface "$src/claude/settings.json" "$PROJECT/.claude/settings.json" # The Makefile fragment is project-owned: never auto-fix it, never edit the # project Makefile. If the project uses the .ai/ inbox convention and hasn't # already adopted the fragment, drop a copy there for deliberate adoption. inbox_drop "$src/coverage-makefile.txt" "coverage-makefile.txt" fi if [ "$MANUAL" -gt "$manual_before" ]; then if [ "$kind" = team ]; then OUT+=" → reconcile: make install-team TEAM=$name PROJECT=."$'\n' else OUT+=" → reconcile: make install-$name PROJECT=."$'\n' fi fi } if [ -d "$LANG_ROOT" ]; then for d in "$LANG_ROOT"/*/; do [ -d "$d" ] && process_bundle language "$d" done fi if [ -d "$TEAM_ROOT" ]; then for d in "$TEAM_ROOT"/*/; do [ -d "$d" ] && process_bundle team "$d" done fi [ -n "$OUT" ] && printf '%s' "$OUT" [ "$MANUAL" -gt 0 ] && exit 3 exit 0