SLOPSHOPPER

modsmith

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…

newpanebandguardcommandprompt
v0.1.0MITupdated 2026-10-02barmoshe/bar-mods/modsmith
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · modsmith
│ ┃ modsmith ✕ › fix the failing auth test and add an audit log call │ ┃ CLI ? · engine 2.1.289 · CLI is older than │ ┃ the engine: validate may fail correct code ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Mods (0) ⏺ Update(src/auth.ts) │ ┃ None seen loading yet. /modsmith refreshes ⎿ Added 2 lines, removed 1 line │ ┃ the list. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Gates · no target │ ┃ · validate ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ · types │ ┃ · tests ✻ Worked for 42s · done 4:20 PM │ ┃ · eyes │ ┃ [ Run all ] [ validate ] [ types ] [ tests › /modsmith │ ┃ ☐ terminal ☐ desktop ☐ narrow ⎿ modsmith: modsmith is open. │ ┃ │ ┃ Tape (13/150) │ ┃ [all] tools turns session [ Pause ] [ Clear │ ┃ 01:53:20 session.start terminal · /work/a │ ┃ 01:53:20 prompt.submit "fix the failing a │ ┃ 01:53:20 turn.start turn turn-1 │ ┃ 01:53:20 tool.call Read auth.ts · ok · 20 │ ┃ 01:53:20 tool.call Grep src · ok · 20ms │ ┃ 01:53:20 tool.call Edit auth.ts · ok · 20 │ ┃ 01:53:20 tool.call Write audit.ts · ok · │ ┃ 01:53:20 tool.call Write cache.ts · ok · ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · modsmith
CLI ? · engine 2.1.289 · CLI is older than the engine: validate may fail correct code Mods (0) None seen loading yet. /modsmith refreshes the list. Gates · no target · validate · types · tests · eyes [ Run all ] [ validate ] [ types ] [ tests ] ☐ terminal ☐ desktop ☐ narrow Tape (13/150) [all] tools turns session [ Pause ] [ Clear ] 01:53:20 session.start terminal · /work/app 01:53:20 prompt.submit "fix the failing auth test and add 01:53:20 turn.start turn turn-1 01:53:20 tool.call Read auth.ts · ok · 20ms 01:53:20 tool.call Grep src · ok · 20ms 01:53:20 tool.call Edit auth.ts · ok · 20ms 01:53:20 tool.call Write audit.ts · ok · 20ms 01:53:20 tool.call Write cache.ts · ok · 20ms 01:53:20 tool.call Bash bun test · error · 20ms 01:53:20 tool.call Bash git status --porcelain · ok · 20m 01:53:20 tool.call Bash rm -rf build && git push --force 01:53:20 tool.call Bash cat .env · ok · 20ms 01:53:20 turn.complete answer 42.0s
README

modsmith

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

What you get

  • Mods: every mod this session saw load, with the events it hooks, the $ calls it makes and how many times it reloaded; installed plugins and dev mods on disk beside them. Pick one as the gate target.
  • Tape: the last 150 events (tool calls with their outcome and time, prompts, turns, commands, subagents, compaction, plugin loads, and other mods' button presses, inputs and selects). Pick a row to see the real 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.
  • Gates: the four from the bar every bar-mods mod passes:
  • validate: claude plugin validate, green only with no warnings;
  • types: 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);
  • tests: claude plugin test;
  • eyes: three checkboxes for terminal, desktop and a narrow width.

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.

  • Band: right after a dev mod reloads, until your next prompt: ↻ 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.

Tools for Claude

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

Options

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 |

Footprint

From claude plugin validate:

  • hooks: 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}
  • calls: $.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.resolve

It never changes another mod's event: every hook passes the event on unchanged.

Notes

  • A 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.
  • Draws in the terminal and the desktop app's Code tab. In 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.

Source 3 files
hooks/register.tsx 576 lines
1import { 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}
576
hooks/lib.ts 213 lines
1// 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)
213
types/index.d.ts 50 lines
1export 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