SLOPSHOPPER

format-on-edit

After Claude edits a file, formats just that file with the project's formatter (Air for R, Ruff for Python, Prettier or Biome for JS when the project uses one)…

newguardcommandprocess
★ 1v0.1.0MITupdated 2026-10-13samuelbfernandes/moddeck/mods/format-on-edit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · format-on-edit
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /format-on-edit ⎿ format-on-edit: format-on-edit · off (type /format-on-edit on) ⎿ format-on-edit: lint fixes: off (the lint setting) · files reformatted this session: 0 ⎿ format-on-edit: air: found (found) · R ⎿ format-on-edit: ruff: found (found) · Python ⎿ format-on-edit: jarl: found (found) · R lint, used only with the lint setting on ⎿ format-on-edit: JS/TS/CSS/HTML: left alone (no Prettier config and no biome.json in this project) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

format-on-edit

Install: /plugin install format-on-edit --marketplace samuelbfernandes/moddeck

After Claude edits a file, formats just that file with the formatter your project already uses. Then Claude is told to read the file again, so its next Edit is not made against text that has changed. It is off until you turn it on.

What it does

After an Edit, Write or MultiEdit succeeds, and only when the switch is on:

  • .R / .r: air format <file>
  • .py / .pyi: ruff format <file>
  • .js .mjs .cjs .jsx .ts .tsx .css .scss .html .vue .json: only when the project already uses a formatter. With a Prettier config (a "prettier" key in package.json, a .prettierrc* file or a prettier.config.* file) it runs node_modules/.bin/prettier --write <file>, or prettier from PATH if the project has no local copy. Otherwise, with biome.json or biome.jsonc, it runs biome format --write <file> (again, the local copy first). With neither, JS files are left alone.

It never changes the tool call itself. It runs after the edit and only adds a note to the result. If the file's text changed, the note says format-on-edit: <tool> reformatted <file>. Read it again before editing it.. If the formatter changed nothing, there is no note. Each formatter is checked once per session (<tool> --version). A formatter that is not installed is skipped without a message. Each run may take up to 20 s.

It never touches files outside the session's folder (it resolves links first), or files under node_modules, .git, renv, .venv, dist or build.

Use

/format-on-edit on turn it on (remembered across sessions) /format-on-edit off turn it off /format-on-edit status on or off, files reformatted this session, which formatters were found

For a missing formatter, status says where to get it: Air at https://posit-dev.github.io/air/, Ruff at https://docs.astral.sh/ruff/, jarl at https://github.com/etiennebacher/jarl. Prettier and Biome come from the project's own devDependencies.

Settings

lint (default off): also apply lint fixes before formatting, with ruff check --fix <file> for Python and jarl check <file> --fix for R.

Cost

No model calls. The note is one short line of context, added only when a file really changed.

Turn off

/format-on-edit off, or /plugin disable format-on-edit.

State for the moddeck side bar

{ plugin: 'format-on-edit', key: 'isOff' } (boolean, true while off, which is the default) and { plugin: 'format-on-edit', key: 'formatted' } (number of files reformatted this session).

Programs it may start

air, ruff, jarl, prettier, biome, or the project's node_modules/.bin/prettier / biome (.cmd on Windows). They are always started as argument lists, never through a shell. It uses no network.

Limits and what was checked

  • Tested with the plugin test kit and a mocked engine (claude plugin test): the switch, each language, the Prettier and Biome rules, the folder rules, the note, and failures leaving the result alone. It was not run against real Air, Ruff, jarl, Prettier or Biome, because none is installed on the machine where it was written.
  • jarl's check <file> --fix form comes from jarl's README. Its --version flag is UNVERIFIED: any start that does not fail counts as found.
  • Windows: the .cmd shims in node_modules/.bin are tried first, but this is UNVERIFIED. Whether the engine can start a .cmd file without a shell is untested.
  • It formats the whole file, not only the lines Claude changed. If your project has not been formatted before, the first edit to a file can produce a large diff.
  • The JS rules look only at the session's root folder (package.json, config files). A front end kept in a subfolder with its own Prettier config is not picked up.
