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

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.
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.
/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.
lint (default off): also apply lint fixes before formatting, with ruff check --fix <file> for Python and jarl check <file> --fix for R.
No model calls. The note is one short line of context, added only when a file really changed.
/format-on-edit off, or /plugin disable format-on-edit.
{ 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).
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.
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.check <file> --fix form comes from jarl's README. Its --version flag is UNVERIFIED: any start that does not fail counts as found..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.package.json, config files). A front end kept in a subfolder with its own Prettier config is not picked up.hooks/register.tsx 193 lines1// 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}
193hooks/lib.ts 107 lines1// 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)
107types/index.d.ts 11 lines1declare 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