SLOPSHOPPER

mod-doctor

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.

newpanebandcommandtoastprocess
v0.1.0MITupdated 2026-10-16hearsawho25/roberts-mods/mod-doctor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-doctor
│ ┃ Mod Doctor ✕ › fix the failing auth test and add an audit log call │ ┃ couldn't check: plugin list output not │ ┃ understood · checking… ⏺ Read(src/auth.ts) │ ┃ [ Re-check ] [ Copy report ] [ Close ] ⎿ 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 │ │ › /mod-doctor │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mod Doctor
couldn't check: plugin list output not understood · checking… [ Re-check ] [ Copy report ] [ Close ]
README

Mod Doctor

A Claude Code mod that answers one question: did my mods load?

It compares two lists:

  • every enabled plugin that ships a hooks module
  • the hooks modules Claude Code actually loaded

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.

Install

claude plugin marketplace add hearsawho25/roberts-mods
claude plugin install mod-doctor@roberts-mods

The row

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.

The pane

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.
  • Skipped: enabled plugins with no hooks module (skills, commands or classic hooks only). Nothing loads for them, so there is nothing to check.

The buttons:

ButtonWhat it does
Re-checkRuns claude plugin validate on every checked mod, not just missing ones
Copy reportCopies the pane as plain text, ready to paste to Claude
CloseCloses the pane

When it checks

Mod Doctor checks at these times:

  • when the session starts
  • 2 seconds after /reload-plugins finishes, so a mod you fixed and reloaded clears the row by itself
  • when you open the pane
  • when you press Re-check

It 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.

What it never does

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.

Source 3 files
hooks/register.tsx 476 lines
1import { 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}
476
hooks/core.ts 261 lines
1/** 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}
261
types/index.d.ts 52 lines
1/** 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