Created
March 11, 2026 15:00
-
-
Save dannysteenman/26acf501545eb426bcc4b35cbbb2d97c to your computer and use it in GitHub Desktop.
PostToolUse hook: auto-lint-format and typecheck files after Edit/Write/MultiEdit.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # PostToolUse hook: auto-lint-format and typecheck files after Edit/Write/MultiEdit. | |
| # Detects the project's formatter from config files and package.json scripts. | |
| # Exit 0 always — formatting/typecheck failure shouldn't block Claude. | |
| INPUT=$(cat) | |
| FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') | |
| if [[ -z "$FILE_PATH" || ! -f "$FILE_PATH" ]]; then | |
| exit 0 | |
| fi | |
| # Walk up from the file to find the project root (first dir with .git or package.json) | |
| find_project_root() { | |
| local dir="$1" | |
| while [[ "$dir" != "/" ]]; do | |
| if [[ -d "$dir/.git" || -f "$dir/package.json" || -f "$dir/pyproject.toml" || -f "$dir/go.mod" ]]; then | |
| echo "$dir" | |
| return | |
| fi | |
| dir=$(dirname "$dir") | |
| done | |
| } | |
| PROJECT_ROOT=$(find_project_root "$(dirname "$FILE_PATH")") | |
| if [[ -z "$PROJECT_ROOT" ]]; then | |
| exit 0 | |
| fi | |
| cd "$PROJECT_ROOT" || exit 0 | |
| # ── Shared helpers ────────────────────────────────────────────────────────── | |
| # Emit a hook block decision (tells Claude to fix errors before continuing) | |
| block() { | |
| jq -n --arg reason "$1" '{"decision": "block", "reason": $reason}' | |
| } | |
| # Run a command and block on errors, filtering output to lines matching a pattern. | |
| # Usage: run_and_block <label> <filter> <cmd...> | |
| run_and_block() { | |
| local label="$1" filter="$2"; shift 2 | |
| local result exit_code | |
| result=$("$@" 2>&1) | |
| exit_code=$? | |
| if [[ $exit_code -ne 0 && -n "$result" ]]; then | |
| local file_errors | |
| file_errors=$(echo "$result" | rg -F -- "$filter") | |
| if [[ -n "$file_errors" ]]; then | |
| block "$label found errors in $FILE_PATH. Fix all errors before proceeding: | |
| $file_errors" | |
| fi | |
| fi | |
| } | |
| # Run a command and block on any non-zero exit (no output filtering). | |
| # Usage: run_and_block_all <label> <cmd...> | |
| run_and_block_all() { | |
| local label="$1"; shift | |
| local result exit_code | |
| result=$("$@" 2>&1) | |
| exit_code=$? | |
| if [[ $exit_code -ne 0 && -n "$result" ]]; then | |
| block "$label found issues in $FILE_PATH. Fix all issues before proceeding: | |
| $result" | |
| fi | |
| } | |
| # Search upward from PROJECT_ROOT to the git root for any of the given filenames. | |
| find_config_upward() { | |
| local dir="$PROJECT_ROOT" | |
| while [[ "$dir" != "/" ]]; do | |
| for name in "$@"; do | |
| [[ -f "$dir/$name" ]] && return 0 | |
| done | |
| [[ -e "$dir/.git" ]] && break | |
| dir=$(dirname "$dir") | |
| done | |
| return 1 | |
| } | |
| # Detect package manager from lock files | |
| detect_pkg_manager() { | |
| if [[ -f "$PROJECT_ROOT/pnpm-lock.yaml" ]]; then echo "pnpm" | |
| elif [[ -f "$PROJECT_ROOT/yarn.lock" ]]; then echo "yarn" | |
| elif [[ -f "$PROJECT_ROOT/bun.lockb" || -f "$PROJECT_ROOT/bun.lock" ]]; then echo "bun" | |
| else echo "npm" | |
| fi | |
| } | |
| # ── Cached package.json data (read once, avoids repeated jq forks) ────────── | |
| _PKG_SCRIPT_VALUES="" | |
| _HAS_FORMAT_SCRIPT=false | |
| if [[ -f "$PROJECT_ROOT/package.json" ]]; then | |
| _PKG_SCRIPT_VALUES=$(jq -r '.scripts // {} | to_entries[] | .value' "$PROJECT_ROOT/package.json" 2>/dev/null) | |
| jq -e '.scripts.format' "$PROJECT_ROOT/package.json" &>/dev/null && _HAS_FORMAT_SCRIPT=true | |
| fi | |
| # Check if any package.json script value contains a tool name | |
| pkg_script_has() { | |
| [[ -n "$_PKG_SCRIPT_VALUES" ]] && echo "$_PKG_SCRIPT_VALUES" | rg -q "$1" | |
| } | |
| # ── JS/TS/CSS/JSON ────────────────────────────────────────────────────────── | |
| format_js() { | |
| local is_biome="$1" | |
| if [[ "$is_biome" == true ]]; then | |
| npx @biomejs/biome format --no-errors-on-unmatched --write "$FILE_PATH" &>/dev/null | |
| return | |
| fi | |
| # Try project's format script first | |
| if [[ "$_HAS_FORMAT_SCRIPT" == true ]]; then | |
| local pkg_mgr | |
| pkg_mgr=$(detect_pkg_manager) | |
| $pkg_mgr run format -- "$FILE_PATH" &>/dev/null | |
| return | |
| fi | |
| # Fall back to config-detected formatters | |
| if find_config_upward ".prettierrc" ".prettierrc.json" ".prettierrc.js" ".prettierrc.mjs" \ | |
| ".prettierrc.yaml" ".prettierrc.yml" ".prettierrc.toml" \ | |
| "prettier.config.js" "prettier.config.mjs" "prettier.config.ts" \ | |
| || pkg_script_has "prettier"; then | |
| npx prettier --write "$FILE_PATH" &>/dev/null | |
| elif find_config_upward "dprint.json" ".dprint.json"; then | |
| npx dprint fmt "$FILE_PATH" &>/dev/null | |
| fi | |
| } | |
| lint_js() { | |
| local is_biome="$1" | |
| if [[ "$is_biome" == true ]]; then | |
| local biome_result exit_code | |
| biome_result=$(npx @biomejs/biome lint --no-errors-on-unmatched --write "$FILE_PATH" 2>&1) | |
| exit_code=$? | |
| if [[ $exit_code -ne 0 && -n "$biome_result" ]]; then | |
| block "Biome found lint errors in $FILE_PATH. Fix all errors before proceeding: | |
| $biome_result" | |
| fi | |
| return | |
| fi | |
| # ESLint: legacy .eslintrc* requires ESLINT_USE_FLAT_CONFIG=false on ESLint 9+ | |
| local eslint_cmd=() | |
| if find_config_upward ".eslintrc" ".eslintrc.json" ".eslintrc.js" ".eslintrc.cjs" \ | |
| ".eslintrc.yml" ".eslintrc.yaml"; then | |
| eslint_cmd=(env ESLINT_USE_FLAT_CONFIG=false npx eslint --fix "$FILE_PATH") | |
| elif find_config_upward "eslint.config.js" "eslint.config.mjs" "eslint.config.ts"; then | |
| eslint_cmd=(npx eslint --fix "$FILE_PATH") | |
| elif pkg_script_has "eslint"; then | |
| eslint_cmd=(npx eslint --fix "$FILE_PATH") | |
| fi | |
| if [[ ${#eslint_cmd[@]} -gt 0 ]]; then | |
| local eslint_result exit_code | |
| eslint_result=$("${eslint_cmd[@]}" 2>&1) | |
| exit_code=$? | |
| if [[ $exit_code -ne 0 && -n "$eslint_result" ]]; then | |
| block "ESLint found errors in $FILE_PATH. Fix all errors before proceeding: | |
| $eslint_result" | |
| fi | |
| fi | |
| } | |
| typecheck_ts() { | |
| [[ "$FILE_PATH" != *.ts && "$FILE_PATH" != *.tsx ]] && return | |
| find_config_upward "tsconfig.json" || return | |
| local rel_path="${FILE_PATH#"$PROJECT_ROOT"/}" | |
| if command -v tsgo &>/dev/null; then | |
| run_and_block "Typecheck" "$rel_path" npx tsgo --noEmit | |
| else | |
| run_and_block "Typecheck" "$rel_path" npx tsc --noEmit | |
| fi | |
| } | |
| check_react_doctor() { | |
| [[ ! -f "$PROJECT_ROOT/package.json" ]] && return | |
| jq -e '.dependencies.react // .devDependencies.react // .peerDependencies.react' \ | |
| "$PROJECT_ROOT/package.json" &>/dev/null || return | |
| local result exit_code | |
| result=$(npx react-doctor . --verbose --diff --no-ami 2>&1) | |
| exit_code=$? | |
| if [[ $exit_code -ne 0 && -n "$result" ]]; then | |
| block "react-doctor found issues after editing $FILE_PATH. Fix errors first, then re-run to verify the score improved: | |
| $result" | |
| fi | |
| } | |
| # ── Python ────────────────────────────────────────────────────────────────── | |
| typecheck_py() { | |
| command -v ty &>/dev/null || return | |
| local rel_path="${FILE_PATH#"$PROJECT_ROOT"/}" | |
| run_and_block "ty" "$rel_path" ty check "$FILE_PATH" | |
| } | |
| # ── Go ────────────────────────────────────────────────────────────────────── | |
| check_go() { | |
| local rel_path="${FILE_PATH#"$PROJECT_ROOT"/}" | |
| local rel_dir | |
| rel_dir=$(dirname "$rel_path") | |
| if command -v go &>/dev/null; then | |
| go fmt "$FILE_PATH" &>/dev/null | |
| run_and_block "go vet" "$rel_path" go vet "./$rel_dir/..." | |
| fi | |
| if command -v golangci-lint &>/dev/null \ | |
| && find_config_upward ".golangci.yml" ".golangci.yaml" ".golangci.toml" ".golangci.json"; then | |
| local result exit_code | |
| result=$(golangci-lint run "./$rel_dir/..." 2>&1) | |
| exit_code=$? | |
| if [[ $exit_code -ne 0 && -n "$result" ]]; then | |
| local file_errors | |
| file_errors=$(echo "$result" | rg -F -- "$rel_path:") | |
| if [[ -n "$file_errors" ]]; then | |
| block "golangci-lint found issues in $FILE_PATH. Fix all issues before proceeding: | |
| $file_errors" | |
| elif [[ $exit_code -ne 1 ]]; then | |
| echo "golangci-lint exited with code $exit_code (possible config/tool error)" >&2 | |
| fi | |
| fi | |
| fi | |
| } | |
| # ── Shell ─────────────────────────────────────────────────────────────────── | |
| check_shellcheck() { | |
| command -v shellcheck &>/dev/null || return | |
| run_and_block_all "shellcheck" shellcheck "$FILE_PATH" | |
| } | |
| # ── GitHub Actions ────────────────────────────────────────────────────────── | |
| lint_github_actions() { | |
| command -v actionlint &>/dev/null || return | |
| run_and_block_all "actionlint" actionlint "$FILE_PATH" | |
| } | |
| # ── Dispatch ──────────────────────────────────────────────────────────────── | |
| case "$FILE_PATH" in | |
| *.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs|*.json|*.css|*.html) | |
| is_biome=false | |
| if find_config_upward "biome.json" "biome.jsonc" || pkg_script_has "biome"; then | |
| is_biome=true | |
| fi | |
| format_js "$is_biome" | |
| lint_js "$is_biome" | |
| typecheck_ts | |
| check_react_doctor | |
| ;; | |
| *.py) | |
| if command -v ruff &>/dev/null; then | |
| ruff format "$FILE_PATH" &>/dev/null | |
| run_and_block_all "Ruff" ruff check --fix "$FILE_PATH" | |
| elif command -v black &>/dev/null; then | |
| black -q "$FILE_PATH" &>/dev/null | |
| fi | |
| typecheck_py | |
| ;; | |
| *.go) | |
| check_go | |
| ;; | |
| *.md|*.mdx) | |
| if command -v rumdl &>/dev/null; then | |
| rumdl fmt "$FILE_PATH" &>/dev/null | |
| fi | |
| ;; | |
| *.rs) | |
| if command -v rustfmt &>/dev/null; then | |
| rustfmt "$FILE_PATH" &>/dev/null | |
| fi | |
| ;; | |
| *.sh|*.bash) | |
| check_shellcheck | |
| ;; | |
| *.yml|*.yaml) | |
| if [[ "$FILE_PATH" == */.github/workflows/* ]]; then | |
| lint_github_actions | |
| fi | |
| ;; | |
| *) | |
| if [[ -f "$FILE_PATH" ]]; then | |
| shebang=$(head -1 "$FILE_PATH" 2>/dev/null) | |
| if [[ "$shebang" == "#!/bin/bash"* || "$shebang" == "#!/usr/bin/env bash"* || \ | |
| "$shebang" == "#!/bin/sh"* || "$shebang" == "#!/usr/bin/env sh"* ]]; then | |
| check_shellcheck | |
| fi | |
| fi | |
| ;; | |
| esac | |
| exit 0 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment