@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.
- package/docs/commands.md +2 -2
- package/docs/hooks.md +62 -6
- package/docs/index.md +7 -4
- package/docs/install-steps.md +3 -3
- package/docs/jobs.md +5 -4
- package/docs/mod.md +57 -7
- package/docs/testing.md +25 -9
- package/docs/ui.md +134 -5
- package/jobs/check.js +2 -2
- package/jobs/context.d.ts.map +1 -1
- package/jobs/context.js +2 -0
- package/jobs/permissions/decide-permission.js +2 -2
- package/jobs/permissions/find-project-scope.d.ts +1 -1
- package/jobs/permissions/find-project-scope.d.ts.map +1 -1
- package/jobs/permissions/find-project-scope.js +9 -9
- package/jobs/permissions/match-target.d.ts.map +1 -1
- package/jobs/permissions/match-target.js +4 -3
- package/mod.d.ts +16 -6
- package/mod.d.ts.map +1 -1
- package/package.json +2 -1
- package/path.d.ts +2 -0
- package/path.d.ts.map +1 -0
- package/path.js +1 -0
- package/register.d.ts.map +1 -1
- package/register.js +8 -2
- package/runtime/claude.d.ts +4 -1
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/hooks.d.ts +10 -2
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hooks.js +56 -18
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +12 -2
- package/runtime/ui.d.ts.map +1 -1
- package/runtime/ui.js +42 -26
- package/shell.d.ts +2 -0
- package/shell.d.ts.map +1 -0
- package/shell.js +1 -0
- package/src/jobs/check.ts +2 -2
- package/src/jobs/context.ts +2 -0
- package/src/jobs/permissions/decide-permission.ts +2 -2
- package/src/jobs/permissions/find-project-scope.ts +11 -10
- package/src/jobs/permissions/match-target.ts +4 -3
- package/src/mod.ts +24 -4
- package/src/path.ts +1 -0
- package/src/register.ts +8 -2
- package/src/runtime/claude.ts +11 -1
- package/src/runtime/hooks.ts +62 -23
- package/src/runtime/lifecycle.ts +11 -4
- package/src/runtime/ui.ts +39 -26
- package/src/shell.ts +1 -0
- package/src/testing/fake-claude.ts +14 -5
- package/src/testing/fake-elements.ts +5 -7
- package/src/testing/fake-files.ts +5 -5
- package/src/testing.ts +83 -11
- package/src/ui/components.ts +70 -0
- package/src/ui/define-pane.ts +5 -1
- package/src/utils/call-effects.ts +12 -11
- package/src/utils/parse-shell.ts +28 -36
- package/src/utils/paths.ts +4 -19
- package/src/vendor.ts +1 -0
- package/testing/fake-claude.d.ts +5 -0
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +13 -3
- package/testing/fake-elements.d.ts +5 -8
- package/testing/fake-elements.d.ts.map +1 -1
- package/testing/fake-elements.js +6 -6
- package/testing/fake-files.js +5 -5
- package/testing.d.ts +4 -2
- package/testing.d.ts.map +1 -1
- package/testing.js +65 -9
- package/ui/components.d.ts +48 -0
- package/ui/components.d.ts.map +1 -0
- package/ui/components.js +56 -0
- package/ui/define-pane.d.ts +5 -1
- package/ui/define-pane.d.ts.map +1 -1
- package/utils/call-effects.d.ts.map +1 -1
- package/utils/call-effects.js +12 -11
- package/utils/parse-shell.d.ts +5 -9
- package/utils/parse-shell.d.ts.map +1 -1
- package/utils/parse-shell.js +5 -10
- package/utils/paths.d.ts +0 -3
- package/utils/paths.d.ts.map +1 -1
- package/utils/paths.js +3 -18
- package/vendor.d.ts +1 -0
- package/vendor.d.ts.map +1 -1
- package/vendor.js +209 -12
package/src/runtime/hooks.ts
CHANGED
|
@@ -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.
|
|
26
|
+
export const permissionEvents: readonly RoutedEvent[] = ['tool.check', 'classic.PermissionRequest']
|
|
26
27
|
|
|
27
28
|
type ClassicFields = Record<string, unknown>
|
|
28
29
|
|
|
29
|
-
type
|
|
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
|
|
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
|
|
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(
|
|
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<'
|
|
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
|
|
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
|
}
|
package/src/runtime/lifecycle.ts
CHANGED
|
@@ -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(
|
|
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:
|
|
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
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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: {
|
|
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
|
|
32
|
+
export function findElement(node: RenderNode, type: string, key: string): Drawn['props'] | undefined {
|
|
35
33
|
if (typeof node === 'string') return undefined
|
|
36
|
-
const
|
|
37
|
-
if (type ===
|
|
38
|
-
for (const child of children) {
|
|
39
|
-
const found =
|
|
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 {
|
|
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]) => [
|
|
5
|
+
const contents = new Map(Object.entries(files).map(([path, text]) => [resolve('/', path), text]))
|
|
6
6
|
const realPathOf = (path: string): string => {
|
|
7
|
-
const absolute =
|
|
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 =
|
|
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,
|
|
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 {
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
159
|
-
if (
|
|
160
|
-
|
|
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()
|