Register a PostToolUse hook with the matcher Edit|Write. Claude Code runs it after every successful Edit or Write and passes the edited file’s absolute path in tool_input.file_path, and the hook runs the formatter on that one file. This page gives you that hook: one settings.json block and one bash script that picks the formatter by file extension (Prettier, Ruff or Black, gofmt, rustfmt, shfmt), runs only the ones you have installed, and then hands lint errors from Ruff or ESLint back to Claude so it fixes them in the same turn.

The script was fed 14 recorded hook payloads against sample files and the real formatters. The results table below shows what each one did to the file and what Claude would have been told, including the cases where the hook does nothing and one where it gets in the way.

Everything below about hook events, the payload and the feedback format comes from the official Claude Code hooks reference and the hooks guide, both fetched on 2026-10-09, when this machine’s claude --version reported 2.1.295. Each formatter flag comes from that formatter’s own documentation, linked where it is used.

What a PostToolUse hook gets

The reference describes PostToolUse in one line: “Runs immediately after a tool completes successfully.” The edit is already on disk when the hook starts, which is what a formatter needs.

The hook reads a JSON payload on stdin. The input section says “The input includes both tool_input, the arguments sent to the tool, and tool_response, the result it returned.” For Write and Edit, tool_input.file_path is the file Claude touched, and file paths arrive “always absolute, with the platform’s native separators, so backslashes on Windows”. That one field is all this script reads. The reference’s own sample payload for a Write looks like this, trimmed:

{
  "hook_event_name": "PostToolUse",
  "tool_name": "Write",
  "tool_input": {
    "file_path": "/path/to/file.txt",
    "content": "file content"
  },
  "tool_response": {
    "filePath": "/path/to/file.txt",
    "type": "create"
  }
}

The hooks guide has a one-line version of this hook that pipes jq -r '.tool_input.file_path' into npx prettier --write. It works for a JavaScript project that has jq installed. The script below does the same job for six languages and without jq, and it adds the part the one-liner cannot do: telling Claude when something is wrong.

Sending lint errors back to Claude

A hook can talk to Claude after an edit in two documented ways, and one easy mistake makes it talk to nobody.

  • JSON on stdout, exit 0. Print one JSON object on stdout, {“decision”: “block”, “reason”: ”…”}, and exit 0. The reference says block adds the reason next to the tool result, so Claude reads the lint errors with the result of its own edit. The exact cell in the PostToolUse decision table reads: “"block" adds the reason next to the tool result. Claude still sees the original output”. Despite the word, nothing is blocked: the edit already happened.
  • Exit 2 with a message on stderr. The exit code 2 table gives PostToolUse the row “Shows stderr to Claude; the tool already ran”.

Why would Claude ignore a hook’s output? Most often because it wrote to stderr and exited 0. The reference says stderr from a hook that exits 0 goes to the debug log only and Claude never sees it. Either print the JSON decision on stdout, or exit 2 so that Claude sees stderr. The exact words: “Stderr from a hook that exits 0 goes to the debug log only, never the transcript, and Claude never sees it.” For exit 1: “Without valid JSON on stdout, Claude Code treats exit code 1 as a non-blocking error and proceeds with the action”. That gets you a hook error notice in the transcript, not lint errors in front of Claude. A hook that pipes ruff check output to stderr and ends with exit 1 looks like it should work and does nothing useful.

This script uses the JSON form and always exits 0. The reference says to “Choose one approach per hook: either use exit codes alone for signaling, or exit 0 and print JSON for structured control”, and adds: “Your hook’s stdout must contain only the JSON object.” That second rule is why the script captures every formatter’s own output instead of letting it through. Prettier prints the name of each file it writes, and one stray line ahead of the JSON is enough to make Claude Code treat the whole output as plain text.

The settings.json block

Add this to ~/.claude/settings.json. If the file already has a hooks key, put the PostToolUse entry inside it rather than replacing it.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/claude-format.sh",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

The matcher. The guide uses the same one for its Prettier example: “This hook uses the PostToolUse event with an Edit|Write matcher, so it runs only after file-editing tools.” A matcher made only of letters and | is a list of exact tool names, so this fires for Edit and Write and nothing else: not after Bash, Read or a web fetch. Without a matcher, the hook would run after every tool call and exit at once for most of them, because they carry no file_path.

The timeout. timeout is in seconds, and the common fields table gives “Defaults: 600 for command, http, and mcp_tool”. Ten minutes is far too long to wait on a formatter for one file. Thirty seconds leaves room for a cold Prettier or ESLint start in a big project. If a run does hit the limit, the timeouts section says Claude Code cancels the hook, “discarding the hook’s output, so on most events a timed-out hook renders no decision.” You lose that one round of feedback and the edit stands.

