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…

<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>
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+.
Plugins built on function hooks: they change what Claude Code shows and does, live.
| Mod | What it does | Command | |
|---|---|---|---|
| ✍️ | proofread | Fixes spelling and grammar in your prompt before Claude reads it, and highlights your prompts with the fixed words called out | /proofread |
| 📓 | work-journal | Daily, 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-tooling | Runs 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-msg | Writes 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 |
Instructions Claude loads when your request matches them. Ask in plain words.
| Skill | What it does | Try | |
|---|---|---|---|
| 📊 | commit-summary | Summarizes 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?" |
hooks/register.tsx 405 lines1import { 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}
405hooks/parse.ts 155 lines1import 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}
155types/index.d.ts 22 lines1export 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