Skip to content

Instantly share code, notes, and snippets.

@dannysteenman
Created March 11, 2026 15:00
Show Gist options
  • Select an option

  • Save dannysteenman/26acf501545eb426bcc4b35cbbb2d97c to your computer and use it in GitHub Desktop.

Select an option

Save dannysteenman/26acf501545eb426bcc4b35cbbb2d97c to your computer and use it in GitHub Desktop.
PostToolUse hook: auto-lint-format and typecheck files after Edit/Write/MultiEdit.
#!/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