How to Make Claude Code Automatically Format and Lint Files

Every file Claude Code writes is a file your linter hasn’t seen yet. I kept catching formatting and linting violations that CI would have flagged anyway. You can set up PostToolUse hooks that run the formatter and linter after every file write. When the linter fails, the hook returns a block decision with the output, and Claude fixes the issues on its next turn automatically.

Here’s how to set it up for PHP, CSS/JavaScript/TypeScript, and Go (the primary languages I’ve been working with), based on the tools I use.

Why a shell script

You could inline the command directly in settings.json, but it turns into escaping hell fast. Hook commands are JSON strings, so any shell logic has to survive JSON escaping → shell escaping → nested quoting. The inline version of the PHP example looks like:

"command": "jq -r '.tool_input.file_path // .tool_response.filePath' | { read -r f; case \"$f\" in *.php) mago format \"$f\" >/dev/null 2>&1; if ! out=$(mago lint --minimum-fail-level=warning \"$f\" 2>&1); then printf '{\"decision\":\"block\",\"reason\":%s}' \"$(printf '%s' \"$out\" | jq -Rs .)\"; fi ;; esac; } || true"

Versus a script you can read, comment, shellcheck, test standalone with echo '{"tool_input":{"file_path":"app/Foo.php"}}' | .claude/hooks/format-lint.sh, and extend without turning the command into an illegible string. The settings.json entry stays a one-liner reference: "$CLAUDE_PROJECT_DIR"/.claude/hooks/format-lint.sh.

Setup

Every example uses the same .claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format-lint.sh"
          }
        ]
      }
    ]
  }
}

And the same script structure in .claude/hooks/format-lint.sh. The only thing that changes is the case block. Make the script executable with chmod +x .claude/hooks/format-lint.sh.

PHP — mago

Mago handles both formatting and linting for PHP.

#!/usr/bin/env bash
set -euo pipefail

f=$(jq -r '.tool_input.file_path // .tool_response.filePath // empty')
[ -n "$f" ] || exit 0

block() {
  printf '{"decision":"block","reason":%s}' "$(printf '%s' "$1" | jq -Rs .)"
  exit 0
}

case "$f" in
  *.php)
    mago format "$f" >/dev/null 2>&1 || true
    if ! out=$(mago lint --minimum-fail-level=warning "$f" 2>&1); then
      block "$out"
    fi
    ;;
esac

The rest of the script is always the same — drop a different case block inside the case "$f" in ... esac from above.

JavaScript/TypeScript/CSS — oxc

oxc provides oxlint for linting and oxfmt for formatting.

  *.js|*.jsx|*.ts|*.tsx|*.css)
    npx --no-install oxfmt "$f" >/dev/null 2>&1 || true
    if ! out=$(npx --no-install oxlint --fix --deny-warnings "$f" 2>&1); then
      block "$out"
    fi
    ;;

Go — gofmt + golangci-lint

Note: golangci-lint needs a package path, not a file path — that’s why this does dir=$(dirname "$f") and passes "$dir/..." instead of just "$f".

  *.go)
    gofmt -w "$f" 2>/dev/null || true
    dir=$(dirname "$f")
    if ! out=$(golangci-lint run --fix "$dir/..." 2>&1); then
      block "$out"
    fi
    ;;

Multiple languages

Combine the case blocks into one script:

case "$f" in
  *.php)
    mago format "$f" >/dev/null 2>&1 || true
    if ! out=$(mago lint --minimum-fail-level=warning "$f" 2>&1); then
      block "$out"
    fi
    ;;
  *.js|*.jsx|*.ts|*.tsx|*.css)
    npx --no-install oxfmt "$f" >/dev/null 2>&1 || true
    if ! out=$(npx --no-install oxlint --fix --deny-warnings "$f" 2>&1); then
      block "$out"
    fi
    ;;
esac

Quick setup

Want Claude Code to set this up for you?

Copy this prompt

Read https://benword.com/how-to-make-claude-code-automatically-format-and-lint-files and set up the PostToolUse format/lint hook for this project. Use whatever linter and formatter this project already uses.