1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
|
#!/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/<lang>/) — generic rules + language rules +
# hooks + githooks, with settings.json surfaced (not auto-fixed).
# - TEAM overlays (teams/<team>/) — 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))
}
# 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/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"
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
|