@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
@@ -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, marketplaceOf, oldestCmodFor, openPageMethod, parseConsent, pendingStepsMethod, parseEvent, permissionWords, readRecord, readSteps, scriptsSha256, settingsPagesMethod, storeFolder, updatesToTurnOn, 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) => {
@@ -209,6 +275,13 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
209
275
  if (added.length > 0) runtime.claude.ui.log(`${definition.name} added ${listed(added)}.`)
210
276
  }
211
277
 
278
+ const updatesToShow = async () => {
279
+ const [home, configHome] = await Promise.all([claude().env.home(), claude().env.configHome()])
280
+ const configRoot = configHome ?? `${home ?? ''}/.claude`
281
+ const known = await claude().fs.read(`${configRoot}/plugins/known_marketplaces.json`).then((text): unknown => JSON.parse(text), () => undefined)
282
+ return updatesToTurnOn(marketplaceOf(claude().plugin.root, claude().plugin.name), await claude().settings.read({ source: 'user' }), known)
283
+ }
284
+
212
285
  const askConsent = async (event: Extract<RunnerEvent, { kind: 'needs-consent' }>) => {
213
286
  const name = definition.name
214
287
  if ((await claude().session.surfaces()).length === 0) {
@@ -217,13 +290,8 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
217
290
  return
218
291
  }
219
292
  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)
293
+ if (await installer.consent({ ...event, updates: await updatesToShow().catch(() => undefined) })) return install(event.sha256)
294
+ await installer.close()
227
295
  phase = 'declined'
228
296
  endLine()
229
297
  claude().ui.log(`${name} is not installed. Run cmod install ${name} to install it.`)
@@ -244,13 +312,16 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
244
312
  if (event.kind === 'progress') progress.report(event)
245
313
  if (event.kind === 'done' || event.kind === 'failed' || event.kind === 'needs-consent') outcome = event
246
314
  })
247
- if (outcome?.kind === 'done') return finish()
315
+ if (outcome?.kind === 'done') {
316
+ for (const item of plugin.steps.permissions ?? []) granted.add(item)
317
+ return finish()
318
+ }
248
319
  if (outcome?.kind === 'needs-consent') return askConsent(outcome)
249
320
  const reason = outcome?.kind === 'failed' ? outcome.message : `cmod setup ${formatExit(code, lastError)}`
250
321
  fail(reason, `Fix the cause, then run: cmod install ${definition.name}`)
251
322
  }
252
323
 
253
- const fits = (found: string | undefined): found is string => found !== undefined && isAtLeast(found, oldestCmodFor(plugin?.keys ?? {}))
324
+ const fits = (found: string | undefined): found is string => found !== undefined && isAtLeast(found, oldestCmodFor(plugin?.steps ?? {}))
254
325
 
