SLOPSHOPPER

lint-watch

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

newpaneguardcommandtoaststatus
v0.1.0MITupdated 2026-10-05vincentlauriat/ModsTools/mods/lint-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lint-watch
│ ┃ Lint ✕ › fix the failing auth test and add an audit log call │ ┃ No linter found. Not found: swiftlint, │ ┃ eslint, ruff, shellcheck. ⏺ Read(src/auth.ts) │ ┃ No lint results yet. ⎿ 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 │ │ › /lint-watch │ ⎿ lint-watch: lint-watch is on. No linter found. Not found: swiftl │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Lint
No linter found. Not found: swiftlint, eslint, ruff, shellcheck. No lint results yet.
README

lint-watch

Lints the files Claude just edited, in the background, so lint errors show up while the code is still fresh.

What it does

  • Watches successful Edit/Write calls made by the main conversation and picks a linter by extension:
FilesLinterRuns whenFrom folder
.swiftswiftlint lint --reporter json --quiet <files>swiftlint is installedclosest .swiftlint.yml, else .git / Package.swift / project.yml
.ts .tsx .js .jsxnpx --no-install eslint -f json <files>the project has node_modules/.bin/eslint up the tree (nothing is ever installed)that folder
.pyruff check --output-format json <files>ruff is installedclosest ruff.toml / .ruff.toml / pyproject.toml, else .git
.shshellcheck -f json <files>shellcheck is installedclosest .shellcheckrc, else .git
  • Edits are batched: the run starts 2 s after the last edit, never before the tool result is returned. One run at a time per linter and folder; files edited meanwhile are linted right after.
  • Each linter is looked up once (command -v). No linter available: nothing happens.
  • Reads the linters' JSON reports, not their text. Severity: SwiftLint's own, ESLint 2 or fatal, ShellCheck error; Ruff has none, so syntax errors and E9/F63/F7/F82 count as errors and the rest as warnings.
  • Status line for the last run: lint ✓, or lint ⚠ 3 · ✗ 1 (warnings · errors), plus N not linted when a linter gave no report.
  • Toast only when errors appear on a file that had none (or had not been linted yet this session).

Commands

CommandEffect
/lint-watchOpens the pane (✗ file:line rule message, errors first) and says which linters were found
/lint-watch runRe-lints every file edited this session, now
/lint-watch offStops linting and clears the status line (kept across sessions)
/lint-watch onResumes

Install

claude --plugin-dir /path/to/ModsTools/mods/lint-watch

Limits

  • Only the main conversation's Edit/Write: files changed by Bash, subagents or formatters are not linted.
  • A file's first lint toasts if it already had errors before Claude touched it.
  • Up to 50 issues kept per file; a linter run is cut off after 2 minutes.
  • ESLint needs a working project config; an ESLint crash shows as "not linted".

Develop

claude plugin validate mods/lint-watch
claude plugin test mods/lint-watch   # 20 tests
Source 3 files
hooks/register.tsx 250 lines
1import { 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}
250
hooks/lint.ts 264 lines
1// 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}
264
types/index.d.ts 30 lines
1export 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