aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rwxr-xr-xscripts/diff-lang.sh80
-rwxr-xr-xscripts/doctor.sh177
-rwxr-xr-xscripts/install-lang.sh106
-rwxr-xr-xscripts/lint.sh126
-rwxr-xr-xscripts/readability109
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())