#!/usr/bin/env bash # Install a language ruleset into a target project. # Usage: install-lang.sh [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 [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."