diff --git a/HOOKS.md b/HOOKS.md index e0d1dc3..3b25369 100644 --- a/HOOKS.md +++ b/HOOKS.md @@ -7,6 +7,32 @@ Claude Code hooks that run automatically in response to events. Source of truth | Hook | Event | File | |------|-------|------| | Pre-compact backup | PreCompact (auto + manual) | `hooks/pre-compact-backup.sh` | +| Post-edit lint | PostToolUse (Edit/Write/MultiEdit) | `hooks/post-edit-lint.sh` | +| Pre-commit lint | Git pre-commit | `hooks/pre-commit-lint.sh` | + +## Post-edit Lint + +Formats and lints files after every Edit/Write/MultiEdit. Dispatches to project-local `formatters/` directory (symlinks to `claude-foundations/formatters/`). + +**Flow:** +1. Extracts `file_path` and `cwd` from stdin JSON +2. Walks up from `cwd` to find `formatters/` directory +3. Creates a `git hash-object` checkpoint (`.pre-lint` file with blob SHA) +4. Runs the matching formatter script +5. On clean pass: removes checkpoint, exits 0 (silent) +6. On lint errors: keeps checkpoint, exits 2 (feeds errors back to Claude via stderr) + +**Requires:** `python3` (for JSON parsing from stdin). Formatter tools are optional — missing tools silently pass. + +**Revert:** `git cat-file blob $(cat .pre-lint) > ` + +## Pre-commit Lint + +Git pre-commit hook that runs project formatters on staged files. Reuses the same formatter scripts. + +**Flow:** iterates staged files → runs matching formatter → re-stages formatted files → exits non-zero if lint errors remain (blocks commit). + +**Install:** symlink `.git/hooks/pre-commit` → `pre-commit-lint.sh`, or use `scripts/setup-formatters.sh`. ## Pre-compact Backup @@ -18,10 +44,23 @@ Saves a copy of the session transcript before context compaction so no conversat ## Installation +The easiest way is to run the install script: + +```bash +cd ~/dev/claude/projects/claude-foundations +scripts/install-hooks.sh +``` + +This symlinks all hooks into `~/.claude/hooks/` and prints the `settings.json` config to add. + +### Manual installation + 1. Symlink each hook into `~/.claude/hooks/`: ```bash mkdir -p ~/.claude/hooks ln -sf "$(pwd)/hooks/pre-compact-backup.sh" ~/.claude/hooks/pre-compact-backup.sh + ln -sf "$(pwd)/hooks/post-edit-lint.sh" ~/.claude/hooks/post-edit-lint.sh + ln -sf "$(pwd)/hooks/pre-commit-lint.sh" ~/.claude/hooks/pre-commit-lint.sh ``` 2. Add the hook configuration to `~/.claude/settings.json`: @@ -31,17 +70,28 @@ Saves a copy of the session transcript before context compaction so no conversat "PreCompact": [ { "matcher": "auto", - "hooks": [{ "type": "command", "command": "~/.claude/hooks/pre-compact-backup.sh", "timeout": 15 }] + "hooks": [{ "type": "command", "command": "~/.claude/hooks/pre-compact-backup.sh", "timeout": 15, "statusMessage": "Backing up transcript before compaction..." }] }, { "matcher": "manual", - "hooks": [{ "type": "command", "command": "~/.claude/hooks/pre-compact-backup.sh", "timeout": 15 }] + "hooks": [{ "type": "command", "command": "~/.claude/hooks/pre-compact-backup.sh", "timeout": 15, "statusMessage": "Backing up transcript before compaction..." }] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [{ "type": "command", "command": "~/.claude/hooks/post-edit-lint.sh", "timeout": 30, "statusMessage": "Formatting and linting..." }] } ] } } ``` +3. For per-project formatters, run: + ```bash + scripts/setup-formatters.sh [ ...] + ``` + ## Adding New Hooks 1. Create the script in `hooks/` diff --git a/README.md b/README.md new file mode 100644 index 0000000..1c9f97b --- /dev/null +++ b/README.md @@ -0,0 +1,139 @@ +# claude-foundations + +Shared infrastructure for Claude Code sessions: hooks, formatters, best practices, and the knowledge distillation pipeline. + +## Repository Structure + +``` +claude-foundations/ + formatters/ # Canonical formatter scripts (one per extension) + py # ruff format + ruff check + sh # shfmt + shellcheck + ts # biome format + biome check + js -> ts # symlink (biome handles both) + sql # sqlfluff fix + sqlfluff lint + json # prettier + yaml -> json # symlink (prettier auto-detects) + md -> json # symlink + hooks/ + post-edit-lint.sh # PostToolUse: auto-format/lint on Edit/Write + pre-commit-lint.sh # Git pre-commit: lint staged files + pre-compact-backup.sh # PreCompact: backup transcript before compaction + scripts/ + install-hooks.sh # Symlink hooks into ~/.claude/hooks/ + setup-formatters.sh # Set up formatters for a project + best-practices/ # Generalised best practices (one file per topic) + memory/ # Session logs and reflections + settings.yaml # Knowledge pipeline configuration + CLAUDE.md # Global project guidelines (symlinked to ~/dev/claude/) + HOOKS.md # Hook documentation + TODO.md # Improvement backlog +``` + +## Quick Start + +### 1. Install hooks (one-time) + +```bash +cd ~/dev/claude/projects/claude-foundations +scripts/install-hooks.sh +``` + +This symlinks all hooks into `~/.claude/hooks/` and prints the `settings.json` config to add. The PostToolUse hook is what triggers auto-formatting on every Edit/Write. + +### 2. Opt a project into auto-formatting + +```bash +scripts/setup-formatters.sh ~/dev/claude/projects/ [ ...] +``` + +For example, to enable Python and shell formatting for `cluster-bootstrap`: + +```bash +scripts/setup-formatters.sh ~/dev/claude/projects/cluster-bootstrap py sh +``` + +This creates a `formatters/` directory in the target project with symlinks back to the canonical formatter scripts here. It also installs a git pre-commit hook if one doesn't exist. + +**Available formatters:** `py`, `sh`, `ts`, `js`, `sql`, `json`, `yaml`, `md` + +Run with no arguments to see the full list: + +```bash +scripts/setup-formatters.sh +``` + +### 3. Install required tools + +Each formatter gracefully skips if its tool isn't installed, but for full functionality: + +| Formatter | Tools needed | Install | +|-----------|-------------|---------| +| `py` | ruff | `pip install ruff` or `pipx install ruff` | +| `sh` | shfmt, shellcheck | `sudo apt install shfmt shellcheck` | +| `ts`/`js` | biome | `npm i -g @biomejs/biome` | +| `sql` | sqlfluff | `pip install sqlfluff` | +| `json`/`yaml`/`md` | prettier | `npm i -g prettier` | + +### 4. Add per-project config (optional) + +Formatters respect project-level config files: + +| Formatter | Config file | Purpose | +|-----------|------------|---------| +| `py` | `pyproject.toml` | ruff rules, line length, target Python version | +| `sh` | `.editorconfig` | shfmt indent style, binary ops, switch cases | +| `ts`/`js` | `biome.json` | biome rules, formatting options | +| `sql` | `.sqlfluff` | SQL dialect, rules | +| `json`/`yaml`/`md` | `.prettierrc` | prettier options | + +Without config files, tools use their defaults. + +### 5. Verify setup + +Use the `/linter` skill inside a Claude Code session: + +``` +/linter +``` + +This scans the project, shows which formatters are active, which tools are installed, and suggests fixes for any gaps. + +## How It Works + +### PostToolUse hook (auto-format on edit) + +When Claude edits or writes a file: + +1. The `post-edit-lint.sh` hook fires +2. It walks up from the working directory to find `formatters/` +3. If a formatter exists for the file's extension, it: + - Creates a checkpoint (git blob hash stored in `.pre-lint`) + - Runs the formatter (format in place + lint) + - On clean pass: removes checkpoint, exits silently + - On lint errors: keeps checkpoint, exits with errors shown to Claude + +### Pre-commit hook (lint on commit) + +When you `git commit`, the `pre-commit-lint.sh` hook: + +1. Finds staged files with matching formatters +2. Runs each formatter +3. Re-stages formatted files +4. Blocks the commit if lint errors remain + +### Reverting after lint + +If you don't like what the formatter did: + +```bash +git cat-file blob "$(cat .pre-lint)" > +rm .pre-lint +``` + +## Scripts + +| Script | Purpose | +|--------|---------| +| `scripts/install-hooks.sh` | Symlink all hooks to `~/.claude/hooks/` and print settings.json config | +| `scripts/setup-formatters.sh` | Create formatter symlinks in a target project | diff --git a/TODO.md b/TODO.md index 3d23746..0301d2f 100644 --- a/TODO.md +++ b/TODO.md @@ -2,9 +2,9 @@ Prioritised by impact and effort. Each item references its detailed topic file in `improvements/`. -## 1. Configure PostToolUse auto-formatting hook -**Impact: High | Effort: Low** -Auto-format code after every Edit/Write. Removes all formatting rules from CLAUDE.md, saves tokens, and guarantees consistent output. Start with `shfmt` for shell and expand. +## 1. ~~Configure PostToolUse auto-formatting hook~~ → DONE +**Impact: High | Effort: Low** ✅ +Composable multi-language linting system implemented. Canonical formatters in `formatters/` (py, sh, ts, sql, json + symlinks for js, yaml, md). PostToolUse dispatcher hook with checkpoint/revert. Pre-commit hook. Setup scripts. `/linter` skill for auditing. See: [hooks-and-automation.md](improvements/hooks-and-automation.md) ## 2. ~~Create global `~/.claude/CLAUDE.md`~~ → DONE (parent symlink) @@ -61,3 +61,7 @@ See: [planning-and-workflow.md](improvements/planning-and-workflow.md) - **Parent CLAUDE.md symlink** — `~/dev/claude/CLAUDE.md` → claude-foundations, all projects inherit automatically. - **PreCompact transcript backup hook** — `~/.claude/hooks/pre-compact-backup.sh` with auto+manual matchers. - **claude-foundations Gitea repo** — Created under skynet org, pushed. + +## Completed (2026-03-13) + +- **PostToolUse auto-formatting hook** — Composable multi-language linting system: 5 formatter scripts + 3 symlinks, PostToolUse dispatcher with git-blob checkpoints, pre-commit hook, setup scripts, `/linter` skill, `best-practices/linting.md`. diff --git a/best-practices/INDEX.md b/best-practices/INDEX.md index 0a16b2b..29e5dc4 100644 --- a/best-practices/INDEX.md +++ b/best-practices/INDEX.md @@ -15,3 +15,4 @@ Generalised best practices extracted from real project work. Each topic file is - [Milestones & Reflections](milestones.md) — Milestone workflow, verification, reflection process - [Debugging Methodology](debugging.md) — Systematic diagnosis, full-chain testing, common pitfalls - [Claude Code Skills](skills-development.md) — Skill authoring, context injection, tool restrictions +- [Linting & Formatting](linting.md) — Tool choices per language, PostToolUse hook, pre-commit integration, formatter contract diff --git a/best-practices/linting.md b/best-practices/linting.md new file mode 100644 index 0000000..4b8f4a4 --- /dev/null +++ b/best-practices/linting.md @@ -0,0 +1,94 @@ +# Linting & Formatting + +Automated code formatting and linting integrated into the Claude Code workflow via PostToolUse hooks and git pre-commit hooks. + +## Architecture + +``` +claude-foundations/formatters/ ← canonical formatter scripts (one per extension) +project/formatters/ ← symlinks to the formatters this project uses +~/.claude/hooks/post-edit-lint.sh ← dispatches to project formatters on Edit/Write +.git/hooks/pre-commit ← symlink to pre-commit-lint.sh +``` + +Projects opt in by symlinking only the formatters they need. No formatter = no-op. + +## Tool Choices by Language + +| Language | Formatter | Linter | Config File | +|----------|-----------|--------|-------------| +| Python | `ruff format` | `ruff check` | `pyproject.toml` | +| Shell | `shfmt` | `shellcheck` | `.editorconfig` | +| TypeScript/JavaScript | `biome format` | `biome check` | `biome.json` | +| SQL | `sqlfluff fix` | `sqlfluff lint` | `.sqlfluff` | +| JSON/YAML/Markdown | `prettier` | — | `.prettierrc` | + +### Why these tools? +- **ruff**: Rust-based, extremely fast, replaces black + isort + flake8 + pyflakes in one tool +- **biome**: Rust-based, replaces prettier + eslint for JS/TS in one tool +- **shfmt + shellcheck**: The standard combo for shell scripts; shfmt formats, shellcheck catches bugs +- **prettier**: Handles JSON/YAML/MD well; biome doesn't cover these yet + +## Formatter Script Contract + +Every script in `formatters/` follows the same interface: + +- **Input:** `$1` = absolute file path +- **Behaviour:** format the file in place, then lint it +- **Stdout:** suppressed +- **Stderr:** lint warnings/errors that couldn't be auto-fixed +- **Exit code:** `0` if clean, `1` if lint errors remain +- **Missing tools:** exit `0` silently (`command -v` check) +- **No `set -e`:** individual commands may fail; execution must continue + +## PostToolUse Integration + +The `post-edit-lint.sh` hook fires on Edit/Write/MultiEdit: + +1. Extracts `file_path` and `cwd` from stdin JSON +2. Walks up from `cwd` to find `formatters/` directory +3. Creates a checkpoint using `git hash-object` (fast, no commits) +4. Runs the matching formatter +5. On clean pass: removes checkpoint, exits 0 (silent) +6. On lint errors: keeps checkpoint, exits 2 (feeds errors to Claude) + +Exit code 2 is special for PostToolUse — it feeds stderr back to Claude as feedback without blocking the edit. + +## Checkpoint Mechanism + +Uses `git hash-object -w` to store pre-format content as an orphan blob (~1ms, no commits, no stash). The `.pre-lint` file stores only a 40-char SHA. Falls back to `cp` outside git repos. + +```bash +# Revert after lint errors: +git cat-file blob "$(cat file.py.pre-lint)" > file.py +rm file.py.pre-lint +``` + +## Subagent Fix Pattern + +When lint errors occur, Claude sees the errors via stderr. The recommended workflow: + +1. Claude reports the errors to the user +2. If the user says "fix", Claude spawns a subagent via the Agent tool +3. The subagent reads the file and errors, makes Edit calls to fix them +4. Each Edit triggers the hook again (re-format, re-lint) in the subagent +5. Fix iterations stay in the subagent's context, not the main conversation + +## Pre-commit Integration + +`pre-commit-lint.sh` reuses the same formatter scripts: + +1. Iterates staged files (Added/Modified only) +2. Runs matching formatters +3. Re-stages formatted files +4. Exits non-zero if lint errors remain (blocks commit) + +Install: symlink `.git/hooks/pre-commit` → `pre-commit-lint.sh`, or use `setup-formatters.sh` which does this automatically. + +## Setup Checklist + +1. Run `scripts/setup-formatters.sh [ ...]` to symlink formatters +2. Install the required tools (`ruff`, `shfmt`, `shellcheck`, `biome`, `prettier`, `sqlfluff`) +3. Create per-project config files as needed (`pyproject.toml`, `.editorconfig`, etc.) +4. Run `scripts/install-hooks.sh` to install the PostToolUse hook (one-time global setup) +5. Use `/linter scan` to verify everything is connected diff --git a/formatters/js b/formatters/js new file mode 120000 index 0000000..b3c373c --- /dev/null +++ b/formatters/js @@ -0,0 +1 @@ +ts \ No newline at end of file diff --git a/formatters/json b/formatters/json new file mode 100755 index 0000000..d8c2eb4 --- /dev/null +++ b/formatters/json @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Formatter: JSON/YAML/Markdown — prettier +# Contract: $1 = absolute file path, format in place, lint, exit 0 if clean / 1 if errors +# Missing tools: exit 0 silently + +FILE="$1" +[ -z "$FILE" ] && exit 0 +[ -f "$FILE" ] || exit 0 + +command -v prettier >/dev/null 2>&1 || exit 0 + +# Format in place (prettier auto-detects parser from extension) +ERRORS=$(prettier --write "$FILE" 2>&1) +if [ $? -ne 0 ]; then + echo "$ERRORS" >&2 + exit 1 +fi + +exit 0 diff --git a/formatters/md b/formatters/md new file mode 120000 index 0000000..c87a0a0 --- /dev/null +++ b/formatters/md @@ -0,0 +1 @@ +json \ No newline at end of file diff --git a/formatters/py b/formatters/py new file mode 100755 index 0000000..870b73c --- /dev/null +++ b/formatters/py @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Formatter: Python — ruff format + ruff check +# Contract: $1 = absolute file path, format in place, lint, exit 0 if clean / 1 if errors +# Missing tools: exit 0 silently + +FILE="$1" +[ -z "$FILE" ] && exit 0 +[ -f "$FILE" ] || exit 0 + +command -v ruff >/dev/null 2>&1 || exit 0 + +# Format in place +ruff format --quiet "$FILE" 2>/dev/null + +# Auto-fix what we can +ruff check --fix --quiet "$FILE" 2>/dev/null + +# Final lint pass — remaining errors go to stderr +ERRORS=$(ruff check "$FILE" 2>&1) +if [ $? -ne 0 ]; then + echo "$ERRORS" >&2 + exit 1 +fi + +exit 0 diff --git a/formatters/sh b/formatters/sh new file mode 100755 index 0000000..ddb276b --- /dev/null +++ b/formatters/sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Formatter: Shell — shfmt + shellcheck +# Contract: $1 = absolute file path, format in place, lint, exit 0 if clean / 1 if errors +# Missing tools: exit 0 silently + +FILE="$1" +[ -z "$FILE" ] && exit 0 +[ -f "$FILE" ] || exit 0 + +HAD_ERRORS=0 + +# Format with shfmt if available +if command -v shfmt >/dev/null 2>&1; then + shfmt -w "$FILE" 2>/dev/null +fi + +# Lint with shellcheck if available +if command -v shellcheck >/dev/null 2>&1; then + ERRORS=$(shellcheck -f gcc "$FILE" 2>&1) + if [ $? -ne 0 ]; then + echo "$ERRORS" >&2 + HAD_ERRORS=1 + fi +fi + +# If neither tool is installed, silently pass +if ! command -v shfmt >/dev/null 2>&1 && ! command -v shellcheck >/dev/null 2>&1; then + exit 0 +fi + +exit $HAD_ERRORS diff --git a/formatters/sql b/formatters/sql new file mode 100755 index 0000000..fea4624 --- /dev/null +++ b/formatters/sql @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Formatter: SQL — sqlfluff fix + sqlfluff lint +# Contract: $1 = absolute file path, format in place, lint, exit 0 if clean / 1 if errors +# Missing tools: exit 0 silently + +FILE="$1" +[ -z "$FILE" ] && exit 0 +[ -f "$FILE" ] || exit 0 + +command -v sqlfluff >/dev/null 2>&1 || exit 0 + +# Auto-fix in place +sqlfluff fix --force --quiet "$FILE" 2>/dev/null + +# Final lint pass — remaining errors go to stderr +ERRORS=$(sqlfluff lint "$FILE" 2>&1) +if [ $? -ne 0 ]; then + echo "$ERRORS" >&2 + exit 1 +fi + +exit 0 diff --git a/formatters/ts b/formatters/ts new file mode 100755 index 0000000..d6cc66b --- /dev/null +++ b/formatters/ts @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Formatter: TypeScript/JavaScript — biome format + biome check +# Contract: $1 = absolute file path, format in place, lint, exit 0 if clean / 1 if errors +# Missing tools: exit 0 silently + +FILE="$1" +[ -z "$FILE" ] && exit 0 +[ -f "$FILE" ] || exit 0 + +command -v biome >/dev/null 2>&1 || exit 0 + +# Format in place +biome format --write "$FILE" 2>/dev/null + +# Auto-fix what we can +biome check --fix "$FILE" 2>/dev/null + +# Final lint pass — remaining errors go to stderr +ERRORS=$(biome check "$FILE" 2>&1) +if [ $? -ne 0 ]; then + echo "$ERRORS" >&2 + exit 1 +fi + +exit 0 diff --git a/formatters/yaml b/formatters/yaml new file mode 120000 index 0000000..c87a0a0 --- /dev/null +++ b/formatters/yaml @@ -0,0 +1 @@ +json \ No newline at end of file diff --git a/hooks/post-edit-lint.sh b/hooks/post-edit-lint.sh new file mode 100755 index 0000000..0abf3ed --- /dev/null +++ b/hooks/post-edit-lint.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# PostToolUse hook: format and lint edited/written files. +# Dispatches to project-local formatters/ directory (symlinks to claude-foundations/formatters/). +# +# Input: JSON on stdin with tool_name, file_path, cwd fields +# Exit 0: silent pass (no formatter, clean lint, or missing tools) +# Exit 2: lint errors remain — stderr fed back to Claude as feedback + +# Parse stdin JSON for file_path and cwd +INPUT="$(cat)" +FILE_PATH="$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('file_path',''))" 2>/dev/null)" +CWD="$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('cwd',''))" 2>/dev/null)" + +# No file path — nothing to do +[ -z "$FILE_PATH" ] && exit 0 + +# Make path absolute if relative +if [[ "$FILE_PATH" != /* ]]; then + FILE_PATH="${CWD:-.}/$FILE_PATH" +fi + +# File must exist +[ -f "$FILE_PATH" ] || exit 0 + +# Extract extension (normalise yml → yaml) +EXT="${FILE_PATH##*.}" +case "$EXT" in + yml) EXT="yaml" ;; +esac + +# No extension — nothing to do +[ -z "$EXT" ] || [ "$EXT" = "$FILE_PATH" ] && exit 0 + +# Find the project's formatters/ directory by walking up from cwd +find_formatters_dir() { + local dir="${1:-$CWD}" + while [ "$dir" != "/" ] && [ -n "$dir" ]; do + if [ -d "$dir/formatters" ]; then + echo "$dir/formatters" + return 0 + fi + dir="$(dirname "$dir")" + done + return 1 +} + +FORMATTERS_DIR="$(find_formatters_dir "$CWD")" +[ -z "$FORMATTERS_DIR" ] && exit 0 + +# Check if a formatter exists for this extension +FORMATTER="$FORMATTERS_DIR/$EXT" +[ -x "$FORMATTER" ] || exit 0 + +# --- Checkpoint: save pre-format content --- +if git -C "$(dirname "$FILE_PATH")" rev-parse --git-dir >/dev/null 2>&1; then + # Inside a git repo — use git hash-object for efficient checkpoint + BLOB="$(git -C "$(dirname "$FILE_PATH")" hash-object -w "$FILE_PATH" 2>/dev/null)" + if [ -n "$BLOB" ]; then + echo "$BLOB" > "${FILE_PATH}.pre-lint" + fi +else + # Not a git repo — fall back to file copy + cp "$FILE_PATH" "${FILE_PATH}.pre-lint" +fi + +# --- Run formatter --- +LINT_OUTPUT=$("$FORMATTER" "$FILE_PATH" 2>&1) +FORMATTER_EXIT=$? + +if [ $FORMATTER_EXIT -eq 0 ]; then + # Clean — remove checkpoint + rm -f "${FILE_PATH}.pre-lint" + exit 0 +fi + +# --- Lint errors remain --- +# Keep .pre-lint for revert, exit 2 to feed errors back to Claude +{ + echo "Lint errors in ${FILE_PATH}:" + echo "$LINT_OUTPUT" + echo "" + echo "To fix: use the Agent tool to resolve these lint errors (keeps fix loop out of main context)." + echo "To revert formatting: git cat-file blob \$(cat ${FILE_PATH}.pre-lint) > ${FILE_PATH}" +} >&2 + +exit 2 diff --git a/hooks/pre-commit-lint.sh b/hooks/pre-commit-lint.sh new file mode 100755 index 0000000..04eab3a --- /dev/null +++ b/hooks/pre-commit-lint.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Git pre-commit hook: format and lint staged files using project formatters. +# Reuses the same formatter scripts from formatters/. +# Exits non-zero if lint errors remain after auto-fix (blocks commit). + +# Find formatters/ directory relative to repo root +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" +[ -z "$REPO_ROOT" ] && exit 0 + +FORMATTERS_DIR="$REPO_ROOT/formatters" +[ -d "$FORMATTERS_DIR" ] || exit 0 + +HAD_ERRORS=0 +ERROR_OUTPUT="" + +# Iterate staged files (only Added/Modified, not Deleted) +while IFS= read -r FILE; do + [ -z "$FILE" ] && continue + + ABS_FILE="$REPO_ROOT/$FILE" + [ -f "$ABS_FILE" ] || continue + + # Extract extension (normalise yml → yaml) + EXT="${FILE##*.}" + case "$EXT" in + yml) EXT="yaml" ;; + esac + [ -z "$EXT" ] || [ "$EXT" = "$FILE" ] && continue + + # Check for formatter + FORMATTER="$FORMATTERS_DIR/$EXT" + [ -x "$FORMATTER" ] || continue + + # Run formatter + ERRORS=$("$FORMATTER" "$ABS_FILE" 2>&1) + EXIT_CODE=$? + + # Re-stage the file (formatter may have modified it) + git add "$ABS_FILE" + + if [ $EXIT_CODE -ne 0 ]; then + HAD_ERRORS=1 + ERROR_OUTPUT="${ERROR_OUTPUT}${FILE}:\n${ERRORS}\n\n" + fi +done < <(git diff --cached --name-only --diff-filter=AM) + +if [ $HAD_ERRORS -ne 0 ]; then + echo "Pre-commit lint errors — commit blocked:" >&2 + echo -e "$ERROR_OUTPUT" >&2 + echo "Fix the errors above and try again." >&2 + exit 1 +fi + +exit 0 diff --git a/memory/log/2026-03-13.100758.md b/memory/log/2026-03-13.100758.md new file mode 100644 index 0000000..818cd41 --- /dev/null +++ b/memory/log/2026-03-13.100758.md @@ -0,0 +1,22 @@ +# Session Log — 2026-03-13 + +## Summary +Implemented the composable multi-language linting and formatting system for Claude Code. Created formatter scripts, PostToolUse/pre-commit hooks, install scripts, `/linter` skill, best-practices documentation, and a README with project opt-in instructions. + +## Decisions +- Decision: Formatter scripts exit 1 on lint errors, dispatcher hook decides exit code (exit 2 for PostToolUse feedback) — Rationale: separates formatter logic from hook semantics; same scripts work for both PostToolUse and pre-commit +- Decision: Checkpoint uses `git hash-object -w` with `.pre-lint` sidecar file — Rationale: fast (~1ms), no commits/stash, orphan blobs auto-GC'd; falls back to `cp` outside git repos +- Decision: Projects opt in via symlinks in `formatters/` rather than config — Rationale: zero-config, visible in `ls`, no parsing needed; hook walks up directory tree to find `formatters/` +- Decision: Symlinked README.md into `~/dev/claude/` for visibility — Rationale: makes setup guide discoverable from the top-level working directory + +## Key Context +- claude-foundations lives at `~/dev/claude/projects/claude-foundations/` (not `~/dev/claude/claude-foundations/`) +- PostToolUse exit code 2 feeds stderr back to Claude as feedback without blocking the edit +- Agent-type hooks are read-only (no Edit/Write) — lint fixing must happen via Agent tool subagent, not hooks +- All hooks in an array run in parallel, not sequentially +- Formatter contract: `$1` = absolute path, format in place, stderr for errors, exit 0/1, no `set -e`, silent on missing tools + +## Process Notes +- Plan was thorough and complete — implementation was straightforward with minimal deviation +- Smoke tests confirmed all four paths: clean file, missing tools, no formatters dir, no extension +- No actual lint errors were triggered during testing since test files were already clean diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh new file mode 100755 index 0000000..6da3a8d --- /dev/null +++ b/scripts/install-hooks.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Symlink all hooks from claude-foundations/hooks/ into ~/.claude/hooks/ +# and print the settings.json configuration to add. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +HOOKS_SRC="$(cd "$SCRIPT_DIR/../hooks" && pwd)" +HOOKS_DST="$HOME/.claude/hooks" + +mkdir -p "$HOOKS_DST" + +echo "Installing hooks from $HOOKS_SRC → $HOOKS_DST" +echo "" + +for HOOK in "$HOOKS_SRC"/*.sh; do + NAME="$(basename "$HOOK")" + ln -sf "$HOOK" "$HOOKS_DST/$NAME" + echo " ✓ $NAME" +done + +echo "" +echo "Hooks symlinked. Ensure your ~/.claude/settings.json includes:" +echo "" +cat <<'EOF' +{ + "hooks": { + "PreCompact": [ + { + "matcher": "auto", + "hooks": [{ "type": "command", "command": "~/.claude/hooks/pre-compact-backup.sh", "timeout": 15, "statusMessage": "Backing up transcript before compaction..." }] + }, + { + "matcher": "manual", + "hooks": [{ "type": "command", "command": "~/.claude/hooks/pre-compact-backup.sh", "timeout": 15, "statusMessage": "Backing up transcript before compaction..." }] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [{ "type": "command", "command": "~/.claude/hooks/post-edit-lint.sh", "timeout": 30, "statusMessage": "Formatting and linting..." }] + } + ] + } +} +EOF diff --git a/scripts/setup-formatters.sh b/scripts/setup-formatters.sh new file mode 100755 index 0000000..24c0cb9 --- /dev/null +++ b/scripts/setup-formatters.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Set up formatters for a project by symlinking selected formatters from claude-foundations. +# +# Usage: setup-formatters.sh [ ...] +# Example: setup-formatters.sh ~/dev/claude/projects/my-project py sh +# +# This creates a formatters/ directory in the target project and symlinks +# the requested formatter scripts from claude-foundations/formatters/. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +FOUNDATIONS_FORMATTERS="$(cd "$SCRIPT_DIR/../formatters" && pwd)" + +if [ $# -lt 2 ]; then + echo "Usage: $(basename "$0") [ ...]" + echo "" + echo "Available formatters:" + for f in "$FOUNDATIONS_FORMATTERS"/*; do + [ -e "$f" ] || continue + NAME="$(basename "$f")" + if [ -L "$f" ]; then + TARGET="$(readlink "$f")" + echo " $NAME → $TARGET" + else + echo " $NAME" + fi + done + exit 1 +fi + +PROJECT_DIR="$1" +shift + +if [ ! -d "$PROJECT_DIR" ]; then + echo "Error: $PROJECT_DIR is not a directory" >&2 + exit 1 +fi + +DEST="$PROJECT_DIR/formatters" +mkdir -p "$DEST" + +# Compute relative path from project formatters/ to foundations formatters/ +REL_PATH="$(python3 -c "import os.path; print(os.path.relpath('$FOUNDATIONS_FORMATTERS', '$DEST'))")" + +echo "Setting up formatters in $DEST" +echo "" + +for EXT in "$@"; do + SRC="$FOUNDATIONS_FORMATTERS/$EXT" + if [ ! -e "$SRC" ]; then + echo " ✗ $EXT — not found in $FOUNDATIONS_FORMATTERS" >&2 + continue + fi + ln -sf "$REL_PATH/$EXT" "$DEST/$EXT" + echo " ✓ $EXT" +done + +echo "" +echo "Done. Add formatters/ to your .gitignore or commit the symlinks." + +# Also set up pre-commit hook if git repo +if git -C "$PROJECT_DIR" rev-parse --git-dir >/dev/null 2>&1; then + GIT_DIR="$(git -C "$PROJECT_DIR" rev-parse --git-dir)" + PRECOMMIT="$GIT_DIR/hooks/pre-commit" + PRECOMMIT_SRC="$SCRIPT_DIR/../hooks/pre-commit-lint.sh" + + if [ ! -e "$PRECOMMIT" ]; then + ln -sf "$(cd "$(dirname "$PRECOMMIT_SRC")" && pwd)/$(basename "$PRECOMMIT_SRC")" "$PRECOMMIT" + echo "Git pre-commit hook installed: $PRECOMMIT → pre-commit-lint.sh" + else + echo "Git pre-commit hook already exists at $PRECOMMIT (not overwritten)." + fi +fi