@cmodjs/core 0.1.14 → 0.2.0

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 (95) hide show
  1. package/docs/commands.md +31 -19
  2. package/docs/index.md +12 -4
  3. package/docs/install-steps.md +30 -4
  4. package/docs/jobs.md +25 -9
  5. package/docs/mod.md +103 -37
  6. package/docs/options.md +93 -0
  7. package/docs/permissions.md +88 -0
  8. package/docs/state.md +4 -31
  9. package/docs/testing.md +13 -2
  10. package/installer.d.ts +10 -0
  11. package/installer.d.ts.map +1 -0
  12. package/installer.js +8 -0
  13. package/jobs/context.d.ts +2 -2
  14. package/jobs/context.d.ts.map +1 -1
  15. package/jobs/context.js +5 -3
  16. package/jobs/permissions.js +1 -1
  17. package/jobs/program.d.ts.map +1 -1
  18. package/jobs/program.js +5 -1
  19. package/jobs/prompt.js +5 -5
  20. package/jobs/schedule.d.ts.map +1 -1
  21. package/jobs/schedule.js +2 -1
  22. package/jobs/slash-command.d.ts.map +1 -1
  23. package/jobs/slash-command.js +3 -2
  24. package/jobs/status-line.d.ts.map +1 -1
  25. package/jobs/status-line.js +3 -2
  26. package/jobs/tool.d.ts.map +1 -1
  27. package/jobs/tool.js +3 -2
  28. package/mod.d.ts +48 -10
  29. package/mod.d.ts.map +1 -1
  30. package/mod.js +3 -0
  31. package/options.d.ts +58 -0
  32. package/options.d.ts.map +1 -0
  33. package/options.js +66 -0
  34. package/package.json +1 -1
  35. package/records.d.ts +14 -1
  36. package/records.d.ts.map +1 -1
  37. package/records.js +106 -7
  38. package/register.d.ts +4 -2
  39. package/register.d.ts.map +1 -1
  40. package/register.js +8 -2
  41. package/runtime/claude.d.ts +5 -1
  42. package/runtime/claude.d.ts.map +1 -1
  43. package/runtime/grants.d.ts +28 -0
  44. package/runtime/grants.d.ts.map +1 -0
  45. package/runtime/grants.js +286 -0
  46. package/runtime/installer.d.ts +23 -0
  47. package/runtime/installer.d.ts.map +1 -0
  48. package/runtime/installer.js +113 -0
  49. package/runtime/lifecycle.d.ts +6 -3
  50. package/runtime/lifecycle.d.ts.map +1 -1
  51. package/runtime/lifecycle.js +220 -36
  52. package/runtime/metadata.d.ts +20 -0
  53. package/runtime/metadata.d.ts.map +1 -0
  54. package/runtime/metadata.js +143 -0
  55. package/runtime/options.d.ts +21 -0
  56. package/runtime/options.d.ts.map +1 -0
  57. package/runtime/options.js +100 -0
  58. package/runtime/state.d.ts +1 -0
  59. package/runtime/state.d.ts.map +1 -1
  60. package/runtime/state.js +5 -75
  61. package/src/installer.ts +17 -0
  62. package/src/jobs/context.ts +5 -3
  63. package/src/jobs/permissions.ts +1 -1
  64. package/src/jobs/program.ts +4 -1
  65. package/src/jobs/prompt.ts +5 -5
  66. package/src/jobs/schedule.ts +2 -1
  67. package/src/jobs/slash-command.ts +3 -2
  68. package/src/jobs/status-line.ts +3 -2
  69. package/src/jobs/tool.ts +3 -2
  70. package/src/mod.ts +46 -11
  71. package/src/options.ts +91 -0
  72. package/src/records.ts +117 -8
  73. package/src/register.ts +12 -3
  74. package/src/runtime/claude.ts +2 -0
  75. package/src/runtime/grants.ts +306 -0
  76. package/src/runtime/installer.ts +163 -0
  77. package/src/runtime/lifecycle.ts +220 -45
  78. package/src/runtime/metadata.ts +153 -0
  79. package/src/runtime/options.ts +102 -0
  80. package/src/runtime/state.ts +7 -70
  81. package/src/testing/fake-claude.ts +6 -1
  82. package/src/testing/fake-files.ts +9 -3
  83. package/src/testing.ts +40 -15
  84. package/src/utils/metadata.ts +242 -0
  85. package/testing/fake-claude.d.ts +4 -0
  86. package/testing/fake-claude.d.ts.map +1 -1
  87. package/testing/fake-claude.js +5 -1
  88. package/testing/fake-files.d.ts.map +1 -1
  89. package/testing/fake-files.js +9 -3
  90. package/testing.d.ts +6 -2
  91. package/testing.d.ts.map +1 -1
  92. package/testing.js +29 -13
  93. package/utils/metadata.d.ts +21 -0
  94. package/utils/metadata.d.ts.map +1 -0
  95. package/utils/metadata.js +250 -0
