Runs your project's linter on each file Claude edits and feeds any errors back so Claude fixes them in the same turn.

Runs your project's linter on each file Claude edits and feeds any errors back so Claude fixes them in the same turn.
Use Claude Code's /plugin command:
/plugin https://github.com/onk3sh/fix-on-edit
Requires Claude Code 2.1.287+.
Two optional user-config fields (set them when you install the plugin):
| Field | Type | Default | Meaning |
|---|---|---|---|
command | string | "" | Lint command with a {file} placeholder, e.g. eslint --fix {file}. Empty = auto-detect by extension. |
timeoutMs | number | 15000 | Subprocess timeout in milliseconds. |
If no command is set, the mod detects the linter by file extension (first available candidate wins — npx tools must be installed in the project; ruff/gofmt must be on PATH):
| Extensions | Candidates (in order) |
|---|---|
.ts .tsx .js .jsx .mjs .cjs | npx --no-install eslint {file}, then npx --no-install prettier --check {file} |
.py | ruff check {file} |
.go | gofmt |
Node tools use npx --no-install to run project-local binaries without fetching from the network. Other extensions are not auto-detected. For Go, the file is flagged when gofmt reports it isn't formatted.
deny.command you configure with --fix/--write can change the file — that is your choice, not the mod's.tool.call handler, registered for Edit, Write, and MultiEdit.$.process.run call per candidate linter — one for most languages, at most two for JS/TS (eslint, then prettier only if eslint isn't installed).After Claude edits a file:
If any step can't proceed (no linter found, subprocess missing, timeout, etc.), the mod stays silent.
Note: a linter that errors on invocation (missing config, a file outside its configured root, etc.) exits non-zero, so its error is surfaced like a lint failure. Set an explicit command if you need to control exactly what runs.
hooks/register.ts 31 lines1import type { Register } from 'claude-code'
2import { resolveLinter, looksAbsent, appendToResult, clip, header, type Options } from './resolve'
3
4const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit'] as const
5
6export const register: Register = (on, options: Options) => {
7 const opts = options ?? {}
8 const timeoutMs = opts.timeoutMs ?? 15000
9
10 for (const tool of EDIT_TOOLS) {
11 on('tool.call', { tool }, async ($, e, next) => {
12 const r = await next(e) // 1. let the edit run first — never block it
13 const file = (e as { file_path?: unknown }).file_path
14 if (typeof file !== 'string' || !file) return r
15
16 for (const cmd of resolveLinter(file, opts)) {
17 let out
18 try {
19 out = await $.process.run(['sh', '-c', cmd], { timeoutMs })
20 } catch {
21 return r // run failed to spawn/timed out -> swallow, keep the edit
22 }
23 if (looksAbsent(out)) continue // tool not installed -> try the next candidate
24 if (out.exitCode === 0) return r // clean -> invisible
25 return appendToResult(r, header(file) + clip(out.stdout || out.stderr || '')) // issues -> surface
26 }
27 return r // nothing ran, or every candidate was absent
28 })
29 }
30}
31hooks/resolve.ts 63 lines1export interface Options {
2 command?: string
3 timeoutMs?: number
4}
5
6export interface RunResult {
7 exitCode: number
8 stdout: string
9 stderr: string
10}
11
12// Auto-detect table: first extension match wins; its list is tried in order.
13const AUTO: Array<[RegExp, string[]]> = [
14 [/\.(ts|tsx|js|jsx|mjs|cjs)$/i, ['npx --no-install eslint {file}', 'npx --no-install prettier --check {file}']],
15 [/\.py$/i, ['ruff check {file}']],
16 // gofmt -l exits 0 even when it lists unformatted files; wrap so an unformatted file exits nonzero and prints the diff.
17 [/\.go$/i, ['[ -z "$(gofmt -l {file})" ] || { gofmt -d {file}; exit 1; }']],
18]
19
20export function shellQuote(s: string): string {
21 return `'${s.replace(/'/g, `'\\''`)}'`
22}
23
24// Pure: file + options -> ordered shell commands to try (first non-absent wins). [] = run nothing.
25export function resolveLinter(file: string, options: Options): string[] {
26 const q = shellQuote(file)
27 const sub = (tpl: string) => tpl.replace(/\{file\}/g, () => q)
28 const configured = (options?.command ?? '').trim()
29 if (configured) return [sub(configured)]
30 for (const [re, tpls] of AUTO) if (re.test(file)) return tpls.map(sub)
31 return []
32}
33
34// Pure: did the command fail because the tool isn't installed, rather than finding issues?
35// ponytail: string-match heuristic for npx/npm "no local binary" exit (code 1, wording is
36// version-dependent); upgrade to an explicit $.fs.exists / `command -v` probe if it misfires.
37export function looksAbsent(out: RunResult): boolean {
38 if (out.exitCode === 127) return true
39 return /: command not found|could not determine executable to run/i.test(out.stderr)
40}
41
42export function clip(s: string, max = 4000): string {
43 return s.length <= max ? s : `${s.slice(0, max)}\n…(${s.length - max} more chars truncated by fix-on-edit)`
44}
45
46export function header(file: string): string {
47 return `fix-on-edit: ${file} did not pass lint:\n`
48}
49
50// Pure: append text to whatever shape the tool result takes; unknown shape -> unchanged, never throws.
51export function appendToResult(r: unknown, text: string): unknown {
52 if (!r || typeof r !== 'object') return r
53 const env = r as { result?: unknown }
54 const res = env.result
55 if (typeof res === 'string') return { ...env, result: `${res}\n\n${text}` }
56 if (Array.isArray(res)) return { ...env, result: [...res, { type: 'text', text }] }
57 if (res && typeof res === 'object' && Array.isArray((res as { content?: unknown }).content)) {
58 const c = res as { content: unknown[] }
59 return { ...env, result: { ...c, content: [...c.content, { type: 'text', text }] } }
60 }
61 return r
62}
63