SLOPSHOPPER

laravel-tooling

Runs Pint and Larastan after Claude edits PHP, and Pest on demand, then shows the results in a live pane and the status line. /laravel-tooling opens the pane…

newpaneguardcommandtoaststatus
★ 1v0.1.0MITupdated 2026-10-07tomshaw/claude-code-plugins/mods/laravel-tooling
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · laravel-tooling
│ ┃ laravel-tooling ✕ › fix the failing auth test and add an audit log call │ ┃ ⚒ Laravel tooling waiting f │ ┃ ⏺ Read(src/auth.ts) │ ┃ ○ Pint not run yet ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ○ Larastan not run yet ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ○ Tests run from the pane or /laravel-to ⎿ 3 pass, 1 fail │ ┃ │ ┃ [ Run all ] [ Tests ] ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · laravel-tooling
⚒ Laravel tooling waiting for PHP edits ○ Pint not run yet ○ Larastan not run yet ○ Tests run from the pane or /laravel-tooling tests [ Run all ] [ Tests ]
README

<h1 align="center">Claude Code plugins</h1>

<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT"></a> <a href="https://github.com/tomshaw/claude-code-plugins/actions/workflows/ci.yml"><img src="https://github.com/tomshaw/claude-code-plugins/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>

<a href="#-install">Install</a> · <a href="#-mods">Mods</a> · <a href="#-skills">Skills</a> · <a href="docs/mods.md">Docs</a> · <a href="https://claude.dev/blog/getting-started-with-claude-code-mods/">What are mods?</a>

🚀 Install

Add the marketplace once, then install any plugin by name:

claude plugin marketplace add tomshaw/claude-code-plugins
claude plugin install proofread@tomshaw
claude plugin install work-journal@tomshaw
claude plugin install laravel-tooling@tomshaw
claude plugin install commit-msg@tomshaw

Or from inside Claude Code:

/plugin install proofread --marketplace tomshaw/claude-code-plugins

Restart Claude Code after installing. To try one without installing:

git clone https://github.com/tomshaw/claude-code-plugins && cd claude-code-plugins
claude --plugin-dir mods/proofread

Needs Claude Code 2.1.293+.

🧩 Mods

Plugins built on function hooks: they change what Claude Code shows and does, live.

ModWhat it doesCommand
✍️proofreadFixes spelling and grammar in your prompt before Claude reads it, and highlights your prompts with the fixed words called out/proofread
📓work-journalDaily, weekly and monthly reviews of your work, summarized from the prompts you typed: projects, threads, decisions and open items. In a pane, or as a webpage/journal
⚒️laravel-toolingRuns Pint and Larastan after Claude edits PHP, and Pest on demand, with the results in a live pane and the status line/laravel-tooling
📝commit-msgWrites the commit message for your staged changes from the Claude session that made them, so it says why, not just what. Matches your repo's commit style; edit, regenerate and copy it/commit-msg

🧠 Skills

Instructions Claude loads when your request matches them. Ask in plain words.

SkillWhat it doesTry
📊commit-summarySummarizes git commits for a day, week or month, for everyone or one author, grouped by theme with line counts and highlights"what did I do this week?"

📚 More

  • 📖 docs/mods.md: how the mods behave, setup notes and build your own
  • 🧠 docs/skills.md: what each skill does and how to ask for it
  • 🤝 CONTRIBUTING.md: where each kind of plugin goes, and how to add one
