SLOPSHOPPER

fix-on-edit

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

newguardprocess
v0.1.0no licenseupdated 2026-10-09onk3sh/fix-on-edit
A shopper browsing a rack in a slop shop
README

fix-on-edit

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

Install

Use Claude Code's /plugin command:

/plugin https://github.com/onk3sh/fix-on-edit

Requires Claude Code 2.1.287+.

Configuration

Two optional user-config fields (set them when you install the plugin):

FieldTypeDefaultMeaning
commandstring""Lint command with a {file} placeholder, e.g. eslint --fix {file}. Empty = auto-detect by extension.
timeoutMsnumber15000Subprocess timeout in milliseconds.

Auto-detect table

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):

ExtensionsCandidates (in order)
.ts .tsx .js .jsx .mjs .cjsnpx --no-install eslint {file}, then npx --no-install prettier --check {file}
.pyruff check {file}
.gogofmt

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.

Guarantees

  • Invisible on success: if the linter exits cleanly (code 0), the result is unchanged.
  • Never breaks an edit: if the linter can't be spawned or times out, the edit still succeeds and the mod stays silent. (A linter that runs and reports problems surfaces them — that is the point.)
  • Never denies: always returns the original result, never uses deny.
  • Never modifies the file itself: the mod only observes. A command you configure with --fix/--write can change the file — that is your choice, not the mod's.

Footprint

  • A single tool.call handler, registered for Edit, Write, and MultiEdit.
  • One $.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).
  • No network access; no file I/O.
  • Subprocess timeout is configurable.

How it works

After Claude edits a file:

  1. Resolve a linter command (config, or auto-detect by extension).
  2. Run it on the single changed file with the configured timeout.
  3. If it fails (exit code ≠ 0), append the error output to the tool result so Claude sees it.
  4. Claude self-corrects in the same turn.

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.

Source 2 files
hooks/register.ts 31 lines
1import 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}
31
hooks/resolve.ts 63 lines
1export 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