Lints the files Claude just edited (SwiftLint, ESLint, Ruff, ShellCheck when available) in the background, with a status line count and a pane of issues

Lints the files Claude just edited, in the background, so lint errors show up while the code is still fresh.
Edit/Write calls made by the main conversation and picks a linter by extension:| Files | Linter | Runs when | From folder |
|---|---|---|---|
.swift | swiftlint lint --reporter json --quiet <files> | swiftlint is installed | closest .swiftlint.yml, else .git / Package.swift / project.yml |
.ts .tsx .js .jsx | npx --no-install eslint -f json <files> | the project has node_modules/.bin/eslint up the tree (nothing is ever installed) | that folder |
.py | ruff check --output-format json <files> | ruff is installed | closest ruff.toml / .ruff.toml / pyproject.toml, else .git |
.sh | shellcheck -f json <files> | shellcheck is installed | closest .shellcheckrc, else .git |
command -v). No linter available: nothing happens.2 or fatal, ShellCheck error; Ruff has none, so syntax errors and E9/F63/F7/F82 count as errors and the rest as warnings.lint ✓, or lint ⚠ 3 · ✗ 1 (warnings · errors), plus N not linted when a linter gave no report.| Command | Effect |
|---|---|
/lint-watch | Opens the pane (✗ file:line rule message, errors first) and says which linters were found |
/lint-watch run | Re-lints every file edited this session, now |
/lint-watch off | Stops linting and clears the status line (kept across sessions) |
/lint-watch on | Resumes |
claude --plugin-dir /path/to/ModsTools/mods/lint-watch
Edit/Write: files changed by Bash, subagents or formatters are not linted.claude plugin validate mods/lint-watch
claude plugin test mods/lint-watch # 20 testshooks/register.tsx 250 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { FileResult, LinterName } from '../types'
5import {
6 EXECUTABLE,
7 LINTERS,
8 ROOTS,
9 ancestors,
10 argv,
11 dirName,
12 fileResult,
13 issueLine,
14 join,
15 linterFor,
16 linterSummary,
17 newlyFailing,
18 parseReport,
19 shown,
20 statusText,
21 toastText,
22} from './lint'
23
24const PANE = 'lint-watch'
25const TITLE = 'Lint'
26const DEBOUNCE_MS = 2000
27const LINT_MS = 2 * 60 * 1000
28const WHICH_MS = 5000
29const MAX_EDITED = 500
30
31const results = atom({ plugin: 'lint-watch', key: 'results' } as const, {})
32const edited = atom({ plugin: 'lint-watch', key: 'edited' } as const, [])
33const summary = atom({ plugin: 'lint-watch', key: 'summary' } as const, null)
34
35type Group = { key: string; linter: LinterName; root: string; files: string[] }
36
37// Files edited since the last flush, the debounce timer, linter availability, runs in flight.
38const pending = new Set<string>()
39let timer: Timer | undefined
40const available = new Map<LinterName, boolean>()
41const running = new Set<string>()
42const queued = new Map<string, Group>()
43
44async function isEnabled($: EngineInterface): Promise<boolean> {
45 return (await $.store.get('enabled').catch(() => undefined)) !== false
46}
47
48// Checked once per linter and module load: `command -v`, through sh since there is no shell.
49async function isAvailable($: EngineInterface, linter: LinterName): Promise<boolean> {
50 const known = available.get(linter)
51 if (known !== undefined) return known
52 let found = false
53 try {
54 const ran = await $.process.run(['/bin/sh', '-c', 'command -v "$1"', 'sh', EXECUTABLE[linter]], { timeoutMs: WHICH_MS })
55 found = ran.exitCode === 0 && ran.stdout.trim() !== ''
56 } catch {
57 found = false
58 }
59 available.set(linter, found)
60
61 return found
62}
63
64async function closest($: EngineInterface, dir: string, names: readonly string[]): Promise<string | null> {
65 if (names.length === 0) return null
66 for (const folder of ancestors(dir)) {
67 for (const name of names) if (await $.fs.exists(join(folder, name)).catch(() => false)) return folder
68 }
69
70 return null
71}
72
73// The folder the linter runs from, or null when it must not run for this file.
74async function rootFor($: EngineInterface, linter: LinterName, file: string): Promise<string | null> {
75 const rule = ROOTS[linter]
76 const dir = dirName(file)
77 const config = await closest($, dir, rule.config)
78 if (config !== null || rule.required) return config
79
80 return (await closest($, dir, rule.fallback)) ?? dir
81}
82
83async function groupFiles($: EngineInterface, files: readonly string[]): Promise<Group[]> {
84 const groups = new Map<string, Group>()
85 for (const file of files) {
86 const linter = linterFor(file)
87 if (linter === null || !(await isAvailable($, linter))) continue
88 const root = await rootFor($, linter, file)
89 if (root === null) continue
90 const key = `${linter}:${root}`
91 const group = groups.get(key) ?? { key, linter, root, files: [] }
92 if (!group.files.includes(file)) group.files.push(file)
93 groups.set(key, group)
94 }
95
96 return [...groups.values()]
97}
98
99async function lintOnce($: EngineInterface, group: Group): Promise<FileResult[]> {
100 const cwd = await $.session.cwd().catch(() => '/')
101 const view = (file: string) => shown(file, cwd)
102 try {
103 const ran = await $.process.run(argv(group.linter, group.files), { cwd: group.root, timeoutMs: LINT_MS })
104 const report = parseReport(group.linter, ran.stdout, group.root, group.files)
105 if (report === null) {
106 return group.files.map(file => fileResult(file, view(file), group.linter, [], `${group.linter} gave no JSON report (exit ${ran.exitCode})`))
107 }
108
109 return group.files.map(file => fileResult(file, view(file), group.linter, report.get(file) ?? []))
110 } catch {
111 return group.files.map(file => fileResult(file, view(file), group.linter, [], `${group.linter} could not run or timed out`))
112 }
113}
114
115// One run at a time per linter and root; files arriving meanwhile run right after.
116async function runGroup($: EngineInterface, group: Group): Promise<FileResult[]> {
117 if (running.has(group.key)) {
118 const waiting = queued.get(group.key)
119 queued.set(group.key, waiting === undefined ? group : { ...waiting, files: [...new Set([...waiting.files, ...group.files])] })
120 return []
121 }
122 running.add(group.key)
123 let done: FileResult[]
124 try {
125 done = await lintOnce($, group)
126 } finally {
127 running.delete(group.key)
128 }
129 const next = queued.get(group.key)
130 if (next !== undefined) {
131 queued.delete(group.key)
132 done = [...done, ...(await runGroup($, next))]
133 }
134
135 return done
136}
137
138async function lintFiles($: EngineInterface, files: readonly string[]): Promise<FileResult[]> {
139 const groups = await groupFiles($, files)
140 const done = (await Promise.all(groups.map(group => runGroup($, group)))).flat()
141 if (done.length === 0) return done
142 const before = await read($, results)
143 const failing = newlyFailing(before, done)
144 await update($, results, all => ({ ...all, ...Object.fromEntries(done.map(one => [one.path, one])) }))
145 $.ui.status(statusText(done))
146 if (failing.length > 0) $.ui.toast(toastText(failing))
147
148 return done
149}
150
151async function flush($: EngineInterface): Promise<void> {
152 timer = undefined
153 const files = [...pending]
154 pending.clear()
155 if (files.length === 0 || !(await isEnabled($))) return
156 await update($, edited, list => [...new Set([...list, ...files])].slice(-MAX_EDITED))
157 await lintFiles($, files)
158}
159
160async function detect($: EngineInterface): Promise<string> {
161 const found = {} as Record<LinterName, boolean>
162 for (const linter of LINTERS) found[linter] = await isAvailable($, linter)
163 const cwd = await $.session.cwd().catch(() => '/')
164 const hasProjectEslint = found.eslint && (await closest($, cwd, ROOTS.eslint.config)) !== null
165 const text = linterSummary(found, hasProjectEslint)
166 await update($, summary, () => text)
167
168 return text
169}
170
171function schedule($: EngineInterface) {
172 timer?.cancel()
173 timer = $.clock.after(DEBOUNCE_MS, () => void flush($))
174}
175
176export const register: Register = on => {
177 on('session.start', async ($, e, next) => {
178 await $.command.register({
179 name: 'lint-watch',
180 description: 'Lint issues of the files Claude edited, in a pane (off | on | run)',
181 })
182
183 return next(e)
184 })
185
186 on('command.run', { command: 'lint-watch' }, async ($, e) => {
187 const arg = e.args.trim()
188 if (arg === 'off' || arg === 'on') {
189 await $.store.set('enabled', arg === 'on')
190 if (arg === 'off') {
191 timer?.cancel()
192 timer = undefined
193 pending.clear()
194 $.ui.status(undefined)
195 }
196 return { text: `lint-watch is ${arg}.` }
197 }
198 if (arg === 'run') {
199 const files = await read($, edited)
200 if (files.length === 0) return { text: 'No file edited this session yet.' }
201 const done = await lintFiles($, files)
202 if (done.length === 0) return { text: `No linter for the ${files.length} edited file${files.length === 1 ? '' : 's'}. ${await detect($)}` }
203
204 return { text: `${statusText(done)} over ${done.length} file${done.length === 1 ? '' : 's'}.` }
205 }
206 const text = await detect($)
207 await $.ui.open({ id: PANE, title: TITLE })
208
209 return { text: `lint-watch is ${(await isEnabled($)) ? 'on' : 'off'}. ${text}` }
210 })
211
212 on('tool.call', async ($, e, next) => {
213 const ran = await next(e)
214 if (e.agentId !== undefined || (e.tool !== 'Edit' && e.tool !== 'Write')) return ran
215 if (ran.deny !== undefined || ran.isError === true || linterFor(e.file_path) === null) return ran
216 pending.add(e.file_path)
217 schedule($)
218
219 return ran
220 })
221
222 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
223 const { Box, Text } = $.ui.resolve(e)
224 const all = Object.values(await read($, results)).sort((a, b) => a.shown.localeCompare(b.shown))
225 const header = await read($, summary)
226 const lines: { text: string; color?: string; dim?: boolean }[] = []
227 for (const one of all) {
228 if (one.failure !== undefined) lines.push({ text: `${one.shown}: ${one.failure}`, dim: true })
229 for (const issue of one.issues) lines.push({ text: issueLine(one, issue), color: issue.severity === 'error' ? 'red' : 'yellow' })
230 const hidden = one.errors + one.warnings - one.issues.length
231 if (hidden > 0) lines.push({ text: `${one.shown}: ${hidden} more`, dim: true })
232 }
233 const room = Math.max(1, (e.viewport?.rows ?? 24) - 5)
234 const shownLines = lines.slice(0, room)
235
236 return (
237 <Box flexDirection="column">
238 {header !== null && <Text dimColor>{header}</Text>}
239 <Text bold>{all.length === 0 ? 'No lint results yet.' : `${statusText(all)} · ${all.length} file${all.length === 1 ? '' : 's'}`}</Text>
240 {shownLines.map(line => (
241 <Text color={line.color} dimColor={line.dim === true}>
242 {line.text}
243 </Text>
244 ))}
245 {lines.length > room && <Text dimColor>… {lines.length - room} more lines</Text>}
246 </Box>
247 )
248 })
249}
250hooks/lint.ts 264 lines1// Pure logic of lint-watch: which linter a file takes, the command line, and the
2// linters' JSON reports read into issues. No `$` here: the hooks find roots and run.
3
4import type { FileResult, Issue, LinterName, Severity } from '../types'
5
6export const LINTERS: readonly LinterName[] = ['swiftlint', 'eslint', 'ruff', 'shellcheck']
7
8/** The executable whose presence makes a linter available. */
9export const EXECUTABLE: Record<LinterName, string> = {
10 swiftlint: 'swiftlint',
11 eslint: 'npx',
12 ruff: 'ruff',
13 shellcheck: 'shellcheck',
14}
15
16const EXTENSIONS: Record<string, LinterName> = {
17 swift: 'swiftlint',
18 ts: 'eslint',
19 tsx: 'eslint',
20 js: 'eslint',
21 jsx: 'eslint',
22 py: 'ruff',
23 sh: 'shellcheck',
24}
25
26export function linterFor(path: string): LinterName | null {
27 const name = baseName(path)
28 const dot = name.lastIndexOf('.')
29 if (dot <= 0) return null
30
31 return EXTENSIONS[name.slice(dot + 1).toLowerCase()] ?? null
32}
33
34/**
35 * Where a linter runs from: the closest folder holding one of `config`, else one of
36 * `fallback`, else the file's folder. `required`: without a `config` folder, no run.
37 */
38export const ROOTS: Record<LinterName, { config: string[]; fallback: string[]; required: boolean }> = {
39 swiftlint: { config: ['.swiftlint.yml'], fallback: ['.git', 'Package.swift', 'project.yml'], required: false },
40 eslint: { config: ['node_modules/.bin/eslint'], fallback: [], required: true },
41 ruff: { config: ['ruff.toml', '.ruff.toml', 'pyproject.toml'], fallback: ['.git'], required: false },
42 shellcheck: { config: ['.shellcheckrc'], fallback: ['.git'], required: false },
43}
44
45export function argv(linter: LinterName, files: readonly string[]): string[] {
46 switch (linter) {
47 case 'swiftlint':
48 return ['swiftlint', 'lint', '--reporter', 'json', '--quiet', ...files]
49 case 'eslint':
50 return ['npx', '--no-install', 'eslint', '-f', 'json', ...files]
51 case 'ruff':
52 return ['ruff', 'check', '--output-format', 'json', ...files]
53 case 'shellcheck':
54 return ['shellcheck', '-f', 'json', ...files]
55 }
56}
57
58export const baseName = (path: string) => path.slice(path.lastIndexOf('/') + 1)
59
60export function dirName(path: string): string {
61 const cut = path.lastIndexOf('/')
62
63 return cut <= 0 ? '/' : path.slice(0, cut)
64}
65
66/** The folder and every folder above it, closest first, ending at `/`. */
67export function ancestors(dir: string): string[] {
68 const list = [dir]
69 for (let current = dir; current !== '/'; ) {
70 current = dirName(current)
71 list.push(current)
72 }
73
74 return list
75}
76
77export const join = (dir: string, name: string) => (dir === '/' ? `/${name}` : `${dir}/${name}`)
78
79const absolute = (root: string, path: string) => (path.startsWith('/') ? path : join(root, path.replace(/^\.\//, '')))
80
81/** `path` relative to `base` when inside it, else unchanged. */
82export function shown(path: string, base: string): string {
83 return path.startsWith(`${base}/`) ? path.slice(base.length + 1) : path
84}
85
86type Found = { file: string; issue: Issue }
87
88const record = (value: unknown): Record<string, unknown> =>
89 typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
90
91const num = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
92
93const str = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback)
94
95// SwiftLint `--reporter json`: [{ file, line, character, severity: "Warning"|"Error", rule_id, reason }]
96function swiftlint(json: unknown[]): Found[] {
97 return json.map((raw): Found => {
98 const one = record(raw)
99
100 return {
101 file: str(one.file),
102 issue: {
103 line: num(one.line),
104 column: num(one.character),
105 rule: str(one.rule_id, 'swiftlint'),
106 message: str(one.reason),
107 severity: str(one.severity).toLowerCase() === 'error' ? 'error' : 'warning',
108 },
109 }
110 })
111}
112
113// ESLint `-f json`: [{ filePath, messages: [{ ruleId, severity: 1|2, fatal?, message, line, column }] }]
114function eslint(json: unknown[]): Found[] {
115 return json.flatMap(raw => {
116 const one = record(raw)
117 const messages = Array.isArray(one.messages) ? one.messages : []
118
119 return messages.map((message): Found => {
120 const m = record(message)
121 const isError = m.fatal === true || m.severity === 2
122
123 return {
124 file: str(one.filePath),
125 issue: {
126 line: num(m.line),
127 column: num(m.column),
128 rule: str(m.ruleId, 'eslint'),
129 message: str(m.message),
130 severity: isError ? 'error' : 'warning',
131 },
132 }
133 })
134 })
135}
136
137// Ruff has no severity: syntax errors and the codes flake8 calls errors (E9, F63, F7, F82) are errors.
138const RUFF_ERRORS = /^(E9|F63|F7|F82)/
139
140// Ruff `--output-format json`: [{ filename, code, message, location: { row, column } }]
141function ruff(json: unknown[]): Found[] {
142 return json.map((raw): Found => {
143 const one = record(raw)
144 const code = typeof one.code === 'string' ? one.code : null
145 const location = record(one.location)
146 const isError = code === null || code === 'invalid-syntax' || RUFF_ERRORS.test(code)
147
148 return {
149 file: str(one.filename),
150 issue: {
151 line: num(location.row),
152 column: num(location.column),
153 rule: code ?? 'syntax-error',
154 message: str(one.message),
155 severity: isError ? 'error' : 'warning',
156 },
157 }
158 })
159}
160
161// ShellCheck `-f json`: [{ file, line, column, level: "error"|"warning"|"info"|"style", code, message }]
162function shellcheck(json: unknown[]): Found[] {
163 return json.map((raw): Found => {
164 const one = record(raw)
165
166 return {
167 file: str(one.file),
168 issue: {
169 line: num(one.line),
170 column: num(one.column),
171 rule: typeof one.code === 'number' ? `SC${one.code}` : str(one.code, 'shellcheck'),
172 message: str(one.message),
173 severity: one.level === 'error' ? 'error' : 'warning',
174 },
175 }
176 })
177}
178
179const PARSERS: Record<LinterName, (json: unknown[]) => Found[]> = { swiftlint, eslint, ruff, shellcheck }
180
181const ORDER: Record<Severity, number> = { error: 0, warning: 1 }
182
183/**
184 * The issues of each linted file (absolute paths, every file present, maybe empty),
185 * or null when the output is not the linter's JSON report.
186 */
187export function parseReport(linter: LinterName, stdout: string, root: string, files: readonly string[]): Map<string, Issue[]> | null {
188 let json: unknown
189 try {
190 json = JSON.parse(stdout.trim() === '' ? 'null' : stdout)
191 } catch {
192 return null
193 }
194 if (!Array.isArray(json)) return null
195 const byFile = new Map<string, Issue[]>(files.map(file => [file, []]))
196 for (const { file, issue } of PARSERS[linter](json)) {
197 const path = absolute(root, file)
198 byFile.set(path, [...(byFile.get(path) ?? []), issue])
199 }
200 for (const [file, issues] of byFile) {
201 byFile.set(file, [...issues].sort((a, b) => ORDER[a.severity] - ORDER[b.severity] || a.line - b.line || a.column - b.column))
202 }
203
204 return byFile
205}
206
207export const MAX_ISSUES = 50
208
209/** One file's result: its issues (at most MAX_ISSUES kept) with the full counts. */
210export function fileResult(path: string, shownPath: string, linter: LinterName, issues: readonly Issue[], failure?: string): FileResult {
211 const errors = issues.filter(issue => issue.severity === 'error').length
212 const result: FileResult = { path, shown: shownPath, linter, errors, warnings: issues.length - errors, issues: issues.slice(0, MAX_ISSUES) }
213
214 return failure === undefined ? result : { ...result, failure }
215}
216
217export function totals(results: readonly FileResult[]): { errors: number; warnings: number } {
218 return results.reduce((sum, one) => ({ errors: sum.errors + one.errors, warnings: sum.warnings + one.warnings }), {
219 errors: 0,
220 warnings: 0,
221 })
222}
223
224/** `lint ✓`, or `lint ⚠ 3 · ✗ 1` (warnings · errors, a zero part left out), then files not linted. */
225export function statusText(results: readonly FileResult[]): string {
226 const { errors, warnings } = totals(results)
227 const failed = results.filter(one => one.failure !== undefined).length
228 if (errors === 0 && warnings === 0 && failed === 0) return 'lint ✓'
229 const parts = [warnings > 0 ? `⚠ ${warnings}` : '', errors > 0 ? `✗ ${errors}` : '', failed > 0 ? `${failed} not linted` : ''].filter(Boolean)
230
231 return `lint ${parts.join(' · ')}`
232}
233
234/** The files of `next` with errors that had none (or no result) in `before`. */
235export function newlyFailing(before: Readonly<Record<string, FileResult>>, next: readonly FileResult[]): FileResult[] {
236 return next.filter(one => one.errors > 0 && (before[one.path]?.errors ?? 0) === 0)
237}
238
239export function toastText(failing: readonly FileResult[]): string {
240 const errors = failing.reduce((sum, one) => sum + one.errors, 0)
241 const names = failing.map(one => baseName(one.path))
242 const list = names.length > 3 ? `${names.slice(0, 3).join(', ')} +${names.length - 3}` : names.join(', ')
243
244 return `lint: ✗ ${errors} error${errors === 1 ? '' : 's'} in ${list} — /lint-watch`
245}
246
247export function issueLine(result: FileResult, issue: Issue): string {
248 return `${issue.severity === 'error' ? '✗' : '⚠'} ${result.shown}:${issue.line} ${issue.rule} ${issue.message}`
249}
250
251/** `/lint-watch` summary of which linters were found. */
252export function linterSummary(found: Readonly<Record<LinterName, boolean>>, hasProjectEslint: boolean): string {
253 const names = LINTERS.filter(name => found[name] && (name !== 'eslint' || hasProjectEslint))
254 const missing = LINTERS.filter(name => !names.includes(name))
255 const label = (name: LinterName) => (name === 'eslint' && found.eslint ? 'eslint (no node_modules/.bin/eslint in this project)' : name)
256
257 return [
258 names.length === 0 ? 'No linter found.' : `Linters found: ${names.join(', ')}.`,
259 missing.length === 0 ? '' : `Not found: ${missing.map(label).join(', ')}.`,
260 ]
261 .filter(Boolean)
262 .join(' ')
263}
264types/index.d.ts 30 lines1export type LinterName = 'swiftlint' | 'eslint' | 'ruff' | 'shellcheck'
2
3export type Severity = 'error' | 'warning'
4
5export type Issue = {
6 line: number
7 column: number
8 rule: string
9 message: string
10 severity: Severity
11}
12
13export type FileResult = {
14 path: string
15 shown: string
16 linter: LinterName
17 errors: number
18 warnings: number
19 issues: Issue[]
20 failure?: string
21}
22
23export type LintResults = Record<string, FileResult>
24
25declare module 'claude-code' {
26 interface PluginState {
27 'lint-watch': { results: LintResults; edited: string[]; summary: string | null }
28 }
29}
30