Checks that every enabled mod's hooks module actually loaded, and shows why one didn't. Quiet unless something is broken; /mod-doctor opens the report.

A Claude Code mod that answers one question: did my mods load?
It compares two lists:
If a mod is missing or broken, Mod Doctor shows why, using the error from claude plugin validate. It stays quiet while everything is fine.
claude plugin marketplace add hearsawho25/roberts-mods
claude plugin install mod-doctor@roberts-mods
There is no row while every mod is fine. If an enabled mod didn't load, a red line appears above the prompt:
✗ Mod Doctor 1 mod didn't load: relay [ Open ]
A mod that loaded but now fails validation reads 1 mod is broken: relay, and a mix of both reads N mods have problems. The button reads [ Close ] while the pane is open. You also get one toast per problem per session.
Open it with the row's [ Open ], with /mod-doctor, or from Robert's Toolbox. It shows:
Checked 12:41 · 11 mods · 1 problem
✗ relay not loaded
hooks\register.tsx does not parse: Expected ")" but found end of file (line 2, column 46)
✓ git-ship 0.1.0 · /ship
⚠ folder is 0.2.0, on record 0.1.0
✓ clean-view 0.1.0 · /simple
9 skipped (no hooks module)
What the marks mean:
✗ not loaded: the mod is enabled, but Claude Code didn't load its hooks module. The indented lines are validate's errors. If validate passes anyway, the line says to try /reload-plugins.✗ broken on disk: the mod loaded, but its files fail validation now, for example after an edit. Only Re-check finds this.⚠ folder is X, on record Y: the mod is read from a folder whose version differs from the installed one.The buttons:
| Button | What it does |
|---|---|
| Re-check | Runs claude plugin validate on every checked mod, not just missing ones |
| Copy report | Copies the pane as plain text, ready to paste to Claude |
| Close | Closes the pane |
Mod Doctor checks at these times:
/reload-plugins finishes, so a mod you fixed and reloaded clears the row by itselfIt never polls on a timer, and it never holds up start-up. A whole check gives up after 90 seconds, and each claude call after 30 seconds.
It is read-only. It runs only claude plugin list --json and claude plugin validate <folder>. It never enables, disables, edits or reinstalls anything. If a check can't run (for example, the claude CLI isn't found), the pane says couldn't check: <reason> and the row stays hidden. It never guesses that a mod is broken.
Design notes: docs/DESIGN.md.
hooks/register.tsx 476 lines1import { atom, read, update } from 'claude-code'
2import type { CommandInfo, EngineInterface, ProcessRunResult, Register, RenderSurface, Timer } from 'claude-code'
3
4import {
5 commandOwners,
6 diagnose,
7 hasHooksModule,
8 hhmm,
9 isInlineProvenance,
10 mergeLoaded,
11 paneLines,
12 parsePluginList,
13 parseValidate,
14 reportText,
15 rowText,
16 toastKey,
17 toastText,
18} from './core'
19import type { ExpectedEntry, Report, Tone, Validation } from './core'
20
21/**
22 * Mod Doctor: did every enabled mod's hooks module load? The loaded set comes from
23 * engine.create and plugin.register; the expected set from `claude plugin list --json`
24 * and each mod's hooks/hooks.json; the reasons from `claude plugin validate`.
25 * Pure logic lives in core.ts; this file holds every hook, every function that takes
26 * `$` and every atom. See docs/DESIGN.md.
27 */
28
29type Engine = EngineInterface
30
31/** Robert's Toolbox palette, plus red for failures. */
32const COLORS = {
33 gold: '#F2C46D',
34 green: '#3DD68C',
35 muted: '#7D8590',
36 red: '#F85149',
37 text: '#E6EDF3',
38} as const
39
40const TONE_COLORS: Record<Tone, string> = { ok: COLORS.green, bad: COLORS.red, warn: COLORS.gold, muted: COLORS.muted }
41
42const PANE = 'mod-doctor'
43const PANE_TITLE = 'Mod Doctor'
44const CHECKING = 'checking…'
45
46const loadedAtom = atom({ plugin: 'mod-doctor', key: 'loaded' } as const, [])
47const inlineAtom = atom({ plugin: 'mod-doctor', key: 'inline' } as const, [])
48const reportAtom = atom({ plugin: 'mod-doctor', key: 'report' } as const, null)
49const checkingAtom = atom({ plugin: 'mod-doctor', key: 'checking' } as const, false)
50const toastedAtom = atom({ plugin: 'mod-doctor', key: 'toasted' } as const, [])
51const paneOpenAtom = atom({ plugin: 'mod-doctor', key: 'paneOpen' } as const, false)
52
53/** Each `claude` call may take this long before it counts as failed. */
54const CLAUDE_TIMEOUT_MS = 30_000
55/** A `/reload-plugins` that reloads several mods causes one check. */
56const REGISTER_DEBOUNCE_MS = 2_000
57/** How many `claude plugin validate` runs go at once. */
58const VALIDATE_AT_ONCE = 4
59/** A whole check still running after this counts as hung: `check timed out`. */
60const CHECK_WATCHDOG_MS = CLAUDE_TIMEOUT_MS * 3
61
62/** What the module keeps between events; a hot reload starts it over (the report is in $.state). */
63const runtime = {
64 /** engine.create's list, held until session.start can write it (`$` is still being built there). */
65 pendingCreate: [] as readonly string[],
66 /** Whether a check is running now. */
67 isRunning: false,
68 /** A check was asked for while one ran: run once more when it ends. */
69 isAgain: false,
70 /** Whether that follow-up validates every checked mod. */
71 isAgainFull: false,
72 /** The pending check after a plugin.register, if any. */
73 debounce: null as Timer | null,
74 /** The check whose results may still be written; a hung one outlived by its watchdog writes nothing. */
75 checkId: 0,
76}
77
78/** One `claude` command's result, or undefined when it could not start or timed out. */
79async function runClaude($: Engine, args: readonly string[], timeoutMs: number): Promise<ProcessRunResult | undefined> {
80 try {
81 return await $.process.run(['claude', ...args], { timeoutMs })
82 } catch {
83 return undefined
84 }
85}
86
87async function now($: Engine): Promise<number> {
88 try {
89 return await $.clock.now()
90 } catch {
91 return 0
92 }
93}
94
95/** Whether a plugin's hooks/hooks.json names a hooks module; a missing or unreadable file does not. */
96async function hasModule($: Engine, folder: string): Promise<boolean> {
97 try {
98 // `/` joins on Windows too.
99 const text = await $.fs.read(`${folder.replace(/[\\/]+$/, '')}/hooks/hooks.json`)
100 return typeof text === 'string' && hasHooksModule(text)
101 } catch {
102 return false
103 }
104}
105
106async function validateOne($: Engine, entry: ExpectedEntry): Promise<Validation> {
107 const result = await runClaude($, ['plugin', 'validate', entry.folder], CLAUDE_TIMEOUT_MS)
108 if (result === undefined) return { ok: null, reason: 'timed out or could not start' }
109 if (result.exitCode === 0) return { ok: true }
110 const errors = parseValidate(`${result.stdout}\n${result.stderr}`, entry.folder)
111 return { ok: false, errors: errors.length > 0 ? errors : [`validate failed (exit ${result.exitCode})`] }
112}
113
114/** Validates each target, VALIDATE_AT_ONCE at a time; keyed by mod name. */
115async function validateAll($: Engine, targets: readonly ExpectedEntry[]): Promise<Record<string, Validation>> {
116 const out: Record<string, Validation> = {}
117 let next = 0
118 const worker = async () => {
119 while (next < targets.length) {
120 const entry = targets[next++]!
121 out[entry.name] = await validateOne($, entry)
122 }
123 }
124 await Promise.all(Array.from({ length: Math.min(VALIDATE_AT_ONCE, targets.length) }, worker))
125 return out
126}
127
128/** Each plugin's slash commands, without a leading `<plugin>:`; empty when the engine cannot list them. */
129/** The session's commands; none when the list cannot be read. */
130async function commandList($: Engine): Promise<CommandInfo[]> {
131 try {
132 return await $.command.list()
133 } catch {
134 return []
135 }
136}
137
138function commandsByPlugin(list: readonly CommandInfo[]): Record<string, string[]> {
139 const out: Record<string, string[]> = {}
140 for (const c of list) {
141 if (c.plugin === undefined) continue
142 const prefix = `${c.plugin}:`
143 const name = c.name.startsWith(prefix) ? c.name.slice(prefix.length) : c.name
144 ;(out[c.plugin] ??= []).push(name)
145 }
146 return out
147}
148
149/** A check that could not run: `couldn't check: <error>`, no mods, so no row and no toast. */
150async function writeError($: Engine, error: string) {
151 const inline = await read($, inlineAtom)
152 const report: Report = { checkedAt: await now($), mods: [], inline: [...inline], skipped: 0, error }
153 await update($, reportAtom, () => report)
154}
155
156/** One toast per mod, status and first reason per session; raised only once the check's validates are done. */
157async function toastProblems($: Engine, report: Report) {
158 const toasted = await read($, toastedAtom)
159 const seen = new Set(toasted)
160 const fresh = report.mods.filter(m => m.status !== 'loaded' && !seen.has(toastKey(m)))
161 if (fresh.length === 0) return
162 await update($, toastedAtom, prev => [...new Set([...prev, ...fresh.map(toastKey)])])
163 for (const m of fresh) {
164 try {
165 $.ui.toast(toastText(m))
166 } catch {
167 // The report and the row still show it.
168 }
169 }
170}
171
172/**
173 * One check, start to finish. `full` validates every checked mod, not only the missing ones.
174 * Writes nothing once check `id` is no longer the current one (its watchdog gave up on it).
175 */
176async function checkOnce($: Engine, full: boolean, id: number) {
177 const isCurrent = () => runtime.checkId === id
178 const fail = async (error: string) => {
179 if (isCurrent()) await writeError($, error)
180 }
181 const list = await runClaude($, ['plugin', 'list', '--json'], CLAUDE_TIMEOUT_MS)
182 if (list === undefined) return fail('claude CLI not found')
183 if (list.exitCode !== 0) return fail(`plugin list failed (exit ${list.exitCode})`)
184 let expected: ExpectedEntry[]
185 try {
186 expected = parsePluginList(list.stdout)
187 } catch {
188 return fail('plugin list output not understood')
189 }
190 const isHooked = await Promise.all(expected.map(entry => hasModule($, entry.folder)))
191 const checked = expected.filter((_, i) => isHooked[i] === true)
192 // A /reload-plugins newcomer raises no plugin.register here: its own bare commands say it runs.
193 const commandInfo = await commandList($)
194 const loaded = await update($, loadedAtom, prev => mergeLoaded(prev, commandOwners(commandInfo)))
195 const isLoaded = new Set(loaded)
196 const validations = await validateAll(
197 $,
198 checked.filter(entry => full || !isLoaded.has(entry.name)),
199 )
200 const commands = commandsByPlugin(commandInfo)
201 const inline = await read($, inlineAtom)
202 const report = diagnose({
203 expected: checked,
204 loaded,
205 validations,
206 commands,
207 inline,
208 skipped: expected.length - checked.length,
209 checkedAt: await now($),
210 })
211 if (!isCurrent()) return
212 await update($, reportAtom, () => report)
213 await toastProblems($, report)
214}
215
216/** checkOnce, given up on after CHECK_WATCHDOG_MS (`check timed out`): a hung call never wedges the single flight. */
217async function checkWithWatchdog($: Engine, full: boolean) {
218 const id = ++runtime.checkId
219 let fire = () => {}
220 const watchdog = new Promise<'timed out'>(resolve => {
221 fire = () => resolve('timed out')
222 })
223 let timer: Timer | null = null
224 try {
225 timer = $.clock.after(CHECK_WATCHDOG_MS, () => fire())
226 } catch {
227 timer = null
228 }
229 try {
230 const outcome = await Promise.race([checkOnce($, full, id).then(() => 'done' as const), watchdog])
231 if (outcome === 'timed out') {
232 // The hung check may still settle later: it is no longer current, so it writes nothing.
233 runtime.checkId++
234 await writeError($, 'check timed out')
235 }
236 } finally {
237 timer?.cancel()
238 }
239}
240
241/**
242 * Runs a check; never rejects. Single flight: one asked for while one runs sets a
243 * single follow-up (full when any asker wanted full), run when the current one ends.
244 */
245async function runCheck($: Engine, options: { full: boolean }): Promise<void> {
246 if (runtime.isRunning) {
247 runtime.isAgain = true
248 runtime.isAgainFull ||= options.full
249 return
250 }
251 runtime.isRunning = true
252 let full = options.full
253 try {
254 await update($, checkingAtom, () => true)
255 for (;;) {
256 runtime.isAgain = false
257 runtime.isAgainFull = false
258 try {
259 await checkWithWatchdog($, full)
260 } catch (err) {
261 await writeError($, err instanceof Error ? err.message : String(err)).catch(() => undefined)
262 }
263 if (!runtime.isAgain) break
264 full = runtime.isAgainFull
265 }
266 } finally {
267 runtime.isRunning = false
268 await update($, checkingAtom, () => false).catch(() => undefined)
269 }
270}
271
272/** A check REGISTER_DEBOUNCE_MS after the last plugin.register; no clock, no check. */
273function scheduleCheck($: Engine) {
274 runtime.debounce?.cancel()
275 runtime.debounce = null
276 try {
277 runtime.debounce = $.clock.after(REGISTER_DEBOUNCE_MS, () => {
278 runtime.debounce = null
279 void runCheck($, { full: false }).catch(() => undefined)
280 })
281 } catch {
282 runtime.debounce = null
283 }
284}
285
286function errorText(err: unknown): string {
287 return err instanceof Error ? err.message : String(err)
288}
289
290/** Runs a button's work; a rejection becomes a toast instead of an unhandled error. */
291async function safely($: Engine, work: () => Promise<unknown>) {
292 try {
293 await work()
294 } catch (err) {
295 $.ui.toast(`Mod Doctor: ${errorText(err)}`)
296 }
297}
298
299/** Opens the pane, then checks again beside it (never held on the check). */
300async function openPane($: Engine) {
301 await update($, paneOpenAtom, () => true)
302 try {
303 const opened = await $.ui.open({ id: PANE, title: PANE_TITLE })
304 if (!opened.isPlaced) $.ui.toast('Widen the window to see the Mod Doctor pane.')
305 } catch (err) {
306 await update($, paneOpenAtom, () => false)
307 $.ui.toast(`Mod Doctor: the pane could not open — ${errorText(err)}`)
308 return
309 }
310 void runCheck($, { full: false }).catch(() => undefined)
311}
312
313/** Re-check: validates every checked mod. Pressed while a check runs, it queues a full follow-up (drawn dim only as a hint). */
314async function recheck($: Engine) {
315 await runCheck($, { full: true })
316}
317
318/** Copy report: the pane as plain text on the clipboard of the surface pressed on. */
319async function copyReport($: Engine, surface: RenderSurface) {
320 const report = await read($, reportAtom)
321 if (report === null) return
322 const copied = await $.ui.copy({ text: reportText(report, hhmm(report.checkedAt)), surface })
323 $.ui.toast(copied.isCopied ? 'Mod Doctor report copied' : `Mod Doctor: could not copy the report (${copied.reason})`)
324}
325
326async function closePane($: Engine) {
327 await update($, paneOpenAtom, () => false)
328 try {
329 await $.ui.close({ id: PANE })
330 } catch {
331 // Already closed.
332 }
333}
334
335/** Whether the pane is open, by the engine's own list of this plugin's panes (the atom when it cannot say). */
336async function isPaneOpen($: Engine): Promise<boolean> {
337 try {
338 return (await $.ui.panes()).some(pane => pane.id === PANE)
339 } catch {
340 return read($, paneOpenAtom)
341 }
342}
343
344/** The row's Open/Close, decided from the engine's own list of open panes. */
345async function togglePane($: Engine) {
346 if (await isPaneOpen($)) await closePane($)
347 else await openPane($)
348}
349
350export const register: Register = on => {
351 // `$` is still being built here: only note the list; session.start writes it.
352 on('engine.create', async ($, e, next) => {
353 const built = await next(e)
354 runtime.pendingCreate = [...e.plugins]
355 return built
356 })
357
358 // Mod Doctor never refuses a mod: whatever happens here, the module joins.
359 on('plugin.register', async ($, e, next) => {
360 const result = await next(e)
361 if (result.allow === true) {
362 await update($, loadedAtom, prev => mergeLoaded(prev, [e.name]))
363 if (isInlineProvenance(e.name, e.provenance)) {
364 await update($, inlineAtom, prev => (prev.includes(e.name) ? prev : [...prev, e.name].sort()))
365 }
366 scheduleCheck($)
367 }
368 return result
369 }).catch(($, e, next) => next(e))
370
371 on('session.start', async ($, e, next) => {
372 const result = await next(e)
373 // /mod-doctor first: Robert's Toolbox runs a mod's first registered command.
374 try {
375 await $.command.register({ name: 'mod-doctor', description: "Show which mods loaded and why any didn't" })
376 } catch {
377 // The row and the toast still show a problem.
378 }
379 const created = runtime.pendingCreate
380 await update($, loadedAtom, prev => mergeLoaded(prev, created))
381 // The first load's plugin.register calls armed a debounced check: this one covers them.
382 runtime.debounce?.cancel()
383 runtime.debounce = null
384 // No hook waits on a check.
385 void runCheck($, { full: false }).catch(() => undefined)
386 return result
387 }).catch(($, e, next) => next(e))
388
389 // The person's own close (the pane's mark or key) keeps the row's button in step.
390 on('ui.close', { id: PANE }, async ($, e, next) => {
391 const result = await next(e)
392 await update($, paneOpenAtom, () => false)
393 return result
394 }).catch(($, e, next) => next(e))
395
396 // /reload-plugins tells a plugin already loaded nothing about the mods it loads (probed
397 // 2026-10-15), so check again once it has finished. It always runs: Mod Doctor only watches.
398 on('command.run', { command: 'reload-plugins' }, async ($, e, next) => {
399 const result = await next(e)
400 scheduleCheck($)
401 return result
402 }).catch(($, e, next) => next(e))
403
404 on('command.run', { command: 'mod-doctor' }, async $ => {
405 await safely($, () => openPane($))
406 return {}
407 })
408
409 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
410 const { Box, Text, Button } = $.ui.resolve(e)
411 // A drawing that throws would drop the pane (an unload close): a dim line instead.
412 try {
413 const [report, checking] = await Promise.all([read($, reportAtom), read($, checkingAtom)])
414 const lines = report === null ? [] : paneLines(report, hhmm(report.checkedAt), e.props.bodyColumns > 0 ? e.props.bodyColumns : undefined)
415 return (
416 <Box flexDirection="column">
417 {report === null ? (
418 <Box key="mod-doctor-waiting">
419 <Text color={COLORS.muted}>{CHECKING}</Text>
420 </Box>
421 ) : null}
422 {/* Each line sits in a keyed Box: a Text keeps no key of its own. */}
423 {lines.map((line, i) => (
424 <Box key={`mod-doctor-line-${i}`}>
425 <Text color={TONE_COLORS[line.tone]}>{i === 0 && checking ? `${line.text} · ${CHECKING}` : line.text}</Text>
426 </Box>
427 ))}
428 {/* Wraps onto more rows in a narrow pane; each button keeps its label whole. */}
429 <Box key="mod-doctor-buttons" flexDirection="row" flexWrap="wrap" columnGap={1}>
430 <Box key="mod-doctor-cell-recheck" flexShrink={0}>
431 <Button key="mod-doctor-recheck" label="Re-check" dimColor={checking} onPress={() => safely($, () => recheck($))} />
432 </Box>
433 <Box key="mod-doctor-cell-copy" flexShrink={0}>
434 <Button
435 key="mod-doctor-copy"
436 label="Copy report"
437 dimColor={report === null}
438 onPress={press => safely($, () => copyReport($, press.surface))}
439 />
440 </Box>
441 <Box key="mod-doctor-cell-close" flexShrink={0}>
442 <Button key="mod-doctor-close" label="Close" onPress={() => safely($, () => closePane($))} />
443 </Box>
444 </Box>
445 </Box>
446 )
447 } catch (err) {
448 return <Text dimColor>{`Mod Doctor could not draw: ${errorText(err)}`}</Text>
449 }
450 })
451
452 // Shares the band: always next's rows first, then Mod Doctor's, only when a mod didn't load.
453 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
454 const below = await next(e)
455 if (e.props.hasSurvey) return below
456 const report = await read($, reportAtom)
457 const text = report === null ? null : rowText(report)
458 if (text === null) return below
459 // The atom, not $.ui.panes(): reading it subscribes the band, so an open or close redraws the label.
460 const isOpen = await read($, paneOpenAtom)
461 const { Box, Text, Button } = $.ui.resolve(e)
462 return (
463 <Box flexDirection="column">
464 {below}
465 <Box key="mod-doctor-row" flexDirection="row">
466 <Box key="mod-doctor-text" flexShrink={1}>
467 <Text color={COLORS.red} bold>{text}</Text>
468 </Box>
469 <Box key="mod-doctor-spacer" flexGrow={1} />
470 <Button key="mod-doctor-toggle" label={isOpen ? 'Close' : 'Open'} onPress={() => safely($, () => togglePane($))} />
471 </Box>
472 </Box>
473 )
474 }).catch(($, e, next) => next(e))
475}
476hooks/core.ts 261 lines1/** Mod Doctor's pure logic: no `$`, no engine. See docs/DESIGN.md. */
2
3export const SELF = 'mod-doctor'
4const MAX_ERROR_LINES = 3
5
6/** An enabled plugin from `claude plugin list --json`. */
7export type ExpectedEntry = {
8 id: string
9 name: string
10 version: string | null
11 /** readFromFolder when set, else installPath. */
12 folder: string
13 folderVersion: string | null
14 isReadFromFolder: boolean
15}
16
17/** `relay@roberts-mods` → `relay`. */
18export function nameFromId(id: string): string {
19 const at = id.lastIndexOf('@')
20 return at > 0 ? id.slice(0, at) : id
21}
22
23function stripBom(s: string): string {
24 return s.charCodeAt(0) === 0xfeff ? s.slice(1) : s
25}
26
27function str(v: unknown): string | null {
28 return typeof v === 'string' && v !== '' ? v : null
29}
30
31/** The enabled entries of `claude plugin list --json`. Throws on JSON that isn't an array. */
32export function parsePluginList(json: string): ExpectedEntry[] {
33 const raw: unknown = JSON.parse(stripBom(json))
34 if (!Array.isArray(raw)) throw new Error('plugin list is not an array')
35 const out: ExpectedEntry[] = []
36 for (const item of raw) {
37 if (typeof item !== 'object' || item === null) continue
38 const o = item as Record<string, unknown>
39 const id = str(o['id'])
40 if (id === null || o['enabled'] !== true) continue
41 const fromFolder = str(o['readFromFolder'])
42 const folder = fromFolder ?? str(o['installPath'])
43 if (folder === null) continue
44 out.push({
45 id,
46 name: nameFromId(id),
47 version: str(o['version']),
48 folder,
49 folderVersion: str(o['folderVersion']),
50 isReadFromFolder: fromFolder !== null,
51 })
52 }
53 return out
54}
55
56/** Whether a hooks/hooks.json names at least one hooks module. */
57export function hasHooksModule(text: string): boolean {
58 try {
59 const j: unknown = JSON.parse(stripBom(text))
60 const modules = typeof j === 'object' && j !== null ? (j as Record<string, unknown>)['modules'] : undefined
61 return Array.isArray(modules) && modules.length > 0
62 } catch {
63 return false
64 }
65}
66
67/** Adds an engine.create or plugin.register list to the loaded set. A list holding only Mod Doctor (its own reload) adds nothing. */
68export function mergeLoaded(prev: readonly string[], plugins: readonly string[]): string[] {
69 if (plugins.every(p => p === SELF)) return [...prev]
70 return [...new Set([...prev, ...plugins])].sort()
71}
72
73/**
74 * The plugins whose hooks module is running now, as the command list shows: one that owns a
75 * bare-named plugin command registered it through `$.command.register` (skill and manifest
76 * commands are namespaced `<plugin>:<name>`). A reload's newcomers raise no plugin.register
77 * for a plugin already loaded (probed 2026-10-15), so this is what keeps the loaded set true.
78 */
79export function commandOwners(list: readonly { name: string; plugin?: string; source: string }[]): string[] {
80 const owners = new Set<string>()
81 for (const c of list) {
82 if (c.source === 'plugin' && c.plugin !== undefined && !c.name.includes(':')) owners.add(c.plugin)
83 }
84 return [...owners].sort()
85}
86
87/** Whether a plugin.register provenance is a `--plugin-dir` load (`<name>@inline`) of a mod other than Mod Doctor. */
88export function isInlineProvenance(name: string, provenance: string): boolean {
89 return name !== SELF && provenance.endsWith('@inline')
90}
91
92function escapeRegExp(s: string): string {
93 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
94}
95
96/** The error lines of `claude plugin validate` output, at most three, with paths under `root` made relative. */
97export function parseValidate(stdout: string, root: string): string[] {
98 const lines = stdout.split(/\r?\n/).map(l => l.replace(/\x1b\[[0-9;]*m/g, ''))
99 const trimmedRoot = root.replace(/[\\/]+$/, '')
100 const rootPattern =
101 trimmedRoot === ''
102 ? null
103 : new RegExp(`${trimmedRoot.split(/[\\/]/).map(escapeRegExp).join('[\\\\/]')}[\\\\/]`, 'gi')
104 const out: string[] = []
105 let inErrors = false
106 for (const line of lines) {
107 const t = line.trim()
108 if (/^✘ Found \d+ errors?:/.test(t)) {
109 inErrors = true
110 continue
111 }
112 if (t.startsWith('✘') || t.startsWith('✔') || t.startsWith('⚠')) {
113 inErrors = false
114 continue
115 }
116 if (!inErrors || !t.startsWith('❯')) continue
117 let text = t.slice(1).trim().replace(/^modules\S*: [^\s:]+: /, '')
118 if (rootPattern !== null) text = text.replace(rootPattern, '')
119 out.push(text)
120 if (out.length === MAX_ERROR_LINES) break
121 }
122 return out
123}
124
125export type Validation = { ok: true } | { ok: false; errors: string[] } | { ok: null; reason: string }
126export type ModStatus = 'loaded' | 'not-loaded' | 'broken'
127export type ModReport = {
128 name: string
129 version: string | null
130 status: ModStatus
131 errors: string[]
132 note: string | null
133 drift: { folder: string; record: string } | null
134 /** Slash command names without the `/`. */
135 commands: string[]
136}
137export type Report = {
138 checkedAt: number
139 mods: ModReport[]
140 /** `--plugin-dir` mods heard through plugin.register (provenance `@inline`). */
141 inline: string[]
142 /** Enabled plugins with no hooks module. */
143 skipped: number
144 /** Set when the check itself could not run. */
145 error: string | null
146}
147export type Tone = 'ok' | 'bad' | 'warn' | 'muted'
148export type PaneLine = { tone: Tone; text: string; isDetail?: true }
149
150const MAX_COMMANDS = 3
151
152export function diagnose(input: {
153 expected: readonly ExpectedEntry[]
154 loaded: readonly string[]
155 validations: Readonly<Record<string, Validation>>
156 commands: Readonly<Record<string, readonly string[]>>
157 inline: readonly string[]
158 skipped: number
159 checkedAt: number
160}): Report {
161 const loaded = new Set(input.loaded)
162 const mods = input.expected.map((e): ModReport => {
163 const v = input.validations[e.name]
164 const isLoaded = loaded.has(e.name)
165 const status: ModStatus = !isLoaded ? 'not-loaded' : v?.ok === false ? 'broken' : 'loaded'
166 const note =
167 v?.ok === null ? `couldn't validate: ${v.reason}` : !isLoaded && v?.ok === true ? 'validate passes; try /reload-plugins' : null
168 const drift =
169 e.isReadFromFolder && e.folderVersion !== null && e.version !== null && e.folderVersion !== e.version
170 ? { folder: e.folderVersion, record: e.version }
171 : null
172 return { name: e.name, version: e.version, status, errors: v?.ok === false ? v.errors : [], note, drift, commands: [...(input.commands[e.name] ?? [])] }
173 })
174 mods.sort((a, b) => Number(a.status === 'loaded') - Number(b.status === 'loaded') || a.name.localeCompare(b.name))
175 return { checkedAt: input.checkedAt, mods, inline: [...input.inline], skipped: input.skipped, error: null }
176}
177
178function problems(report: Report): ModReport[] {
179 return report.mods.filter(m => m.status !== 'loaded')
180}
181
182/** The band row's text, or null when nothing is wrong (or the check couldn't run). */
183export function rowText(report: Report): string | null {
184 const bad = problems(report)
185 if (report.error !== null || bad.length === 0) return null
186 const names = bad.slice(0, 2).map(m => m.name).join(', ')
187 const more = bad.length > 2 ? ` +${bad.length - 2}` : ''
188 const one = bad.length === 1
189 const count = `${bad.length} ${one ? 'mod' : 'mods'}`
190 const what = bad.every(m => m.status === 'not-loaded')
191 ? "didn't load"
192 : bad.every(m => m.status === 'broken')
193 ? one
194 ? 'is broken'
195 : 'are broken'
196 : 'have problems'
197 return `✗ Mod Doctor ${count} ${what}: ${names}${more}`
198}
199
200/** The toast for a mod with a problem. */
201export function toastText(m: ModReport): string {
202 return m.status === 'broken'
203 ? `Mod Doctor: ${m.name} is broken on disk — /mod-doctor for why`
204 : `Mod Doctor: ${m.name} didn't load — /mod-doctor for why`
205}
206
207const STATUS_WORDS: Record<Exclude<ModStatus, 'loaded'>, string> = { 'not-loaded': 'not loaded', broken: 'broken on disk' }
208
209/** A loaded mod's line: up to three commands, or one plus `+N` where that would be wider than `columns`. */
210function loadedLine(m: ModReport, columns: number | undefined): string {
211 const head = `✓ ${m.name}${m.version !== null ? ` ${m.version}` : ''}`
212 const withCommands = (max: number) => {
213 const shown = m.commands.slice(0, max).map(c => `/${c}`).join(' ')
214 const more = m.commands.length > max ? ` +${m.commands.length - max}` : ''
215 return shown === '' ? head : `${head} · ${shown}${more}`
216 }
217 const full = withCommands(MAX_COMMANDS)
218 return columns !== undefined && full.length > columns ? withCommands(1) : full
219}
220
221/** The pane's lines; `time` is the check's local time, e.g. `12:41`; `columns` the pane's width, when known. */
222export function paneLines(report: Report, time: string, columns?: number): PaneLine[] {
223 if (report.error !== null) return [{ tone: 'bad', text: `couldn't check: ${report.error}` }]
224 const count = report.mods.length
225 const bad = problems(report).length
226 const lines: PaneLine[] = [
227 { tone: 'muted', text: `Checked ${time} · ${count} ${count === 1 ? 'mod' : 'mods'} · ${bad} ${bad === 1 ? 'problem' : 'problems'}` },
228 ]
229 for (const m of report.mods) {
230 if (m.status === 'loaded') {
231 lines.push({ tone: 'ok', text: loadedLine(m, columns) })
232 } else {
233 lines.push({ tone: 'bad', text: `✗ ${m.name} ${STATUS_WORDS[m.status]}` })
234 }
235 for (const err of m.errors) lines.push({ tone: 'bad', text: ` ${err}`, isDetail: true })
236 if (m.note !== null) lines.push({ tone: 'muted', text: ` ${m.note}`, isDetail: true })
237 if (m.drift !== null) lines.push({ tone: 'warn', text: ` ⚠ folder is ${m.drift.folder}, on record ${m.drift.record}`, isDetail: true })
238 }
239 const listed = new Set(report.mods.map(m => m.name))
240 for (const name of report.inline.filter(n => !listed.has(n))) lines.push({ tone: 'ok', text: `✓ ${name} dev folder` })
241 lines.push({ tone: 'muted', text: `${report.skipped} skipped (no hooks module)` })
242 return lines
243}
244
245/** A check's local time as `HH:MM`; `--:--` for 0, a check made with no clock. */
246export function hhmm(ms: number): string {
247 if (ms <= 0) return '--:--'
248 const at = new Date(ms)
249 return `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
250}
251
252/** Plain text for Copy report. */
253export function reportText(report: Report, time: string): string {
254 return ['Mod Doctor report', ...paneLines(report, time).map(l => l.text)].join('\n')
255}
256
257/** One toast per mod, status and first reason per session. */
258export function toastKey(m: ModReport): string {
259 return `${m.name}|${m.status}|${m.errors[0] ?? m.note ?? ''}`
260}
261types/index.d.ts 52 lines1/** A checked mod's standing: in the loaded set and passing, missing from it, or loaded but failing validate. */
2export type ModDoctorModStatus = 'loaded' | 'not-loaded' | 'broken'
3
4/** One checked mod as the report lists it; the same shape as core.ts's ModReport. */
5export type ModDoctorMod = {
6 name: string
7 /** The version on record in `claude plugin list`; null when it states none. */
8 version: string | null
9 status: ModDoctorModStatus
10 /** Up to three error lines from `claude plugin validate`, paths relative to the mod's folder. */
11 errors: string[]
12 /** `validate passes; try /reload-plugins` or `couldn't validate: <reason>`; null when none. */
13 note: string | null
14 /** Set when the mod is read from its folder and the folder's version differs from the one on record. */
15 drift: { folder: string; record: string } | null
16 /** Its slash commands, without the `/`. */
17 commands: string[]
18}
19
20/** One check's result; the same shape as core.ts's Report. */
21export type ModDoctorReport = {
22 /** When the check ran, epoch ms. */
23 checkedAt: number
24 /** Problems first, then by name. */
25 mods: ModDoctorMod[]
26 /** `--plugin-dir` mods heard through plugin.register (provenance `@inline`). */
27 inline: string[]
28 /** Enabled plugins with no hooks module. */
29 skipped: number
30 /** Set when the check itself could not run; the mods are then empty. */
31 error: string | null
32}
33
34declare module 'claude-code' {
35 interface PluginState {
36 'mod-doctor': {
37 /** Every module heard loading this session: engine.create's list plus each plugin.register since, sorted. */
38 loaded: string[]
39 /** The `--plugin-dir` mods heard through plugin.register. */
40 inline: string[]
41 /** The last check's result; null before the first. */
42 report: ModDoctorReport | null
43 /** True while a check runs. */
44 checking: boolean
45 /** The toastKey of every toast raised this session. */
46 toasted: string[]
47 /** Whether the Mod Doctor pane is open: the row's Open/Close. */
48 paneOpen: boolean
49 }
50 }
51}
52