Not async. Hooks also take "async": true, which runs them in the background. Do not add it here. The reference says that after a background hook exits, “Claude Code delivers the additionalContext and systemMessage fields from the hook’s JSON response to Claude on the next conversation turn.” The decision and reason this script prints are not on that list.

The user file is the right home for it because ~/.claude/settings.json applies to all your projects, and the script works out per file which tools each project has. The ~ works because a command hook without args runs in shell form, which the reference says “is passed to a shell: sh -c on macOS and Linux”.

Create the folder, save the script from the next section into it, and make it executable:

mkdir -p ~/.claude/hooks
# save the script below as ~/.claude/hooks/claude-format.sh, then:
chmod +x ~/.claude/hooks/claude-format.sh

The format-on-edit script

It needs bash and python3. Python reads the payload and writes the JSON reply; there is no jq. Every formatter and linter is optional.

#!/usr/bin/env bash
# claude-format.sh: a Claude Code PostToolUse hook. After Claude edits or
# writes a file, run that file's formatter, then its linter, and hand any
# lint errors or formatter failures back to Claude as a JSON "block" reason.
# Every formatter and linter is optional: one that is not installed is skipped.
# Needs bash and python3 (python3 only reads and writes JSON): no jq.

# --- the one field this script needs: tool_input.file_path -------------------
# Anything unexpected (no python3, input that is not JSON, a tool with no
# file_path, a file that is gone) means "nothing to format": exit 0.
file=$(python3 -c '
import json, sys
print((json.load(sys.stdin).get("tool_input") or {}).get("file_path") or "")
' 2>/dev/null) || exit 0
file="${file//\\//}"   # Windows backslashes -> slashes, as the docs advise
[[ -n $file && -f $file ]] || exit 0
name="${file##*/}"

# --- find a tool: the project's own copy first, then your PATH ---------------
# Walks up from the edited file to node_modules/.bin/<tool> or .venv/bin/<tool>,
# so a project that pins its own Prettier or Ruff version gets that version.
# ESLint is only used from the project, because it needs the project's config.
find_tool() {
  local dir="${file%/*}" bin
  while [[ -n $dir ]]; do
    for bin in "$dir/node_modules/.bin/$1" "$dir/.venv/bin/$1"; do
      [[ -x $bin ]] && { printf '%s\n' "$bin"; return 0; }
    done
    dir="${dir%/*}"
  done
  [[ $1 == eslint ]] && return 1
  command -v "$1"
}

# --- run LABEL TOOL ARGS... from the tool's project root; keep the output if --
# --- it fails (lint errors, or a file the formatter could not parse) ----------
problems=""
run() {
  local label=$1 tool=$2 root=. out rc=0
  shift
  case "$tool" in
    */node_modules/.bin/*) root="${tool%/node_modules/.bin/*}" ;;
    */.venv/bin/*) root="${tool%/.venv/bin/*}" ;;
  esac
  out=$(cd "$root" && "$@" 2>&1) || rc=$?
  if ((rc != 0)); then
    problems+="${label} exited ${rc} on ${name}:"$'\n'
    problems+="$(printf '%s\n' "$out" | head -n 40)"$'\n\n'
  fi
}

case "$name" in
  *.js | *.jsx | *.mjs | *.cjs | *.ts | *.tsx | *.css | *.scss | *.json | *.md | *.html | *.yaml | *.yml)
    if t=$(find_tool prettier); then run prettier "$t" --write "$file"; fi
    case "$name" in
      *.css | *.scss | *.json | *.md | *.html | *.yaml | *.yml) ;;
      *) if t=$(find_tool eslint); then run eslint "$t" "$file"; fi ;;
    esac
    ;;
  *.py)
    if t=$(find_tool ruff); then
      run "ruff format" "$t" format "$file"
      run "ruff check" "$t" check --output-format concise "$file"
    elif t=$(find_tool black); then
      run black "$t" --quiet "$file"
    fi
    ;;
  *.go) if t=$(find_tool gofmt); then run gofmt "$t" -w "$file"; fi ;;
  *.rs) if t=$(find_tool rustfmt); then run rustfmt "$t" "$file"; fi ;;
  *.sh | *.bash) if t=$(find_tool shfmt); then run shfmt "$t" -w "$file"; fi ;;
esac

[[ -z $problems ]] && exit 0

# --- the documented PostToolUse feedback: {"decision":"block","reason":...} ---
printf '%s' "$problems" | python3 -c '
import json, sys
print(json.dumps({"decision": "block", "reason": sys.stdin.read().rstrip()}))
'
exit 0

What each part does:

  • It fails open. If python3 is missing, the input is not JSON, the tool call had no file_path or the file no longer exists, the script exits 0 and prints nothing. That is the opposite of the PreToolUse guard, which fails closed, and it is deliberate: a formatter that cannot run should get out of the way, not stop the session.
  • The project’s own tools win. For each tool the script walks up from the edited file looking for node_modules/.bin/<tool> or .venv/bin/<tool>, and only then tries your PATH. A repository that pins Prettier 3 in its package.json gets Prettier 3, not whatever is installed globally. The tool then runs from that project’s root folder, so it finds the project’s config files.
  • ESLint is project-only. ESLint needs a config file to do anything, so the script only uses an ESLint installed in the project. A global ESLint with no config would report a config error on every edit.
  • One formatter per extension. Prettier for JavaScript, TypeScript, CSS, SCSS, JSON, Markdown, HTML and YAML; Ruff for Python, or Black when Ruff is not installed; gofmt for Go; rustfmt for Rust; shfmt for shell scripts. Anything else, including a file with no extension, is left alone.
  • Lint after format. Python files go through ruff check and JavaScript or TypeScript files through the project’s ESLint. A linter or formatter that exits non-zero has its first 40 lines of output collected; on a clean run the script prints nothing at all.
  • One JSON reply. If anything was collected, python3 writes it out as a single {"decision": "block", "reason": ...} object, which takes care of quoting, backslashes and newlines in compiler messages.

Each command line uses only flags its own documentation gives. Prettier’s CLI page on --write: “This rewrites all processed files in place.” Ruff’s formatter page gives ruff format path/to/file.py for a single file, and its configuration page lists concise among the values of ruff check --output-format; one line per problem is easier for Claude to act on than the default with code excerpts. Black’s docs on --quiet: “Stop emitting all non-critical output.” gofmt on -w: “If a file’s formatting is different from gofmt’s, overwrite it with gofmt’s version.” shfmt’s manual on -w: “Write result to file instead of stdout.” The rustfmt README needs no flag: “rustfmt lib.rs main.rs will format “lib.rs” and “main.rs” in place”. And ESLint’s CLI page is why a lint failure is detected by exit code alone: exit 1 means “Linting was successful and there is at least one linting error”.

Every payload it was fed

Each row is a PostToolUse payload in the shape the reference documents, piped into the script with a sample file in place, with the real formatters installed. “What ran” is the exact command the script started, minus the file path. “Sent to Claude” means the script printed the JSON reply, and the cell quotes the first lines of its reason. The test that guards this page re-runs every row against the script as published here and compares the file, the reply and the commands.

#FileWhat ranResult
1app.js (Write)prettier --writeFormatted: single quotes to double, spaces and semicolons added
2util.py (Edit)ruff format, ruff check --output-format conciseFormatted to four-space indents; ruff check found nothing
3report.py (Edit)ruff format, ruff check --output-format conciseSent to Claude: “ruff check exited 1 on report.py:” then “proj/report.py:1:8: F401 [*] os imported but unused”. The file was formatted too
4legacy.py, with only Black installedblack --quietFormatted by Black, since Ruff was not there
5greet.js, in a project with its own Prettier and ESLintprettier --write, eslintSent to Claude: “eslint exited 1 on greet.js:” then “2:9 error ‘unused’ is assigned a value but never used no-unused-vars”. Prettier formatted it first, using the project’s own copy
6broken.js, a syntax errorprettier --writeSent to Claude: “prettier exited 2 on broken.js:” then “SyntaxError: Unexpected token (1:12)”. The file is unchanged
7site.cssprettier --writeFormatted
8main.gogofmt -wFormatted
9main.rsrustfmtFormatted
10fetch.rs, no rustfmt.tomlrustfmtSent to Claude: “rustfmt exited 1 on fetch.rs:” then “error[E0670]: async fn is not permitted in Rust 2015”. A false error: see the limits below
11fetch.rs, with edition = “2021” in rustfmt.tomlrustfmtFormatted; the same file as the row above now parses
12build.shshfmt -wFormatted, with shfmt’s default tab indents
13notes.txtnothingLeft alone: no formatter for .txt; exit 0, nothing printed
14plain.js, with no formatter installednothingLeft alone: Prettier is not installed; exit 0, nothing printed

Rows 3, 5 and 6 are the point of the hook: Claude gets the lint error or the parse error next to the result of the edit that caused it, and can fix it in the same turn. Rows 13 and 14 are the point of “only if installed”: no formatter, no noise. Row 10 is the hook getting in the way, and the fix is one line in a config file.

Test it by piping a payload in

You do not need a Claude Code session to check the script. In an empty folder, write a badly formatted JavaScript file and a Python file with an unused import, then pipe a Write payload for each into the hook:

printf "const greet=(name)=>{return 'hi '+name}\n" > hook-test.js
printf 'import os\n' > hook-test.py
for f in hook-test.js hook-test.py; do
  printf '{"hook_event_name":"PostToolUse","tool_name":"Write","tool_input":{"file_path":"%s"}}' "$PWD/$f" |
    ~/.claude/hooks/claude-format.sh
  echo "exit $?"
done
cat hook-test.js
exit 0
{"decision": "block", "reason": "ruff check exited 1 on hook-test.py:\nhook-test.py:1:8: F401 [*] `os` imported but unused\nFound 1 error.\n[*] 1 fixable with the `--fix` option."}
exit 0
const greet = (name) => {
  return "hi " + name;
};

The JavaScript file is rewritten in place and the hook prints nothing for it. For the Python file it prints the one JSON object Claude would receive, and exit 0 both times. If the .js file comes back unchanged, Prettier is not on your PATH or in a node_modules/.bin above the folder; if there is no JSON for the .py file, the same goes for Ruff.

What this cannot do

  • Files changed by Bash. The matcher only fires for the Edit and Write tools, and the reference says Claude Code doesn’t run a PostToolUse hook matching Edit|Write when a Bash command or a process outside Claude Code rewrites the same file. A sed -i, a code generator or npm run fix started by Claude all go unformatted by this hook. The same section points to the FileChanged event for that case.
  • Undo anything. By the time it runs, the edit is on disk. The hook can reformat the file and tell Claude what is wrong, and that is all. The guide: “PostToolUse hooks can’t undo actions since the tool has already executed.”
  • Tell Claude about a clean reformat. The guide says “When the hook succeeds, Claude Code shows nothing in the conversation.” When Prettier only moved whitespace, Claude is not told that the file now differs from the text it wrote.
  • Lint more than two languages. Only Ruff (Python) and the project’s ESLint (JavaScript and TypeScript) report errors. Go, Rust and shell files are formatted, and they only produce feedback when the formatter itself fails, usually on a syntax error. Add go vet or shellcheck with another run line if you want them.
  • Know your Rust edition. Run directly, rustfmt parses old syntax. Its README says: “However, when running rustfmt directly, the edition defaults to 2015.” Row 10 of the table is the result: async fn is reported as an error that is not there. Put edition = "2021" (or your edition) in a rustfmt.toml at the project root, as row 11 does.
  • Make a slow linter fast. The hook runs after every single edit, and Claude waits for it. A full-project type check or test run belongs in a Stop hook or in CI, not here.
  • See notebooks or other tools. The matcher names Edit and Write only. A Jupyter notebook edit, or an MCP tool that writes files, does not trigger it.
  • Show long error lists in full. Each tool’s output is cut at 40 lines, so a file with hundreds of lint errors arrives as the first 40.

The same hooks file can hold a PreToolUse guard that blocks rm -rf, force pushes and .env reads before they run, and a desktop notification when Claude Code is done. If the formatter keeps fighting Claude and you want it to stop mid-turn, How to Stop Claude Code has the keys.

What was tested, and what was not

The script was tested by piping recorded PostToolUse payloads into it on Linux (bash 5.1, Python 3.10), not inside a live Claude Code session. The formatters were Prettier 3.9.9, ESLint 9.39.5, Ruff 0.16.10, Black 26.10.0, gofmt from Go 1.27.2, rustfmt 1.10.0 and shfmt 3.14.1. It was not run on macOS or Windows.

The test that guards this page takes the settings.json block and the script from the built page, installs the script with the page’s own commands, and pipes every row above into it, directly and through the hook’s command string via sh -c, the way the reference says shell-form hooks run. Those seven tools are not installed where that test runs, so it puts stand-ins in their place that log each command line and play back what the real tool did in these recorded runs. A command line or file the real tool never saw fails the test. The script’s own choices, which tool, which flags, which folder and what it tells Claude, are checked on every run. What the test cannot show is how a particular Claude Code version reacts to the reply in a live session. For that, the documentation quoted above is the source, and the pipe test is the quickest way to check your own setup.

← Back to all posts

Follow new posts by RSS: paste dankdev.com/feed.xml into any feed reader. It is a plain XML file — no signup, no email address, no account. Posts land there the same day they go up, and there is nothing to unsubscribe from later.

Back to the top