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
199
|
#!/usr/bin/env bash
# Install a language ruleset into a target project.
# Usage: install-lang.sh <language> <project-path> [force]
#
# Copies the language's ruleset files into the project. Re-runnable
# (authoritative source overwrites). CLAUDE.md is preserved unless
# force=1, to avoid trampling project-specific customizations.
set -euo pipefail
LANG="${1:-}"
PROJECT="${2:-}"
FORCE="${3:-}"
if [ -z "$LANG" ] || [ -z "$PROJECT" ]; then
echo "Usage: $0 <language> <project-path> [force]" >&2
exit 1
fi
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SRC="$REPO_ROOT/languages/$LANG"
if [ ! -d "$SRC" ]; then
echo "ERROR: no ruleset for language '$LANG' (expected $SRC)" >&2
exit 1
fi
if [ ! -d "$PROJECT" ]; then
echo "ERROR: project path does not exist: $PROJECT" >&2
exit 1
fi
# Resolve to absolute path
PROJECT="$(cd "$PROJECT" && pwd)"
# 0. Cross-bundle collision guard.
#
# Several bundles ship a file at the same path. gitignore-add.txt is appended
# and deduped, and CLAUDE.md is seed-only, so both compose across bundles. Three
# do not: claude/settings.json and githooks/* are installed with `cp -rT`
# (always overwrite), and coverage-makefile.txt is seeded under a fixed name. So
# installing a second bundle replaced the first's hook wiring and pre-commit
# while printing [ok], so a project could lose its paren check or secret scan
# and read the output as success. Refuse instead, naming what would go.
#
# Detection is by rule fingerprint, matching sync-language-bundle.sh: a project
# has bundle X iff one of X's own rule files is in .claude/rules/. No marker
# file, so this works on installs that predate the guard.
project_has_bundle() {
local b="$1" rf
for rf in "$REPO_ROOT/languages/$b/claude/rules"/*.md; do
[ -f "$rf" ] || continue
[ -f "$PROJECT/.claude/rules/$(basename "$rf")" ] && return 0
done
return 1
}
# Files $1's bundle and $LANG both ship, and that install would overwrite.
shared_overwritten_files() {
local other="$1" rel
for rel in claude/settings.json coverage-makefile.txt; do
[ -f "$SRC/$rel" ] && [ -f "$REPO_ROOT/languages/$other/$rel" ] \
&& echo " $(printf '%s' "$rel" | sed 's|^claude/|.claude/|')"
done
if [ -d "$SRC/githooks" ] && [ -d "$REPO_ROOT/languages/$other/githooks" ]; then
for rel in "$SRC/githooks"/*; do
[ -f "$rel" ] || continue
[ -f "$REPO_ROOT/languages/$other/githooks/$(basename "$rel")" ] \
&& echo " githooks/$(basename "$rel")"
done
fi
}
if [ "$FORCE" != "1" ]; then
collisions=""
for other_dir in "$REPO_ROOT/languages"/*/; do
other="$(basename "$other_dir")"
[ "$other" = "$LANG" ] && continue
[ -d "$other_dir/claude/rules" ] || continue
project_has_bundle "$other" || continue
files="$(shared_overwritten_files "$other")"
[ -n "$files" ] && collisions="${collisions}The '$other' bundle is already installed here. Installing '$LANG' would replace:
${files}
"
done
if [ -n "$collisions" ]; then
{
echo "ERROR: bundle collision. Refusing to install '$LANG' into $PROJECT"
echo
printf '%s' "$collisions"
echo "Both bundles ship these files, and installing overwrites rather than merges,"
echo "so the bundle already here would silently lose them."
echo
echo "Whether a project can carry two bundles at once is an open question."
echo "Until it's settled, install one bundle per project."
echo
echo "To override: re-run with FORCE=1. That also re-seeds CLAUDE.md from the"
echo "bundle template, which overwrites any project-specific edits to it."
} >&2
exit 1
fi
fi
echo "Installing '$LANG' ruleset into $PROJECT"
# 1. Generic rules from claude-rules/ (shared across all languages)
if [ -d "$REPO_ROOT/claude-rules" ]; then
mkdir -p "$PROJECT/.claude/rules"
cp "$REPO_ROOT/claude-rules"/*.md "$PROJECT/.claude/rules/" 2>/dev/null || true
count=$(ls -1 "$REPO_ROOT/claude-rules"/*.md 2>/dev/null | wc -l)
echo " [ok] .claude/rules/ — $count generic rule(s) from claude-rules/"
fi
# 2. .claude/ — language-specific rules, hooks, settings (authoritative, always overwrite)
if [ -d "$SRC/claude" ]; then
mkdir -p "$PROJECT/.claude"
cp -rT "$SRC/claude" "$PROJECT/.claude"
if [ -d "$PROJECT/.claude/hooks" ]; then
find "$PROJECT/.claude/hooks" -type f -name '*.sh' -exec chmod +x {} \;
fi
echo " [ok] .claude/ — language-specific content"
fi
# 2. githooks/ — pre-commit etc.
if [ -d "$SRC/githooks" ]; then
mkdir -p "$PROJECT/githooks"
cp -rT "$SRC/githooks" "$PROJECT/githooks"
find "$PROJECT/githooks" -type f -exec chmod +x {} \;
if [ -d "$PROJECT/.git" ]; then
git -C "$PROJECT" config core.hooksPath githooks
echo " [ok] githooks/ installed, core.hooksPath=githooks"
else
echo " [ok] githooks/ installed (not a git repo — skipped core.hooksPath)"
fi
fi
# 3. CLAUDE.md — seed on first install, don't overwrite unless FORCE=1.
# Prefer the bundle's own template; fall back to the language-neutral
# default so a bundle that ships none still seeds an accurate, non-
# mislabeling header instead of nothing. The default names no language,
# so multi-bundle and wrong-bundle installs don't inherit a false header.
CLAUDE_SRC=""
CLAUDE_KIND=""
if [ -f "$SRC/CLAUDE.md" ]; then
CLAUDE_SRC="$SRC/CLAUDE.md"
CLAUDE_KIND="$LANG"
elif [ -f "$REPO_ROOT/languages/default-CLAUDE.md" ]; then
CLAUDE_SRC="$REPO_ROOT/languages/default-CLAUDE.md"
CLAUDE_KIND="language-neutral default"
fi
if [ -n "$CLAUDE_SRC" ]; then
if [ -f "$PROJECT/CLAUDE.md" ] && [ "$FORCE" != "1" ]; then
echo " [skip] CLAUDE.md already exists (use FORCE=1 to overwrite)"
else
cp "$CLAUDE_SRC" "$PROJECT/CLAUDE.md"
echo " [ok] CLAUDE.md installed ($CLAUDE_KIND)"
fi
fi
# 3b. coverage-makefile.txt — project-owned Makefile fragment, seed on first
# install. Never overwrites (the project edits its own copy) unless FORCE=1.
if [ -f "$SRC/coverage-makefile.txt" ]; then
if [ -f "$PROJECT/coverage-makefile.txt" ] && [ "$FORCE" != "1" ]; then
echo " [skip] coverage-makefile.txt already exists (use FORCE=1 to overwrite)"
else
cp "$SRC/coverage-makefile.txt" "$PROJECT/coverage-makefile.txt"
echo " [ok] coverage-makefile.txt installed (copy its targets into your Makefile)"
fi
fi
# 4. .gitignore — append missing lines (deduped, skip comments)
if [ -f "$SRC/gitignore-add.txt" ]; then
touch "$PROJECT/.gitignore"
header="# --- $LANG ruleset ---"
added=0
while IFS= read -r line || [ -n "$line" ]; do
# Skip blank lines and comments in the source file
[ -z "$line" ] && continue
case "$line" in \#*) continue ;; esac
# Only add if not already present
if ! grep -qxF "$line" "$PROJECT/.gitignore"; then
# Prepend header only if it isn't already in the file
if [ "$added" -eq 0 ] && ! grep -qxF "$header" "$PROJECT/.gitignore"; then
printf '\n%s\n' "$header" >> "$PROJECT/.gitignore"
fi
echo "$line" >> "$PROJECT/.gitignore"
added=$((added + 1))
fi
done < "$SRC/gitignore-add.txt"
if [ "$added" -gt 0 ]; then
echo " [ok] .gitignore: $added line(s) added"
else
echo " [skip] .gitignore entries already present"
fi
fi
echo ""
echo "Install complete."
|