aboutsummaryrefslogtreecommitdiff
path: root/scripts/sync-language-bundle.sh
blob: 45f82592d6dd1eb9d0dc15d996de16df475f6901 (plain)
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
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
#!/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))
}

# 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.
    # The documented adoption path (install-lang: "copy its targets into your
    # Makefile") leaves no fragment at root, so a project Makefile that already
    # defines the distinctive coverage-summary target counts as adopted —
    # otherwise the drop gets re-suggested every run after a deliberate
    # copy-and-delete.
    if ! grep -qE '^coverage-summary:' "$PROJECT/Makefile" 2>/dev/null; then
      inbox_drop "$src/coverage-makefile.txt" "coverage-makefile.txt"
    fi
  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