Skip to content

Instantly share code, notes, and snippets.

@ariel-frischer
Last active July 11, 2025 20:44
Show Gist options
  • Save ariel-frischer/aceac390fa5cbcb87c482efc59a8efe1 to your computer and use it in GitHub Desktop.
Save ariel-frischer/aceac390fa5cbcb87c482efc59a8efe1 to your computer and use it in GitHub Desktop.
General Claude Code Lint+Format Hook
#!/bin/bash
# Claude Code PostToolUse Hook for Auto-formatting and Linting
#
# This script integrates with Claude Code's hook system to automatically
# format and lint files after they're edited or created.
#
# How it works:
# 1. Claude Code calls this script after Write/Edit/MultiEdit|Update operations
# 2. The script receives JSON input via stdin containing:
# - session_id: Current Claude session
# - transcript_path: Path to conversation JSON
# - hook_event_name: "PostToolUse"
# - tool_name: The tool that was used (Write, Edit, etc.)
# - tool_input: Contains file_path and other tool-specific data
# - tool_response: The result of the tool operation
#
# 3. The script extracts the file path from the JSON using jq
# 4. Based on file extension, it runs appropriate linters/formatters
# 5. Exit codes determine Claude's behavior:
# - Exit 0: Success (silent, no output shown to user)
# - Exit 2: Blocking error (stderr shown to Claude for processing)
# - Other: Non-blocking error (stderr shown to user)
#
# Supported file types:
# - Python: ruff (import sorting, linting, formatting) + mypy (type checking)
# - JavaScript/TypeScript: eslint + prettier
# - Shell scripts: shellcheck + shfmt
# - JSON: jq validation and formatting
# - YAML: yamllint
# - TOML: taplo
# - Terraform: terraform fmt + validate
# - Dockerfile: hadolint
#
# The script attempts to auto-fix issues where possible. If fixes can't
# be applied automatically (e.g., syntax errors), it reports the error
# back to Claude via stderr with exit code 2.
set -e
# Read JSON from stdin
json_input=$(cat)
# Extract file path from JSON based on tool type
# For Write/Edit/MultiEdit tools, the file path is in tool_input.file_path
file_path=$(echo "$json_input" | jq -r '.tool_input.file_path // empty')
# If no file path found, exit silently
if [ -z "$file_path" ]; then
exit 0
fi
# Check if file exists
if [ ! -f "$file_path" ]; then
exit 0
fi
handle_python() {
local file="$1"
# Try to fix imports first
if ! ruff check --select I --fix "$file" 2>&1; then
echo "Failed to fix imports in $file" >&2
exit 2
fi
# Then try to fix other issues
if ! ruff check --fix "$file" 2>&1; then
echo "Ruff check failed for $file" >&2
exit 2
fi
# Format the file
if ! ruff format "$file" 2>&1; then
echo "Ruff format failed for $file" >&2
exit 2
fi
# Type check - this might fail for syntax errors
if ! mypy "$file" 2>&1; then
echo "MyPy type checking failed for $file" >&2
exit 2
fi
}
handle_javascript() {
local file="$1"
if command -v eslint >/dev/null 2>&1; then
if ! eslint --fix "$file" 2>&1; then
echo "ESLint failed for $file" >&2
exit 2
fi
fi
if command -v prettier >/dev/null 2>&1; then
if ! prettier --write "$file" 2>&1; then
echo "Prettier failed for $file" >&2
exit 2
fi
fi
}
handle_typescript() {
local file="$1"
if command -v eslint >/dev/null 2>&1; then
if ! eslint --fix "$file" 2>&1; then
echo "ESLint failed for $file" >&2
exit 2
fi
fi
if command -v prettier >/dev/null 2>&1; then
if ! prettier --write "$file" 2>&1; then
echo "Prettier failed for $file" >&2
exit 2
fi
fi
if command -v tsc >/dev/null 2>&1; then
if ! tsc --noEmit "$file" 2>&1; then
echo "TypeScript compilation failed for $file" >&2
exit 2
fi
fi
}
handle_shell() {
local file="$1"
if command -v shellcheck >/dev/null 2>&1; then
if ! shellcheck "$file" 2>&1; then
echo "ShellCheck failed for $file" >&2
exit 2
fi
fi
if command -v shfmt >/dev/null 2>&1; then
if ! shfmt -w "$file" 2>&1; then
echo "shfmt failed for $file" >&2
exit 2
fi
fi
}
handle_json() {
local file="$1"
if command -v jq >/dev/null 2>&1; then
tmp=$(mktemp)
if ! jq . "$file" >"$tmp" 2>&1; then
echo "JSON validation failed for $file" >&2
rm -f "$tmp"
exit 2
fi
mv "$tmp" "$file"
fi
}
handle_yaml() {
local file="$1"
if command -v yamllint >/dev/null 2>&1; then
if ! yamllint "$file" 2>&1; then
echo "YAML validation failed for $file" >&2
exit 2
fi
fi
}
handle_toml() {
local file="$1"
if command -v taplo >/dev/null 2>&1; then
if ! taplo fmt "$file" 2>&1; then
echo "TOML formatting failed for $file" >&2
exit 2
fi
fi
}
handle_tf() {
local file="$1"
if command -v terraform >/dev/null 2>&1; then
if ! terraform fmt "$file" 2>&1; then
echo "Terraform fmt failed for $file" >&2
exit 2
fi
if ! terraform validate "$file" 2>&1; then
echo "Terraform validation failed for $file" >&2
exit 2
fi
fi
}
handle_dockerfile() {
local file="$1"
if command -v hadolint >/dev/null 2>&1; then
if ! hadolint "$file" 2>&1; then
echo "Hadolint failed for $file" >&2
exit 2
fi
fi
}
# Process the file based on its extension
case "$file_path" in
*.py | *.pyi | *.pyx | *.pxd)
handle_python "$file_path"
;;
*.js | *.cjs | *.mjs)
handle_javascript "$file_path"
;;
*.ts | *.tsx)
handle_typescript "$file_path"
;;
*.sh)
handle_shell "$file_path"
;;
*.json)
handle_json "$file_path"
;;
*.yaml | *.yml)
handle_yaml "$file_path"
;;
*.toml)
handle_toml "$file_path"
;;
*.tf | *.tfvars | *.hcl)
handle_tf "$file_path"
;;
*dockerfile | *Dockerfile)
handle_dockerfile "$file_path"
;;
*)
# Silently skip files we don't handle
;;
esac
# Exit with success if we reach here
exit 0
@ariel-frischer
Copy link
Author

Add to .claude/settings.json:

  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit|MultiEdit|Update",
        "hooks": [
          {
            "type": "command",
            "command": "/home/USER/project/scripts/format-code.sh"
          }
        ]
      }
    ]
  },

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment