@cmodjs/core 0.1.14 → 0.2.1

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 (96) hide show
  1. package/docs/commands.md +32 -21
  2. package/docs/dependencies.md +8 -0
  3. package/docs/index.md +12 -4
  4. package/docs/install-steps.md +36 -4
  5. package/docs/jobs.md +25 -9
  6. package/docs/mod.md +103 -37
  7. package/docs/options.md +93 -0
  8. package/docs/permissions.md +88 -0
  9. package/docs/state.md +4 -31
  10. package/docs/testing.md +13 -2
  11. package/installer.d.ts +10 -0
  12. package/installer.d.ts.map +1 -0
  13. package/installer.js +8 -0
  14. package/jobs/context.d.ts +2 -2
  15. package/jobs/context.d.ts.map +1 -1
  16. package/jobs/context.js +5 -3
  17. package/jobs/permissions.js +1 -1
  18. package/jobs/program.d.ts.map +1 -1
  19. package/jobs/program.js +5 -1
  20. package/jobs/prompt.js +5 -5
  21. package/jobs/schedule.d.ts.map +1 -1
  22. package/jobs/schedule.js +2 -1
  23. package/jobs/slash-command.d.ts.map +1 -1
  24. package/jobs/slash-command.js +3 -2
  25. package/jobs/status-line.d.ts.map +1 -1
  26. package/jobs/status-line.js +3 -2
  27. package/jobs/tool.d.ts.map +1 -1
  28. package/jobs/tool.js +3 -2
  29. package/mod.d.ts +48 -10
  30. package/mod.d.ts.map +1 -1
  31. package/mod.js +3 -0
  32. package/options.d.ts +58 -0
  33. package/options.d.ts.map +1 -0
  34. package/options.js +66 -0
  35. package/package.json +1 -1
  36. package/records.d.ts +16 -1
  37. package/records.d.ts.map +1 -1
  38. package/records.js +119 -7
  39. package/register.d.ts +4 -2
  40. package/register.d.ts.map +1 -1
  41. package/register.js +8 -2
  42. package/runtime/claude.d.ts +5 -1
  43. package/runtime/claude.d.ts.map +1 -1
  44. package/runtime/grants.d.ts +28 -0
  45. package/runtime/grants.d.ts.map +1 -0
  46. package/runtime/grants.js +286 -0
  47. package/runtime/installer.d.ts +24 -0
  48. package/runtime/installer.d.ts.map +1 -0
  49. package/runtime/installer.js +118 -0
  50. package/runtime/lifecycle.d.ts +6 -3
  51. package/runtime/lifecycle.d.ts.map +1 -1
  52. package/runtime/lifecycle.js +226 -36
  53. package/runtime/metadata.d.ts +20 -0
  54. package/runtime/metadata.d.ts.map +1 -0
  55. package/runtime/metadata.js +143 -0
  56. package/runtime/options.d.ts +21 -0
  57. package/runtime/options.d.ts.map +1 -0
  58. package/runtime/options.js +100 -0
  59. package/runtime/state.d.ts +1 -0
  60. package/runtime/state.d.ts.map +1 -1
  61. package/runtime/state.js +5 -75
  62. package/src/installer.ts +17 -0
  63. package/src/jobs/context.ts +5 -3
  64. package/src/jobs/permissions.ts +1 -1
  65. package/src/jobs/program.ts +4 -1
  66. package/src/jobs/prompt.ts +5 -5
  67. package/src/jobs/schedule.ts +2 -1
  68. package/src/jobs/slash-command.ts +3 -2
  69. package/src/jobs/status-line.ts +3 -2
  70. package/src/jobs/tool.ts +3 -2
  71. package/src/mod.ts +46 -11
  72. package/src/options.ts +91 -0
  73. package/src/records.ts +131 -8
  74. package/src/register.ts +12 -3
  75. package/src/runtime/claude.ts +2 -0
  76. package/src/runtime/grants.ts +306 -0
  77. package/src/runtime/installer.ts +169 -0
  78. package/src/runtime/lifecycle.ts +227 -45
  79. package/src/runtime/metadata.ts +153 -0
  80. package/src/runtime/options.ts +102 -0
  81. package/src/runtime/state.ts +7 -70
  82. package/src/testing/fake-claude.ts +6 -1
  83. package/src/testing/fake-files.ts +9 -3
  84. package/src/testing.ts +40 -15
  85. package/src/utils/metadata.ts +242 -0
  86. package/testing/fake-claude.d.ts +4 -0
  87. package/testing/fake-claude.d.ts.map +1 -1
  88. package/testing/fake-claude.js +5 -1
  89. package/testing/fake-files.d.ts.map +1 -1
  90. package/testing/fake-files.js +9 -3
  91. package/testing.d.ts +6 -2
  92. package/testing.d.ts.map +1 -1
  93. package/testing.js +29 -13
  94. package/utils/metadata.d.ts +21 -0
  95. package/utils/metadata.d.ts.map +1 -0
  96. package/utils/metadata.js +250 -0
@@ -0,0 +1,306 @@
1
+ import { permissionWords } from '../records.js'
2
+ import { relativePath } from '../utils/paths.js'
3
+ import type { Claude } from './claude.js'
4
+ import type { RoutedEvent } from './hooks.js'
5
+
6
+ export type Grants = {
7
+ readonly name: string
8
+ readonly declared: readonly string[]
9
+ readonly granted: () => ReadonlySet<string>
10
+ readonly refresh: () => Promise<void>
11
+ readonly home: string | undefined
12
+ readonly configRoot: string | undefined
13
+ readonly projectRoot: () => string
14
+ readonly freeFolders: () => readonly string[]
15
+ }
16
+
17
+ export class PermissionRefused extends Error {}
18
+
19
+ const configFiles: readonly string[] = ['.claude/settings.json', '.claude/settings.local.json', '.mcp.json']
20
+
21
+ const startsTurns: readonly string[] = ['CronCreate']
22
+
23
+ const ownSchedule: readonly string[] = ['CronDelete', 'CronList']
24
+
25
+ async function checkGrant(grants: Grants, call: string, item: string | undefined): Promise<void> {
26
+ if (item === undefined) return
27
+ await grants.refresh()
28
+ if (!isCovered(grants.granted(), item, grants.home)) throw new PermissionRefused(refusal(grants, call, item))
29
+ }
30
+
31
+ export function checkWrite(grants: Grants, call: string, path: string): Promise<void> {
32
+ return checkGrant(grants, call, writeItem(grants, path))
33
+ }
34
+
35
+ export function checkingGrants(claude: Claude, grants: Grants): Claude {
36
+ const gate = (call: string, item: string | undefined) => checkGrant(grants, call, item)
37
+ return {
38
+ ...claude,
39
+ process: {
40
+ async run(argv, init) {
41
+ await gate(`process.run(${argv.join(' ')})`, `run:${programOf(argv)}`)
42
+ return claude.process.run(argv, init)
43
+ },
44
+ spawn(request) {
45
+ const started = gate(`process.spawn(${request.argv.join(' ')})`, `run:${programOf(request.argv)}`).then(() => claude.process.spawn(request))
46
+ return spawned(started)
47
+ },
48
+ },
49
+ fs: {
50
+ ...claude.fs,
51
+ async read(path) {
52
+ await gate(`fs.read(${path})`, readItem(grants, path))
53
+ return claude.fs.read(path)
54
+ },
55
+ async write(path, text) {
56
+ await gate(`fs.write(${path})`, writeItem(grants, path))
57
+ return claude.fs.write(path, text)
58
+ },
59
+ },
60
+ http: {
61
+ async fetch(url, init) {
62
+ await gate(`http.fetch(${url})`, `network:${init?.socketPath ?? hostOf(url)}`)
63
+ return claude.http.fetch(url, init)
64
+ },
65
+ },
66
+ config: {
67
+ ...claude.config,
68
+ set: (async (args: Parameters<Claude['config']['set']>[0]) => {
69
+ await gate('config.set', 'config')
70
+ return claude.config.set(args)
71
+ }) as Claude['config']['set'],
72
+ },
73
+ session: {
74
+ ...claude.session,
75
+ messages: (async (...args: Parameters<Claude['session']['messages']>) => {
76
+ await gate('session.messages', 'conversation')
77
+ return claude.session.messages(...args)
78
+ }) as Claude['session']['messages'],
79
+ append: (async (args: Parameters<Claude['session']['append']>[0]) => {
80
+ await gate('session.append', 'prompt')
81
+ return claude.session.append(args)
82
+ }) as Claude['session']['append'],
83
+ },
84
+ prompt: {
85
+ submit: (async (args: Parameters<Claude['prompt']['submit']>[0]) => {
86
+ await gate('session.submit', 'prompt')
87
+ return claude.prompt.submit(args)
88
+ }) as Claude['prompt']['submit'],
89
+ },
90
+ model: {
91
+ complete: (async (...args: Parameters<Claude['model']['complete']>) => {
92
+ await gate('model.complete', 'model')
93
+ return claude.model.complete(...args)
94
+ }) as Claude['model']['complete'],
95
+ },
96
+ agent: {
97
+ ...claude.agent,
98
+ async spawn(args) {
99
+ await gate('agent.spawn', 'agents')
100
+ return claude.agent.spawn(args)
101
+ },
102
+ },
103
+ tool: {
104
+ ...claude.tool,
105
+ call: (async (input: Parameters<Claude['tool']['call']>[0]) => {
106
+ const item = startsTurns.includes(input.tool) ? 'prompt' : ownSchedule.includes(input.tool) ? undefined : 'tools'
107
+ await gate(`tool.call(${input.tool})`, item)
108
+ return claude.tool.call(input)
109
+ }) as Claude['tool']['call'],
110
+ },
111
+ }
112
+ }
113
+
114
+ type Stream = ReturnType<Claude['process']['spawn']>
115
+
116
+ function spawned(started: Promise<Stream>): Stream {
117
+ const result = started.then((stream) => stream.result)
118
+ result.catch(() => undefined)
119
+ const stream = {
120
+ next: async () => (await started).next(),
121
+ async return(value: never) {
122
+ const running = await started.catch(() => undefined)
123
+ return (await running?.return?.(value)) ?? { done: true as const, value }
124
+ },
125
+ [Symbol.asyncIterator]: () => stream,
126
+ result,
127
+ }
128
+ return stream as unknown as Stream
129
+ }
130
+
131
+ export function itemOf(name: string, value?: string): string {
132
+ return value === undefined ? name : `${name}:${value}`
133
+ }
134
+
135
+ export function isCovered(granted: ReadonlySet<string>, item: string, home: string | undefined): boolean {
136
+ if (granted.has(item)) return true
137
+ const split = item.indexOf(':')
138
+ if (split === -1) return false
139
+ const name = item.slice(0, split)
140
+ const target = expanded(item.slice(split + 1), home)
141
+ const values = [...granted].filter((each) => each.startsWith(`${name}:`)).map((each) => expanded(each.slice(name.length + 1), home))
142
+ if (name === 'run') return values.includes('*') || values.includes(target)
143
+ if (name === 'files') return values.some((each) => relativePath(each, target) !== undefined)
144
+ return values.includes(target)
145
+ }
146
+
147
+ function readItem(grants: Grants, path: string): string | undefined {
148
+ if (grants.configRoot === undefined) return undefined
149
+ return relativePath(`${grants.configRoot}/projects`, path) === undefined ? undefined : 'conversation'
150
+ }
151
+
152
+ function writeItem(grants: Grants, path: string): string | undefined {
153
+ const root = grants.projectRoot()
154
+ if (configFiles.some((file) => `${root}/${file}` === path)) return 'config'
155
+ if (grants.freeFolders().some((folder) => relativePath(folder, path) !== undefined)) return undefined
156
+ return `files:${path}`
157
+ }
158
+
159
+ export function refusal(grants: Pick<Grants, 'name' | 'declared' | 'home'>, call: string, item: string): string {
160
+ const { name } = grants
161
+ if (isCovered(new Set(grants.declared), item, grants.home)) return `${name} calls ${call} without your grant to "${permissionWords(item)}". Turn it on in /mods ${name}.`
162
+ return `${name} calls ${call}, which needs ${declaration(item, grants.home)} in package.json "cmod".`
163
+ }
164
+
165
+ function declaration(item: string, home: string | undefined): string {
166
+ const split = item.indexOf(':')
167
+ if (split === -1) return `"permissions": { "${item}": true }`
168
+ const name = item.slice(0, split)
169
+ const target = item.slice(split + 1)
170
+ const fromHome = home === undefined ? undefined : relativePath(home, target)
171
+ const shown = name !== 'run' && fromHome !== undefined ? `~/${fromHome}` : target
172
+ return `"permissions": { "${name}": ["${shown}"] }`
173
+ }
174
+
175
+ type Fields = Record<string, unknown>
176
+
177
+ const classicParts: Readonly<Record<string, string>> = {
178
+ additionalContext: 'prompt',
179
+ initialUserMessage: 'prompt',
180
+ suppressOriginalPrompt: 'prompt',
181
+ updatedToolOutput: 'prompt',
182
+ updatedMCPToolOutput: 'prompt',
183
+ updatedInput: 'tools',
184
+ retry: 'approve',
185
+ }
186
+
187
+ const blocksKeepWorking: readonly RoutedEvent[] = ['classic.Stop', 'classic.PostToolUse']
188
+
189
+ const reservedToolKeys: readonly string[] = ['tool', 'tool_use_id']
190
+
191
+ export type AnswerCheck = {
192
+ readonly plugin: string
193
+ isGranted(item: string): boolean
194
+ dropped(item: string, part: string): void
195
+ }
196
+
197
+ export function checksAnswers(event: RoutedEvent): boolean {
198
+ return event.startsWith('classic.') || event === 'tool.check' || event === 'tool.call' || event === 'prompt.context' || event === 'prompt.submit'
199
+ }
200
+
201
+ export function passedDown(event: RoutedEvent, original: unknown, passed: unknown, check: AnswerCheck): unknown {
202
+ if (passed === original) return passed
203
+ if (event === 'tool.call') {
204
+ if (sameExcept(original, passed, reservedToolKeys) || check.isGranted('tools')) return passed
205
+ check.dropped('tools', 'a changed tool input')
206
+ return original
207
+ }
208
+ if (event === 'prompt.context' || event === 'prompt.submit') {
209
+ if (same(original, passed) || check.isGranted('prompt')) return passed
210
+ check.dropped('prompt', event === 'prompt.submit' ? 'a rewritten prompt' : 'changed context')
211
+ return original
212
+ }
213
+ return passed
214
+ }
215
+
216
+ export async function checkedAnswer(event: RoutedEvent, e: unknown, answer: unknown, below: () => Promise<unknown>, check: AnswerCheck): Promise<unknown> {
217
+ if (!isFields(answer)) return answer
218
+ if (event === 'tool.check') return decided(answer['decision'], answer, below, check)
219
+ if (event === 'classic.PermissionRequest') {
220
+ const decision = isFields(answer['decision']) ? answer['decision']['behavior'] : undefined
221
+ return decided(decision, answer, below, check)
222
+ }
223
+ if (event.startsWith('classic.')) return classicAnswer(event, answer, below, check)
224
+ if (event === 'tool.call') {
225
+ if (answer['deny'] !== undefined || check.isGranted('prompt')) return answer
226
+ const tool = isFields(e) ? e['tool'] : undefined
227
+ if (typeof tool === 'string' && tool.startsWith(`mcp__${check.plugin}__`)) {
228
+ if (answer['context'] === undefined) return answer
229
+ check.dropped('prompt', 'context after a tool call')
230
+ const { context: _context, ...rest } = answer
231
+ return rest
232
+ }
233
+ const theirs = await below()
234
+ if (same(answer, theirs)) return answer
235
+ check.dropped('prompt', same(contextOf(answer), contextOf(theirs)) ? 'a changed tool result' : 'context after a tool call')
236
+ return theirs
237
+ }
238
+ if (event === 'prompt.submit' && answer['drop'] !== undefined) return answer
239
+ if (event === 'prompt.context' || event === 'prompt.submit') {
240
+ if (check.isGranted('prompt')) return answer
241
+ const theirs = await below()
242
+ if (same(answer, theirs)) return answer
243
+ check.dropped('prompt', event === 'prompt.submit' ? 'a rewritten prompt' : 'changed context')
244
+ return theirs
245
+ }
246
+ return answer
247
+ }
248
+
249
+ async function decided(decision: unknown, answer: Fields, below: () => Promise<unknown>, check: AnswerCheck): Promise<unknown> {
250
+ if ((decision !== 'allow' && decision !== 'ask') || check.isGranted('approve')) return answer
251
+ const theirs = await below()
252
+ if (same(answer, theirs)) return answer
253
+ check.dropped('approve', `${decision} on a tool call`)
254
+ return theirs
255
+ }
256
+
257
+ async function classicAnswer(event: RoutedEvent, answer: Fields, below: () => Promise<unknown>, check: AnswerCheck): Promise<unknown> {
258
+ const blocks = blocksKeepWorking.includes(event) ? ([['block', 'prompt']] as const) : []
259
+ const parts = [...Object.entries(classicParts), ...blocks].filter(([part, item]) => answer[part] !== undefined && !check.isGranted(item))
260
+ if (parts.length === 0) return answer
261
+ const theirs = await below()
262
+ const kept: Fields = { ...answer }
263
+ const theirFields = isFields(theirs) ? theirs : {}
264
+ for (const [part, item] of parts) {
265
+ if (same(answer[part], theirFields[part])) continue
266
+ check.dropped(item, part)
267
+ if (theirFields[part] === undefined) delete kept[part]
268
+ else kept[part] = theirFields[part]
269
+ }
270
+ return kept
271
+ }
272
+
273
+ function contextOf(answer: unknown): unknown {
274
+ return isFields(answer) ? answer['context'] : undefined
275
+ }
276
+
277
+ function same(left: unknown, right: unknown): boolean {
278
+ return left === right || JSON.stringify(left) === JSON.stringify(right)
279
+ }
280
+
281
+ function sameExcept(left: unknown, right: unknown, keys: readonly string[]): boolean {
282
+ if (!isFields(left) || !isFields(right)) return same(left, right)
283
+ const without = (fields: Fields) => Object.fromEntries(Object.entries(fields).filter(([key]) => !keys.includes(key)))
284
+ return same(without(left), without(right))
285
+ }
286
+
287
+ function isFields(value: unknown): value is Fields {
288
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
289
+ }
290
+
291
+ function programOf(argv: readonly string[]): string {
292
+ const command = argv[0] ?? ''
293
+ return command.slice(command.lastIndexOf('/') + 1)
294
+ }
295
+
296
+ function hostOf(url: string): string {
297
+ try {
298
+ return new URL(url).hostname
299
+ } catch {
300
+ return url
301
+ }
302
+ }
303
+
304
+ function expanded(path: string, home: string | undefined): string {
305
+ return path.startsWith('~/') && home !== undefined ? `${home}${path.slice(1)}` : path
306
+ }
@@ -0,0 +1,169 @@
1
+ import type { Args, Frozen, RenderElement, RenderPropsOf } from 'claude-code'
2
+ import type { Step } from '../installer.js'
3
+ import type { Mod } from '../mod.js'
4
+ import type { Option } from '../options.js'
5
+ import { permissionWords } from '../records.js'
6
+ import { Box, Button, drawWith, Input, Text } from '../ui/elements.js'
7
+ import { messageOf } from '../utils/text.js'
8
+ import type { Claude } from './claude.js'
9
+
10
+ export const installerPaneId = 'cmod-installer'
11
+
12
+ export type Consent = {
13
+ readonly install: string
14
+ readonly uninstall: string
15
+ readonly keys: string
16
+ readonly permissions: readonly string[]
17
+ readonly updates?: string | undefined
18
+ }
19
+
20
+ type Page = {
21
+ draw(props: Frozen<RenderPropsOf['Pane']>): RenderElement
22
+ cancel(): void
23
+ }
24
+
25
+ export type Installer = {
26
+ draws(e: Frozen<Args<'ui.render'>>): boolean
27
+ draw(e: Frozen<Args<'ui.render'>>): RenderElement
28
+ closed(id: string): void
29
+ consent(consent: Consent): Promise<boolean>
30
+ options(missing: readonly (readonly [string, Option])[], save: (key: string, value: string) => Promise<string | undefined>): Promise<boolean>
31
+ step<State extends object>(mod: Mod<State>, step: Step<State>, index: number, total: number): Promise<boolean>
32
+ close(): Promise<void>
33
+ }
34
+
35
+ export function createInstaller(name: string, claude: () => Claude): Installer {
36
+ let page: Page | undefined
37
+ let isOpen = false
38
+
39
+ const show = async <Answer>(build: (answer: (value: Answer) => void) => Omit<Page, 'cancel'>, cancelled: Answer): Promise<Answer> => {
40
+ page?.cancel()
41
+ const answer = new Promise<Answer>((resolve) => {
42
+ const done = (value: Answer) => {
43
+ if (page === shown) page = undefined
44
+ resolve(value)
45
+ }
46
+ const shown: Page = { ...build(done), cancel: () => done(cancelled) }
47
+ page = shown
48
+ })
49
+ if (!isOpen) {
50
+ isOpen = true
51
+ await claude().ui.open({ id: installerPaneId, title: `Install ${name}`, focus: true, holdToasts: true, closeOnEscape: true })
52
+ }
53
+ claude().ui.invalidate('ui.render')
54
+ return answer
55
+ }
56
+
57
+ const frame = (title: string, body: readonly RenderElement[], actions: readonly RenderElement[]) =>
58
+ Box({ flexDirection: 'column', gap: 1, paddingX: 1, children: [Text({ bold: true, children: title }), ...body, Box({ gap: 3, children: actions })] })
59
+
60
+ const bullet = (text: string) => Text({ children: [Text({ color: 'claude', children: '◆ ' }), text] })
61
+
62
+ return {
63
+ draws: (e) => e.component === 'Pane' && e.requestId === installerPaneId,
64
+ draw(e) {
65
+ return drawWith(claude().ui.resolve(e), () => page?.draw(e.props as Frozen<RenderPropsOf['Pane']>) ?? Text({ dimColor: true, children: `${name} is installing.` }))
66
+ },
67
+ closed(id) {
68
+ if (id !== installerPaneId) return
69
+ isOpen = false
70
+ page?.cancel()
71
+ },
72
+ consent({ install, uninstall, keys, permissions, updates }) {
73
+ const changes = [
74
+ ...(install === '' ? [] : [`Run ${install} now${uninstall === '' ? '' : `, and ${uninstall} when you remove it`}`]),
75
+ ...(install === '' && uninstall !== '' ? [`Run ${uninstall} when you remove it`] : []),
76
+ ...(keys === '' ? [] : [`Bind ${keys}`]),
77
+ ...(updates === undefined ? [] : [`Update ${name} automatically from the ${updates} marketplace`]),
78
+ ]
79
+ return show<boolean>(
80
+ (answer) => ({
81
+ draw: () =>
82
+ frame(
83
+ `${name} wants to:`,
84
+ [
85
+ ...(permissions.length === 0 ? [] : [Box({ flexDirection: 'column', children: permissions.map((item) => bullet(permissionWords(item))) })]),
86
+ ...(changes.length === 0 ? [] : [Text({ children: permissions.length === 0 ? 'Change your computer:' : 'and change your computer:' }), Box({ flexDirection: 'column', children: changes.map(bullet) })]),
87
+ Text({ dimColor: true, children: 'You can turn each permission off later in /mods.' }),
88
+ ],
89
+ [Button({ key: 'not-now', hotkey: 'n', label: 'Not now', onPress: () => answer(false) }), Button({ key: 'accept', hotkey: 'a', label: 'Accept', onPress: () => answer(true) })],
90
+ ),
91
+ }),
92
+ false,
93
+ )
94
+ },
95
+ options(missing, save) {
96
+ const saved = new Map<string, string>()
97
+ const failures = new Map<string, string>()
98
+ return show<boolean>(
99
+ (answer) => ({
100
+ draw: () =>
101
+ frame(
102
+ `${name} needs ${missing.length === 1 ? 'one setting' : `${missing.length} settings`}:`,
103
+ missing.map(([key, option]) => {
104
+ const label = Text({ children: [Text({ bold: true, children: option.title }), option.description === '' ? '' : Text({ dimColor: true, children: ` ${option.description}` })] })
105
+ if (option.kind === 'secret') return Box({ flexDirection: 'column', children: [label, Text({ dimColor: true, children: `Set it in /config, where Claude Code keeps it in your keychain, then press Next.` })] })
106
+ const failure = failures.get(key)
107
+ const field = Input({
108
+ key: `option:${key}`,
109
+ label: '› ',
110
+ placeholder: option.kind === 'choice' ? (option.choices ?? []).join(', ') : option.kind === 'toggle' ? 'true or false' : option.kind === 'list' ? 'comma-separated' : '',
111
+ value: saved.get(key) ?? '',
112
+ submitLabel: 'save',
113
+ onSubmit: (value: string) =>
114
+ void save(key, value)
115
+ .then((refused) => {
116
+ if (refused === undefined) {
117
+ saved.set(key, value)
118
+ failures.delete(key)
119
+ } else failures.set(key, refused)
120
+ })
121
+ .catch((error: unknown) => failures.set(key, messageOf(error)))
122
+ .finally(() => claude().ui.invalidate('ui.render')),
123
+ })
124
+ const status = failure !== undefined ? Text({ color: 'error', children: failure }) : saved.has(key) ? Text({ color: 'success', children: '✔ saved' }) : Text({ children: '' })
125
+ return Box({ flexDirection: 'column', children: [label, field, status] })
126
+ }),
127
+ [Button({ key: 'not-now', hotkey: 'n', label: 'Not now', onPress: () => answer(false) }), Button({ key: 'next', hotkey: 'x', label: 'Next', onPress: () => answer(true) })],
128
+ ),
129
+ }),
130
+ false,
131
+ )
132
+ },
133
+ step(mod, step, index, total) {
134
+ let notice: string | undefined
135
+ return show<boolean>(
136
+ (answer) => ({
137
+ draw: (props) => {
138
+ let body: RenderElement
139
+ try {
140
+ body = step.render(mod, props)
141
+ } catch (error) {
142
+ body = Text({ color: 'error', children: `The ${step.title} step could not draw: ${messageOf(error)}` })
143
+ }
144
+ const next = async () => {
145
+ const isDone = await Promise.resolve(step.isDone(mod)).catch(() => false)
146
+ if (isDone) answer(true)
147
+ else {
148
+ notice = `Finish ${step.title} first.`
149
+ claude().ui.invalidate('ui.render')
150
+ }
151
+ }
152
+ return frame(
153
+ `${step.title} (${index} of ${total})`,
154
+ [body, ...(notice === undefined ? [] : [Text({ color: 'warning', children: notice })])],
155
+ [Button({ key: 'not-now', hotkey: 'n', label: 'Not now', onPress: () => answer(false) }), Button({ key: 'next', hotkey: 'x', label: index === total ? 'Finish' : 'Next', onPress: () => void next() })],
156
+ )
157
+ },
158
+ }),
159
+ false,
160
+ )
161
+ },
162
+ async close() {
163
+ page = undefined
164
+ if (!isOpen) return
165
+ isOpen = false
166
+ await claude().ui.close({ id: installerPaneId })
167
+ },
168
+ }
169
+ }