Source 3 files
hooks/register.tsx 405 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CheckName, CheckResult, Checks, Issue } from '../types'
5import { parsePhpstan, parsePint, parseTests } from './parse'
6
7const PANE = 'laravel-tooling'
8const PROGRESS_EVERY = 500
9
10const idle = (summary: string): CheckResult => ({ status: 'idle', summary, issues: [], durationMs: 0, ranAt: 0 })
11
12const checks = atom({ plugin: 'laravel-tooling', key: 'checks' } as const, {
13  pint: idle('not run yet'),
14  phpstan: idle('not run yet'),
15  tests: idle('run from the pane or /laravel-tooling tests'),
16} satisfies Checks)
17const pending = atom({ plugin: 'laravel-tooling', key: 'pending' } as const, [] as string[])
18
19const LABELS: Record<CheckName, string> = { pint: 'Pint', phpstan: 'Larastan', tests: 'Tests' }
20const SHORT: Record<CheckName, string> = { pint: 'Pint', phpstan: 'Stan', tests: 'Tests' }
21const ORDER: CheckName[] = ['pint', 'phpstan', 'tests']
22
23const ICONS: Record<CheckResult['status'], string> = { idle: '○', running: '◐', pass: '✓', fail: '✗', error: '!' }
24const COLORS: Record<CheckResult['status'], string> = {
25  idle: 'inactive',
26  running: 'suggestion',
27  pass: 'success',
28  fail: 'error',
29  error: 'warning',
30}
31
32const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit'])
33
34const settings = { autoTests: false, testFilter: '' }
35const session = { root: '', isLaravel: false, phpstanConfig: undefined as string | undefined }
36const runner = { isRunning: false, queued: undefined as CheckName[] | undefined, queuedFilter: '' }
37
38const seconds = (ms: number): string => (ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`)
39
40const clock = (at: number): string => {
41  const date = new Date(at)
42  const pad = (value: number): string => String(value).padStart(2, '0')
43
44  return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
45}
46
47const statusLine = (all: Checks): string =>
48  ORDER.map(name => {
49    const check = all[name]
50    if (name === 'tests' && check.status === 'pass') {
51      return `${SHORT[name]} ${check.summary.split(' ')[0]}`
52    }
53    if (check.status === 'fail' && name === 'phpstan') {
54      return `${SHORT[name]} ${check.issues.length}${ICONS.fail}`
55    }
56
57    return `${SHORT[name]} ${ICONS[check.status]}`
58  }).join(' · ')
59
60const commandFor = (name: CheckName, filter: string): string[] => {
61  if (name === 'pint') {
62    return ['vendor/bin/pint', '--dirty', '--format=json']
63  }
64  if (name === 'phpstan') {
65    const config = session.phpstanConfig === undefined ? [] : [`--configuration=${session.phpstanConfig}`]
66
67    return ['vendor/bin/phpstan', 'analyse', '--memory-limit=2G', ...config, '--error-format=json', '--no-progress']
68  }
69
70  return ['vendor/bin/pest', '--teamcity', ...(filter === '' ? [] : [`--filter=${filter}`])]
71}
72
73const parse = (name: CheckName, stdout: string, stderr: string) =>
74  name === 'pint'
75    ? parsePint(stdout, stderr, session.root)
76    : name === 'phpstan'
77      ? parsePhpstan(stdout, stderr, session.root)
78      : parseTests(stdout, stderr)
79
80const setCheck = async ($: EngineInterface, name: CheckName, result: CheckResult): Promise<Checks> => {
81  let after = await read($, checks)
82  await update($, checks, all => {
83    after = { ...all, [name]: result }
84
85    return after
86  })
87  $.ui.status(statusLine(after))
88
89  return after
90}
91
92const progressOf = (output: string): string => {
93  const finished = output.split('##teamcity[testFinished').length - 1
94  const failed = output.split('##teamcity[testFailed').length - 1
95
96  return `running… ${finished} done${failed > 0 ? `, ${failed} failed` : ''}`
97}
98
99const capture = async (
100  $: EngineInterface,
101  argv: string[],
102  onOutput: (stdout: string) => Promise<void>,
103): Promise<{ stdout: string; stderr: string }> => {
104  let stdout = ''
105  let stderr = ''
106  for await (const chunk of $.process.spawn({ argv, cwd: session.root, input: '' })) {
107    if (chunk.stream === 'stdout') {
108      stdout += chunk.text
109      await onOutput(stdout)
110    } else {
111      stderr += chunk.text
112    }
113  }
114
115  return { stdout, stderr }
116}
117
118const runOne = async ($: EngineInterface, name: CheckName, filter: string): Promise<void> => {
119  const before = (await read($, checks))[name]
120  const startedAt = await $.clock.now()
121  const running: CheckResult = { ...before, status: 'running', summary: 'running…' }
122  await setCheck($, name, running)
123
124  let shownAt = startedAt
125  const showProgress = async (stdout: string): Promise<void> => {
126    const now = await $.clock.now()
127    if (name !== 'tests' || now - shownAt < PROGRESS_EVERY) {
128      return
129    }
130    shownAt = now
131    await setCheck($, name, { ...running, summary: progressOf(stdout) })
132  }
133
134  let result: CheckResult
135  try {
136    const ran = await capture($, commandFor(name, filter), showProgress)
137    result = { ...parse(name, ran.stdout, ran.stderr), durationMs: (await $.clock.now()) - startedAt, ranAt: startedAt }
138  } catch (error) {
139    const message = error instanceof Error ? error.message : String(error)
140    $.ui.log(`laravel-tooling: ${LABELS[name]} could not run: ${message}`, { to: 'debug' })
141    result = {
142      status: 'error',
143      summary: 'could not run',
144      issues: [{ file: '', message }],
145      durationMs: (await $.clock.now()) - startedAt,
146      ranAt: startedAt,
147    }
148  }
149
150  await setCheck($, name, result)
151
152  if (result.status === 'fail' && before.status !== 'fail') {
153    $.ui.toast(`${LABELS[name]}: ${result.summary}`)
154  }
155}
156
157const runChecks = async ($: EngineInterface, names: CheckName[], filter: string): Promise<void> => {
158  if (runner.isRunning) {
159    runner.queued = [...new Set([...(runner.queued ?? []), ...names])]
160    runner.queuedFilter = filter
161
162    return
163  }
164
165  runner.isRunning = true
166  try {
167    for (const name of ORDER.filter(one => names.includes(one))) {
168      await runOne($, name, filter)
169    }
170  } finally {
171    runner.isRunning = false
172  }
173
174  const next = runner.queued
175  if (next !== undefined) {
176    runner.queued = undefined
177    await runChecks($, next, runner.queuedFilter)
178  }
179}
180
181const start = ($: EngineInterface, names: CheckName[], filter = settings.testFilter): void => {
182  $.clock.after(0, () => {
183    void runChecks($, names, filter)
184  })
185}
186
187const fixPrompt = (all: Checks): string => {
188  const sections = ORDER.filter(name => all[name].status === 'fail' || all[name].status === 'error').map(name => {
189    const lines = all[name].issues
190      .slice(0, 40)
191      .map(issue => `- ${issue.file}${issue.line === undefined ? '' : `:${issue.line}`} ${issue.message}`.trim())
192
193    return `${LABELS[name]} (${all[name].summary}):\n${lines.join('\n')}`
194  })
195
196  return `Fix these Laravel tooling failures:\n\n${sections.join('\n\n')}\n`
197}
198
199const byFile = (issues: Issue[]): [string, Issue[]][] => {
200  const groups = new Map<string, Issue[]>()
201  for (const issue of issues) {
202    groups.set(issue.file, [...(groups.get(issue.file) ?? []), issue])
203  }
204
205  return [...groups.entries()]
206}
207
208const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Laravel tooling' })
209
210export const register: Register = (on, options) => {
211  settings.autoTests = options.autoTests === true
212  settings.testFilter = String(options.testFilter ?? '').trim()
213
214  on('session.start', async ($, e, next) => {
215    const started = await next(e)
216    session.root = started.cwd
217    const has = (path: string) => $.fs.exists(`${session.root}/${path}`)
218    session.isLaravel = (await has('artisan')) && (await has('vendor/bin/pint'))
219    if (!session.isLaravel) {
220      return started
221    }
222
223    session.phpstanConfig = (await has('phpstan.neon'))
224      ? 'phpstan.neon'
225      : (await has('phpstan.neon.dist'))
226        ? 'phpstan.neon.dist'
227        : undefined
228
229    try {
230      await $.command.register({
231        name: 'laravel-tooling',
232        description: 'Laravel tooling: open the pane, `run` every check, or `tests [filter]` to run Pest',
233        argumentHint: '[run | tests [filter] | pint | stan]',
234      })
235    } catch (error) {
236      $.ui.log(`laravel-tooling: /laravel-tooling not registered: ${String(error)}`, { to: 'debug' })
237    }
238    $.ui.status(statusLine(await read($, checks)))
239
240    return started
241  })
242
243  on('command.run', { command: 'laravel-tooling' }, async ($, e) => {
244    if (!session.isLaravel) {
245      return { text: 'This is not a Laravel project (no artisan and vendor/bin/pint).' }
246    }
247
248    const [action = '', ...rest] = e.args.trim().split(/\s+/)
249    const filter = rest.join(' ')
250    await openPane($)
251
252    if (action === 'run') {
253      start($, ORDER)
254
255      return { text: 'Running Pint, Larastan and Pest.' }
256    }
257    if (action === 'tests') {
258      start($, ['tests'], filter === '' ? settings.testFilter : filter)
259
260      return { text: filter === '' ? 'Running Pest.' : `Running Pest with --filter=${filter}.` }
261    }
262    if (action === 'pint' || action === 'stan') {
263      start($, [action === 'pint' ? 'pint' : 'phpstan'])
264
265      return { text: `Running ${action === 'pint' ? 'Pint' : 'Larastan'}.` }
266    }
267
268    return { text: 'Laravel tooling pane opened.' }
269  })
270
271  on('tool.call', async ($, e, next) => {
272    const ran = await next(e)
273    if (!session.isLaravel || !EDIT_TOOLS.has(String(e.tool)) || ran.isError === true) {
274      return ran
275    }
276
277    const path = (e as { file_path?: unknown }).file_path
278    if (typeof path === 'string' && path.endsWith('.php')) {
279      await update($, pending, list => (list.includes(path) ? list : [...list, path]))
280    }
281
282    return ran
283  }).catch(($, e, next) => next(e))
284
285  on('turn.complete', async ($, e, next) => {
286    const done = await next(e)
287    if (!session.isLaravel || e.agentId !== undefined) {
288      return done
289    }
290
291    const edited = await read($, pending)
292    if (edited.length === 0) {
293      return done
294    }
295
296    await update($, pending, () => [])
297    start($, settings.autoTests ? ORDER : ['pint', 'phpstan'])
298
299    return done
300  })
301
302  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
303    const { Box, Text, Button, Link } = $.ui.resolve(e)
304    const all = await read($, checks)
305    const width = e.props.bodyColumns ?? e.viewport?.columns ?? 80
306    const room = Math.max(4, (e.viewport?.rows ?? 30) - 12)
307    const hasFailures = ORDER.some(name => all[name].status === 'fail' || all[name].status === 'error')
308    const isBusy = ORDER.some(name => all[name].status === 'running')
309    const lastRan = Math.max(...ORDER.map(name => all[name].ranAt))
310    let budget = room
311
312    const issueRows = (name: CheckName) => {
313      const issues = all[name].issues
314      if (issues.length === 0 || budget <= 0) {
315        return []
316      }
317
318      let shownCount = 0
319      const rows = byFile(issues).flatMap(([file, list]) => {
320        if (budget <= 0) {
321          return []
322        }
323        budget -= 1
324        const header =
325          file === '' ? null : (
326            <Box key={`${name}:${file}`} paddingLeft={4}>
327              <Link href={`file://${session.root}/${file}`} label={file} />
328            </Box>
329          )
330        const shown = list.slice(0, Math.max(0, budget))
331        budget -= shown.length
332        shownCount += shown.length
333
334        return [
335          header,
336          ...shown.map((issue, index) => (
337            <Box key={`${name}:${file}:${index}`} paddingLeft={file === '' ? 4 : 6} flexDirection="row" gap={1}>
338              {issue.line !== undefined && (
339                <Text color="warning">{String(issue.line).padStart(4)}</Text>
340              )}
341              <Text color="subtle" wrap="wrap">
342                {issue.message}
343              </Text>
344            </Box>
345          )),
346        ]
347      })
348      const hidden = issues.length - shownCount
349
350      return hidden > 0
351        ? [
352            ...rows,
353            <Box key={`${name}:more`} paddingLeft={4}>
354              <Text dimColor>…and {hidden} more</Text>
355            </Box>,
356          ]
357        : rows
358    }
359
360    return (
361      <Box flexDirection="column" width={width}>
362        <Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
363          <Text bold color="claude">
364            ⚒ Laravel tooling
365          </Text>
366          <Text dimColor>{lastRan > 0 ? `last run ${clock(lastRan)}` : 'waiting for PHP edits'}</Text>
367        </Box>
368        {ORDER.map(name => {
369          const check = all[name]
370
371          return (
372            <Box key={name} flexDirection="column" marginBottom={1}>
373              <Box flexDirection="row" justifyContent="space-between">
374                <Box flexDirection="row" gap={1}>
375                  <Text color={COLORS[check.status]} bold>
376                    {ICONS[check.status]}
377                  </Text>
378                  <Text bold>{LABELS[name].padEnd(9)}</Text>
379                  <Text color={check.status === 'idle' ? 'inactive' : COLORS[check.status]}>{check.summary}</Text>
380                </Box>
381                {check.durationMs > 0 && check.status !== 'running' && <Text dimColor>{seconds(check.durationMs)}</Text>}
382              </Box>
383              {issueRows(name)}
384            </Box>
385          )
386        })}
387        <Box flexDirection="row" gap={1}>
388          <Button key="run" label={isBusy ? 'Running…' : 'Run all'} hotkey="r" variant="primary" onPress={() => start($, ORDER)} />
389          <Button key="tests" label="Tests" hotkey="t" onPress={() => start($, ['tests'])} />
390          {hasFailures && (
391            <Button
392              key="fix"
393              label="Ask Claude to fix"
394              hotkey="f"
395              onPress={async () => {
396                await $.prompt.fill({ text: fixPrompt(await read($, checks)), mode: 'replace' })
397              }}
398            />
399          )}
400        </Box>
401      </Box>
402    )
403  })
404}
405
hooks/parse.ts 155 lines
1import type { CheckResult, Issue } from '../types'
2
3type Parsed = Pick<CheckResult, 'status' | 'summary' | 'issues'>
4
5const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? '' : 's'}`
6
7export const relativeTo = (root: string, path: string): string => {
8  const base = root.endsWith('/') ? root : `${root}/`
9
10  return path.startsWith(base) ? path.slice(base.length) : path
11}
12
13const firstJsonObject = (text: string): unknown => {
14  const start = text.indexOf('{')
15  const end = text.lastIndexOf('}')
16  if (start === -1 || end <= start) {
17    return undefined
18  }
19  try {
20    return JSON.parse(text.slice(start, end + 1))
21  } catch {
22    return undefined
23  }
24}
25
26const failure = (summary: string, stderr: string): Parsed => ({
27  status: 'error',
28  summary,
29  issues: stderr.trim() === '' ? [] : [{ file: '', message: stderr.trim().split('\n').slice(0, 6).join('\n') }],
30})
31
32type PintJson = { result?: string; files?: { path: string; fixers?: string[] }[] }
33
34export const parsePint = (stdout: string, stderr: string, root: string): Parsed => {
35  const json = firstJsonObject(stdout) as PintJson | undefined
36  if (json === undefined || json.result === undefined) {
37    return failure('Pint did not report', stderr || stdout)
38  }
39
40  const files = json.files ?? []
41  if (files.length === 0) {
42    return { status: 'pass', summary: 'clean', issues: [] }
43  }
44
45  const verb = json.result === 'fail' ? 'needs' : 'fixed'
46
47  return {
48    status: json.result === 'fail' ? 'fail' : 'pass',
49    summary: `${verb} ${plural(files.length, 'file')}`,
50    issues: files.map(file => ({
51      file: relativeTo(root, file.path),
52      message: (file.fixers ?? []).join(', '),
53    })),
54  }
55}
56
57type StanJson = {
58  totals?: { errors: number; file_errors: number }
59  files?: Record<string, { messages: { message: string; line?: number | null }[] }>
60  errors?: string[]
61}
62
63export const parsePhpstan = (stdout: string, stderr: string, root: string): Parsed => {
64  const json = firstJsonObject(stdout) as StanJson | undefined
65  if (json === undefined || json.totals === undefined) {
66    return failure('Larastan did not report', stderr || stdout)
67  }
68
69  const issues: Issue[] = [
70    ...(json.errors ?? []).map(message => ({ file: '', message })),
71    ...Object.entries(json.files ?? {}).flatMap(([path, file]) =>
72      file.messages.map(item => ({
73        file: relativeTo(root, path),
74        line: item.line ?? undefined,
75        message: item.message,
76      })),
77    ),
78  ]
79  const count = json.totals.file_errors + json.totals.errors
80  if (count === 0) {
81    return { status: 'pass', summary: 'no errors', issues: [] }
82  }
83
84  const files = Object.keys(json.files ?? {}).length
85
86  return {
87    status: 'fail',
88    summary: files > 0 ? `${plural(count, 'error')} in ${plural(files, 'file')}` : plural(count, 'error'),
89    issues,
90  }
91}
92
93const unescapeTeamcity = (value: string): string =>
94  value.replace(/\|(['nr|\[\]])/g, (_, char: string) =>
95    char === 'n' ? '\n' : char === 'r' ? '' : char,
96  )
97
98const attributes = (line: string): Record<string, string> => {
99  const found: Record<string, string> = {}
100  for (const match of line.matchAll(/(\w+)='((?:\|.|[^'|])*)'/g)) {
101    found[match[1]!] = unescapeTeamcity(match[2]!)
102  }
103
104  return found
105}
106
107const LOCATION = /((?:tests|app|routes|database)\/[^\s:]+\.php):(\d+)/
108
109export const parseTests = (stdout: string, stderr: string): Parsed => {
110  let passed = 0
111  let skipped = 0
112  const issues: Issue[] = []
113  let failing = new Set<string>()
114
115  for (const piece of stdout.split('##teamcity[').slice(1)) {
116    const line = piece.split('\n')[0] ?? ''
117    const event = line.match(/^(\w+)/)?.[1]
118    if (event === undefined) {
119      continue
120    }
121    const fields = attributes(line)
122    const name = fields.name ?? ''
123
124    if (event === 'testFailed') {
125      failing.add(name)
126      const location = (fields.details ?? '').match(LOCATION) ?? (fields.locationHint ?? '').match(LOCATION)
127      const hintFile = (fields.locationHint ?? '').replace(/^pest_qn:\/\//, '').split('::')[0] ?? ''
128      issues.push({
129        file: location?.[1] ?? hintFile,
130        line: location?.[2] === undefined ? undefined : Number(location[2]),
131        message: `${name}: ${(fields.message ?? '').trim()}`,
132      })
133    } else if (event === 'testIgnored') {
134      skipped += 1
135      failing.add(name)
136    } else if (event === 'testFinished' && !failing.has(name)) {
137      passed += 1
138    } else if (event === 'testSuiteFinished') {
139      failing = new Set()
140    }
141  }
142
143  const failed = issues.length
144  const total = passed + failed
145  if (total === 0 && skipped === 0) {
146    return failure('no tests ran', stderr || stdout.split('\n').slice(-8).join('\n'))
147  }
148
149  const tail = skipped > 0 ? `, ${skipped} skipped` : ''
150
151  return failed === 0
152    ? { status: 'pass', summary: `${passed}/${total} passed${tail}`, issues: [] }
153    : { status: 'fail', summary: `${plural(failed, 'failure')}, ${passed}/${total} passed${tail}`, issues }
154}
155
types/index.d.ts 22 lines
1export type CheckName = 'pint' | 'phpstan' | 'tests'
2
3export type CheckStatus = 'idle' | 'running' | 'pass' | 'fail' | 'error'
4
5export type Issue = { file: string; line?: number; message: string }
6
7export type CheckResult = {
8  status: CheckStatus
9  summary: string
10  issues: Issue[]
11  durationMs: number
12  ranAt: number
13}
14
15export type Checks = Record<CheckName, CheckResult>
16
17declare module 'claude-code' {
18  interface PluginState {
19    'laravel-tooling': { checks: Checks; pending: string[] }
20  }
21}
22