A workbench for building Claude Code mods: which mods loaded, a live tape of every event with its real payload, the four ship gates on the mod in progress, and…

A workbench for building Claude Code mods. It shows what the engine is really doing (which mods loaded, every event and its real payload) and runs the four ship gates on the mod you are working on. It also gives Claude three tools, so while it builds a mod it can read a real event payload instead of guessing from the 14,000-line types file, and run the gates in one call instead of by hand.
Reach, up front: modsmith starts local processes (claude and npx, for the gates and a version check), reads mod folders and your settings, and reads HOME. It makes no model calls and sends nothing anywhere; the only network use is npx fetching TypeScript the first time the types gate runs. With its tools on, their descriptions add a few hundred tokens to every request. By default it runs claude plugin validate on a dev mod each time that mod reloads. Both are switches (below).
claude plugin install modsmith@bar-mods
$ calls it makes and how many times it reloaded; installed plugins and dev mods on disk beside them. Pick one as the gate target.e as JSON and copy it. Pause, clear, filter by family. Obvious secrets (sk-…, ghp_…, bearer tokens, token= values) are scrubbed before anything is kept, and the tape lives only in the session's memory.claude plugin validate, green only with no warnings;tsc against the types Claude Code writes beside the mod, zero errors outside tests/ (the first run fetches TypeScript and takes about half a minute; after that, a few seconds);claude plugin test;It also warns when your claude CLI is older than the engine (an older CLI fails correct mod code), when the README's footprint misses a hook or call validate found, and when the installed copy has the same version as the one you are about to ship.
↻ my-mod reloaded · 4 hooks · validate ✓. It stacks above any other mod's band./modsmith opens the pane. It also opens by itself when a dev mod loads, where there is room (144 columns or more). /gates [mod folder] runs the gates with no Claude turn and prints the result.
| Tool | What it answers | | :- | :- | | mcp__modsmith__events | Recent events with their payloads; event filters by name or prefix (tool.call, ui.), limit defaults to 20 | | mcp__modsmith__gates | Runs the gates on dir (default: the target), only picks some; pass/fail with trimmed output per gate | | mcp__modsmith__mods | The roster, with each mod's events and calls |
Set in /plugin on the installed plugin:
| Option | Default | What it does | | :- | :- | :- | | tools | on | Register the three tools | | autoValidate | on | Validate a dev mod on every reload | | recordAppends | off | Also record session.append, every transcript row (floods the tape) | | cliPath, npxPath | claude, npx | Where the gates find them, when the app's PATH does not |
From claude plugin validate:
session.start, session.end, plugin.register, prompt.submit, turn.start, turn.complete, agent.spawn, session.compact, tool.call (observes; answers only its own tools), command.run (answers only /modsmith and /gates), ui.press, ui.input, ui.select, session.append (only with recordAppends), ui.render{component=AbovePrompt}, ui.render{component=Pane}$.clock.after, $.clock.now, $.command.register, $.env.get (HOME), $.fs.exists, $.fs.list, $.fs.read, $.process.run (claude, npx), $.session.id, $.session.version, $.settings.read, $.state.get, $.state.set, $.tool.register, $.ui.copy, $.ui.open, $.ui.resolveIt never changes another mod's event: every hook passes the event on unchanged.
plugin.register hook only sees mods that load after modsmith, so a mod that loaded first shows as installed or on disk until it reloads.claude -p the commands and tools still work: claude -p "/gates ." --plugin-dir ./my-mod gates a mod from a script.Tested on Claude Code 2.1.288.
hooks/register.tsx 576 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Entry, Family, Gate, GateName, GateStatus, Mod } from '../types'
5import {
6 FAMILY_COLOR,
7 GATE_NAMES,
8 TAPE_SIZE,
9 baseName,
10 clip,
11 clockTime,
12 familyOf,
13 formatGates,
14 isGateName,
15 isOlder,
16 markOf,
17 parseTsc,
18 parseValidate,
19 resolveDir,
20 staleFootprint,
21 summarize,
22 tail,
23} from './lib'
24
25const ME = 'modsmith'
26const PANE = 'modsmith'
27const TOOL_PREFIX = 'mcp__modsmith__'
28// A dev mod: loaded by --plugin-dir or hot reload (@inline), or by claude plugin test.
29const isDev = (provenance: string) => /@(inline|claude-plugin-test)$/.test(provenance)
30const IDLE: Gate = { status: 'idle', tail: '' }
31const FAMILIES: Family[] = ['tools', 'turns', 'session', 'ui', 'plugins', 'commands', 'agents']
32
33const tape = atom({ plugin: 'modsmith', key: 'tape' } as const, [])
34const selected = atom({ plugin: 'modsmith', key: 'selected' } as const, null)
35const filter = atom({ plugin: 'modsmith', key: 'filter' } as const, 'all')
36const paused = atom({ plugin: 'modsmith', key: 'isPaused' } as const, false)
37const mods = atom({ plugin: 'modsmith', key: 'mods' } as const, [])
38const target = atom({ plugin: 'modsmith', key: 'target' } as const, null)
39const gates = atom({ plugin: 'modsmith', key: 'gates' } as const, { validate: IDLE, types: IDLE, tests: IDLE })
40const eyes = atom({ plugin: 'modsmith', key: 'eyes' } as const, { terminal: false, desktop: false, narrow: false })
41const versions = atom({ plugin: 'modsmith', key: 'versions' } as const, null)
42const lastReload = atom({ plugin: 'modsmith', key: 'lastReload' } as const, null)
43const ship = atom({ plugin: 'modsmith', key: 'ship' } as const, [])
44
45type Config = { tools: boolean; autoValidate: boolean; recordAppends: boolean; cli: string; npx: string }
46
47// Module variables reset on reload; session.start sets them again.
48let config: Config = { tools: true, autoValidate: true, recordAppends: false, cli: 'claude', npx: 'npx' }
49let cwd = ''
50let home = ''
51let seq = 0
52
53const text = (body: string) => ({ result: [{ type: 'text', text: body }] })
54
55const withNote = (gate: Gate, note: string | undefined): Gate => (note ? { ...gate, note } : gate)
56
57async function record($: EngineInterface, event: string, e: unknown, outcome?: string) {
58 try {
59 if (await read($, paused)) return
60 const at = await $.clock.now()
61 seq += 1
62 const fields = (e ?? {}) as Record<string, unknown>
63 const base: Entry = { id: `${at}-${seq}`, at, event, family: familyOf(event), summary: summarize(event, fields), payload: clip(e) }
64 const entry: Entry = outcome ? { ...base, outcome } : base
65 await update($, tape, list => [...list, entry].slice(-TAPE_SIZE))
66 } catch {
67 // Recording never breaks the event it watches.
68 }
69}
70
71async function run($: EngineInterface, argv: string[], dir?: string) {
72 const started = await $.clock.now()
73 try {
74 const where = dir ?? cwd
75 const r = await $.process.run(argv, where ? { cwd: where, timeoutMs: 180_000 } : { timeoutMs: 180_000 })
76 return { code: r.exitCode, out: `${r.stdout}\n${r.stderr}`, ms: (await $.clock.now()) - started }
77 } catch (err) {
78 return { code: 127, out: `could not run ${argv[0] ?? '?'}: ${String(err)}`, ms: (await $.clock.now()) - started }
79 }
80}
81
82async function setGate($: EngineInterface, name: GateName, gate: Gate) {
83 await update($, gates, all => ({ ...all, [name]: gate }))
84}
85
86async function checkVersions($: EngineInterface) {
87 const engine = (await $.session.version()).version
88 const r = await run($, [config.cli, '--version'])
89 const cli = r.code === 0 ? (r.out.match(/\d+\.\d+\.\d+/)?.[0] ?? '?') : 'not found'
90 await update($, versions, () => ({ cli, engine, isCliOlder: cli === 'not found' || isOlder(cli, engine) }))
91}
92
93async function shipCheck($: EngineInterface, dir: string, hooks: string[], calls: string[]) {
94 const notes: string[] = []
95 if (hooks.length > 0) {
96 try {
97 const readme = await $.fs.read(`${dir}/README.md`)
98 const missing = typeof readme === 'string' ? staleFootprint(readme, hooks, calls) : []
99 if (missing.length > 0) notes.push(`README footprint is stale: ${missing.slice(0, 6).join(', ')}`)
100 } catch {
101 notes.push('No README.md to carry the footprint')
102 }
103 }
104 try {
105 const raw = await $.fs.read(`${dir}/.claude-plugin/plugin.json`)
106 const manifest = JSON.parse(typeof raw === 'string' ? raw : '{}') as { name?: string; version?: string }
107 const installed = (await read($, mods)).find(
108 m => m.name === manifest.name && m.source !== 'disk' && !isDev(m.provenance),
109 )
110 if (installed?.version !== undefined && installed.version === manifest.version) {
111 notes.push(`Installed ${installed.provenance} is also ${installed.version}: bump the version before reinstalling`)
112 }
113 } catch {
114 // No manifest: validate already says so.
115 }
116 await update($, ship, () => notes)
117}
118
119async function runGates($: EngineInterface, dir: string, only: GateName[]) {
120 await checkVersions($)
121 const results: Partial<Record<GateName, Gate>> = {}
122 for (const name of only) await setGate($, name, { status: 'running', tail: '' })
123 let hooks: string[] = []
124 let calls: string[] = []
125
126 if (only.includes('validate')) {
127 const r = await run($, [config.cli, 'plugin', 'validate', dir])
128 const v = parseValidate(r.code, r.out)
129 hooks = v.hooks
130 calls = v.calls
131 const warned = v.warnings.length > 0 ? `${v.warnings.length} warning line(s)` : undefined
132 results.validate = withNote({ status: v.isGreen ? 'pass' : 'fail', ms: r.ms, tail: tail(r.out) }, warned)
133 await setGate($, 'validate', results.validate)
134 }
135
136 if (only.includes('types')) {
137 if (await $.fs.exists(`${dir}/.claude-plugin/types`)) {
138 const r = await run($, [config.npx, '-y', '-p', 'typescript@5', 'tsc', '-p', '.'], dir)
139 const t = parseTsc(r.out)
140 const ignored = t.inTests > 0 ? `${t.inTests} error(s) in tests/ ignored` : undefined
141 const shown = t.outside.length > 0 ? t.outside.join('\n') : r.code === 0 ? '' : r.out
142 results.types = withNote({ status: t.isGreen ? 'pass' : 'fail', ms: r.ms, tail: tail(shown) }, ignored)
143 } else {
144 results.types = {
145 status: 'fail',
146 tail: '',
147 note: `no generated types yet; load the mod once: claude -p "/<its command>" --plugin-dir ${dir}`,
148 }
149 }
150 await setGate($, 'types', results.types)
151 }
152
153 if (only.includes('tests')) {
154 const r = await run($, [config.cli, 'plugin', 'test'], dir)
155 results.tests = { status: r.code === 0 ? 'pass' : 'fail', ms: r.ms, tail: tail(r.out) }
156 await setGate($, 'tests', results.tests)
157 }
158
159 await shipCheck($, dir, hooks, calls)
160 return results
161}
162
163async function autoValidate($: EngineInterface, name: string, root: string) {
164 const r = await run($, [config.cli, 'plugin', 'validate', root])
165 const v = parseValidate(r.code, r.out)
166 const status: GateStatus = v.isGreen ? 'pass' : 'fail'
167 await update($, lastReload, was => (was && was.name === name ? { ...was, validate: status } : was))
168 if ((await read($, target)) === root) {
169 await setGate($, 'validate', { status, ms: r.ms, tail: tail(r.out) })
170 }
171}
172
173async function refreshRoster($: EngineInterface) {
174 const others: Mod[] = []
175 const blank = { tier: 'user', events: [], calls: [], reloads: 0, lastLoad: 0 }
176 try {
177 const id = await $.session.id()
178 const dir = `${home}/.claude/dev-mods/${id}`
179 if (home && (await $.fs.exists(dir))) {
180 for (const entry of await $.fs.list(dir)) {
181 if (entry.kind === 'dir') {
182 others.push({ ...blank, name: entry.name, root: `${dir}/${entry.name}`, provenance: `${entry.name}@inline`, source: 'disk' })
183 }
184 }
185 }
186 } catch {
187 // No dev-mods folder this session.
188 }
189 try {
190 const settings = await $.settings.read()
191 const enabled = (settings.enabledPlugins ?? {}) as Record<string, unknown>
192 for (const [key, isOn] of Object.entries(enabled)) {
193 if (isOn === true) others.push({ ...blank, name: key.split('@')[0] ?? key, root: '', provenance: key, source: 'installed' })
194 }
195 } catch {
196 // Settings unreadable: the roster keeps what it saw load.
197 }
198 await update($, mods, list => {
199 const seen = list.filter(m => m.source === 'seen')
200 const known = new Set(seen.map(m => m.provenance))
201 return [...seen, ...others.filter(m => !known.has(m.provenance) && !(known.add(m.provenance) && false))]
202 })
203}
204
205async function answerTool($: EngineInterface, name: string, input: Record<string, unknown>) {
206 if (name === 'events') {
207 const limit = Math.min(100, Math.max(1, typeof input.limit === 'number' ? input.limit : 20))
208 const want = typeof input.event === 'string' ? input.event : ''
209 const list = (await read($, tape)).filter(x => !want || x.event.startsWith(want)).slice(-limit)
210 const body = list
211 .map(x => `## ${x.event} · ${x.summary}${x.outcome ? ` · ${x.outcome}` : ''}\n${x.payload}`)
212 .join('\n\n')
213 return text(body || 'No events recorded yet. The tape fills as the session runs.')
214 }
215 if (name === 'gates') {
216 const asked = typeof input.dir === 'string' && input.dir ? resolveDir(input.dir, cwd, home) : null
217 const dir = asked ?? (await read($, target))
218 if (!dir) return text('No target mod. Pass dir: the folder holding .claude-plugin/plugin.json.')
219 await update($, target, () => dir)
220 const picked = Array.isArray(input.only) ? input.only.filter((s): s is GateName => typeof s === 'string' && isGateName(s)) : []
221 const results = await runGates($, dir, picked.length > 0 ? picked : GATE_NAMES)
222 return text(formatGates(dir, results, await read($, ship)))
223 }
224 if (name === 'mods') {
225 await refreshRoster($)
226 const list = await read($, mods)
227 return text(JSON.stringify(list, null, 2))
228 }
229 return text(`modsmith has no tool named ${name}.`)
230}
231
232export const register: Register = (on, options) => {
233 config = {
234 tools: options.tools !== false,
235 autoValidate: options.autoValidate !== false,
236 recordAppends: options.recordAppends === true,
237 cli: typeof options.cliPath === 'string' && options.cliPath ? options.cliPath : 'claude',
238 npx: typeof options.npxPath === 'string' && options.npxPath ? options.npxPath : 'npx',
239 }
240
241 on('session.start', async ($, e, next) => {
242 cwd = e.cwd
243 home = (await $.env.get('HOME')) ?? ''
244 await $.command.register({ name: 'modsmith', description: 'Open the modsmith workbench pane' })
245 await $.command.register({
246 name: 'gates',
247 description: 'Run the four ship gates on a mod folder (default: the current target)',
248 argumentHint: '[mod folder]',
249 })
250 if (config.tools) {
251 await $.tool.register({
252 name: 'events',
253 description:
254 'modsmith: recent hook events from this live session with their real payloads (the `e` a hook receives), newest last. Read this instead of guessing an event shape from the .d.ts while building a Claude Code mod. `event` filters by name or prefix ("tool.call", "ui."); `limit` defaults to 20.',
255 inputSchema: {
256 type: 'object',
257 properties: { event: { type: 'string' }, limit: { type: 'number' } },
258 },
259 })
260 await $.tool.register({
261 name: 'gates',
262 description:
263 'modsmith: run the ship gates on a Claude Code mod folder (claude plugin validate; tsc against the generated types, zero errors outside tests/; claude plugin test) and return pass/fail with trimmed output per gate, plus ship notes.',
264 inputSchema: {
265 type: 'object',
266 properties: {
267 dir: { type: 'string', description: 'The mod folder; default: the current target' },
268 only: { type: 'array', items: { type: 'string', enum: ['validate', 'types', 'tests'] } },
269 },
270 },
271 })
272 await $.tool.register({
273 name: 'mods',
274 description: 'modsmith: the mods this session saw load (with the events they hook and the $ calls they make), installed plugins, and dev mods on disk.',
275 })
276 }
277 const ran = await next(e)
278 await record($, 'session.start', e)
279 return ran
280 })
281
282 on('session.end', async ($, e, next) => {
283 await record($, 'session.end', e)
284 return next(e)
285 })
286
287 on('plugin.register', async ($, e, next) => {
288 const result = await next(e)
289 if (e.name === ME) return result
290 await record($, 'plugin.register', e, result.refuse === undefined ? 'allowed' : `refused: ${result.refuse}`)
291 const now = await $.clock.now()
292 const base: Mod = {
293 name: e.name,
294 root: e.root,
295 tier: e.tier,
296 provenance: e.provenance,
297 events: [...e.uses.events],
298 calls: [...e.uses.calls],
299 reloads: 0,
300 lastLoad: now,
301 source: 'seen',
302 }
303 const mod: Mod = e.version === undefined ? base : { ...base, version: e.version }
304 await update($, mods, list => {
305 const old = list.find(m => m.provenance === mod.provenance && m.source === 'seen')
306 return [{ ...mod, reloads: old ? old.reloads + 1 : 0 }, ...list.filter(m => m.provenance !== mod.provenance)]
307 })
308
309 if (isDev(e.provenance)) {
310 await update($, lastReload, () => ({
311 name: e.name,
312 root: e.root,
313 hooks: e.uses.events.length,
314 validate: config.autoValidate ? 'running' : 'idle',
315 at: now,
316 }))
317 if ((await read($, target)) === null) await update($, target, () => e.root)
318 // A dev mod is loading: open the workbench where there is room for it (144+ columns).
319 $.ui.open({ id: PANE, title: 'modsmith' }).catch(() => undefined)
320 if (config.autoValidate) {
321 $.clock.after(0, () => {
322 autoValidate($, e.name, e.root).catch(() => undefined)
323 })
324 }
325 }
326 return result
327 })
328
329 on('prompt.submit', async ($, e, next) => {
330 await update($, lastReload, () => null)
331 await record($, 'prompt.submit', e)
332 return next(e)
333 })
334
335 on('turn.start', async ($, e, next) => {
336 const ran = await next(e)
337 await record($, 'turn.start', e)
338 return ran
339 })
340
341 on('turn.complete', async ($, e, next) => {
342 const ran = await next(e)
343 await record($, 'turn.complete', e)
344 return ran
345 })
346
347 on('agent.spawn', async ($, e, next) => {
348 const ran = await next(e)
349 await record($, 'agent.spawn', e)
350 return ran
351 })
352
353 on('session.compact', async ($, e, next) => {
354 const ran = await next(e)
355 await record($, 'session.compact', e)
356 return ran
357 })
358
359 // One hook serves modsmith's own tools and records everyone else's calls.
360 on('tool.call', async ($, e, next) => {
361 if (e.tool.startsWith(TOOL_PREFIX)) {
362 return answerTool($, e.tool.slice(TOOL_PREFIX.length), e as unknown as Record<string, unknown>)
363 }
364 const started = await $.clock.now()
365 const ran = await next(e)
366 const ms = (await $.clock.now()) - started
367 const outcome = ran.deny !== undefined ? 'denied' : ran.isError === true ? 'error' : 'ok'
368 await record($, 'tool.call', e, `${outcome} · ${ms}ms`)
369 return ran
370 })
371
372 on('command.run', async ($, e, next) => {
373 if (e.command === 'modsmith') {
374 await $.ui.open({ id: PANE, title: 'modsmith' })
375 await refreshRoster($)
376 await checkVersions($)
377 return { text: 'modsmith is open.' }
378 }
379 if (e.command === 'gates') {
380 const dir = e.args.trim() ? resolveDir(e.args, cwd, home) : await read($, target)
381 if (!dir) return { text: 'No target mod. Run /gates <mod folder>, or open /modsmith and pick one.' }
382 await update($, target, () => dir)
383 const results = await runGates($, dir, GATE_NAMES)
384 return { text: formatGates(dir, results, await read($, ship)) }
385 }
386 const ran = await next(e)
387 await record($, 'command.run', e)
388 return ran
389 })
390
391 on('ui.press', async ($, e, next) => {
392 if (e.plugin !== ME) await record($, 'ui.press', e)
393 return next(e)
394 })
395
396 on('ui.input', async ($, e, next) => {
397 if (e.plugin !== ME) await record($, 'ui.input', e)
398 return next(e)
399 })
400
401 on('ui.select', async ($, e, next) => {
402 if (e.plugin !== ME) await record($, 'ui.select', e)
403 return next(e)
404 })
405
406 if (config.recordAppends) {
407 on('session.append', async ($, e, next) => {
408 const ran = await next(e)
409 await record($, 'session.append', e)
410 return ran
411 })
412 }
413
414 // The band: only right after a dev mod reloads, until the next prompt; stacks above any other band.
415 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
416 const r = await read($, lastReload)
417 if (r === null || e.props.hasSurvey) return next(e)
418 const g = await read($, gates)
419 const isTarget = (await read($, target)) === r.root
420 const parts = [`↻ ${r.name} reloaded`, `${r.hooks} hooks`]
421 if (r.validate !== 'idle') parts.push(`validate ${markOf(r.validate)}`)
422 if (isTarget && g.types.status !== 'idle') parts.push(`types ${markOf(g.types.status)}`)
423 if (isTarget && g.tests.status !== 'idle') parts.push(`tests ${markOf(g.tests.status)}`)
424 const { Box, Text } = $.ui.resolve(e)
425 const mine = <Text color={r.validate === 'fail' ? '#ff4d4f' : '#2fbf71'}>{parts.join(' · ')}</Text>
426 const below = await next(e)
427 return (
428 <Box flexDirection="column">
429 {mine}
430 {below}
431 </Box>
432 )
433 })
434
435 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
436 const list = await read($, tape)
437 const pick = await read($, selected)
438 const only = await read($, filter)
439 const isPaused = await read($, paused)
440 const roster = await read($, mods)
441 const dir = await read($, target)
442 const g = await read($, gates)
443 const seen = await read($, eyes)
444 const v = await read($, versions)
445 const notes = await read($, ship)
446 const cols = e.props.bodyColumns
447 const isNarrow = cols < 50
448 const rows = list.filter(x => only === 'all' || x.family === only)
449 const chosen = rows.find(x => x.id === pick) ?? null
450 const present = FAMILIES.filter(f => list.some(x => x.family === f))
451 const devs = roster.filter(m => isDev(m.provenance) && m.root)
452
453 const runAll = () => {
454 if (dir) runGates($, dir, GATE_NAMES).catch(() => undefined)
455 }
456 const runOne = (name: GateName) => () => {
457 if (dir) runGates($, dir, [name]).catch(() => undefined)
458 }
459 const flip = (k: 'terminal' | 'desktop' | 'narrow') => () => update($, eyes, was => ({ ...was, [k]: !was[k] }))
460 const line = (x: Entry) => `${clockTime(x.at)} ${x.event} ${x.summary}${x.outcome ? ` · ${x.outcome}` : ''}`
461 const gateLine = (name: GateName) => {
462 const it = g[name]
463 const time = it.ms === undefined ? '' : ` ${(it.ms / 1000).toFixed(1)}s`
464 return `${markOf(it.status)} ${name}${time}${it.note ? `: ${it.note}` : ''}`
465 }
466 const failing = GATE_NAMES.find(n => g[n].status === 'fail' && g[n].tail)
467 const banner = v
468 ? `CLI ${v.cli} · engine ${v.engine}${v.isCliOlder ? ' · CLI is older than the engine: validate may fail correct code' : ''}`
469 : 'Versions unchecked'
470
471 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
472
473 const gatesSection = (
474 <Box flexDirection="column">
475 <Text bold>{`Gates · ${dir ? baseName(dir) : 'no target'}`}</Text>
476 {GATE_NAMES.map(n => (
477 <Text color={g[n].status === 'pass' ? '#2fbf71' : g[n].status === 'fail' ? '#ff4d4f' : undefined}>{gateLine(n)}</Text>
478 ))}
479 <Text>{`${seen.terminal && seen.desktop && seen.narrow ? '✓' : '·'} eyes`}</Text>
480 <Box flexWrap="wrap" columnGap={1}>
481 <Button key="run-all" label="Run all" variant="primary" onPress={runAll} />
482 <Button key="run-validate" label="validate" onPress={runOne('validate')} />
483 <Button key="run-types" label="types" onPress={runOne('types')} />
484 <Button key="run-tests" label="tests" onPress={runOne('tests')} />
485 </Box>
486 <Box flexWrap="wrap" columnGap={1}>
487 <Button key="eye-terminal" plain label={`${seen.terminal ? '☑' : '☐'} terminal`} onPress={flip('terminal')} />
488 <Button key="eye-desktop" plain label={`${seen.desktop ? '☑' : '☐'} desktop`} onPress={flip('desktop')} />
489 <Button key="eye-narrow" plain label={`${seen.narrow ? '☑' : '☐'} narrow`} onPress={flip('narrow')} />
490 </Box>
491 {notes.map(n => (
492 <Text color="#ffb020">{`! ${n}`}</Text>
493 ))}
494 {failing && <Markdown key="gate-tail" text={`\`\`\`\n${g[failing].tail}\n\`\`\``} />}
495 </Box>
496 )
497
498 if (isNarrow) {
499 return (
500 <Box flexDirection="column" gap={1}>
501 {gatesSection}
502 <Box flexDirection="column">
503 {rows.slice(-8).map(x => (
504 <Text color={FAMILY_COLOR[x.family]} wrap="truncate-end">{line(x)}</Text>
505 ))}
506 </Box>
507 </Box>
508 )
509 }
510
511 return (
512 <Box flexDirection="column" gap={1} paddingX={1}>
513 <Text dimColor={!v?.isCliOlder} color={v?.isCliOlder ? '#ffb020' : undefined}>{banner}</Text>
514
515 <Box flexDirection="column">
516 <Text bold>{`Mods (${roster.length})`}</Text>
517 {roster.length === 0 && <Text dimColor>None seen loading yet. /modsmith refreshes the list.</Text>}
518 {roster.slice(0, 12).map((m, i) => (
519 <Box columnGap={1}>
520 <Text color={m.source === 'seen' ? '#2fbf71' : undefined} dimColor={m.source !== 'seen'} wrap="truncate-end">
521 {`${m.provenance}${m.version ? ` ${m.version}` : ''} · ${m.source}${m.source === 'seen' ? ` · ${m.events.length} hooks · ${m.calls.length} calls · ↻${m.reloads}` : ''}`}
522 </Text>
523 {m.root && m.root !== dir && (isDev(m.provenance) || devs.length === 0) && (
524 <Button key={`target-${i}`} plain label="target" onPress={() => update($, target, () => m.root)} />
525 )}
526 </Box>
527 ))}
528 </Box>
529
530 {gatesSection}
531
532 <Box flexDirection="column">
533 <Text bold>{`Tape (${list.length}/${TAPE_SIZE})${isPaused ? ' · paused' : ''}`}</Text>
534 <Box flexWrap="wrap" columnGap={1}>
535 <Button key="filter-all" plain label={only === 'all' ? '[all]' : 'all'} onPress={() => update($, filter, () => 'all')} />
536 {present.map(f => (
537 <Button key={`filter-${f}`} plain label={only === f ? `[${f}]` : f} onPress={() => update($, filter, () => f)} />
538 ))}
539 <Button key="pause" label={isPaused ? 'Resume' : 'Pause'} onPress={() => update($, paused, was => !was)} />
540 <Button
541 key="clear"
542 label="Clear"
543 onPress={async () => {
544 await update($, tape, () => [])
545 await update($, selected, () => null)
546 }}
547 />
548 </Box>
549 {rows.length === 0 && <Text dimColor>Nothing recorded yet.</Text>}
550 {rows.slice(-30).map(x => (
551 <Button
552 key={`row-${x.id}`}
553 plain
554 label={line(x)}
555 dimColor={x.id !== pick}
556 onPress={() => update($, selected, was => (was === x.id ? null : x.id))}
557 />
558 ))}
559 </Box>
560
561 {chosen && (
562 <Box flexDirection="column">
563 <Text bold>{`${chosen.event} · e`}</Text>
564 <Markdown key="payload" text={`\`\`\`json\n${chosen.payload}\n\`\`\``} />
565 <Button
566 key="copy"
567 label="Copy payload"
568 onPress={press => $.ui.copy({ text: chosen.payload, surface: press.surface })}
569 />
570 </Box>
571 )}
572 </Box>
573 )
574 })
575}
576hooks/lib.ts 213 lines1// Pure helpers: no `$` here, so every function is unit-testable on its own.
2
3import type { Family, GateName } from '../types'
4
5export const TAPE_SIZE = 150
6export const FIELD_LIMIT = 2048
7export const ENTRY_LIMIT = 8192
8
9// Best-effort secret scrubbing before anything is stored in session state.
10const SECRETS: [RegExp, string][] = [
11 [/sk-[A-Za-z0-9_-]{16,}/g, 'sk-…[redacted]'],
12 [/gh[pousr]_[A-Za-z0-9]{20,}/g, 'gh_…[redacted]'],
13 [/Bearer\s+[A-Za-z0-9._~+/-]{10,}=*/gi, 'Bearer …[redacted]'],
14 [/((?:token|key|secret|password|passwd)=)[^&\s"']+/gi, '$1…[redacted]'],
15]
16
17export const redact = (text: string) =>
18 SECRETS.reduce((out, [pattern, swap]) => out.replace(pattern, swap), text)
19
20// JSON of a payload: each string field cut at FIELD_LIMIT, the whole at ENTRY_LIMIT.
21export const clip = (value: unknown) => {
22 let json: string
23 try {
24 json = JSON.stringify(
25 value,
26 (_key, v: unknown) =>
27 typeof v === 'string' && v.length > FIELD_LIMIT
28 ? `${v.slice(0, FIELD_LIMIT)}…[cut ${v.length - FIELD_LIMIT} chars]`
29 : v,
30 2,
31 ) ?? 'undefined'
32 } catch {
33 json = '"[not serializable]"'
34 }
35 const safe = redact(json)
36 return safe.length > ENTRY_LIMIT ? `${safe.slice(0, ENTRY_LIMIT)}\n…[cut]` : safe
37}
38
39const FAMILY: [RegExp, Family][] = [
40 [/^tool\./, 'tools'],
41 [/^(prompt|turn)\./, 'turns'],
42 [/^session\./, 'session'],
43 [/^ui\./, 'ui'],
44 [/^plugin\./, 'plugins'],
45 [/^command\./, 'commands'],
46 [/^agent\./, 'agents'],
47]
48
49export const familyOf = (event: string): Family =>
50 FAMILY.find(([re]) => re.test(event))?.[1] ?? 'session'
51
52export const FAMILY_COLOR: Record<Family, string> = {
53 tools: '#ff8a3d',
54 turns: '#4c8dff',
55 session: '#8a94a6',
56 ui: '#a66bff',
57 plugins: '#2fbf71',
58 commands: '#19b8c7',
59 agents: '#ff5fa2',
60}
61
62const str = (e: Record<string, unknown>, key: string) =>
63 typeof e[key] === 'string' ? (e[key] as string) : ''
64
65const oneLine = (text: string, max = 60) => {
66 const flat = text.replace(/\s+/g, ' ').trim()
67 return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
68}
69
70const toolLabel = (e: Record<string, unknown>) => {
71 const path = str(e, 'file_path') || str(e, 'notebook_path') || str(e, 'path')
72 if (path) return path.split('/').filter(Boolean).pop() ?? path
73 return oneLine(str(e, 'command') || str(e, 'pattern') || str(e, 'url') || str(e, 'query') || str(e, 'description'), 40)
74}
75
76// One line per tape row.
77export const summarize = (event: string, e: Record<string, unknown>) => {
78 switch (event) {
79 case 'tool.call': {
80 const label = toolLabel(e)
81 return redact(label ? `${str(e, 'tool')} ${label}` : str(e, 'tool'))
82 }
83 case 'prompt.submit':
84 return redact(`"${oneLine(str(e, 'text'))}"`)
85 case 'turn.start':
86 return `turn ${str(e, 'turnId').slice(0, 8)}`
87 case 'turn.complete': {
88 const ms = typeof e.durationMs === 'number' ? ` ${(e.durationMs / 1000).toFixed(1)}s` : ''
89 const who = typeof e.agentId === 'string' ? ` (agent ${e.agentId.slice(0, 6)})` : ''
90 return `${str(e, 'reason') || 'done'}${ms}${who}`
91 }
92 case 'plugin.register':
93 return `${str(e, 'provenance') || str(e, 'name')} · ${str(e, 'tier')}`
94 case 'ui.press':
95 case 'ui.input':
96 case 'ui.select':
97 return `${str(e, 'plugin')}:${str(e, 'element')} in ${str(e, 'component')}`
98 case 'command.run':
99 return oneLine(`/${str(e, 'command')} ${str(e, 'args')}`)
100 case 'session.start':
101 return `${str(e, 'surface') || 'headless'} · ${str(e, 'cwd')}`
102 case 'session.end':
103 return str(e, 'reason')
104 case 'agent.spawn':
105 return oneLine(str(e, 'subagentType') || str(e, 'description') || 'agent')
106 default:
107 return Object.keys(e).slice(0, 5).join(', ')
108 }
109}
110
111// Split on commas outside {...} and (...): `ui.render{component=Pane, requestId=x}`, `$.fs.read (via a, b)`.
112export const splitTop = (list: string) => {
113 const out: string[] = []
114 let depth = 0
115 let cur = ''
116 for (const ch of list) {
117 if (ch === '{' || ch === '(') depth += 1
118 if (ch === '}' || ch === ')') depth = Math.max(0, depth - 1)
119 if (ch === ',' && depth === 0) {
120 out.push(cur.trim())
121 cur = ''
122 } else {
123 cur += ch
124 }
125 }
126 out.push(cur.trim())
127 return out.filter(Boolean)
128}
129
130// `claude plugin validate` output: green, the footprint lines, and any warnings.
131export const parseValidate = (exitCode: number, out: string) => {
132 const lines = out.split('\n')
133 const pick = (label: string) =>
134 lines
135 .filter(l => l.includes(` ${label}: `) && l.includes('❯'))
136 .flatMap(l => splitTop(l.split(` ${label}: `)[1] ?? ''))
137 const warnings = lines.filter(l => /⚠|warning/i.test(l) && !/passed/i.test(l))
138 const hasPassed = /Validation passed/.test(out) && !/with warnings/.test(out)
139 return {
140 isGreen: exitCode === 0 && hasPassed && warnings.length === 0,
141 hooks: pick('hooks'),
142 calls: pick('calls'),
143 warnings,
144 }
145}
146
147// tsc output: errors outside tests/ are the gate; test stubs may not type-check.
148export const parseTsc = (out: string) => {
149 const errors = out.split('\n').filter(l => /error TS\d+/.test(l))
150 const outside = errors.filter(l => !/(^|\/)tests\//.test(l))
151 return { isGreen: outside.length === 0, outside, inTests: errors.length - outside.length }
152}
153
154export const tail = (text: string, lines = 15) =>
155 redact(text.trimEnd().split('\n').slice(-lines).join('\n'))
156
157// "2.1.288 (Claude Code)" -> [2, 1, 288]
158export const versionOf = (text: string) =>
159 (text.match(/\d+\.\d+\.\d+/)?.[0] ?? '0.0.0').split('.').map(Number)
160
161export const isOlder = (a: string, b: string) => {
162 const [x, y] = [versionOf(a), versionOf(b)]
163 for (let i = 0; i < 3; i += 1) {
164 const d = (x[i] ?? 0) - (y[i] ?? 0)
165 if (d !== 0) return d < 0
166 }
167 return false
168}
169
170// The README's footprint names every hook and call validate found.
171export const staleFootprint = (readme: string, hooks: readonly string[], calls: readonly string[]) => {
172 const bare = (s: string) => s.replace(/\{.*\}$/, '').replace(/^\$\./, '').replace(/ \(via .*\)$/, '')
173 return [...hooks, ...calls].map(bare).filter(name => name && !readme.includes(name))
174}
175
176export const GATE_NAMES: GateName[] = ['validate', 'types', 'tests']
177
178export const isGateName = (s: string): s is GateName => (GATE_NAMES as string[]).includes(s)
179
180const MARK = { idle: '·', running: '…', pass: '✓', fail: '✗' } as const
181export const markOf = (status: keyof typeof MARK) => MARK[status]
182
183// Text for /gates and the gates tool.
184export const formatGates = (
185 dir: string,
186 results: Partial<Record<GateName, { status: keyof typeof MARK; ms?: number; tail: string; note?: string }>>,
187 notes: readonly string[],
188) => {
189 const lines = [`modsmith gates on ${dir}`]
190 for (const name of GATE_NAMES) {
191 const g = results[name]
192 if (!g) continue
193 const time = g.ms === undefined ? '' : ` ${(g.ms / 1000).toFixed(1)}s`
194 lines.push(`${markOf(g.status)} ${name}${time}${g.note ? `: ${g.note}` : ''}`)
195 if (g.status === 'fail' && g.tail) lines.push(g.tail.split('\n').map(l => ` ${l}`).join('\n'))
196 }
197 for (const note of notes) lines.push(`! ${note}`)
198 return lines.join('\n')
199}
200
201export const resolveDir = (dir: string, cwd: string, home: string): string => {
202 const trimmed = dir.trim().replace(/(\/\.)+$/, '').replace(/\/+$/, '')
203 if (trimmed === '' || trimmed === '.') return cwd
204 if (trimmed.startsWith('./')) return resolveDir(trimmed.slice(2), cwd, home)
205 if (trimmed.startsWith('~/')) return `${home}/${trimmed.slice(2)}`
206 if (trimmed.startsWith('/')) return trimmed
207 return cwd ? `${cwd}/${trimmed}` : trimmed
208}
209
210export const baseName = (path: string) => path.split('/').filter(Boolean).pop() ?? path
211
212export const clockTime = (at: number) => new Date(at).toTimeString().slice(0, 8)
213types/index.d.ts 50 lines1export type Family = 'tools' | 'turns' | 'session' | 'ui' | 'plugins' | 'commands' | 'agents'
2export type GateName = 'validate' | 'types' | 'tests'
3export type GateStatus = 'idle' | 'running' | 'pass' | 'fail'
4
5export type Entry = {
6 id: string
7 at: number
8 event: string
9 family: Family
10 summary: string
11 payload: string
12 outcome?: string
13}
14
15export type Mod = {
16 name: string
17 version?: string
18 root: string
19 tier: string
20 provenance: string
21 events: string[]
22 calls: string[]
23 reloads: number
24 lastLoad: number
25 source: 'seen' | 'installed' | 'disk'
26}
27
28export type Gate = { status: GateStatus; ms?: number; tail: string; note?: string }
29export type Eyes = { terminal: boolean; desktop: boolean; narrow: boolean }
30export type Versions = { cli: string; engine: string; isCliOlder: boolean }
31export type Reload = { name: string; root: string; hooks: number; validate: GateStatus; at: number }
32
33declare module 'claude-code' {
34 interface PluginState {
35 modsmith: {
36 tape: Entry[]
37 selected: string | null
38 filter: Family | 'all'
39 isPaused: boolean
40 mods: Mod[]
41 target: string | null
42 gates: Record<GateName, Gate>
43 eyes: Eyes
44 versions: Versions | null
45 lastReload: Reload | null
46 ship: string[]
47 }
48 }
49}
50