255
326
  const waitForcmod = (found: string | undefined) => {
256
327
  phase = 'waiting'
@@ -279,7 +350,7 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
279
350
  showLine().wait(
280
351
  latest === undefined
281
352
  ? "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.`,
353
+ : `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
354
  ),
284
355
  )
285
356
  }
@@ -300,6 +371,46 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
300
371
  if (phase !== 'failed') fail(messageOf(error), `Run cmod install ${definition.name} in a terminal to see the whole log.`)
301
372
  }
302
373
 
374
+ let consentMs = 0
375
+ const consentStamp = async (path: string) => {
376
+ const stat = await claude().fs.stat(path).catch(() => undefined)
377
+ return stat?.kind === 'file' ? stat.mtimeMs : 0
378
+ }
379
+ const refreshGrants = async () => {
380
+ if (plugin === undefined || plugin.name === cmodPluginName) return
381
+ const path = consentPath(plugin.store)
382
+ const modifiedMs = await consentStamp(path)
383
+ if (modifiedMs === consentMs) return
384
+ consentMs = modifiedMs
385
+ const approved = modifiedMs === 0 ? [] : (parseConsent(JSON.parse(await claude().fs.read(path)), path)[plugin.name] ?? [])
386
+ granted.clear()
387
+ for (const item of plugin.steps.permissions ?? []) {
388
+ if (approved.includes(item)) granted.add(item)
389
+ }
390
+ }
391
+
392
+ const droppedParts = new Set<string>()
393
+ const answerCheck: AnswerCheck = {
394
+ get plugin() {
395
+ return plugin?.name ?? definition.name
396
+ },
397
+ isGranted: (item) => isCovered(granted, item, undefined),
398
+ dropped(item, part) {
399
+ if (droppedParts.has(item)) return
400
+ droppedParts.add(item)
401
+ const declared = plugin?.steps.permissions ?? []
402
+ const fix = declared.includes(item) ? `Turn it on in /mods ${definition.name}.` : `It needs "permissions": { "${item}": true } in package.json "cmod".`
403
+ claude().ui.log(`${definition.name} answered ${part} without your grant to "${permissionWords(item)}", so cmod dropped it. ${fix}`)
404
+ },
405
+ }
406
+
407
+ const checkedRoute = async <N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>> => {
408
+ let below: Promise<EventResult<N>> | undefined
409
+ const passOn: RouterNext<N> = (passed) => (below ??= next(passedDown(event, e, passed, answerCheck) as Frozen<Args<N>>))
410
+ const answer = await router.dispatch(event, e, passOn)
411
+ return (await checkedAnswer(event, e, answer, () => passOn(e), answerCheck)) as EventResult<N>
412
+ }
413
+
303
414
  const whenActive = async () => {
304
415
  if (phase === 'ready') activation ??= activate().catch(report)
305
416
  if (activation !== undefined && phase !== 'active') await activation
@@ -334,6 +445,8 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
334
445
  })
335
446
  if (started === undefined) return
336
447
  plugin = started
448
+ for (const item of started.granted) granted.add(item)
449
+ consentMs = await consentStamp(consentPath(started.store))
337
450
  if (started.isInstalled) {
338
451
  shouldRecord = started.shouldRecord
339
452
  if (shouldRecord) record()
@@ -346,6 +459,8 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
346
459
  }
347
460
  },
348
461
  async route<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>> {
462
+ if (event === 'ui.render' && installer.draws(e as Frozen<Args<'ui.render'>>)) return installer.draw(e as Frozen<Args<'ui.render'>>) as EventResult<N>
463
+ if (event === 'ui.close') installer.closed((e as Frozen<Args<'ui.close'>>).id)
349
464
  if (event === 'classic.SessionStart') missedSessionStart = undefined
350
465
  if (event === 'classic.SessionStart' && runtime === undefined) {
351
466
  missedSessionStart = e as Frozen<Args<'classic.SessionStart'>>
@@ -358,6 +473,13 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
358
473
  const isStopped = phase === 'declined' || phase === 'failed'
359
474
  return { deny: isStopped ? notInstalled(definition.name) : `${definition.name} is installing. Try again when it's ready.` } as EventResult<N>
360
475
  }
476
+ const call = e as Frozen<Args<'cmod.call'>>
477
+ if (event === 'cmod.call' && call.to === definition.name && mod !== undefined) {
478
+ if (call.method === pendingStepsMethod) return { value: await pendingSteps(mod) } as EventResult<N>
479
+ if (call.method === finishStepsMethod) return (await runSteps(mod), { value: null }) as EventResult<N>
480
+ }
481
+ 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' }))
482
+ if (checksAnswers(event) && phase === 'active') return checkedRoute(event, e, next)
361
483
  const answer = router.dispatch(event, e, next)
362
484
  const render = e as Frozen<Args<'ui.render'>>
363
485
  if (event !== 'ui.render' || render.component !== 'AbovePrompt' || runtime === undefined || !runtime.progress.isShown) return answer as Promise<EventResult<N>>
@@ -366,24 +488,63 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
366
488
  }
367
489
  }
368
490
 
