Automatic Code Checking with Hooks: Every File, Every Time

Use PostToolUse Hooks to auto-trigger linting after Claude writes files. Exit code 2 feeds errors back to Claude for automatic fixing — a write→check→fix loop with no manual steps.


Claude Code writes code fast — but it doesn't always remember to run lint. You ask it to "check things while you're at it," and sometimes it does, sometimes it doesn't. Hooks solve this by shifting code checking from "Claude's responsibility" to "the system's responsibility."

This article focuses on one thing: using PostToolUse Hooks to automatically trigger checks after Claude writes code, so every file change meets your coding standards.


The Approach: PostToolUse + File Type Filtering

The logic for a code-checking Hook is straightforward:

  1. Claude calls Write or Edit to write a file
  2. The Hook intercepts this event and gets the file path
  3. Based on the file extension, it decides which tool to run
  4. Check passes: exit code 0, Claude continues
  5. Check fails: exit code 2, errors are fed back to Claude so it can fix them

Exit code 2 is the key — it makes Claude receive the error output and automatically fix it, creating a "write → check → fix" loop.


Basic Configuration: Single-Language Projects

Ruby (RuboCop)

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "bash -c 'file=$(echo \"$CLAUDE_TOOL_INPUT\" | jq -r .file_path 2>/dev/null); [[ \"$file\" == *.rb ]] && rubocop -A \"$file\" --no-color -q || true'"
          }
        ]
      }
    ]
  }
}

The -A flag tells RuboCop to auto-fix whatever it can. Issues it can't fix are written to stderr with a non-zero exit code — Claude receives them and handles them.

JavaScript/TypeScript (ESLint)

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "bash -c 'file=$(echo \"$CLAUDE_TOOL_INPUT\" | jq -r .file_path 2>/dev/null); [[ \"$file\" =~ \\.(js|ts|jsx|tsx)$ ]] && npx eslint --fix \"$file\" || true'"
          }
        ]
      }
    ]
  }
}

Python (ruff)

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "bash -c 'file=$(echo \"$CLAUDE_TOOL_INPUT\" | jq -r .file_path 2>/dev/null); [[ \"$file\" == *.py ]] && ruff check --fix \"$file\" && ruff format \"$file\" || true'"
          }
        ]
      }
    ]
  }
}

Advanced Configuration: Multi-Language Projects

Real projects often mix languages. Moving the check logic into a standalone script is easier to maintain than cramming everything into a JSON string.

Create .claude/hooks/lint.sh:

#!/bin/bash
set -e

# Read tool call info from stdin
input=$(cat)
file=$(echo "$input" | jq -r '.tool_input.file_path // empty')

# No file path — exit
[[ -z "$file" ]] && exit 0

# File doesn't exist (maybe a delete) — exit
[[ ! -f "$file" ]] && exit 0

ext="${file##*.}"

case "$ext" in
  rb)
    rubocop -A "$file" --no-color -q
    ;;
  js|ts|jsx|tsx)
    npx eslint --fix "$file" --quiet
    ;;
  py)
    ruff check --fix "$file"
    ruff format "$file"
    ;;
  go)
    gofmt -w "$file"
    golangci-lint run "$file" 2>&1
    ;;
  *)
    exit 0
    ;;
esac

Reference the script in settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/lint.sh"
          }
        ]
      }
    ]
  }
}

Scripts can be commented, tested independently, and committed to git for the whole team.


Getting Claude to Auto-Fix

Exit code 2 + stderr output is what triggers Claude's auto-fix behavior. By default, a linter failing with a non-zero exit code will surface the error — but using exactly 2 signals Claude to act on it immediately.

#!/bin/bash
input=$(cat)
file=$(echo "$input" | jq -r '.tool_input.file_path // empty')

[[ -z "$file" || ! -f "$file" ]] && exit 0

ext="${file##*.}"

run_lint() {
  local output exit_code
  output=$(rubocop "$file" --no-color 2>&1)
  exit_code=$?

  if [[ $exit_code -ne 0 ]]; then
    echo "$output" >&2
    exit 2  # Use 2, not the original code, to ensure Claude acts on it
  fi

  rubocop -A "$file" --no-color -q
}

[[ "$ext" == "rb" ]] && run_lint

Claude sees the exact error output and fixes it in the next turn.


Type Checking: A Separate Hook

Type checks are usually slower than linting — better to keep them separate so small edits don't wait on a full type check run.

TypeScript type checking (.ts files only):

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "bash -c 'file=$(echo \"$CLAUDE_TOOL_INPUT\" | jq -r .file_path 2>/dev/null); [[ \"$file\" == *.ts ]] && npx tsc --noEmit --skipLibCheck 2>&1 | head -20 || true'"
          }
        ]
      }
    ]
  }
}

head -20 caps the output — tsc errors can be enormous, and feeding Claude a wall of text hurts more than it helps.


Excluding Files That Shouldn't Be Checked

Some files shouldn't be linted: auto-generated code, vendor directories, test fixtures.

#!/bin/bash
input=$(cat)
file=$(echo "$input" | jq -r '.tool_input.file_path // empty')

[[ -z "$file" || ! -f "$file" ]] && exit 0

skip_patterns=(
  "vendor/"
  "node_modules/"
  "db/schema.rb"
  "db/queue_schema.rb"
  ".min.js"
  "_test.go"
)

for pattern in "${skip_patterns[@]}"; do
  [[ "$file" == *"$pattern"* ]] && exit 0
done

ext="${file##*.}"
[[ "$ext" == "rb" ]] && rubocop -A "$file" --no-color -q

What It Looks Like in Practice

With this set up, the workflow becomes:

  1. You ask Claude to write a Ruby file
  2. Claude writes it with the Write tool
  3. The Hook runs rubocop -A automatically — finds 3 issues
  4. RuboCop auto-fixes 2 of them; the 3rd needs a judgment call
  5. The 3rd issue is fed back to Claude via stderr
  6. Claude fixes the code; the Hook fires again
  7. Second check passes — done

You never manually run a check command.


Global vs. Project-Level

Global (~/.claude/settings.json): Good when you use the same standards across all projects — personal projects with a consistent ESLint + RuboCop setup.

Project-level (.claude/settings.json committed to git): Recommended for team projects. Anyone who opens the repo gets the right hooks automatically, no setup required.

Both can coexist — project-level hooks merge with global ones and both run.


Summary

Automatic code checking with Hooks comes down to three steps:

  1. Use PostToolUse + "Write|Edit" matcher to intercept file writes
  2. Extract the file path and route it to the right linter by extension
  3. On failure, exit with code 2 so Claude sees the error and fixes it

Start with one language. Once it's running, add more. Commit the scripts to .claude/hooks/ so the whole team benefits without any extra setup.