HookStack
Back to catalogue
ValidationPostToolUse· Write|Edit

Ruff format + lint after write

Python lands PEP 8-clean on every save

Runs ruff format then ruff check --fix on the modified Python file: style is applied silently and remaining lint violations are reported to the agent immediately.

What does the Ruff format + lint after write hook do?

Ruff format + lint after write is a Claude Code PostToolUse hook matching Write|Edit. It fires automatically at that lifecycle event — outside the model, so it can't be skipped or forgotten. Python lands PEP 8-clean on every save.

As a PostToolUse hook it runs after the action, reacting to what just happened rather than blocking it. Because it is a deterministic Node.js script, it executes on every matching event without relying on the model to remember — the guarantee that makes agentic workflows safe to automate.

Use cases

  • Catch unused imports and undefined names before they accumulate
  • Auto-fix trivial violations (isort, unnecessary pass, etc.)
  • Enforce project ruff rules without a CI round-trip

Tags

#validation#ruff#lint#format#python#quality

settings.json fragment

{
  "hooks": {
    "PostToolUse": [
      {
        "hooks": [
          {
            "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/ruff-check.mjs",
            "type": "command"
          }
        ],
        "matcher": "Write|Edit"
      }
    ]
  }
}

Script · .claude/hooks/ruff-check.mjs

#!/usr/bin/env node
// @hookstack post-write-ruff-check
import { execSync } from "node:child_process";
// @hookstack post-write-ruff-check
// Formate (silencieux) puis analyse et auto-corrige le fichier Python avec ruff
// après écriture (PostToolUse Write|Edit) — fusion de ruff-format + ruff-check.
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";

function defaultExec(cmd) {
	return execSync(cmd, { encoding: "utf8", stdio: "pipe", timeout: 15_000 });
}	export function run(input, { exec = defaultExec } = {}) {
		const filePath = input.tool_input?.file_path ?? input.tool_input?.path ?? "";
		if (!filePath.endsWith(".py")) return null;

		// Format silencieux (non bloquant), puis lint --fix (erreurs remontées).
		try {
			exec(`uv run ruff format "${filePath}"`);
		} catch {
			// uv/ruff absent — non bloquant
		}

		try {
			exec(`uv run ruff check --fix "${filePath}"`);
			return null;
		} catch (err) {
			const output = err.stdout?.toString() ?? "";
			return output ? { message: `[ruff-check] ${output.trim()}\n` } : null;
		}
	}

/* v8 ignore next 5 */
if (process.argv[1] === fileURLToPath(import.meta.url)) {
	const input = JSON.parse(readFileSync(0, "utf8"));
	const result = run(input);
	if (result?.message) process.stderr.write(result.message);
}

Learn more

Related hooks