@@ -0,0 +1,163 @@
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
+ }
18
+
19
+ type Page = {
20
+ draw(props: Frozen<RenderPropsOf['Pane']>): RenderElement
21
+ cancel(): void
22
+ }
23
+
24
+ export type Installer = {
25
+ draws(e: Frozen<Args<'ui.render'>>): boolean
26
+ draw(e: Frozen<Args<'ui.render'>>): RenderElement
27
+ closed(id: string): void
28
+ consent(consent: Consent): Promise<boolean>
29
+ options(missing: readonly (readonly [string, Option])[], save: (key: string, value: string) => Promise<string | undefined>): Promise<boolean>
30
+ step<State extends object>(mod: Mod<State>, step: Step<State>, index: number, total: number): Promise<boolean>
31
+ close(): Promise<void>
32
+ }
33
+
34
+ export function createInstaller(name: string, claude: () => Claude): Installer {
35
+ let page: Page | undefined
36
+ let isOpen = false
37
+
38
+ const show = async <Answer>(build: (answer: (value: Answer) => void) => Omit<Page, 'cancel'>, cancelled: Answer): Promise<Answer> => {
39
+ page?.cancel()
40
+ const answer = new Promise<Answer>((resolve) => {
41
+ const done = (value: Answer) => {
42
+ if (page === shown) page = undefined
43
+ resolve(value)
44
+ }
45
+ const shown: Page = { ...build(done), cancel: () => done(cancelled) }
46
+ page = shown
47
+ })
48
+ if (!isOpen) {
49
+ isOpen = true
50
+ await claude().ui.open({ id: installerPaneId, title: `Install ${name}`, focus: true, holdToasts: true, closeOnEscape: true })
51
+ }
52
+ claude().ui.invalidate('ui.render')
53
+ return answer
54
+ }
55
+
56
+ const frame = (title: string, body: readonly RenderElement[], actions: readonly RenderElement[]) =>
57
+ Box({ flexDirection: 'column', gap: 1, paddingX: 1, children: [Text({ bold: true, children: title }), ...body, Box({ gap: 3, children: actions })] })
58
+
59
+ const bullet = (text: string) => Text({ children: [Text({ color: 'claude', children: '◆ ' }), text] })
60
+
61
+ return {
62
+ draws: (e) => e.component === 'Pane' && e.requestId === installerPaneId,
63
+ draw(e) {
64
+ return drawWith(claude().ui.resolve(e), () => page?.draw(e.props as Frozen<RenderPropsOf['Pane']>) ?? Text({ dimColor: true, children: `${name} is installing.` }))
65
+ },
66
+ closed(id) {
67
+ if (id !== installerPaneId) return
68
+ isOpen = false
69
+ page?.cancel()
70
+ },
71
+ consent({ install, uninstall, keys, permissions }) {
72
+ const changes = [...(install === '' ? [] : [`Run ${install} now${uninstall === '' ? '' : `, and ${uninstall} when you remove it`}`]), ...(install === '' && uninstall !== '' ? [`Run ${uninstall} when you remove it`] : []), ...(keys === '' ? [] : [`Bind ${keys}`])]
73
+ return show<boolean>(
74
+ (answer) => ({
75
+ draw: () =>
76
+ frame(
77
+ `${name} wants to:`,
78
+ [
79
+ ...(permissions.length === 0 ? [] : [Box({ flexDirection: 'column', children: permissions.map((item) => bullet(permissionWords(item))) })]),
80
+ ...(changes.length === 0 ? [] : [Text({ children: permissions.length === 0 ? 'Change your computer:' : 'and change your computer:' }), Box({ flexDirection: 'column', children: changes.map(bullet) })]),
81
+ Text({ dimColor: true, children: 'You can turn each permission off later in /mods.' }),
82
+ ],
83
+ [Button({ key: 'not-now', hotkey: 'n', label: 'Not now', onPress: () => answer(false) }), Button({ key: 'accept', hotkey: 'a', label: 'Accept', onPress: () => answer(true) })],
84
+ ),
85
+ }),
86
+ false,
87
+ )
88
+ },
89
+ options(missing, save) {
90
+ const saved = new Map<string, string>()
91
+ const failures = new Map<string, string>()
92
+ return show<boolean>(
93
+ (answer) => ({
94
+ draw: () =>
95
+ frame(
96
+ `${name} needs ${missing.length === 1 ? 'one setting' : `${missing.length} settings`}:`,
97
+ missing.map(([key, option]) => {
98
+ const label = Text({ children: [Text({ bold: true, children: option.title }), option.description === '' ? '' : Text({ dimColor: true, children: ` ${option.description}` })] })
99
+ 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.` })] })
100
+ const failure = failures.get(key)
101
+ const field = Input({
102
+ key: `option:${key}`,
103
+ label: '› ',
104
+ placeholder: option.kind === 'choice' ? (option.choices ?? []).join(', ') : option.kind === 'toggle' ? 'true or false' : option.kind === 'list' ? 'comma-separated' : '',
105
+ value: saved.get(key) ?? '',
106
+ submitLabel: 'save',
107
+ onSubmit: (value: string) =>
108
+ void save(key, value)
109
+ .then((refused) => {
110
+ if (refused === undefined) {
111
+ saved.set(key, value)
112
+ failures.delete(key)
113
+ } else failures.set(key, refused)
114
+ })
115
+ .catch((error: unknown) => failures.set(key, messageOf(error)))
116
+ .finally(() => claude().ui.invalidate('ui.render')),
117
+ })
118
+ const status = failure !== undefined ? Text({ color: 'error', children: failure }) : saved.has(key) ? Text({ color: 'success', children: '✔ saved' }) : Text({ children: '' })
119
+ return Box({ flexDirection: 'column', children: [label, field, status] })
120
+ }),
121
+ [Button({ key: 'not-now', hotkey: 'n', label: 'Not now', onPress: () => answer(false) }), Button({ key: 'next', hotkey: 'x', label: 'Next', onPress: () => answer(true) })],
122
+ ),
123
+ }),
124
+ false,
125
+ )
126
+ },
127
+ step(mod, step, index, total) {
128
+ let notice: string | undefined
129
+ return show<boolean>(
130
+ (answer) => ({
131
+ draw: (props) => {
132
+ let body: RenderElement
133
+ try {
134
+ body = step.render(mod, props)
135
+ } catch (error) {
136
+ body = Text({ color: 'error', children: `The ${step.title} step could not draw: ${messageOf(error)}` })
137
+ }
138
+ const next = async () => {
139
+ const isDone = await Promise.resolve(step.isDone(mod)).catch(() => false)
140
+ if (isDone) answer(true)
141
+ else {
142
+ notice = `Finish ${step.title} first.`
143
+ claude().ui.invalidate('ui.render')
144
+ }
145
+ }
146
+ return frame(
147
+ `${step.title} (${index} of ${total})`,
148
+ [body, ...(notice === undefined ? [] : [Text({ color: 'warning', children: notice })])],
149
+ [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() })],
150
+ )
151
+ },
152
+ }),
153
+ false,
154
+ )
155
+ },
156
+ async close() {
157
+ page = undefined
158
+ if (!isOpen) return
159
+ isOpen = false
160
+ await claude().ui.close({ id: installerPaneId })
161
+ },
162
+ }
163
+ }
@@ -1,12 +1,18 @@
1
- import type { Args, EventResult, Frozen, HookBudget, HookStream, ProcessSpawnChunk, ProcessSpawnResult } from 'claude-code'
2
- import type { HookInput, Mod, ModDefinition, ModEvent, ModHook } from '../mod.js'
3
- import { dataFolder, isAtLeast, oldestCmodFor, parseEvent, readRecord, readSteps, scriptsSha256, storeFolder, type ReadFile, type RunnerEvent } from '../records.js'
1
+ import type { Args, EventResult, Frozen, HookBudget, HookStream, PluginOptions, ProcessSpawnChunk, ProcessSpawnResult } from 'claude-code'
2
+ import type { HookInput, Mod, ModDefinition, ModEvent, ModHook, PaneHandle } from '../mod.js'
3
+ import type { Options, OptionValues } from '../options.js'
4
+ import { consentPath, dataFolder, finishStepsMethod, isAtLeast, oldestCmodFor, openPageMethod, parseConsent, pendingStepsMethod, parseEvent, permissionWords, readRecord, readSteps, scriptsSha256, settingsPagesMethod, storeFolder, type ReadFile, type RunnerEvent, type Steps } from '../records.js'
4
5
  import { relativePath } from '../utils/paths.js'
5
6
  import { formatExit, listed, messageOf } from '../utils/text.js'
6
7
  import type { Claude } from './claude.js'
7
8
  import { beforeDeadline } from './deadline.js'
8
9
  import { answerCall, dependencyCalls, notInstalled } from './dependencies.js'
9
10
  import { classicHook, heldDecisionHook, permissionEvents, preToolUseHook, userSkillHook, type HeldDecisions, type RoutedEvent } from './hooks.js'
11
+ import { checkedAnswer, checkingGrants, checksAnswers, checkWrite, isCovered, itemOf, passedDown, type AnswerCheck, type Grants } from './grants.js'
12
+ import { createModFiles } from './metadata.js'
13
+ import { fitsOption } from '../options.js'
14
+ import { createInstaller } from './installer.js'
15
+ import { createOptions, MissingOptions } from './options.js'
10
16
  import { createRouter, type Router, type RouterNext } from './router.js'
11
17
  import { createState } from './state.js'
12
18
  import { toolCalls } from './tool-calls.js'
@@ -19,7 +25,8 @@ export type Plugin = {
19
25
  readonly store: string
20
26
  readonly isInstalled: boolean
21
27
  readonly shouldRecord: boolean
22
- readonly keys: Readonly<Record<string, string>>
28
+ readonly steps: Steps
29
+ readonly granted: readonly string[]
23
30
  }
24
31
 
25
32
  export type Phase = 'starting' | 'installing' | 'waiting' | 'declined' | 'failed' | 'ready' | 'active'
@@ -37,12 +44,15 @@ type ModRuntime = {
37
44
  readonly router: Router
38
45
  readonly progress: Progress
39
46
  readonly dataFolder: string
40
- readonly keys: Readonly<Record<string, string>>
47
+ readonly steps: Steps
48
+ readonly granted: ReadonlySet<string>
49
+ readonly refreshGrants: () => Promise<void>
41
50
  readonly checksPermissions: () => boolean
51
+ readonly options: PluginOptions
42
52
  }
43
53
 
44
- type ActiveMod<State extends object> = {
45
- readonly mod: Mod<State>
54
+ type ActiveMod<State extends object, Declared extends Options> = {
55
+ readonly mod: Mod<State, Declared>
46
56
  readonly added: readonly string[]
47
57
  }
48
58
 
@@ -76,12 +86,14 @@ export async function readPlugin(claude: Claude): Promise<Plugin> {
76
86
  const manifest = (await readJson(read, `${root}/.claude-plugin/plugin.json`)) as { version?: unknown } | undefined
77
87
  const version = typeof manifest?.version === 'string' ? manifest.version : undefined
78
88
  const store = storeFolder({ HOME: await claude.env.home(), XDG_DATA_HOME: await claude.env.dataHome() })
89
+ const steps = readSteps(await readJson(read, `${root}/package.json`)) ?? {}
90
+ const declared = steps.permissions ?? []
79
91
  if (name === cmodPluginName) {
80
92
  const installed = await cmodVersion(claude)
81
- return { name, root, version, store, keys: {}, isInstalled: version !== undefined && installed !== undefined && isAtLeast(installed, version), shouldRecord: false }
93
+ return { name, root, version, store, steps, granted: declared, isInstalled: version !== undefined && installed !== undefined && isAtLeast(installed, version), shouldRecord: false }
82
94
  }
83
- const steps = readSteps(await readJson(read, `${root}/package.json`)) ?? {}
84
- const plugin = { name, root, version, store, keys: steps.keys ?? {} }
95
+ const approved = parseConsent(await readJson(read, consentPath(store)), consentPath(store))[name] ?? []
96
+ const plugin = { name, root, version, store, steps, granted: declared.filter((item) => approved.includes(item)) }
85
97
  const record = await readRecord(read, store, name)
86
98
  if (Object.keys(steps).length === 0) return { ...plugin, isInstalled: true, shouldRecord: record?.version !== version }
87
99
  const scripts = await scriptsSha256(steps, {
@@ -123,15 +135,23 @@ async function cmodVersion(claude: Claude): Promise<string | undefined> {
123
135
  return result.stdout.trim().split(/\s+/).at(-1)
124
136
  }
125
137
 
126
- export function createLifecycle<State extends object>(definition: ModDefinition<State>, checksPermissions: () => boolean = () => true): Lifecycle<State> {
138
+ export function createLifecycle<State extends object, Declared extends Options = Options>(
139
+ definition: ModDefinition<State, string, Declared>,
140
+ checksPermissions: () => boolean = () => true,
141
+ options: PluginOptions = {},
142
+ asksPerson = true,
143
+ ): Lifecycle<State> {
127
144
  let router: Router = createRouter()
128
145
  let phase: Phase = 'starting'
129
146
  let runtime: Pick<ModRuntime, 'claude' | 'progress'> | undefined
130
147
  let plugin: Plugin | undefined
148
+ const granted = new Set<string>()
131
149
  let line: ProgressLine | undefined
132
150
  let activation: Promise<void> | undefined
133
151
  let mod: Mod<State> | undefined
134
152
  let failure: unknown
153
+ let pluginOptions = options
154
+ const installer = createInstaller(definition.name, () => claude())
135
155
  let shouldRecord = false
136
156
  let recording: Promise<void> | undefined
137
157
  let missedSessionStart: Frozen<Args<'classic.SessionStart'>> | undefined
@@ -139,7 +159,7 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
139
159
  const startSettled = new Promise<void>((resolve) => (settleStart = resolve))
140
160
 
141
161
  const claude = () => {
142
- if (runtime === undefined) throw new Error(`${definition.name}: the lifecycle has not started. registerMod(addHook, mod) starts it at session.start.`)
162
+ if (runtime === undefined) throw new Error(`${definition.name}: the lifecycle has not started. registerMod(addHook, mod, options) starts it at session.start.`)
143
163
  return runtime.claude
144
164
  }
145
165
 
@@ -180,19 +200,65 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
180
200
  })
181
201
  }
182
202
 
183
- const activate = async () => {
203
+ const saveOption = async (key: string, text: string): Promise<string | undefined> => {
204
+ const option = (definition.options as Options | undefined)?.[key]
205
+ if (option === undefined || plugin === undefined) return `${definition.name} has no option ${key}.`
206
+ const value = option.kind === 'number' ? Number(text) : option.kind === 'toggle' ? text.trim() === 'true' : option.kind === 'list' ? text.split(',').map((item) => item.trim()).filter((item) => item !== '') : text
207
+ const misfit = option.kind === 'toggle' && !['true', 'false'].includes(text.trim()) ? 'takes true or false' : fitsOption(option, value)
208
+ if (misfit !== undefined) return `${option.title} ${misfit}.`
209
+ const saved = await claude().config.set({ key: `${plugin.name}.${key}`, value })
210
+ return saved.deny
211
+ }
212
+
213
+ const askOptions = async (missing: MissingOptions): Promise<boolean> => {
214
+ if (!asksPerson || plugin === undefined || (await claude().session.surfaces()).length === 0) return false
215
+ const declared: Options = definition.options ?? {}
216
+ const asked = missing.keys.flatMap((key) => {
217
+ const option = declared[key]
218
+ return option === undefined ? [] : [[key, option] as const]
219
+ })
220
+ if (!(await installer.options(asked, saveOption))) return false
221
+ const prefix = `${plugin.name}.`
222
+ const rows = await claude().config.list()
223
+ pluginOptions = { ...pluginOptions, ...Object.fromEntries(rows.filter((row) => row.key.startsWith(prefix)).map((row) => [row.key.slice(prefix.length), row.value])) }
224
+ return true
225
+ }
226
+
227
+ const pendingSteps = async (active: Mod<State>) => {
228
+ const pending: string[] = []
229
+ for (const step of definition.installer ?? []) if (!(await Promise.resolve(step.isDone(active)).catch(() => false))) pending.push(step.title)
230
+ return pending
231
+ }
232
+
233
+ const runSteps = async (active: Mod<State>) => {
234
+ const steps = asksPerson ? (definition.installer ?? []) : []
235
+ for (const [index, step] of steps.entries()) {
236
+ if (await Promise.resolve(step.isDone(active)).catch(() => false)) continue
237
+ const isAnswered = (await claude().session.surfaces()).length > 0 && (await installer.step(active, step, index + 1, steps.length))
238
+ if (!isAnswered) {
239
+ claude().ui.log(`${definition.name} needs ${steps.length - index === 1 ? 'one more step' : `${steps.length - index} more steps`}, starting with ${step.title}. Run /mods ${definition.name} to finish.`)
240
+ break
241
+ }
242
+ }
243
+ await installer.close()
244
+ }
245
+
246
+ const activate = async (): Promise<void> => {
184
247
  if (runtime === undefined || plugin === undefined) return
185
248
  const activeRouter = createRouter()
186
- const active = await createMod(definition, { ...runtime, router: activeRouter, dataFolder: dataFolder(plugin.store, plugin.name), keys: plugin.keys, checksPermissions }).catch((error: unknown) => {
187
- fail(messageOf(error), 'Fix it, then run /reload-plugins.')
249
+ const active = await createMod(definition, { ...runtime, router: activeRouter, dataFolder: dataFolder(plugin.store, plugin.name), steps: plugin.steps, granted, refreshGrants, checksPermissions, options: pluginOptions }).catch(async (error: unknown) => {
250
+ if (error instanceof MissingOptions && (await askOptions(error))) return undefined
251
+ fail(messageOf(error), error instanceof MissingOptions ? `Set ${error.keys.length === 1 ? 'it' : 'them'} in /config.` : 'Fix it, then run /reload-plugins.')
188
252
  throw error
189
253
  })
254
+ if (active === undefined) return activate()
190
255
  const { added } = active
191
256
  mod = active.mod
192
257
  router = activeRouter
193
258
  phase = 'active'
194
259
  runtime.claude.ui.invalidate('ui.render')
195
260
  endLine()
261
+ void runSteps(active.mod).catch((error: unknown) => claude().ui.log(`${definition.name}: its installer stopped: ${messageOf(error)}`))
196
262
  const missed = missedSessionStart
197
263
  missedSessionStart = undefined
198
264
  const lateAnswer = missed === undefined ? {} : await activeRouter.dispatch('classic.SessionStart', missed, async () => ({})).catch((error: unknown) => {
@@ -217,13 +283,8 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
217
283
  return
218
284
  }
219
285
  showLine().wait('Waiting for your answer')
220
- const actions = [
221
- ...(event.install === '' ? [] : [`runs ${event.install} to install`]),
222
- ...(event.uninstall === '' ? [] : [`runs ${event.uninstall} when you remove it`]),
223
- ...(event.keys === '' ? [] : [`binds ${event.keys}`]),
224
- ]
225
- const answer = await claude().ui.ask(`${name} ${listed(actions)}. Install it now?`, { options: ['Install', 'Not now'], header: 'Install' })
226
- if (answer === 'Install') return install(event.sha256)
286
+ if (await installer.consent(event)) return install(event.sha256)
287
+ await installer.close()
227
288
  phase = 'declined'
228
289
  endLine()
229
290
  claude().ui.log(`${name} is not installed. Run cmod install ${name} to install it.`)
@@ -244,13 +305,16 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
244
305
  if (event.kind === 'progress') progress.report(event)
245
306
  if (event.kind === 'done' || event.kind === 'failed' || event.kind === 'needs-consent') outcome = event
246
307
  })
247
- if (outcome?.kind === 'done') return finish()
308
+ if (outcome?.kind === 'done') {
309
+ for (const item of plugin.steps.permissions ?? []) granted.add(item)
310
+ return finish()
311
+ }
248
312
  if (outcome?.kind === 'needs-consent') return askConsent(outcome)
249
313
  const reason = outcome?.kind === 'failed' ? outcome.message : `cmod setup ${formatExit(code, lastError)}`
250
314
  fail(reason, `Fix the cause, then run: cmod install ${definition.name}`)
251
315
  }
252
316
 
253
- const fits = (found: string | undefined): found is string => found !== undefined && isAtLeast(found, oldestCmodFor(plugin?.keys ?? {}))
317
+ const fits = (found: string | undefined): found is string => found !== undefined && isAtLeast(found, oldestCmodFor(plugin?.steps ?? {}))
254
318
 
255
319
  const waitForcmod = (found: string | undefined) => {
256
320
  phase = 'waiting'
@@ -279,7 +343,7 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
279
343
  showLine().wait(
280
344
  latest === undefined
281
345
  ? "Still waiting for cmod to download cmod. See cmod's own line."
282
- : `PATH finds cmod ${latest}, and ${definition.name} needs cmod ${oldestCmodFor(plugin?.keys ?? {})} or later. Run npm i -g @cmodjs/cli, or put ~/.local/bin ahead of the old cmod on PATH.`,
346
+ : `PATH finds cmod ${latest}, and ${definition.name} needs cmod ${oldestCmodFor(plugin?.steps ?? {})} or later. Run npm i -g @cmodjs/cli, or put ~/.local/bin ahead of the old cmod on PATH.`,
283
347
  ),
284
348
  )
285
349
  }
@@ -300,6 +364,46 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
300
364
  if (phase !== 'failed') fail(messageOf(error), `Run cmod install ${definition.name} in a terminal to see the whole log.`)
301
365
  }
302
366
 
367
+ let consentMs = 0
368
+ const consentStamp = async (path: string) => {
369
+ const stat = await claude().fs.stat(path).catch(() => undefined)
370
+ return stat?.kind === 'file' ? stat.mtimeMs : 0
371
+ }
372
+ const refreshGrants = async () => {
373
+ if (plugin === undefined || plugin.name === cmodPluginName) return
374
+ const path = consentPath(plugin.store)
375
+ const modifiedMs = await consentStamp(path)
376
+ if (modifiedMs === consentMs) return
377
+ consentMs = modifiedMs
378
+ const approved = modifiedMs === 0 ? [] : (parseConsent(JSON.parse(await claude().fs.read(path)), path)[plugin.name] ?? [])
379
+ granted.clear()
380
+ for (const item of plugin.steps.permissions ?? []) {
381
+ if (approved.includes(item)) granted.add(item)
382
+ }
383
+ }
384
+
385
+ const droppedParts = new Set<string>()
386
+ const answerCheck: AnswerCheck = {
387
+ get plugin() {
388
+ return plugin?.name ?? definition.name
389
+ },
390
+ isGranted: (item) => isCovered(granted, item, undefined),
391
+ dropped(item, part) {
392
+ if (droppedParts.has(item)) return
393
+ droppedParts.add(item)
394
+ const declared = plugin?.steps.permissions ?? []
395
+ const fix = declared.includes(item) ? `Turn it on in /mods ${definition.name}.` : `It needs "permissions": { "${item}": true } in package.json "cmod".`
396
+ claude().ui.log(`${definition.name} answered ${part} without your grant to "${permissionWords(item)}", so cmod dropped it. ${fix}`)
397
+ },
398
+ }
399
+
400
+ const checkedRoute = async <N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>> => {
401
+ let below: Promise<EventResult<N>> | undefined
402
+ const passOn: RouterNext<N> = (passed) => (below ??= next(passedDown(event, e, passed, answerCheck) as Frozen<Args<N>>))
403
+ const answer = await router.dispatch(event, e, passOn)
404
+ return (await checkedAnswer(event, e, answer, () => passOn(e), answerCheck)) as EventResult<N>
405
+ }
406
+
303
407
  const whenActive = async () => {
304
408
  if (phase === 'ready') activation ??= activate().catch(report)
305
409
  if (activation !== undefined && phase !== 'active') await activation
@@ -334,6 +438,8 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
334
438
  })
335
439
  if (started === undefined) return
336
440
  plugin = started
441
+ for (const item of started.granted) granted.add(item)
442
+ consentMs = await consentStamp(consentPath(started.store))
337
443
  if (started.isInstalled) {
338
444
  shouldRecord = started.shouldRecord
339
445
  if (shouldRecord) record()
@@ -346,6 +452,8 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
346
452
  }
347
453
  },
348
454
  async route<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>> {
455
+ if (event === 'ui.render' && installer.draws(e as Frozen<Args<'ui.render'>>)) return installer.draw(e as Frozen<Args<'ui.render'>>) as EventResult<N>
456
+ if (event === 'ui.close') installer.closed((e as Frozen<Args<'ui.close'>>).id)
349
457
  if (event === 'classic.SessionStart') missedSessionStart = undefined
350
458
  if (event === 'classic.SessionStart' && runtime === undefined) {
351
459
  missedSessionStart = e as Frozen<Args<'classic.SessionStart'>>
@@ -358,6 +466,13 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
358
466
  const isStopped = phase === 'declined' || phase === 'failed'
359
467
  return { deny: isStopped ? notInstalled(definition.name) : `${definition.name} is installing. Try again when it's ready.` } as EventResult<N>
360
468
  }
469
+ const call = e as Frozen<Args<'cmod.call'>>
470
+ if (event === 'cmod.call' && call.to === definition.name && mod !== undefined) {
471
+ if (call.method === pendingStepsMethod) return { value: await pendingSteps(mod) } as EventResult<N>
472
+ if (call.method === finishStepsMethod) return (await runSteps(mod), { value: null }) as EventResult<N>
473
+ }
474
+ if (event === 'classic.UserPromptSubmit' || event === 'classic.SessionStart') await refreshGrants().catch((error: unknown) => claude().ui.log(`${definition.name} keeps its grants from before: ${messageOf(error)}`, { to: 'debug' }))
475
+ if (checksAnswers(event) && phase === 'active') return checkedRoute(event, e, next)
361
476
  const answer = router.dispatch(event, e, next)
362
477
  const render = e as Frozen<Args<'ui.render'>>
363
478
  if (event !== 'ui.render' || render.component !== 'AbovePrompt' || runtime === undefined || !runtime.progress.isShown) return answer as Promise<EventResult<N>>
@@ -366,24 +481,63 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
366
481
  }
367
482
  }
368
483
 
369
- async function createMod<State extends object>(definition: ModDefinition<State>, runtime: ModRuntime): Promise<ActiveMod<State>> {
484
+ async function createMod<State extends object, Declared extends Options>(definition: ModDefinition<State, string, Declared>, runtime: ModRuntime): Promise<ActiveMod<State, Declared>> {
370
485
  const { router, progress } = runtime
371
- const claude = checkingKeys(runtime.claude, definition.name, runtime.keys)
486
+ const claude = checkingKeys(runtime.claude, definition.name, runtime.steps.keys ?? {})
372
487
  const added: string[] = []
373
488
  const hookEvents: ModEvent[] = []
374
489
  const announce = (feature: string) => {
375
490
  added.push(feature)
376
491
  }
377
- const [session, root, startCwd] = await Promise.all([claude.session.id(), claude.session.root(), claude.session.cwd()])
492
+ const [session, root, startCwd, home, configHome] = await Promise.all([claude.session.id(), claude.session.root(), claude.session.cwd(), claude.env.home(), claude.env.configHome()])
493
+ const { granted } = runtime
494
+ const configRoot = configHome ?? (home === undefined ? undefined : `${home}/.claude`)
495
+ const grants: Grants = {
496
+ name: definition.name,
497
+ declared: runtime.steps.permissions ?? [],
498
+ granted: () => granted,
499
+ refresh: runtime.refreshGrants,
500
+ home,
501
+ configRoot,
502
+ projectRoot: () => modState.root,
503
+ freeFolders: () => [modState.root, runtime.dataFolder],
504
+ }
505
+ const checked = checkingGrants(claude, grants)
506
+ const files = createModFiles({
507
+ name: definition.name,
508
+ claude,
509
+ places: { home, configRoot, projectRoot: () => modState.root },
510
+ checkWrite: (path) => checkWrite(grants, `metadata.update(${path})`, path),
511
+ })
512
+ const pages = new Map<string, { readonly title: string; readonly handle: PaneHandle }>()
513
+ const settingsPages: Mod<State>['settings'] = {
514
+ page(page) {
515
+ if (pages.has(page.id)) throw new Error(`${definition.name}: the settings page "${page.id}" is already added. Give each page its own id.`)
516
+ pages.set(page.id, { title: page.title, handle: area.ui.pane(page) })
517
+ },
518
+ async open(pageId) {
519
+ if (pageId === undefined) {
520
+ await claude.cmod.call({ to: cmodPluginName, method: 'openSettings', input: { mod: definition.name } })
521
+ return
522
+ }
523
+ const page = pages.get(pageId)
524
+ if (page === undefined) throw new Error(`${definition.name} has no settings page "${pageId}". It has: ${[...pages.keys()].join(', ') || 'none'}.`)
525
+ await page.handle.open({ focus: true })
526
+ },
527
+ }
378
528
  let cwd = startCwd
379
529
  let loadedCwd = startCwd
380
530
  let staleCwd: string | undefined
381
531
  const area = createUi<State>({ name: definition.name, claude, router, progress, announce, mod: () => mod })
382
532
  const modState = createState<State>({ name: definition.name, initial: definition.state ?? {}, session, root, claude, changed: area.changed })
533
+ const modOptions = createOptions({ name: definition.name, declared: definition.options ?? {}, fromClaude: runtime.options, claude, changed: modState.changed })
383
534
  const held: HeldDecisions | undefined = runtime.checksPermissions() ? new Map() : undefined
384
- const mod: Mod<State> = {
535
+ const mod: Mod<State, Declared> = {
385
536
  name: definition.name,
386
537
  state: modState.state,
538
+ get options() {
539
+ return modOptions.values as OptionValues<Declared>
540
+ },
387
541
  dataFolder: runtime.dataFolder,
388
542
  on(event, hook, options) {
389
543
  const bounded = options?.timeoutMs === undefined ? hook : timedHook(definition.name, event, hook, checkedMs(definition.name, `the ${event} hook's timeoutMs`, options.timeoutMs), claude)
@@ -391,7 +545,7 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
391
545
  if (event === 'PreToolUse') router.add('tool.call', preToolUseHook(definition.name, bounded as ModHook<'PreToolUse'>, claude, agents, held))
392
546
  else router.add(`classic.${event as Exclude<ModEvent, 'PreToolUse'>}`, classicHook(definition.name, event as Exclude<ModEvent, 'PreToolUse'>, bounded as ModHook<Exclude<ModEvent, 'PreToolUse'>>, claude, agents))
393
547
  },
394
- use: (job) => job({ mod, claude, on: (event, hook) => router.add(event, hook), announce, reserveName, toolCalls: agents }),
548
+ use: (job) => job({ mod, announce, reserveName, toolCalls: agents }),
395
549
  every(ms, hook) {
396
550
  let isRunning = false
397
551
  return claude.clock.every(checkedMs(definition.name, 'mod.every', ms), () => {
@@ -407,31 +561,40 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
407
561
  },
408
562
  ui: area.ui,
409
563
  process: {
410
- run: (argv, init) => claude.process.run(argv, init),
411
- spawn: (argv, init) => claude.process.spawn({ ...init, argv }),
564
+ run: (argv, init) => checked.process.run(argv, init),
565
+ spawn: (argv, init) => checked.process.spawn({ ...init, argv }),
412
566
  },
413
567
  fs: {
414
- read: (path) => claude.fs.read(path),
415
- write: (path, text) => claude.fs.write(path, text),
416
- list: (path) => claude.fs.list(path),
417
- exists: (path) => claude.fs.exists(path),
418
- stat: (path, options) => claude.fs.stat(path, options),
568
+ read: (path) => checked.fs.read(path),
569
+ write: (path, text) => checked.fs.write(path, text),
570
+ list: (path) => checked.fs.list(path),
571
+ exists: (path) => checked.fs.exists(path),
572
+ stat: (path, options) => checked.fs.stat(path, options),
573
+ find: (glob) => files.find(glob),
419
574
  },
420
- http: { fetch: (url, init) => claude.http.fetch(url, init) },
421
- settings: { read: (args) => claude.settings.read(args) },
575
+ metadata: {
576
+ read: (path) => files.read(path),
577
+ update: (path, change) => files.update(path, change),
578
+ },
579
+ http: { fetch: (url, init) => checked.http.fetch(url, init) },
580
+ settings: settingsPages,
422
581
  session: {
423
- messages: claude.session.messages,
582
+ messages: ((args?: { readonly agentId: string }) => (args === undefined ? checked.session.messages() : checked.session.messages({ agentId: args.agentId }))) as Mod<State>['session']['messages'],
424
583
  async append(text) {
425
- const added = await claude.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
584
+ const added = await checked.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
426
585
  if (added.deny !== undefined) throw new Error(`${definition.name}: Claude Code refused the note: ${added.deny}`)
427
586
  },
428
587
  async submit(text) {
429
- const submitted = await claude.prompt.submit({ text })
588
+ const submitted = await checked.prompt.submit({ text })
430
589
  if ('drop' in submitted && typeof submitted.drop === 'string') throw new Error(`${definition.name}: Claude Code dropped the prompt: ${submitted.drop}`)
431
590
  },
432
591
  },
433
- agent: { spawn: (args) => claude.agent.spawn(args) },
434
- model: { complete: (request, options) => claude.model.complete(request, options) },
592
+ agent: { spawn: (args) => checked.agent.spawn(args) },
593
+ model: { complete: (completion, options) => checked.model.complete(completion, options) },
594
+ permissions: {
595
+ has: (name: string, value?: string) => isCovered(granted, itemOf(name, value), home),
596
+ },
597
+ claude: { ...checked, on: (event, hook) => router.add(event, hook) },
435
598
  get projectRoot() {
436
599
  return modState.root
437
600
  },
@@ -454,6 +617,7 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
454
617
  if (cwd === nextCwd) cwd = loadedCwd
455
618
  throw error
456
619
  })
620
+ if (hasMovedRoot) await modOptions.load(nextRoot)
457
621
  loadedCwd = nextCwd
458
622
  if (!hasMovedRoot) area.changed()
459
623
  if (nextCwd === oldCwd) return
@@ -487,7 +651,15 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
487
651
  })
488
652
  router.add('skill.prompt', userSkillHook(claude))
489
653
  const api = Object.fromEntries(Object.entries(definition.api ?? {}).map(([method, run]) => [method, (input: never) => run(input, mod)]))
490
- router.add('cmod.call', (e, next) => (e.to === definition.name ? answerCall(definition.name, api, e) : next(e)))
654
+ router.add('cmod.call', async (e, next) => {
655
+ if (e.to !== definition.name) return next(e)
656
+ if (e.method === settingsPagesMethod) return { value: [...pages].map(([id, { title }]) => ({ id, title })) }
657
+ if (e.method === openPageMethod) {
658
+ await settingsPages.open(String(e.input))
659
+ return { value: null }
660
+ }
661
+ return answerCall(definition.name, api, e)
662
+ })
491
663
  const agents = toolCalls(claude, router)
492
664
  const names = new Set<string>()
493
665
  const reserveName = (kind: string, name: string, taken: string) => {
@@ -496,6 +668,9 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
496
668
  names.add(key)
497
669
  }
498
670
  await failsAs('its state did not load', () => modState.load())
671
+ await failsAs('its options did not load', () => modOptions.load(root))
672
+ const missing = modOptions.missing
673
+ if (missing.length > 0) throw new MissingOptions(missing, missing.map((key) => definition.options?.[key]?.title ?? key))
499
674
  await failsAs('its setup function threw', () => definition.setup(mod))
500
675
  const permissionHooks = permissionEvents.filter((event) => router.has(event))
501
676
  if (permissionHooks.length > 0 && !runtime.checksPermissions()) {