Source 3 files
hooks/register.tsx 193 lines
1// format-on-edit: after a successful Edit, Write or MultiEdit, formats just that file with the project's own formatter
2// (Air for R, Ruff for Python, Prettier or Biome for JS/TS/CSS/HTML/JSON/Vue when the project already uses one), and
3// tells the model in a note beside the result to read the file again, so its next Edit is not made on stale text.
4// Off until `/format-on-edit on`. Never changes the call itself.
5// FAIL OPEN: any error (no formatter, a timeout, a store or file problem) leaves the tool result exactly as it came back.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import { WHERE, isSkipped, isWindowsPath, labelOf, langOf, noteFor, parseArgs, relInside, sepOf, stepsFor, versionLine, webFormatter } from './lib'
10import type { Step, ToolName } from './lib'
11
12type Dollar = EngineInterface
13type Cfg = { lint: boolean }
14
15const COMMAND = 'format-on-edit'
16const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit'])
17const RUN_MS = 20_000
18const PROBE_MS = 5_000
19
20/** True while off (it ships off). The persisted switch is `$.store` 'on'. */
21const isOff = atom({ plugin: 'format-on-edit', key: 'isOff' } as const, true)
22const formatted = atom({ plugin: 'format-on-edit', key: 'formatted' } as const, 0)
23
24// Probe results for this module's life (a session; a reload probes again): program -> its version line, or undefined
25// when it would not start. Not drawn, so a module variable is the right place.
26let probes = new Map<string, Promise<string | undefined>>()
27
28function probe($: Dollar, bin: string): Promise<string | undefined> {
29  let found = probes.get(bin)
30  if (found === undefined) {
31    found = $.process
32      .run([bin, '--version'], { timeoutMs: PROBE_MS })
33      // It started: that is enough (127 is a shell's "not found", kept for safety).
34      .then(r => (r.exitCode === 127 ? undefined : versionLine(r.stdout || r.stderr) || 'found'))
35      .catch(() => undefined)
36    probes.set(bin, found)
37  }
38  return found
39}
40
41async function isOn($: Dollar): Promise<boolean> {
42  try {
43    return (await $.store.get('on')) === true
44  } catch {
45    return false
46  }
47}
48
49const join = (root: string, ...parts: string[]): string => [root.replace(/[\\/]+$/, ''), ...parts].join(sepOf(root))
50
51/** The project's own copy in node_modules/.bin when there is one, else the bare name (found on PATH). */
52async function localBin($: Dollar, root: string, name: 'prettier' | 'biome'): Promise<string> {
53  const names = isWindowsPath(root) ? [`${name}.cmd`, name] : [name]
54  for (const n of names) {
55    const path = join(root, 'node_modules', '.bin', n)
56    if (await $.fs.exists(path).catch(() => false)) return path
57  }
58  return name
59}
60
61async function projectWeb($: Dollar, root: string): Promise<'prettier' | 'biome' | undefined> {
62  const names = (await $.fs.list(root)).map(entry => entry.name)
63  const pkg = names.includes('package.json') ? await $.fs.read(join(root, 'package.json')).catch(() => '') : ''
64  return webFormatter(names, String(pkg))
65}
66
67const readText = async ($: Dollar, path: string): Promise<string | undefined> => {
68  try {
69    return String(await $.fs.read(path))
70  } catch {
71    return undefined
72  }
73}
74
75/** Formats the file when it should be; answers the note for the model when its content changed, else undefined. */
76async function formatAfter($: Dollar, cfg: Cfg, filePath: unknown): Promise<string | undefined> {
77  if (typeof filePath !== 'string' || filePath === '') return undefined
78  const lang = langOf(filePath)
79  if (lang === undefined || !(await isOn($))) return undefined
80
81  const cwd = await $.session.cwd()
82  const root = (await $.fs.stat(cwd, { resolve: true })).realPath ?? cwd
83  const stat = await $.fs.stat(filePath, { resolve: true })
84  if (stat.kind !== 'file' || stat.realPath === undefined) return undefined
85  const rel = relInside(root, stat.realPath)
86  if (rel === undefined || isSkipped(rel)) return undefined // outside the session's folder, or in a vendored one
87
88  const web = lang === 'web' ? await projectWeb($, root) : undefined
89  if (lang === 'web' && web === undefined) return undefined // the project formats no JS: leave it alone
90  const bins: Partial<Record<ToolName, string>> = web === undefined ? {} : { [web]: await localBin($, root, web) }
91  const steps = stepsFor(lang, stat.realPath, cfg.lint, bins, web)
92
93  let text = await readText($, stat.realPath)
94  if (text === undefined) return undefined // unreadable or over 4 MiB: not ours to touch
95  const changed: Step[] = []
96  for (const step of steps) {
97    if ((await probe($, step.bin)) === undefined) continue
98    const ran = await $.process.run(step.argv, { cwd: root, timeoutMs: RUN_MS }).catch(() => undefined)
99    if (ran === undefined) continue
100    const after = await readText($, stat.realPath)
101    if (after !== undefined && after !== text) {
102      changed.push(step)
103      text = after
104    }
105  }
106  if (changed.length === 0) return undefined
107  await update($, formatted, n => n + 1).catch(() => undefined)
108  return noteFor(labelOf(changed), filePath)
109}
110
111async function status($: Dollar, cfg: Cfg): Promise<string> {
112  const on = await isOn($)
113  const count = await read($, formatted).catch(() => 0)
114  const lines = [
115    `format-on-edit · ${on ? 'on' : 'off (type /format-on-edit on)'}`,
116    `lint fixes: ${cfg.lint ? 'on' : 'off (the lint setting)'} · files reformatted this session: ${count}`,
117  ]
118  const tell = async (tool: ToolName, bin: string, note = '') => {
119    const v = await probe($, bin)
120    lines.push(v === undefined ? `${tool}: not found · get it from ${WHERE[tool]}${note}` : `${tool}: found (${v})${note}`)
121  }
122  await tell('air', 'air', ' · R')
123  await tell('ruff', 'ruff', ' · Python')
124  await tell('jarl', 'jarl', cfg.lint ? ' · R lint' : ' · R lint, used only with the lint setting on')
125  let root = ''
126  try {
127    const cwd = await $.session.cwd()
128    root = (await $.fs.stat(cwd, { resolve: true })).realPath ?? cwd
129  } catch {
130    root = ''
131  }
132  const web = root === '' ? undefined : await projectWeb($, root).catch(() => undefined)
133  if (web === undefined) lines.push('JS/TS/CSS/HTML: left alone (no Prettier config and no biome.json in this project)')
134  else await tell(web, await localBin($, root, web), ' · this project uses it')
135  return lines.join('\n')
136}
137
138async function flip($: Dollar, to: boolean): Promise<string> {
139  try {
140    if (to) await $.store.set('on', true)
141    else await $.store.delete('on')
142    await update($, isOff, () => !to)
143  } catch {
144    return 'format-on-edit · that did not work, nothing changed'
145  }
146  return to ? 'format-on-edit · on · each file Claude edits is formatted with the project formatter' : 'format-on-edit · off'
147}
148
149export const register: Register = (on, options) => {
150  const cfg: Cfg = { lint: options.lint === true }
151
152  on('session.start', async ($, e, next) => {
153    probes = new Map()
154    try {
155      const stored = (await $.store.get('on')) === true
156      await update($, isOff, () => !stored)
157    } catch {
158      // the switch then reads as off
159    }
160    try {
161      await $.command.register({
162        name: COMMAND,
163        description: 'Format each file Claude edits with the project formatter: /format-on-edit on | off | status',
164        argumentHint: 'on | off | status',
165      })
166    } catch {
167      // fail-open: without the command the mod is simply quiet
168    }
169    return next(e)
170  })
171
172  on('command.run', { command: COMMAND }, async ($, e) => {
173    const p = parseArgs(e.args)
174    if (p.kind === 'on') return { text: await flip($, true) }
175    if (p.kind === 'off') return { text: await flip($, false) }
176    if (p.kind === 'status') return { text: await status($, cfg) }
177    return { text: `format-on-edit · unknown option "${p.kind === 'unknown' ? p.word : ''}"\nUse /format-on-edit on, off or status.` }
178  }).catch(() => ({ text: 'format-on-edit · could not read its own state' }))
179
180  // Runs the tool first and never changes the call; only adds a note beside a successful result.
181  on('tool.call', async ($, e, next) => {
182    const ran = await next(e)
183    if (!EDIT_TOOLS.has(String(e.tool)) || ran.deny !== undefined || ran.isError === true) return ran
184    try {
185      const note = await formatAfter($, cfg, (e as unknown as Record<string, unknown>).file_path)
186      if (note === undefined) return ran
187      return { ...ran, context: [...(ran.context ?? []), note] } as typeof ran
188    } catch {
189      return ran
190    }
191  }).catch(($, e, next) => next(e))
192}
193
hooks/lib.ts 107 lines
1// Pure helpers: which formatter a file gets, the argv of each step, where a file lies, and the command's words.
2
3export type Lang = 'r' | 'python' | 'web'
4/** A program format-on-edit may run, by its short name. */
5export type ToolName = 'air' | 'ruff' | 'jarl' | 'prettier' | 'biome'
6
7/** One program run on the file: `tool` names it for probing and the note, `argv` is what runs (no shell). */
8export type Step = { tool: ToolName; bin: string; argv: string[] }
9
10const WEB = new Set(['js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'css', 'scss', 'html', 'vue', 'json'])
11
12/** Folders whose files are never formatted, wherever they are in the path. */
13export const SKIP_DIRS = new Set(['node_modules', '.git', 'renv', '.venv', 'dist', 'build'])
14
15export const WHERE: Record<ToolName, string> = {
16  air: 'posit-dev.github.io/air',
17  ruff: 'docs.astral.sh/ruff',
18  jarl: 'github.com/etiennebacher/jarl',
19  prettier: "the project's own devDependencies (package.json)",
20  biome: "the project's own devDependencies (package.json)",
21}
22
23export function extOf(path: string): string {
24  const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
25  const dot = name.lastIndexOf('.')
26  return dot <= 0 ? '' : name.slice(dot + 1).toLowerCase()
27}
28
29export function langOf(path: string): Lang | undefined {
30  const ext = extOf(path)
31  if (ext === 'r') return 'r'
32  if (ext === 'py' || ext === 'pyi') return 'python'
33  if (WEB.has(ext)) return 'web'
34  return undefined
35}
36
37/** The separator a root path is spelled with: `\` for a Windows path with no `/`, else `/`. */
38export const sepOf = (root: string): string => (root.includes('\\') && !root.includes('/') ? '\\' : '/')
39
40/** Whether a root looks like a Windows path (a drive letter or backslashes). */
41export const isWindowsPath = (root: string): boolean => /^[A-Za-z]:[\\/]/.test(root) || sepOf(root) === '\\'
42
43/** The file's path below `root`, or undefined when it is not strictly inside. Both are resolved real paths. */
44export function relInside(root: string, real: string): string | undefined {
45  const trimmed = root.replace(/[\\/]+$/, '')
46  if (trimmed === '') return undefined
47  const sep = sepOf(trimmed)
48  if (!real.startsWith(trimmed + sep)) return undefined
49  const rel = real.slice(trimmed.length + 1)
50  return rel === '' ? undefined : rel
51}
52
53/** True when any folder on the path below the root is one never formatted. */
54export function isSkipped(rel: string): boolean {
55  const parts = rel.split(/[\\/]+/)
56  return parts.slice(0, -1).some(part => SKIP_DIRS.has(part))
57}
58
59/** Prettier when the project has a Prettier config, else Biome when it has biome.json(c), else nothing. */
60export function webFormatter(rootNames: readonly string[], packageJson: string): 'prettier' | 'biome' | undefined {
61  const hasPrettierFile = rootNames.some(name => /^\.prettierrc(\..+)?$/.test(name) || /^prettier\.config\.[a-z]+$/.test(name))
62  let hasPrettierKey = false
63  try {
64    const parsed = JSON.parse(packageJson) as Record<string, unknown>
65    hasPrettierKey = parsed !== null && typeof parsed === 'object' && parsed.prettier !== undefined
66  } catch {
67    hasPrettierKey = false
68  }
69  if (hasPrettierFile || hasPrettierKey) return 'prettier'
70  if (rootNames.includes('biome.json') || rootNames.includes('biome.jsonc')) return 'biome'
71  return undefined
72}
73
74/** The steps for one file: lint fixes first (when asked), then the formatter. `bins` maps a tool to the program to start. */
75export function stepsFor(lang: Lang, file: string, lint: boolean, bins: Partial<Record<ToolName, string>>, web?: 'prettier' | 'biome'): Step[] {
76  const step = (tool: ToolName, args: string[]): Step => {
77    const bin = bins[tool] ?? tool
78    return { tool, bin, argv: [bin, ...args] }
79  }
80  if (lang === 'r') return [...(lint ? [step('jarl', ['check', file, '--fix'])] : []), step('air', ['format', file])]
81  if (lang === 'python') return [...(lint ? [step('ruff', ['check', '--fix', file])] : []), step('ruff', ['format', file])]
82  if (web === 'prettier') return [step('prettier', ['--write', file])]
83  if (web === 'biome') return [step('biome', ['format', '--write', file])]
84  return []
85}
86
87/** The words for the steps that changed the file: "air", "jarl and air", "ruff". */
88export function labelOf(steps: readonly Step[]): string {
89  const names = [...new Set(steps.map(s => s.tool))]
90  return names.length <= 1 ? (names[0] ?? 'the formatter') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
91}
92
93export const noteFor = (label: string, file: string): string =>
94  `format-on-edit: ${label} reformatted ${file}. Read it again before editing it.`
95
96export type Parsed = { kind: 'on' | 'off' | 'status' } | { kind: 'unknown'; word: string }
97
98export function parseArgs(args: string): Parsed {
99  const w = (args.trim().split(/\s+/)[0] ?? '').toLowerCase()
100  if (w === '' || w === 'status') return { kind: 'status' }
101  if (w === 'on' || w === 'off') return { kind: w }
102  return { kind: 'unknown', word: w.slice(0, 30) }
103}
104
105/** The first line of a version output, kept short. */
106export const versionLine = (out: string): string => (out.split(/\r?\n/).find(l => l.trim() !== '') ?? '').trim().slice(0, 60)
107
types/index.d.ts 11 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'format-on-edit': {
4      /** True while format-on-edit is off (it ships off). The persisted switch is `$.store` 'on'; loaded at session.start. */
5      isOff: boolean
6      /** Files a formatter changed this session. */
7      formatted: number
8    }
9  }
10}
11