369
- async function createMod<State extends object>(definition: ModDefinition<State>, runtime: ModRuntime): Promise<ActiveMod<State>> {
491
+ async function createMod<State extends object, Declared extends Options>(definition: ModDefinition<State, string, Declared>, runtime: ModRuntime): Promise<ActiveMod<State, Declared>> {
370
492
  const { router, progress } = runtime
371
- const claude = checkingKeys(runtime.claude, definition.name, runtime.keys)
493
+ const claude = checkingKeys(runtime.claude, definition.name, runtime.steps.keys ?? {})
372
494
  const added: string[] = []
373
495
  const hookEvents: ModEvent[] = []
374
496
  const announce = (feature: string) => {
375
497
  added.push(feature)
376
498
  }
377
- const [session, root, startCwd] = await Promise.all([claude.session.id(), claude.session.root(), claude.session.cwd()])
499
+ const [session, root, startCwd, home, configHome] = await Promise.all([claude.session.id(), claude.session.root(), claude.session.cwd(), claude.env.home(), claude.env.configHome()])
500
+ const { granted } = runtime
501
+ const configRoot = configHome ?? (home === undefined ? undefined : `${home}/.claude`)
502
+ const grants: Grants = {
503
+ name: definition.name,
504
+ declared: runtime.steps.permissions ?? [],
505
+ granted: () => granted,
506
+ refresh: runtime.refreshGrants,
507
+ home,
508
+ configRoot,
509
+ projectRoot: () => modState.root,
510
+ freeFolders: () => [modState.root, runtime.dataFolder],
511
+ }
512
+ const checked = checkingGrants(claude, grants)
513
+ const files = createModFiles({
514
+ name: definition.name,
515
+ claude,
516
+ places: { home, configRoot, projectRoot: () => modState.root },
517
+ checkWrite: (path) => checkWrite(grants, `metadata.update(${path})`, path),
518
+ })
519
+ const pages = new Map<string, { readonly title: string; readonly handle: PaneHandle }>()
520
+ const settingsPages: Mod<State>['settings'] = {
521
+ page(page) {
522
+ if (pages.has(page.id)) throw new Error(`${definition.name}: the settings page "${page.id}" is already added. Give each page its own id.`)
523
+ pages.set(page.id, { title: page.title, handle: area.ui.pane(page) })
524
+ },
525
+ async open(pageId) {
526
+ if (pageId === undefined) {
527
+ await claude.cmod.call({ to: cmodPluginName, method: 'openSettings', input: { mod: definition.name } })
528
+ return
529
+ }
530
+ const page = pages.get(pageId)
531
+ if (page === undefined) throw new Error(`${definition.name} has no settings page "${pageId}". It has: ${[...pages.keys()].join(', ') || 'none'}.`)
532
+ await page.handle.open({ focus: true })
533
+ },
534
+ }
378
535
  let cwd = startCwd
379
536
  let loadedCwd = startCwd
380
537
  let staleCwd: string | undefined
381
538
  const area = createUi<State>({ name: definition.name, claude, router, progress, announce, mod: () => mod })
382
539
  const modState = createState<State>({ name: definition.name, initial: definition.state ?? {}, session, root, claude, changed: area.changed })
540
+ const modOptions = createOptions({ name: definition.name, declared: definition.options ?? {}, fromClaude: runtime.options, claude, changed: modState.changed })
383
541
  const held: HeldDecisions | undefined = runtime.checksPermissions() ? new Map() : undefined
384
- const mod: Mod<State> = {
542
+ const mod: Mod<State, Declared> = {
385
543
  name: definition.name,
386
544
  state: modState.state,
545
+ get options() {
546
+ return modOptions.values as OptionValues<Declared>
547
+ },
387
548
  dataFolder: runtime.dataFolder,
388
549
  on(event, hook, options) {
389
550
  const bounded = options?.timeoutMs === undefined ? hook : timedHook(definition.name, event, hook, checkedMs(definition.name, `the ${event} hook's timeoutMs`, options.timeoutMs), claude)
@@ -391,7 +552,7 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
391
552
  if (event === 'PreToolUse') router.add('tool.call', preToolUseHook(definition.name, bounded as ModHook<'PreToolUse'>, claude, agents, held))
392
553
  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
554
  },
394
- use: (job) => job({ mod, claude, on: (event, hook) => router.add(event, hook), announce, reserveName, toolCalls: agents }),
555
+ use: (job) => job({ mod, announce, reserveName, toolCalls: agents }),
395
556
  every(ms, hook) {
396
557
  let isRunning = false
397
558
  return claude.clock.every(checkedMs(definition.name, 'mod.every', ms), () => {
@@ -407,31 +568,40 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
407
568
  },
408
569
  ui: area.ui,
409
570
  process: {
410
- run: (argv, init) => claude.process.run(argv, init),
411
- spawn: (argv, init) => claude.process.spawn({ ...init, argv }),
571
+ run: (argv, init) => checked.process.run(argv, init),
572
+ spawn: (argv, init) => checked.process.spawn({ ...init, argv }),
412
573
  },
413
574
  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),
575
+ read: (path) => checked.fs.read(path),
576
+ write: (path, text) => checked.fs.write(path, text),
577
+ list: (path) => checked.fs.list(path),
578
+ exists: (path) => checked.fs.exists(path),
579
+ stat: (path, options) => checked.fs.stat(path, options),
580
+ find: (glob) => files.find(glob),
581
+ },
582
+ metadata: {
583
+ read: (path) => files.read(path),
584
+ update: (path, change) => files.update(path, change),
419
585
  },
420
- http: { fetch: (url, init) => claude.http.fetch(url, init) },
421
- settings: { read: (args) => claude.settings.read(args) },
586
+ http: { fetch: (url, init) => checked.http.fetch(url, init) },
587
+ settings: settingsPages,
422
588
  session: {
423
- messages: claude.session.messages,
589
+ messages: ((args?: { readonly agentId: string }) => (args === undefined ? checked.session.messages() : checked.session.messages({ agentId: args.agentId }))) as Mod<State>['session']['messages'],
424
590
  async append(text) {
425
- const added = await claude.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
591
+ const added = await checked.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
426
592
  if (added.deny !== undefined) throw new Error(`${definition.name}: Claude Code refused the note: ${added.deny}`)
427
593
  },
428
594
  async submit(text) {
429
- const submitted = await claude.prompt.submit({ text })
595
+ const submitted = await checked.prompt.submit({ text })
430
596
  if ('drop' in submitted && typeof submitted.drop === 'string') throw new Error(`${definition.name}: Claude Code dropped the prompt: ${submitted.drop}`)
431
597
  },
432
598
  },
433
- agent: { spawn: (args) => claude.agent.spawn(args) },
434
- model: { complete: (request, options) => claude.model.complete(request, options) },
599
+ agent: { spawn: (args) => checked.agent.spawn(args) },
600
+ model: { complete: (completion, options) => checked.model.complete(completion, options) },
601
+ permissions: {
602
+ has: (name: string, value?: string) => isCovered(granted, itemOf(name, value), home),
603
+ },
604
+ claude: { ...checked, on: (event, hook) => router.add(event, hook) },
435
605
  get projectRoot() {
436
606
  return modState.root
437
607
  },
@@ -454,6 +624,7 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
454
624
  if (cwd === nextCwd) cwd = loadedCwd
455
625
  throw error
456
626
  })
627
+ if (hasMovedRoot) await modOptions.load(nextRoot)
457
628
  loadedCwd = nextCwd
458
629
  if (!hasMovedRoot) area.changed()
459
630
  if (nextCwd === oldCwd) return
@@ -487,7 +658,15 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
487
658
  })
