@cmodjs/core 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/docs/commands.md +2 -2
  2. package/docs/hooks.md +62 -6
  3. package/docs/index.md +7 -4
  4. package/docs/install-steps.md +3 -3
  5. package/docs/jobs.md +5 -4
  6. package/docs/mod.md +57 -7
  7. package/docs/testing.md +25 -9
  8. package/docs/ui.md +134 -5
  9. package/jobs/check.js +2 -2
  10. package/jobs/context.d.ts.map +1 -1
  11. package/jobs/context.js +2 -0
  12. package/jobs/permissions/decide-permission.js +2 -2
  13. package/jobs/permissions/find-project-scope.d.ts +1 -1
  14. package/jobs/permissions/find-project-scope.d.ts.map +1 -1
  15. package/jobs/permissions/find-project-scope.js +9 -9
  16. package/jobs/permissions/match-target.d.ts.map +1 -1
  17. package/jobs/permissions/match-target.js +4 -3
  18. package/mod.d.ts +16 -6
  19. package/mod.d.ts.map +1 -1
  20. package/package.json +2 -1
  21. package/path.d.ts +2 -0
  22. package/path.d.ts.map +1 -0
  23. package/path.js +1 -0
  24. package/register.d.ts.map +1 -1
  25. package/register.js +8 -2
  26. package/runtime/claude.d.ts +4 -1
  27. package/runtime/claude.d.ts.map +1 -1
  28. package/runtime/hooks.d.ts +10 -2
  29. package/runtime/hooks.d.ts.map +1 -1
  30. package/runtime/hooks.js +56 -18
  31. package/runtime/lifecycle.d.ts.map +1 -1
  32. package/runtime/lifecycle.js +12 -2
  33. package/runtime/ui.d.ts.map +1 -1
  34. package/runtime/ui.js +42 -26
  35. package/shell.d.ts +2 -0
  36. package/shell.d.ts.map +1 -0
  37. package/shell.js +1 -0
  38. package/src/jobs/check.ts +2 -2
  39. package/src/jobs/context.ts +2 -0
  40. package/src/jobs/permissions/decide-permission.ts +2 -2
  41. package/src/jobs/permissions/find-project-scope.ts +11 -10
  42. package/src/jobs/permissions/match-target.ts +4 -3
  43. package/src/mod.ts +24 -4
  44. package/src/path.ts +1 -0
  45. package/src/register.ts +8 -2
  46. package/src/runtime/claude.ts +11 -1
  47. package/src/runtime/hooks.ts +62 -23
  48. package/src/runtime/lifecycle.ts +11 -4
  49. package/src/runtime/ui.ts +39 -26
  50. package/src/shell.ts +1 -0
  51. package/src/testing/fake-claude.ts +14 -5
  52. package/src/testing/fake-elements.ts +5 -7
  53. package/src/testing/fake-files.ts +5 -5
  54. package/src/testing.ts +83 -11
  55. package/src/ui/components.ts +70 -0
  56. package/src/ui/define-pane.ts +5 -1
  57. package/src/utils/call-effects.ts +12 -11
  58. package/src/utils/parse-shell.ts +28 -36
  59. package/src/utils/paths.ts +4 -19
  60. package/src/vendor.ts +1 -0
  61. package/testing/fake-claude.d.ts +5 -0
  62. package/testing/fake-claude.d.ts.map +1 -1
  63. package/testing/fake-claude.js +13 -3
  64. package/testing/fake-elements.d.ts +5 -8
  65. package/testing/fake-elements.d.ts.map +1 -1
  66. package/testing/fake-elements.js +6 -6
  67. package/testing/fake-files.js +5 -5
  68. package/testing.d.ts +4 -2
  69. package/testing.d.ts.map +1 -1
  70. package/testing.js +65 -9
  71. package/ui/components.d.ts +48 -0
  72. package/ui/components.d.ts.map +1 -0
  73. package/ui/components.js +56 -0
  74. package/ui/define-pane.d.ts +5 -1
  75. package/ui/define-pane.d.ts.map +1 -1
  76. package/utils/call-effects.d.ts.map +1 -1
  77. package/utils/call-effects.js +12 -11
  78. package/utils/parse-shell.d.ts +5 -9
  79. package/utils/parse-shell.d.ts.map +1 -1
  80. package/utils/parse-shell.js +5 -10
  81. package/utils/paths.d.ts +0 -3
  82. package/utils/paths.d.ts.map +1 -1
  83. package/utils/paths.js +3 -18
  84. package/vendor.d.ts +1 -0
  85. package/vendor.d.ts.map +1 -1
  86. package/vendor.js +209 -12
@@ -1,15 +1,15 @@
1
- import type { Args, ClassicHookInputs, Frozen } from 'claude-code'
2
- import type { HookAnswer, ModEvent, ModHook } from '../mod.js'
1
+ import type { Args, ClassicHookInputs, EventResult, Frozen } from 'claude-code'
2
+ import type { HookAnswer, HookInput, ModEvent, ModHook } from '../mod.js'
3
3
  import { configFolders } from '../records.js'
4
4
  import type { Claude } from './claude.js'
5
5
  import type { RoutedHook } from './router.js'
6
- import { toolInputOf, type ToolCalls } from './tool-calls.js'
6
+ import { reservedKeys, toolInputOf, type ToolCalls } from './tool-calls.js'
7
7
  import { callEffects, type FileAccess, type ToolUse } from '../utils/call-effects.js'
8
8
  import { dynamicPattern } from '../utils/parse-shell.js'
9
9
  import { messageOf } from '../utils/text.js'
10
10
 
11
11
  export type RoutedEvent =
12
- | `classic.${ModEvent}`
12
+ | `classic.${Exclude<ModEvent, 'PreToolUse'>}`
13
13
  | 'tool.check'
14
14
  | 'tool.call'
15
15
  | 'prompt.submit'
@@ -19,16 +19,15 @@ export type RoutedEvent =
19
19
  | 'skill.prompt'
