diff options
Diffstat (limited to 'scripts')
| -rwxr-xr-x | scripts/diff-lang.sh | 80 | ||||
| -rwxr-xr-x | scripts/doctor.sh | 177 | ||||
| -rwxr-xr-x | scripts/install-lang.sh | 106 | ||||
| -rwxr-xr-x | scripts/lint.sh | 126 | ||||
| -rwxr-xr-x | scripts/readability | 109 |
5 files changed, 598 insertions, 0 deletions
diff --git a/scripts/diff-lang.sh b/scripts/diff-lang.sh new file mode 100755 index 0000000..a72d2b9 --- /dev/null +++ b/scripts/diff-lang.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Diff installed rulesets in a target project vs the repo source. +# Usage: diff-lang.sh <language> <project-path> +# +# Walks every file the installer would copy and shows a unified diff for +# any that differ. Files missing in the target are flagged separately. + +set -u + +LANG="${1:-}" +PROJECT="${2:-}" + +if [ -z "$LANG" ] || [ -z "$PROJECT" ]; then + echo "Usage: $0 <language> <project-path>" >&2 + exit 1 +fi + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SRC="$REPO_ROOT/languages/$LANG" + +[ -d "$SRC" ] || { echo "ERROR: no ruleset for '$LANG'" >&2; exit 1; } +[ -d "$PROJECT" ] || { echo "ERROR: project path does not exist: $PROJECT" >&2; exit 1; } +PROJECT="$(cd "$PROJECT" && pwd)" + +changed=0 +missing=0 + +compare_file() { + local src="$1" dst="$2" + if [ ! -f "$dst" ]; then + echo "MISSING: $dst" + missing=$((missing + 1)) + return + fi + if ! diff -q "$src" "$dst" >/dev/null 2>&1; then + echo "--- $src" + echo "+++ $dst" + diff -u "$src" "$dst" | tail -n +3 + echo + changed=$((changed + 1)) + fi +} + +echo "Comparing '$LANG' ruleset against $PROJECT" +echo + +# Generic rules (claude-rules/*.md → .claude/rules/) +for f in "$REPO_ROOT/claude-rules"/*.md; do + [ -f "$f" ] || continue + name="$(basename "$f")" + compare_file "$f" "$PROJECT/.claude/rules/$name" +done + +# Language .claude/ tree +if [ -d "$SRC/claude" ]; then + while IFS= read -r f; do + rel="${f#$SRC/claude/}" + compare_file "$f" "$PROJECT/.claude/$rel" + done < <(find "$SRC/claude" -type f) +fi + +# CLAUDE.md is seed-only (install won't overwrite without FORCE=1), so skip it +# in normal diff output. Users can diff it manually if curious. + +# githooks/ +if [ -d "$SRC/githooks" ]; then + while IFS= read -r f; do + rel="${f#$SRC/githooks/}" + compare_file "$f" "$PROJECT/githooks/$rel" + done < <(find "$SRC/githooks" -type f) +fi + +echo "---" +if [ "$changed" -eq 0 ] && [ "$missing" -eq 0 ]; then + echo "No differences." +else + echo "Summary: $changed differ, $missing missing." + [ "$changed" -gt 0 ] && exit 1 + [ "$missing" -gt 0 ] && exit 2 +fi diff --git a/scripts/doctor.sh b/scripts/doctor.sh new file mode 100755 index 0000000..93a4d22 --- /dev/null +++ b/scripts/doctor.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# doctor.sh — verify ~/.claude/ live state matches rulesets repo + settings.json. +# +# Read-only diagnostic. Reports drift line-by-line: ok / WARN / FAIL. +# Exit 0 on clean, 1 if any FAIL was emitted. Warnings do not block. +# +# Run from the repo root via `make doctor`. + +set -uo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +CLAUDE_DIR="$HOME/.claude" +SETTINGS="$REPO/.claude/settings.json" + +ok_count=0 +warn_count=0 +fail_count=0 + +ok() { printf ' ok %s\n' "$1"; ok_count=$((ok_count+1)); } +warn() { printf ' WARN %s\n' "$1"; warn_count=$((warn_count+1)); } +fail() { printf ' FAIL %s\n' "$1"; fail_count=$((fail_count+1)); } + +require() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "ERROR: required tool '$1' not found in PATH" >&2 + exit 2 + fi +} + +is_live_symlink() { + [ -L "$1" ] && [ -e "$1" ] +} + +# Reports ok / dangling / missing for a single symlink. +# Args: <label> <link-path> <severity: fail|warn> [<hint>] +check_symlink() { + local label="$1" link="$2" severity="$3" hint="${4:-}" + local suffix="" + [ -n "$hint" ] && suffix=" ($hint)" + if is_live_symlink "$link"; then + ok "$label" + elif [ -L "$link" ]; then + "$severity" "$label: dangling symlink" + else + "$severity" "$label: not installed$suffix" + fi +} + +require jq + +# ----- 1. Skills ----- +echo "Skills:" +for f in "$REPO"/*/SKILL.md; do + name="$(basename "$(dirname "$f")")" + check_symlink "skill $name" "$CLAUDE_DIR/skills/$name" fail +done + +# ----- 2. Rules ----- +echo +echo "Rules:" +for f in "$REPO"/claude-rules/*.md; do + name="$(basename "$f")" + check_symlink "rule $name" "$CLAUDE_DIR/rules/$name" fail +done + +# ----- 3. Default hooks (warn-only — opt-out is legitimate) ----- +echo +echo "Default hooks:" +shopt -s nullglob +for f in "$REPO"/hooks/*.sh "$REPO"/hooks/*.py; do + name="$(basename "$f")" + case "$name" in + destructive-bash-confirm.py) continue ;; # opt-in + esac + check_symlink "hook $name" "$CLAUDE_DIR/hooks/$name" warn "run: make install-hooks" +done +shopt -u nullglob + +# ----- 4. Claude config (settings.json, .mcp.json, commands dir) ----- +echo +echo "Claude config:" +shopt -s nullglob dotglob +for f in "$REPO"/.claude/*.json; do + name="$(basename "$f")" + case "$name" in + settings.local.json) continue ;; # per-machine, gitignored + esac + check_symlink "config $name" "$CLAUDE_DIR/$name" fail +done +shopt -u nullglob dotglob + +check_symlink "config commands/" "$CLAUDE_DIR/commands" fail + +# ----- 5. Hook commands referenced by settings.json all exist ----- +echo +echo "Hooks referenced by settings.json:" +hook_cmds=$(jq -r ' + [.hooks // {} | to_entries[] | .value[]?.hooks[]?.command] | unique | .[] +' "$SETTINGS" 2>/dev/null) +if [ -z "$hook_cmds" ]; then + ok "no hooks declared" +else + while IFS= read -r cmd; do + expanded="${cmd/#\~/$HOME}" + if [ -e "$expanded" ]; then + ok "hook reference $cmd" + else + fail "hook reference $cmd: file not found at $expanded" + fi + done <<< "$hook_cmds" +fi + +# ----- 6. enabledPlugins each have an install dir under ~/.claude/plugins/data/ ----- +echo +echo "Plugins:" +enabled_keys=$(jq -r ' + .enabledPlugins // {} | to_entries[] | select(.value == true) | .key +' "$SETTINGS" 2>/dev/null) +if [ -z "$enabled_keys" ]; then + ok "no plugins enabled" +else + while IFS= read -r key; do + # key shape: "<plugin>@<marketplace>" → data dir: "<plugin>-<marketplace>" + dir_name="${key/@/-}" + if [ -d "$CLAUDE_DIR/plugins/data/$dir_name" ]; then + ok "plugin $key" + else + fail "plugin $key: no install dir at plugins/data/$dir_name" + fi + done <<< "$enabled_keys" +fi + +# ----- 7. MCP drift: servers.json keys vs ~/.claude.json mcpServers keys ----- +echo +echo "MCP servers (user-scope):" +SERVERS="$REPO/mcp/servers.json" +USER_CLAUDE="$HOME/.claude.json" +if [ ! -f "$SERVERS" ]; then + warn "mcp/servers.json missing — skipping drift check" +elif [ ! -f "$USER_CLAUDE" ]; then + fail "~/.claude.json missing — cannot check MCP drift" +else + want=$(jq -r 'keys[]' "$SERVERS" | sort) + have=$(jq -r '.mcpServers // {} | keys[]' "$USER_CLAUDE" | sort) + while IFS= read -r s; do + [ -z "$s" ] && continue + if grep -qx "$s" <<< "$have"; then + ok "mcp $s" + else + fail "mcp $s: declared in servers.json but not registered" + fi + done <<< "$want" + while IFS= read -r s; do + [ -z "$s" ] && continue + if ! grep -qx "$s" <<< "$want"; then + warn "mcp $s: registered but not declared in servers.json" + fi + done <<< "$have" +fi + +# ----- 8. Dangling symlinks anywhere under ~/.claude/ ----- +echo +echo "Dangling symlinks under ~/.claude/:" +dangle=0 +while IFS= read -r link; do + if [ ! -e "$link" ]; then + fail "$link → $(readlink "$link")" + dangle=$((dangle+1)) + fi +done < <(find "$CLAUDE_DIR" -maxdepth 4 -type l 2>/dev/null) +[ "$dangle" -eq 0 ] && ok "no dangling symlinks" + +# ----- summary ----- +echo +echo "Summary: $ok_count ok, $warn_count warnings, $fail_count failures" +[ "$fail_count" -gt 0 ] && exit 1 +exit 0 diff --git a/scripts/install-lang.sh b/scripts/install-lang.sh new file mode 100755 index 0000000..4097bde --- /dev/null +++ b/scripts/install-lang.sh @@ -0,0 +1,106 @@ +#!/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)" + +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 +if [ -f "$SRC/CLAUDE.md" ]; then + if [ -f "$PROJECT/CLAUDE.md" ] && [ "$FORCE" != "1" ]; then + echo " [skip] CLAUDE.md already exists (use FORCE=1 to overwrite)" + else + cp "$SRC/CLAUDE.md" "$PROJECT/CLAUDE.md" + echo " [ok] CLAUDE.md installed" + 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." diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100755 index 0000000..ae30aa5 --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# Validate ruleset structure. Runs from the rulesets repo root. +# Checks: +# - Every .md rule file starts with a top-level heading +# - Every rule file has an 'Applies to:' header +# - Every language CLAUDE.md has a top-level heading +# - Every hook script has a shebang and is executable +# - Every cross-reference to claude-rules/ from a SKILL.md or +# claude-rules/*.md resolves to a real file (catches the install-layout +# drift that the bridge symlink fixes) + +set -u + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +errors=0 + +warn() { + printf ' WARN: %s\n' "$1" + errors=$((errors + 1)) +} + +check_md_heading() { + local f="$1" + [ -f "$f" ] || return 0 + if ! head -1 "$f" | grep -q '^# '; then + warn "$f — missing top-level heading" + fi +} + +check_md_applies_to() { + local f="$1" + [ -f "$f" ] || return 0 + if ! grep -q '^Applies to:' "$f"; then + warn "$f — missing 'Applies to:' header" + fi +} + +check_hook() { + local f="$1" + [ -f "$f" ] || return 0 + if ! head -1 "$f" | grep -q '^#!'; then + warn "$f — missing shebang" + fi + if [ ! -x "$f" ]; then + warn "$f — not executable (chmod +x)" + fi +} + +check_md_links() { + # Validate cross-references to claude-rules/ — the install-layout problem + # solved by the bridge symlink in `make install`. Doesn't validate + # example file names that skills cite illustratively (e.g. ADR templates, + # arc42 section files), which are intentionally not real source files. + local f="$1" + [ -f "$f" ] || return 0 + local dir + dir="$(dirname "$f")" + while IFS= read -r link; do + local url="${link##*\(}" + url="${url%\)}" + case "$url" in + *claude-rules/*) ;; + *) continue ;; + esac + url="${url%%#*}" + url="${url%%\?*}" + local resolved + resolved="$(cd "$dir" 2>/dev/null && readlink -m "$url" 2>/dev/null)" + if [ -z "$resolved" ] || [ ! -e "$resolved" ]; then + warn "$f — broken claude-rules link: $url" + fi + done < <(grep -oE '\[[^]]*\]\([^)]+\)' "$f" 2>/dev/null || true) +} + +echo "Linting rulesets in $REPO_ROOT" + +# Generic rules +for f in claude-rules/*.md; do + [ -f "$f" ] || continue + check_md_heading "$f" + check_md_applies_to "$f" +done + +# Per-language rule files +for rules_dir in languages/*/claude/rules; do + [ -d "$rules_dir" ] || continue + for f in "$rules_dir"/*.md; do + [ -f "$f" ] || continue + check_md_heading "$f" + check_md_applies_to "$f" + done +done + +# Per-language CLAUDE.md templates +for claude_md in languages/*/CLAUDE.md; do + [ -f "$claude_md" ] || continue + check_md_heading "$claude_md" +done + +# Hook scripts +for h in languages/*/claude/hooks/*.sh languages/*/githooks/*; do + [ -f "$h" ] || continue + check_hook "$h" +done + +# Shared install/diff/lint scripts (sanity check) +for s in scripts/*.sh; do + [ -f "$s" ] || continue + check_hook "$s" +done + +# Markdown link validation across rules and skills +for f in claude-rules/*.md */SKILL.md; do + [ -f "$f" ] || continue + check_md_links "$f" +done + +echo "---" +if [ "$errors" -eq 0 ]; then + echo "All checks passed." +else + echo "$errors warning(s)." + exit 1 +fi diff --git a/scripts/readability b/scripts/readability new file mode 100755 index 0000000..cdae627 --- /dev/null +++ b/scripts/readability @@ -0,0 +1,109 @@ +#!/usr/bin/env -S uv run --quiet --script +# /// script +# requires-python = ">=3.10" +# dependencies = ["textstat"] +# /// +"""Compute readability metrics for one or two text inputs. + +Usage: + readability FILE # single-file metrics + readability FILE1 FILE2 # side-by-side comparison + +Notes: + Each input is read as plain text. PDF/HTML/org-mode markup is passed + through unchanged — strip first if you want a clean reading. + First run downloads textstat (~2 MB) via uv's cache; subsequent runs + are fast. +""" + +from __future__ import annotations + +import pathlib +import sys + +import textstat # type: ignore[import-not-found] + + +METRICS = [ + ("Words", lambda t: textstat.lexicon_count(t, removepunct=True)), + ("Sentences", textstat.sentence_count), + ("Avg sentence length", lambda t: round(textstat.words_per_sentence(t), 1)), + ("Avg syllables/word", lambda t: round(textstat.avg_syllables_per_word(t), 2)), + ("Flesch Reading Ease", lambda t: round(textstat.flesch_reading_ease(t), 1)), + ("Flesch-Kincaid Grade", lambda t: round(textstat.flesch_kincaid_grade(t), 1)), + ("Gunning Fog", lambda t: round(textstat.gunning_fog(t), 1)), + ("SMOG Index", lambda t: round(textstat.smog_index(t), 1)), + ("Coleman-Liau", lambda t: round(textstat.coleman_liau_index(t), 1)), + ("ARI", lambda t: round(textstat.automated_readability_index(t), 1)), + ("Dale-Chall", lambda t: round(textstat.dale_chall_readability_score(t), 1)), + ("Linsear-Write", lambda t: round(textstat.linsear_write_formula(t), 1)), + ("Difficult words", textstat.difficult_words), +] + +SCALE_NOTES = """ +Scale notes: + Flesch Reading Ease: 100 (very easy) → 0 (very confusing); academic prose ~30 + Flesch-Kincaid Grade / Gunning Fog / SMOG / Coleman-Liau / ARI / Linsear-Write + → years of formal education needed to comprehend (US grade level) + Dale-Chall: <5 = grade 4 reader; 7-8 = avg adult; 9+ = college; 10+ = grad +""".strip() + + +def compute(path: str) -> dict: + """Return {metric_name: value} for the file at path.""" + text = pathlib.Path(path).read_text() + return {name: fn(text) for name, fn in METRICS} + + +def label_for(path: str) -> str: + """Use the file's stem (no extension) as a short column label.""" + return pathlib.Path(path).stem + + +def print_single(path: str) -> None: + metrics = compute(path) + label_w = max(len(k) for k in metrics) + col = label_for(path) + col_w = max(14, len(col)) + print(f"{'Metric':<{label_w}} {col:>{col_w}}") + print("-" * (label_w + 2 + col_w)) + for name, value in metrics.items(): + print(f"{name:<{label_w}} {str(value):>{col_w}}") + print() + print(SCALE_NOTES) + + +def print_compare(path_a: str, path_b: str) -> None: + a = compute(path_a) + b = compute(path_b) + label_w = max(len(k) for k in a) + name_a = label_for(path_a) + name_b = label_for(path_b) + col_w = max(12, len(name_a), len(name_b)) + print(f"{'Metric':<{label_w}} {name_a:>{col_w}} {name_b:>{col_w}} {'Δ':>10}") + print("-" * (label_w + 2 + col_w + 2 + col_w + 2 + 10)) + for name in a: + va, vb = a[name], b[name] + if isinstance(va, (int, float)) and isinstance(vb, (int, float)): + delta_str = f"{round(vb - va, 1):+}" + else: + delta_str = "—" + print(f"{name:<{label_w}} {str(va):>{col_w}} {str(vb):>{col_w}} {delta_str:>10}") + print() + print(SCALE_NOTES) + + +def main() -> int: + args = sys.argv[1:] + if len(args) == 1: + print_single(args[0]) + elif len(args) == 2: + print_compare(args[0], args[1]) + else: + sys.stderr.write(__doc__ or "") + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) |