488
659
  router.add('skill.prompt', userSkillHook(claude))
489
660
  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)))
661
+ router.add('cmod.call', async (e, next) => {
662
+ if (e.to !== definition.name) return next(e)
663
+ if (e.method === settingsPagesMethod) return { value: [...pages].map(([id, { title }]) => ({ id, title })) }
664
+ if (e.method === openPageMethod) {
665
+ await settingsPages.open(String(e.input))
666
+ return { value: null }
667
+ }
668
+ return answerCall(definition.name, api, e)
669
+ })
491
670
  const agents = toolCalls(claude, router)
492
671
  const names = new Set<string>()
493
672
  const reserveName = (kind: string, name: string, taken: string) => {
@@ -496,6 +675,9 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
496
675
  names.add(key)
497
676
  }
498
677
  await failsAs('its state did not load', () => modState.load())
678
+ await failsAs('its options did not load', () => modOptions.load(root))
679
+ const missing = modOptions.missing
680
+ if (missing.length > 0) throw new MissingOptions(missing, missing.map((key) => definition.options?.[key]?.title ?? key))
499
681
  await failsAs('its setup function threw', () => definition.setup(mod))
500
682
  const permissionHooks = permissionEvents.filter((event) => router.has(event))
501
683
  if (permissionHooks.length > 0 && !runtime.checksPermissions()) {
@@ -0,0 +1,153 @@
1
+ import type { FileMatch, Metadata } from '../mod.js'
2
+ import { formatOf, MetadataError, readMetadata, sidecarSuffix, writeMetadata, type MetadataFormat } from '../utils/metadata.js'
3
+ import { messageOf } from '../utils/text.js'
4
+ import { picomatch, resolve } from '../vendor.js'
5
+ import type { Claude } from './claude.js'
6
+
7
+ export type Places = {
8
+ readonly home: string | undefined
9
+ readonly configRoot: string | undefined
10
+ readonly projectRoot: () => string
11
+ }
12
+
13
+ export type ModFiles = {
14
+ find(glob: string | readonly string[]): Promise<readonly FileMatch[]>
15
+ read(path: string): Promise<Metadata>
16
+ update(path: string, change: (metadata: Record<string, string>) => void): Promise<void>
17
+ }
18
+
19
+ type Read = { readonly metadata: Metadata; readonly name?: string; readonly error?: string }
20
+
21
+ type Located = { readonly realPath: string; readonly format: MetadataFormat; readonly holder: string; readonly signature: string }
22
+
23
+ const skippedFolders: readonly string[] = ['.git', 'node_modules']
24
+
25
+ const ownKey = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/
26
+
27
+ export function resolvedPath(path: string, places: Places): string {
28
+ const configured = /^~\/\.claude(?=\/|$)/.exec(path)
29
+ if (configured !== null && places.configRoot !== undefined) return resolve(`${places.configRoot}${path.slice(configured[0].length)}`)
30
+ if (path === '~' || path.startsWith('~/')) {
31
+ if (places.home === undefined) throw new Error(`${path} starts with ~, and HOME is not set. Start Claude Code with HOME set.`)
32
+ return resolve(`${places.home}${path.slice(1)}`)
33
+ }
34
+ return path.startsWith('/') ? resolve(path) : resolve(places.projectRoot(), path)
35
+ }
36
+
37
+ export function createModFiles({ name, claude, places, checkWrite }: { readonly name: string; readonly claude: Claude; readonly places: Places; readonly checkWrite: (path: string) => Promise<void> }): ModFiles {
38
+ const prefix = `${name}.`
39
+ const cache = new Map<string, { readonly signature: string; readonly read: Read }>()
40
+ const logged = new Set<string>()
41
+
42
+ const own = (all: Metadata): Metadata => Object.freeze(Object.fromEntries(Object.entries(all).flatMap(([key, value]) => (key.startsWith(prefix) ? [[key.slice(prefix.length), value]] : []))))
43
+
44
+ const locate = async (path: string): Promise<Located> => {
45
+ const stat = await claude.fs.stat(path, { resolve: true })
46
+ if (stat.kind !== 'file') throw new Error(`${path} is not a file, so it holds no metadata.`)
47
+ const realPath = stat.realPath ?? path
48
+ const extensionless = !realPath.slice(realPath.lastIndexOf('/') + 1).includes('.')
49
+ const firstLine = extensionless ? (await claude.fs.read(realPath)).split('\n', 1)[0] : undefined
50
+ const format = formatOf(realPath, firstLine)
51
+ const holder = format.kind === 'sidecar' ? `${realPath}${sidecarSuffix}` : realPath
52
+ const sidecar = format.kind === 'sidecar' ? await claude.fs.stat(holder).catch(() => undefined) : undefined
53
+ return { realPath, format, holder, signature: `${stat.mtimeMs}:${stat.size}:${sidecar?.kind === 'file' ? `${sidecar.mtimeMs}:${sidecar.size}` : ''}` }
54
+ }
55
+
56
+ const textOf = async (holder: string): Promise<string> => ((await claude.fs.stat(holder).catch(() => undefined))?.kind === 'file' ? claude.fs.read(holder) : '')
57
+
58
+ const readEntry = async (path: string): Promise<Read> => {
59
+ const located = await locate(path)
60
+ const cached = cache.get(path)
61
+ if (cached?.signature === located.signature) return cached.read
62
+ let read: Read
63
+ try {
64
+ const { metadata, name: declared } = readMetadata(await textOf(located.holder), located.format)
65
+ read = { metadata, ...(declared === undefined ? {} : { name: declared }) }
66
+ } catch (error) {
67
+ if (!(error instanceof MetadataError)) throw error
68
+ const failure = `${located.holder} ${error.message}`
69
+ read = { metadata: {}, error: failure }
70
+ if (!logged.has(failure)) claude.ui.log(`${name}: ${failure}`)
71
+ logged.add(failure)
72
+ }
73
+ cache.set(path, { signature: located.signature, read })
74
+ return read
75
+ }
76
+
77
+ const matchesOf = async (pattern: string): Promise<string[]> => {
78
+ const absolute = resolvedPath(pattern, places)
79
+ const scanned = picomatch.scan(absolute)
80
+ if (!scanned.isGlob) return (await claude.fs.stat(absolute).catch(() => undefined))?.kind === 'file' ? [absolute] : []
81
+ const isMatch = picomatch(absolute, { dot: true })
82
+ const entersSkipped = skippedFolders.filter((folder) => scanned.glob.split('/').includes(folder))
83
+ const maxDepth = scanned.glob.includes('**') ? Number.POSITIVE_INFINITY : scanned.glob.split('/').length
84
+ const found: string[] = []
85
+ const visited = new Set<string>()
86
+ const walk = async (folder: string, depth: number): Promise<void> => {
87
+ const real = (await claude.fs.stat(folder, { resolve: true }).catch(() => undefined))?.realPath ?? folder
88
+ if (visited.has(real)) return
89
+ visited.add(real)
90
+ const entries = await claude.fs.list(folder).catch(() => [])
91
+ await Promise.all(
92
+ entries.map(async (entry) => {
93
+ const path = `${folder}/${entry.name}`
94
+ const kind = entry.isLink === true ? (await claude.fs.stat(path).catch(() => undefined))?.kind : entry.kind
95
+ if (kind === 'file' && isMatch(path)) found.push(path)
96
+ if (kind !== 'dir' || depth + 1 >= maxDepth) return
97
+ if (skippedFolders.includes(entry.name) && !entersSkipped.includes(entry.name)) return
98
+ await walk(path, depth + 1)
99
+ }),
100
+ )
101
+ }
102
+ const base = scanned.base === '' ? '/' : scanned.base
103
+ if ((await claude.fs.stat(base).catch(() => undefined))?.kind === 'dir') await walk(base, 0)
104
+ return found
105
+ }
106
+
107
+ return {
108
+ async find(glob) {
109
+ const patterns = typeof glob === 'string' ? [glob] : glob
110
+ const paths = [...new Set((await Promise.all(patterns.map(matchesOf))).flat())].sort()
111
+ const kept = paths.filter((path) => !(path.endsWith(sidecarSuffix) && paths.includes(path.slice(0, -sidecarSuffix.length))))
112
+ return Promise.all(
113
+ kept.map(async (path): Promise<FileMatch> => {
114
+ const read = await readEntry(path)
115
+ return { path, ...(read.name === undefined ? {} : { name: read.name }), metadata: own(read.metadata), ...(read.error === undefined ? {} : { error: read.error }) }
116
+ }),
117
+ )
118
+ },
119
+ async read(path) {
120
+ const read = await readEntry(resolvedPath(path, places))
121
+ if (read.error !== undefined) throw new Error(`${name}: ${read.error}`)
122
+ return own(read.metadata)
123
+ },
124
+ async update(path, change) {
125
+ const given = resolvedPath(path, places)
126
+ await checkWrite(given)
127
+ const located = await locate(given)
128
+ const text = await textOf(located.holder)
129
+ let all: Metadata
130
+ try {
131
+ all = readMetadata(text, located.format).metadata
132
+ } catch (error) {
133
+ throw new Error(`${name}: ${located.holder} ${messageOf(error)}`, { cause: error })
134
+ }
135
+ const edited: Record<string, string> = { ...own(all) }
136
+ change(edited)
137
+ for (const [key, value] of Object.entries(edited)) {
138
+ if (!ownKey.test(key)) throw new Error(`${name}: the metadata key "${key}" is not a name. Use letters, digits, "_", ".", and "-".`)
139
+ if (typeof value !== 'string') throw new Error(`${name}: the metadata key "${key}" holds ${JSON.stringify(value)}. Each value is text, and a list is one space-separated text.`)
140
+ if (value.includes('\n')) throw new Error(`${name}: the metadata key "${key}" holds a line break. Each value is one line of text.`)
141
+ }
142
+ const next: Record<string, string> = {}
143
+ for (const [key, value] of Object.entries(all)) {
144
+ if (!key.startsWith(prefix)) next[key] = value
145
+ else if (Object.hasOwn(edited, key.slice(prefix.length))) next[key] = edited[key.slice(prefix.length)] as string
146
+ }
147
+ for (const [key, value] of Object.entries(edited)) next[`${prefix}${key}`] = value
148
+ if (JSON.stringify(next) === JSON.stringify(all)) return
149
+ await claude.fs.write(located.holder, writeMetadata(text, located.format, next))
150
+ cache.delete(given)
151
+ },
152
+ }
153
+ }