20
20
  | 'ui.render'
21
21
  | 'ui.press'
22
+ | 'ui.scroll'
22
23
  | 'ui.close'
23
24
  | 'cmod.call'
24
25
 
25
- export const permissionEvents: readonly RoutedEvent[] = ['tool.check', 'classic.PreToolUse', 'classic.PermissionRequest']
26
+ export const permissionEvents: readonly RoutedEvent[] = ['tool.check', 'classic.PermissionRequest']
26
27
 
27
28
  type ClassicFields = Record<string, unknown>
28
29
 
29
- type PreToolUseInput = Parameters<ModHook<'PreToolUse'>>[0]
30
-
31
- type CallFiles = PreToolUseInput['files']
30
+ type CallFiles = HookInput<'PreToolUse'>['files']
32
31
 
33
32
  const flags: readonly string[] = ['suppressOriginalPrompt', 'reloadSkills', 'retry']
34
33
 
@@ -50,18 +49,18 @@ const readFields: Record<ModEvent, readonly string[]> = {
50
49
  Stop: ['additionalContext'],
51
50
  StopFailure: [],
52
51
  CwdChanged: [],
52
+ FileChanged: [],
53
53
  }
54
54
 
55
- export function classicHook<E extends ModEvent>(name: string, event: E, hook: ModHook<E>, claude: Claude, calls: ToolCalls): RoutedHook<RoutedEvent> {
55
+ export function classicHook<E extends Exclude<ModEvent, 'PreToolUse'>>(name: string, event: E, hook: ModHook<E>, claude: Claude, calls: ToolCalls): RoutedHook<RoutedEvent> {
56
56
  const inputOf = async (e: unknown): Promise<unknown> => {
57
- if (event === 'PreToolUse') return preToolUseInput(name, e as Frozen<Args<'classic.PreToolUse'>>, claude, calls.agentOf)
58
57
  if (event !== 'PostToolUse' && event !== 'PostToolUseFailure') return e
59
58
  const input = e as ClassicHookInputs['PostToolUse' | 'PostToolUseFailure']
60
59
  const cwd = calls.cwdOf(input.tool_use_id) ?? input.cwd
61
60
  return { ...input, files: await callFiles(name, claude, { tool: input.tool_name, input: input.tool_input }, cwd) }
62
61
  }
63
62
  const resultOf = async (e: unknown): Promise<ClassicFields | undefined> => {
64
- const answer = await hook((await inputOf(e)) as Parameters<ModHook<E>>[0])
63
+ const answer = await hook((await inputOf(e)) as HookInput<E>)
65
64
  if (answer === undefined) return undefined
66
65
  if (answer.systemMessage !== undefined) claude.ui.log(answer.systemMessage)
67
66
  return classicResult(event, answer)
@@ -69,17 +68,65 @@ export function classicHook<E extends ModEvent>(name: string, event: E, hook: Mo
69
68
  const routed = async (e: unknown, next: (e: unknown) => Promise<unknown>) => {
70
69
  const ours = await resultOf(e).catch((error: unknown): ClassicFields | undefined => {
71
70
  const reason = `${name}: the ${event} hook failed: ${messageOf(error)}`
72
- if (event === 'PreToolUse') return { deny: reason }
73
71
  if (event === 'PermissionRequest') return { decision: { behavior: 'deny', message: reason } }
74
72
  claude.ui.log(reason)
75
73
  return undefined
76
74
  })
77
75
  if (ours === undefined) return next(e)
78
- return mergeClassic(event, (await next(e)) as ClassicFields, ours)
76
+ return mergeClassic((await next(e)) as ClassicFields, ours)
79
77
  }
80
78
  return routed as unknown as RoutedHook<RoutedEvent>
81
79
  }
82
80
 
81
+ type HeldDecision = { readonly decision: 'allow' | 'ask'; readonly reason?: string }
82
+
83
+ export type HeldDecisions = Map<string, HeldDecision>
84
+
85
+ export function preToolUseHook(name: string, hook: ModHook<'PreToolUse'>, claude: Claude, calls: ToolCalls, held: HeldDecisions | undefined): RoutedHook<'tool.call'> {
86
+ return async (e, next) => {
87
+ let ours: ClassicFields
88
+ try {
89
+ const answer = await hook(await preToolUseInput(name, e, claude, calls.agentOf))
90
+ if (answer?.systemMessage !== undefined) claude.ui.log(answer.systemMessage)
91
+ ours = answer === undefined ? {} : classicResult('PreToolUse', answer)
92
+ } catch (error) {
93
+ return { deny: `${name}: the PreToolUse hook failed: ${messageOf(error)}` }
94
+ }
95
+ if (typeof ours['deny'] === 'string') return { deny: ours['deny'] }
96
+ const decided = heldDecisionOf(ours)
97
+ if (decided !== undefined && held === undefined) {
98
+ return { deny: `${name}: the PreToolUse hook answered permissionDecision "${decided.decision}", so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod.` }
99
+ }
100
+ const updated = ours['updatedInput'] as Record<string, unknown> | undefined
101
+ const reserved = Object.fromEntries(Object.entries(e).filter(([key]) => reservedKeys.includes(key)))
102
+ const earlier = held?.get(e.tool_use_id)
103
+ if (decided !== undefined) held?.set(e.tool_use_id, earlier !== undefined && strictness.indexOf(earlier.decision) >= strictness.indexOf(decided.decision) ? earlier : decided)
104
+ let result: EventResult<'tool.call'>
105
+ try {
106
+ result = await next(updated === undefined ? e : ({ ...reserved, ...updated } as Frozen<Args<'tool.call'>>))
107
+ } finally {
108
+ if (decided !== undefined) held?.delete(e.tool_use_id)
109
+ }
110
+ const context = ours['additionalContext'] as string[] | undefined
111
+ if (context === undefined || result.deny !== undefined) return result
112
+ return { ...result, context: [...(result.context ?? []), ...context] }
113
+ }
114
+ }
115
+
116
+ export function heldDecisionHook(held: HeldDecisions): RoutedHook<'tool.check'> {
117
+ return async (e, next) => {
118
+ const below = await next(e)
119
+ const ours = e.tool_use_id === undefined ? undefined : held.get(e.tool_use_id)
120
+ return ours === undefined || below.decision === 'deny' ? below : ours
121
+ }
122
+ }
123
+
124
+ function heldDecisionOf(ours: ClassicFields): HeldDecision | undefined {
125
+ if (ours['allow'] !== undefined) return { decision: 'allow' }
126
+ if (typeof ours['ask'] !== 'string') return undefined
127
+ return ours['ask'] === '' ? { decision: 'ask' } : { decision: 'ask', reason: ours['ask'] }
128
+ }
129
+
83
130
  const frontmatter = /^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/
84
131
 
85
132
  const baseDirectoryLine = /^Base directory for this skill: [^\n]*\n\n/
@@ -101,7 +148,7 @@ export function userSkillHook(claude: Claude): RoutedHook<'skill.prompt'> {
101
148
  }
102
149
  }
103
150
 
104
- async function preToolUseInput(name: string, envelope: Frozen<Args<'classic.PreToolUse'>>, claude: Claude, agentOf: ToolCalls['agentOf']): Promise<PreToolUseInput> {
151
+ async function preToolUseInput(name: string, envelope: Frozen<Args<'tool.call'>>, claude: Claude, agentOf: ToolCalls['agentOf']): Promise<HookInput<'PreToolUse'>> {
105
152
  const { tool, tool_use_id } = envelope
106
153
  const [session_id, cwd, { agentId, agentType }] = await Promise.all([claude.session.id(), claude.session.cwd(), agentOf(tool_use_id)])
107
154
  const tool_input = toolInputOf(envelope)
@@ -161,17 +208,9 @@ function classicResult(event: ModEvent, answer: HookAnswer): ClassicFields {
161
208
 
162
209
  export const strictness = ['allow', 'ask', 'deny'] as const
163
210
 
164
- function strictnessOf(result: ClassicFields): number {
165
- return strictness.findLastIndex((decision) => result[decision] !== undefined)
166
- }
167
-
168
- function mergeClassic(event: ModEvent, below: ClassicFields, ours: ClassicFields): ClassicFields {
211
+ function mergeClassic(below: ClassicFields, ours: ClassicFields): ClassicFields {
169
212
  const merged: ClassicFields = { ...below, ...ours }
170
213
  const context = [...((below['additionalContext'] as string[] | undefined) ?? []), ...((ours['additionalContext'] as string[] | undefined) ?? [])]
171
214
  if (context.length > 0) merged['additionalContext'] = context
172
- if (event !== 'PreToolUse') return merged
173
- const strictest = strictness[Math.max(strictnessOf(below), strictnessOf(ours))]
174
- for (const decision of strictness) delete merged[decision]
175
- if (strictest !== undefined) merged[strictest] = ours[strictest] ?? below[strictest]
176
215
  return merged
177
216
  }
@@ -1,12 +1,12 @@
1
1
  import type { Args, EventResult, Frozen, HookBudget, HookStream, ProcessSpawnChunk, ProcessSpawnResult } from 'claude-code'
2
- import type { Mod, ModDefinition, ModEvent, ModHook } from '../mod.js'
2
+ import type { HookInput, Mod, ModDefinition, ModEvent, ModHook } from '../mod.js'
3
3
  import { dataFolder, isAtLeast, parseEvent, readRecord, readSteps, scriptsSha256, storeFolder, type ReadFile, type RunnerEvent } from '../records.js'
4
4
  import { relativePath } from '../utils/paths.js'
5
5
  import { formatExit, listed, messageOf } from '../utils/text.js'
6
6
  import type { Claude } from './claude.js'
7
7
  import { beforeDeadline } from './deadline.js'
8
8
  import { answerCall, dependencyCalls, notInstalled } from './dependencies.js'
9
- import { classicHook, permissionEvents, userSkillHook, type RoutedEvent } from './hooks.js'
9
+ import { classicHook, heldDecisionHook, permissionEvents, preToolUseHook, userSkillHook, type HeldDecisions, type RoutedEvent } from './hooks.js'
10
10
  import { createRouter, type Router, type RouterNext } from './router.js'
11
11
  import { createState } from './state.js'
12
12
  import { toolCalls } from './tool-calls.js'
@@ -362,13 +362,15 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
362
362
  let staleCwd: string | undefined
363
363
  const area = createUi<State>({ name: definition.name, claude, router, progress, announce, mod: () => mod })
364
364
  const modState = createState<State>({ name: definition.name, initial: definition.state ?? {}, session, root, claude, changed: area.changed })
365
+ const held: HeldDecisions | undefined = runtime.checksPermissions() ? new Map() : undefined
365
366
  const mod: Mod<State> = {
366
367
  name: definition.name,
367
368
  state: modState.state,
368
369
  dataFolder: runtime.dataFolder,
369
370
  on(event, hook) {
370
371
  if (!hookEvents.includes(event)) hookEvents.push(event)
371
- router.add(`classic.${event}`, classicHook(definition.name, event, hook, claude, agents))
372
+ if (event === 'PreToolUse') router.add('tool.call', preToolUseHook(definition.name, hook as ModHook<'PreToolUse'>, claude, agents, held))
373
+ else router.add(`classic.${event as Exclude<ModEvent, 'PreToolUse'>}`, classicHook(definition.name, event as Exclude<ModEvent, 'PreToolUse'>, hook as ModHook<Exclude<ModEvent, 'PreToolUse'>>, claude, agents))
372
374
  },
373
375
  use: (job) => job({ mod, claude, on: (event, hook) => router.add(event, hook), announce, reserveName, toolCalls: agents }),
374
376
  ui: area.ui,
@@ -380,9 +382,13 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
380
382
  read: (path) => claude.fs.read(path),
381
383
  write: (path, text) => claude.fs.write(path, text),
382
384
  list: (path) => claude.fs.list(path),
385
+ exists: (path) => claude.fs.exists(path),
386
+ stat: (path, options) => claude.fs.stat(path, options),
383
387
  },
384
388
  http: { fetch: (url, init) => claude.http.fetch(url, init) },
385
389
  settings: { read: (args) => claude.settings.read(args) },
390
+ session: { messages: claude.session.messages },
391
+ agent: { spawn: (args) => claude.agent.spawn(args) },
386
392
  get projectRoot() {
387
393
  return modState.root
388
394
  },
@@ -408,7 +414,7 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
408
414
  loadedCwd = nextCwd
409
415
  if (!hasMovedRoot) area.changed()
410
416
  if (nextCwd === oldCwd) return
411
- const moved: Parameters<ModHook<'CwdChanged'>>[0] = { session_id: await claude.session.id(), cwd: nextCwd, hook_event_name: 'CwdChanged', old_cwd: oldCwd, new_cwd: nextCwd }
417
+ const moved: HookInput<'CwdChanged'> = { session_id: await claude.session.id(), cwd: nextCwd, hook_event_name: 'CwdChanged', old_cwd: oldCwd, new_cwd: nextCwd }
412
418
  await router.dispatch('classic.CwdChanged', moved as Frozen<Args<'classic.CwdChanged'>>, async () => ({}))
413
419
  } catch (error) {
414
420
  claude.ui.log(`${definition.name} keeps the state of ${modState.root} until the next prompt or folder move: ${messageOf(error)}`)
@@ -452,6 +458,7 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
452
458
  if (permissionHooks.length > 0 && !runtime.checksPermissions()) {
453
459
  throw new Error(`it decides permissions on ${listed(permissionHooks)}, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod`)
454
460
  }
461
+ if (held !== undefined && hookEvents.includes('PreToolUse')) router.add('tool.check', heldDecisionHook(held))
455
462
  await failsAs('its open panes did not load', () => area.restorePanes())
456
463
  if (hookEvents.length > 0) added.push(`${hookEvents.length === 1 ? 'a hook' : 'hooks'} on ${listed(hookEvents)}`)
457
464
  return { mod, added }
package/src/runtime/ui.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ElementTable, MarkdownProps, PaneOpenArgs, RenderElement, Timer } from 'claude-code'
2
2
  import type { Mod, PaneHandle, ProgressStep } from '../mod.js'
3
+ import { ProgressBar } from '../ui/components.js'
3
4
  import type { Pane } from '../ui/define-pane.js'
4
5
  import { Box, drawWith, Text } from '../ui/elements.js'
5
6
  import type { MarkdownKind, MarkdownReader } from '../ui/markdown.js'
@@ -109,23 +110,8 @@ function drawLine(line: Line, frame: number, columns: number | undefined): Rende
109
110
  const glyph = Text({ color: 'claude', children: spinner[frame % spinner.length] })
110
111
  if (line.step === undefined) return Text({ wrap: 'truncate-end', children: [glyph, ' ', title, Text({ dimColor: true, children: '…' })] })
111
112
  const { done, total, label = '' } = line.step
112
- const count = `${done}/${total}`
113
- const width = barWidth(columns, `${line.title}${count}${label}`.length)
114
- const filled = total > 0 ? Math.round(Math.min(done / total, 1) * width) : 0
115
- return Text({
116
- wrap: 'truncate-end',
117
- children: [
118
- glyph,
119
- ' ',
120
- title,
121
- ' ',
122
- Text({ color: 'claude', children: '█'.repeat(filled) }),
123
- Text({ color: 'subtle', children: '░'.repeat(width - filled) }),
124
- ' ',
125
- Text({ dimColor: true, children: count }),
126
- ...(label === '' ? [] : [' ', label]),
127
- ],
128
- })
113
+ const width = barWidth(columns, `${line.title}${done}/${total}${label}`.length)
114
+ return Text({ wrap: 'truncate-end', children: [glyph, ' ', title, ' ', ProgressBar({ done, total, width }), ...(label === '' ? [] : [' ', label])] })
129
115
  }
130
116
 
131
117
  function barWidth(columns: number | undefined, textLength: number): number {
@@ -224,6 +210,7 @@ export type UiArea<State extends object> = {
224
210
 
225
211
  export function createUi<State extends object>({ name, claude, router, progress, announce, mod }: UiOptions<State>): UiArea<State> {
226
212
  const panes = new Map<string, Pane<State>>()
213
+ const paneLogs = new Map<string, (failure: string) => void>()
227
214
  const openPanes = new Set<string>()
228
215
  const requestedSizes = new Map<string, PaneSize>()
229
216
  const renders = new Set<string>()
@@ -291,9 +278,10 @@ export function createUi<State extends object>({ name, claude, router, progress,
291
278
  })
292
279
  }
293
280
 
294
- const openAt = (pane: Pane<State>, size: PaneSize) => {
281
+ const openAt = (pane: Pane<State>, size: PaneSize, focus?: true) => {
295
282
  requestedSizes.set(pane.id, size)
296
- return claude.ui.open({ id: pane.id, title: pane.title, ...size })
283
+ const { id, title, closeOnEscape, holdToasts } = pane
284
+ return claude.ui.open({ id, title, ...size, ...(closeOnEscape === true ? { closeOnEscape } : {}), ...(holdToasts === true ? { holdToasts } : {}), ...(focus === true ? { focus } : {}) })
297
285
  }
298
286
 
299
287
  const resize = async (pane: Pane<State>) => {
@@ -315,34 +303,58 @@ export function createUi<State extends object>({ name, claude, router, progress,
315
303
  if (pane === undefined || e.component !== 'Pane') return next(e)
316
304
  openPanes.add(pane.id)
317
305
  const table = claude.ui.resolve(e)
318
- return drawWith(table, () => pane.render(mod(), e.props), markdownIn(table))
306
+ try {
307
+ return drawWith(table, () => pane.render(mod(), e.props), markdownIn(table))
308
+ } catch (error) {
309
+ paneLogs.get(pane.id)?.(`threw: ${messageOf(error)}`)
310
+ return drawWith(table, () => Text({ color: 'error', children: `The ${pane.title} pane could not draw: ${messageOf(error)}` }))
311
+ }
312
+ })
313
+ router.add('ui.scroll', async (e, next) => {
314
+ const pane = panes.get(e.requestId)
315
+ const moved = await next(e)
316
+ if (pane?.onScroll === undefined || e.component !== 'Pane' || moved.deny !== undefined) return moved
317
+ await handle(pane, 'onScroll', () => pane.onScroll?.(mod(), e))
318
+ return moved
319
319
  })
320
- router.add('ui.close', (e, next) => {
320
+ router.add('ui.close', async (e, next) => {
321
321
  openPanes.delete(e.id)
322
- return next(e)
322
+ const closed = await next(e)
323
+ const pane = panes.get(e.id)
324
+ if (pane?.onClose !== undefined) await handle(pane, 'onClose', () => pane.onClose?.(mod(), e))
325
+ return closed
323
326
  })
324
327
  }
325
328
 
329
+ const handle = async (pane: Pane<State>, handler: string, run: () => unknown) => {
330
+ try {
331
+ await run()
332
+ } catch (error) {
333
+ claude.ui.log(`${name}: the ${pane.title} pane's ${handler} threw: ${messageOf(error)}`)
334
+ }
335
+ }
336
+
326
337
  return {
327
338
  ui: {
328
339
  pane(pane) {
329
340
  if (panes.has(pane.id)) throw new Error(`${name}: the pane "${pane.id}" is already added. Give each pane its own id.`)
330
341
  panes.set(pane.id, pane)
342
+ paneLogs.set(pane.id, logOnce(`${pane.title} pane`))
331
343
  announce(`the ${pane.title} pane`)
332
344
  route()
333
345
  const handle: PaneHandle = {
334
346
  get isOpen() {
335
347
  return openPanes.has(pane.id)
336
348
  },
337
- async open() {
338
- const { isPlaced } = await openAt(pane, paneSize(pane, mod().state))
349
+ async open(options) {
350
+ const { isPlaced } = await openAt(pane, paneSize(pane, mod().state), options?.focus)
339
351
  if (isPlaced) openPanes.add(pane.id)
340
352
  },
341
353
  async close() {
342
354
  await claude.ui.close({ id: pane.id })
343
355
  openPanes.delete(pane.id)
344
356
  },
345
- toggle: () => (openPanes.has(pane.id) ? handle.close() : handle.open()),
357
+ toggle: (options) => (openPanes.has(pane.id) ? handle.close() : handle.open(options)),
346
358
  }
347
359
  return handle
348
360
  },
@@ -376,7 +388,7 @@ export function createUi<State extends object>({ name, claude, router, progress,
376
388
  return drawWith(table, () => drawPieces([drawn], e.props.isFirstOfReply ? 'opensReply' : 'inReply'))
377
389
  })
378
390
  },
379
- toast: (text) => claude.ui.toast(text),
391
+ toast: (text, options) => claude.ui.toast(text, options),
380
392
  async progress(title, task) {
381
393
  const line = progress.start(title)
382
394
  try {
@@ -386,6 +398,7 @@ export function createUi<State extends object>({ name, claude, router, progress,
386
398
  }
387
399
  },
388
400
  ask: (question, options) => claude.ui.ask(question, options),
401
+ scroll: (args) => claude.ui.scroll(args),
389
402
  },
390
403
  changed() {
391
404
  if (panes.size === 0 && renders.size === 0) return
package/src/shell.ts ADDED
@@ -0,0 +1 @@
1
+ export { parseShell, type ParsedShell, type ShellCommand } from './utils/parse-shell.js'
@@ -12,8 +12,9 @@ export type Fakes = {
12
12
  fs: { read?: Claude['fs']['read']; write?: Claude['fs']['write']; list?: Claude['fs']['list']; exists?: Claude['fs']['exists']; stat?: Claude['fs']['stat'] }
13
13
  http: { fetch?: Claude['http']['fetch'] }
14
14
  settings: { read?: Claude['settings']['read'] }
15
- ui: { ask?: Claude['ui']['ask']; open?: Claude['ui']['open'] }
16
- agent: { list?: Claude['agent']['list'] }
15
+ ui: { ask?: Claude['ui']['ask']; open?: Claude['ui']['open']; scroll?: Claude['ui']['scroll'] }
16
+ session: { messages?: Claude['session']['messages'] }
17
+ agent: { list?: Claude['agent']['list']; spawn?: Claude['agent']['spawn'] }
17
18
  clock: { after?: Claude['clock']['after']; every?: Claude['clock']['every'] }
18
19
  cmod: { call?: Claude['cmod']['call'] }
19
20
  }
@@ -39,9 +40,10 @@ export type FakeClaude = {
39
40
 
40
41
  export function fakeClaude(plugin: { readonly name: string; readonly root: string }): FakeClaude {
41
42
  const calls: TestCall[] = []
42
- const fakes: Fakes = { process: {}, fs: {}, http: {}, settings: {}, ui: {}, agent: {}, clock: {}, cmod: {} }
43
+ const fakes: Fakes = { process: {}, fs: {}, http: {}, settings: {}, ui: {}, session: {}, agent: {}, clock: {}, cmod: {} }
43
44
  const shown: Shown = { toasts: [], logs: [], debug: [], statuses: [], openPanes: new Set(), commands: [], tools: [] }
44
45
  const unplacedPanes = new Set<string>()
46
+ let focusedPane: string | undefined
45
47
  const store = new Map<string, unknown>()
46
48
 
47
49
  const faked = <Args extends readonly unknown[], Result>(call: string, answer: () => ((...args: Args) => Result) | undefined) => {
@@ -81,6 +83,7 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
81
83
  if (answer.isPlaced) {
82
84
  shown.openPanes.add(pane.id)
83
85
  unplacedPanes.delete(pane.id)
86
+ if (pane.focus === true) focusedPane = pane.id
84
87
  } else if (!shown.openPanes.has(pane.id)) {
85
88
  unplacedPanes.add(pane.id)
86
89
  }
@@ -90,11 +93,13 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
90
93
  calls.push({ call: 'ui.close', args: [pane] })
91
94
  shown.openPanes.delete(pane.id)
92
95
  unplacedPanes.delete(pane.id)
96
+ if (focusedPane === pane.id) focusedPane = undefined
93
97
  },
94
98
  panes: async () => [
95
- ...[...shown.openPanes].map((id) => ({ id, title: id, isShown: true, isFocused: false, isPlaced: true })),
99
+ ...[...shown.openPanes].map((id) => ({ id, title: id, isShown: true, isFocused: id === focusedPane, isPlaced: true })),
96
100
  ...[...unplacedPanes].map((id) => ({ id, title: id, isShown: false, isFocused: false, isPlaced: false })),
97
101
  ],
102
+ scroll: rejected('ui.scroll', () => fakes.ui.scroll ?? (async () => ({}))),
98
103
  resolve: () => elements,
99
104
  },
100
105
  process: {
@@ -110,7 +115,10 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
110
115
  },
111
116
  http: { fetch: rejected('http.fetch', () => fakes.http.fetch) },
112
117
  settings: { read: rejected('settings.read', () => fakes.settings.read) },
113
- agent: { list: rejected('agent.list', () => fakes.agent.list) },
118
+ agent: {
119
+ list: rejected('agent.list', () => fakes.agent.list),
120
+ spawn: rejected('agent.spawn', () => fakes.agent.spawn),
121
+ },
114
122
  store: {
115
123
  get: async (key) => store.get(key),
116
124
  set: async (key, value) => {
@@ -133,6 +141,7 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
133
141
  model: async () => 'test-model',
134
142
  usage: async () => ({ context: { window: 200000 }, rateLimits: {}, cost: { usd: 0 } }) as unknown as Awaited<ReturnType<Claude['session']['usage']>>,
135
143
  surfaces: async () => ['terminal'],
144
+ messages: rejected('session.messages', () => fakes.session.messages as ((args?: unknown) => Promise<unknown>) | undefined) as Claude['session']['messages'],
136
145
  },
137
146
  command: {
138
147
  register: async (command) => {
@@ -2,8 +2,6 @@ import type { ElementTable, RenderElement, RenderNode } from 'claude-code'
2
2
 
3
3
  type Drawn = { readonly type: string; readonly props: Record<string, unknown>; readonly children?: readonly RenderNode[] }
4
4
 
5
- type Button = { readonly type: 'Button'; readonly props: { readonly key: string; readonly label: string; readonly onPress: () => unknown } }
6
-
7
5
  const boxInsides = new WeakMap<object, Drawn>()
8
6
 
9
7
  function element(type: string) {
@@ -31,12 +29,12 @@ export const elements = {
31
29
  Image: element('Image'),
32
30
  } as unknown as ElementTable
33
31
 
34
- export function findButton(node: RenderNode, key: string): Button | undefined {
32
+ export function findElement(node: RenderNode, type: string, key: string): Drawn['props'] | undefined {
35
33
  if (typeof node === 'string') return undefined
36
- const { type, props, children = [] } = insideOf(node)
37
- if (type === 'Button' && (props['key'] === key || props['label'] === key)) return node as unknown as Button
38
- for (const child of children) {
39
- const found = findButton(child, key)
34
+ const drawn = insideOf(node)
35
+ if (drawn.type === type && (drawn.props['key'] === key || (type === 'Button' && drawn.props['label'] === key))) return drawn.props
36
+ for (const child of drawn.children ?? []) {
37
+ const found = findElement(child, type, key)
40
38
  if (found !== undefined) return found
41
39
  }
42
40
  return undefined
@@ -1,10 +1,10 @@
1
1
  import type { Claude } from '../runtime/claude.js'
2
- import { resolvePath } from '../utils/paths.js'
2
+ import { resolve } from '../vendor.js'
3
3
 
4
4
  export function fakeFiles(files: Readonly<Record<string, string>>, links: Readonly<Record<string, string>> = {}): Claude['fs'] {
5
- const contents = new Map(Object.entries(files).map(([path, text]) => [resolvePath(path, '/'), text]))
5
+ const contents = new Map(Object.entries(files).map(([path, text]) => [resolve('/', path), text]))
6
6
  const realPathOf = (path: string): string => {
7
- const absolute = resolvePath(path, '/')
7
+ const absolute = resolve('/', path)
8
8
  for (const [link, target] of Object.entries(links)) {
9
9
  if (absolute === link || absolute.startsWith(`${link}/`)) return realPathOf(`${target}${absolute.slice(link.length)}`)
10
10
  }
@@ -35,7 +35,7 @@ export function fakeFiles(files: Readonly<Record<string, string>>, links: Readon
35
35
  if (kindOf(folder) !== 'dir') throw new Error(`ENOENT: no such directory, list '${path}'`)
36
36
  const names = new Set(below(folder).map((file) => file.split('/')[0] as string))
37
37
  return [...names].map((name) => {
38
- const entry = resolvePath(name, folder)
38
+ const entry = resolve(folder, name)
39
39
  if (Object.hasOwn(links, entry)) return { name, kind: 'other', size: 0, mtimeMs: 0, isLink: true }
40
40
  return { name, kind: contents.has(entry) ? 'file' : 'dir', size: sizeOf(entry), mtimeMs: 0, isLink: false }
41
41
  })
@@ -44,7 +44,7 @@ export function fakeFiles(files: Readonly<Record<string, string>>, links: Readon
44
44
  stat: async (path, options) => {
45
45
  const realPath = realPathOf(path)
46
46
  const kind = kindOf(realPath)
47
- const isLink = Object.hasOwn(links, resolvePath(path, '/'))
47
+ const isLink = Object.hasOwn(links, resolve('/', path))
48
48
  if (kind === undefined && isLink) return { kind: 'other', size: 0, mtimeMs: 0, isLink }
49
49
  if (kind === undefined) throw new Error(`ENOENT: no such file or directory, stat '${path}'`)
50
50
  return { kind, size: sizeOf(realPath), mtimeMs: 0, isLink, ...(options?.resolve === true ? { realPath } : {}) }
package/src/testing.ts CHANGED
@@ -1,13 +1,31 @@
1
- import type { Args, ClassicHookInputs, CmodDependencies, CommandPresentation, CommandRunResult, EventResult, Frozen, PaneOpenArgs, RenderChildren, RenderComponent, RenderElement, RenderPropsOf } from 'claude-code'
1
+ import type {
2
+ Args,
3
+ ClassicHookInputs,
4
+ CmodDependencies,
5
+ CommandPresentation,
6
+ CommandRunResult,
7
+ EventResult,
8
+ Frozen,
9
+ PaneOpenArgs,
10
+ PressedLink,
11
+ RenderChildren,
12
+ RenderComponent,
13
+ RenderElement,
14
+ RenderPropsOf,
15
+ UiInputArgument,
16
+ UiPressArgument,
17
+ UiSelectArgument,
18
+ } from 'claude-code'
2
19
  import type { Reply } from './jobs/slash-command.js'
3
20
  import type { ModDefinition, ModEvent } from './mod.js'
4
21
  import { storeFolder } from './records.js'
5
22
  import type { Claude } from './runtime/claude.js'
6
23
  import { answerCall, notInstalled } from './runtime/dependencies.js'
7
24
  import type { RoutedEvent } from './runtime/hooks.js'
25
+ import { toolInputOf } from './runtime/tool-calls.js'
8
26
  import { createLifecycle } from './runtime/lifecycle.js'
9
27
  import { fakeClaude, type Fakes, type Shown, type TestCall } from './testing/fake-claude.js'
10
- import { elements, findButton, rowsOf } from './testing/fake-elements.js'
28
+ import { elements, findElement, rowsOf } from './testing/fake-elements.js'
11
29
  import { fakeFiles } from './testing/fake-files.js'
12
30
  import type { Slot, SlotProps } from './ui/slots.js'
13
31
 
@@ -35,7 +53,9 @@ export type TestedMod<State extends object> = {
35
53
  lines<S extends Slot<object, object, { readonly component: RenderComponent }>>(slot: S, props: Omit<SlotProps<S>, 'Default'>, requestId?: string): Promise<string[]>
36
54
  type(line: string): Promise<Reply>
37
55
  callTool(name: string, input: Record<string, unknown>): Promise<EventResult<'tool.call'>>
38
- press(paneId: string, key: string): Promise<void>
56
+ press(paneId: string, key: string, link?: string): Promise<void>
57
+ input(paneId: string, key: string, text: string, kind?: UiInputArgument['kind']): Promise<void>
58
+ select(paneId: string, key: string, value: string): Promise<void>
39
59
  moveTo(projectRoot: string, cwd?: string): Promise<void>
40
60
  readonly state: Readonly<State>
41
61
  readonly calls: readonly TestCall[]
@@ -67,6 +87,12 @@ export function testMod<State extends object>(definition: ModDefinition<State>,
67
87
  fake.claude.session.root = async () => projectRoot
68
88
  fake.claude.session.cwd = async () => cwd
69
89
  fake.fakes.cmod.call = fakeDependencies(options.dependencies ?? {})
90
+ const firedAgents = new Map<string, string>()
91
+ const listAgents = fake.claude.agent.list
92
+ fake.claude.agent.list = async () => {
93
+ if (fake.fakes.agent.list !== undefined || firedAgents.size === 0) return listAgents()
94
+ return [...firedAgents].map(([id, type]) => ({ id, type, description: '', status: 'running' as const }))
95
+ }
70
96
  Object.assign(fake.fakes.fs, fakeFiles(options.files ?? {}))
71
97
  const starting = options.state as Record<string, object> | undefined
72
98
  const declared = Object.entries(definition.state ?? {}) as [string, object][]
@@ -100,11 +126,20 @@ export function testMod<State extends object>(definition: ModDefinition<State>,
100
126
  }
101
127
  const opened = fake.calls.findLast((call) => call.call === 'ui.open' && (call.args[0] as PaneOpenArgs).id === paneId)?.args[0] as PaneOpenArgs | undefined
102
128
  const columns = opened?.columns ?? defaultColumns
103
- const props: RenderPropsOf['Pane'] = { title: opened?.title ?? paneId, isFocused: false, bodyColumns: columns, placement: 'dock', scroll: { offset: 0, bodyRows: defaultRows }, view: {} }
129
+ const isFocused = (await fake.claude.ui.panes()).some((open) => open.id === paneId && open.isFocused)
130
+ const props: RenderPropsOf['Pane'] = { title: opened?.title ?? paneId, isFocused, bodyColumns: columns, placement: 'dock', scroll: { offset: 0, bodyRows: defaultRows }, view: {} }
104
131
  const drawing = await route('ui.render', { surface: 'terminal', component: 'Pane', requestId: paneId, props }, elements.Box({}))
105
132
  return { drawing, columns }
106
133
  }
107
134
 
135
+ const drawnElement = async (paneId: string, type: 'Button' | 'Input' | 'Select' | 'Markdown', key: string) => {
136
+ const props = findElement((await drawPane(paneId)).drawing, type, key)
137
+ if (props === undefined) throw new Error(`The pane "${paneId}" of ${name} draws no ${type} with the ${type === 'Button' ? 'key or label' : 'key'} "${key}".`)
138
+ return props
139
+ }
140
+
141
+ const eventIn = (paneId: string, key: string) => ({ plugin: name, element: key, component: 'Pane' as const, requestId: paneId, surface: 'terminal' as const })
142
+
108
143
  const drawSlot = async (component: RenderComponent, props: object, requestId = `${component}_${(slotDraws += 1)}`) => {
109
144
  await start()
110
145
  const input = { surface: 'terminal', component, requestId, props } as Frozen<Args<'ui.render'>>
@@ -121,11 +156,31 @@ export function testMod<State extends object>(definition: ModDefinition<State>,
121
156
  }
122
157
  const toolUse = toolUseEvents.includes(event) ? { tool_use_id: `toolu_${(toolUses += 1)}` } : {}
123
158
  const filled = { session_id: sessionId, transcript_path: '/test/transcript.jsonl', cwd, hook_event_name: event, ...toolUse, ...input }
124
- if (event !== 'PreToolUse') return route(`classic.${event as ModEvent}`, filled, below)
125
- const { tool_name, tool_input, tool_use_id } = filled as ClassicHookInputs['PreToolUse']
126
- return route('classic.PreToolUse', { ...(tool_input as object), tool: tool_name, tool_use_id }, below)
159
+ if (event !== 'PreToolUse') return route(`classic.${event as Exclude<ModEvent, 'PreToolUse'>}`, filled, below)
160
+ return firePreToolUse(filled as ClassicHookInputs['PreToolUse'], below as { deny?: string } | undefined)
127
161
  }) as TestedMod<State>['fire']
128
162
 
163
+ const firePreToolUse = async ({ tool_name, tool_input, tool_use_id, agent_id, agent_type }: ClassicHookInputs['PreToolUse'], below: { deny?: string } | undefined) => {
164
+ await start()
165
+ if (agent_id !== undefined && agent_type !== undefined) firedAgents.set(agent_id, agent_type)
166
+ const envelope = { ...(tool_input as object), tool: tool_name, tool_use_id, ...(agent_id === undefined ? {} : { agentId: agent_id }) } as Frozen<Args<'tool.call'>>
167
+ const asks: EventResult<'tool.check'> = below?.deny === undefined ? { decision: 'ask' } : { decision: 'deny', reason: below.deny }
168
+ let reached = envelope
169
+ let check = asks
170
+ const answer = await lifecycle.route('tool.call', envelope, async (e) => {
171
+ reached = e
172
+ check = await lifecycle.route('tool.check', { tool: e.tool, input: toolInputOf(e), tool_use_id: e.tool_use_id, ...(e.agentId === undefined ? {} : { agentId: e.agentId }) }, async () => asks)
173
+ return (check.decision === 'deny' ? { deny: check.reason ?? '' } : { result: '' }) as EventResult<'tool.call'>
174
+ })
175
+ const updatedInput = toolInputOf(reached)
176
+ const decided = check === asks ? {} : check.decision === 'allow' ? { allow: true } : check.decision === 'ask' ? { ask: check.reason ?? '' } : {}
177
+ return {
178
+ ...(answer.deny === undefined ? decided : { deny: answer.deny }),
179
+ ...(JSON.stringify(updatedInput) === JSON.stringify(tool_input) ? {} : { updatedInput }),
180
+ ...(answer.context === undefined || answer.context.length === 0 ? {} : { additionalContext: [...answer.context] }),
181
+ } as EventResult<'classic.PreToolUse'>
182
+ }
183
+
129
184
  return {
130
185
  start,
131
186
  fire,
@@ -154,10 +209,27 @@ export function testMod<State extends object>(definition: ModDefinition<State>,
154
209
  if (answer === unanswered) throw new Error(`${name} has no tool ${toolName}. Add it in setup with mod.use(tool({ name: '${toolName}', … })).`)
155
210
  return answer
156
211
  },
157
- async press(paneId, key) {
158
- const button = findButton((await drawPane(paneId)).drawing, key)
159
- if (button === undefined) throw new Error(`The pane "${paneId}" of ${name} draws no Button with the key or label "${key}".`)
160
- await button.props.onPress()
212
+ async press(paneId, key, link) {
213
+ const props = await drawnElement(paneId, link === undefined ? 'Button' : 'Markdown', key)
214
+ if (link !== undefined && props['onLinkPress'] === undefined) throw new Error(`The Markdown "${key}" in the pane "${paneId}" of ${name} has no onLinkPress, so Claude Code opens its links itself.`)
215
+ const pressed: UiPressArgument = { ...eventIn(paneId, String(props['key'])), ...(link === undefined ? {} : { link: { href: link } }) }
216
+ await lifecycle.route('ui.press', pressed as Frozen<Args<'ui.press'>>, async (e) => {
217
+ if (e.link === undefined) await (props['onPress'] as (e: UiPressArgument) => unknown)(e)
218
+ else await (props['onLinkPress'] as (link: PressedLink, e: UiPressArgument) => unknown)(e.link, e)
219
+ return { element: e.element }
220
+ })
221
+ },
222
+ async input(paneId, key, text, kind = 'submit') {
223
+ const props = await drawnElement(paneId, 'Input', key)
224
+ const typed: UiInputArgument = { ...eventIn(paneId, key), kind, value: text }
225
+ await (props[kind === 'submit' ? 'onSubmit' : 'onInput'] as ((value: string, e: UiInputArgument) => unknown) | undefined)?.(text, typed)
226
+ },
227
+ async select(paneId, key, value) {
228
+ const props = await drawnElement(paneId, 'Select', key)
229
+ const options = (props['options'] as readonly { readonly value: string }[]).map((option) => option.value)
230
+ if (!options.includes(value)) throw new Error(`The Select "${key}" in the pane "${paneId}" of ${name} has no option "${value}". Its options are ${options.map((option) => `"${option}"`).join(', ')}.`)
231
+ const picked: UiSelectArgument = { ...eventIn(paneId, key), value }
232
+ await (props['onSelect'] as (value: string, e: UiSelectArgument) => unknown)(value, picked)
161
233
  },
162
234
  async moveTo(nextRoot, nextCwd = nextRoot) {
163
235
  await start()