@cmodjs/core 0.1.11 → 0.1.13

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 (66) hide show
  1. package/docs/commands.md +21 -14
  2. package/docs/dependencies.md +8 -0
  3. package/docs/hooks.md +10 -1
  4. package/docs/index.md +7 -4
  5. package/docs/install-steps.md +22 -4
  6. package/docs/jobs.md +55 -8
  7. package/docs/mod.md +58 -3
  8. package/docs/testing.md +29 -3
  9. package/jobs/context.d.ts +2 -2
  10. package/jobs/context.d.ts.map +1 -1
  11. package/jobs/context.js +2 -2
  12. package/jobs/permissions/decide-permission.d.ts +2 -2
  13. package/jobs/permissions/decide-permission.d.ts.map +1 -1
  14. package/jobs/permissions/match-target.d.ts +3 -2
  15. package/jobs/permissions/match-target.d.ts.map +1 -1
  16. package/jobs/permissions/match-target.js +24 -15
  17. package/jobs/permissions.d.ts +1 -1
  18. package/jobs/permissions.d.ts.map +1 -1
  19. package/jobs/permissions.js +18 -6
  20. package/jobs/prompt.d.ts.map +1 -1
  21. package/jobs/prompt.js +34 -3
  22. package/jobs/schedule.d.ts +7 -0
  23. package/jobs/schedule.d.ts.map +1 -0
  24. package/jobs/schedule.js +66 -0
  25. package/mod.d.ts +11 -2
  26. package/mod.d.ts.map +1 -1
  27. package/package.json +1 -1
  28. package/records.d.ts +5 -0
  29. package/records.d.ts.map +1 -1
  30. package/records.js +30 -6
  31. package/register.d.ts.map +1 -1
  32. package/register.js +4 -1
  33. package/runtime/claude.d.ts +8 -0
  34. package/runtime/claude.d.ts.map +1 -1
  35. package/runtime/hooks.js +4 -4
  36. package/runtime/lifecycle.d.ts +1 -0
  37. package/runtime/lifecycle.d.ts.map +1 -1
  38. package/runtime/lifecycle.js +92 -16
  39. package/runtime/state.d.ts +2 -1
  40. package/runtime/state.d.ts.map +1 -1
  41. package/runtime/state.js +12 -1
  42. package/src/jobs/check.ts +2 -2
  43. package/src/jobs/context.ts +5 -5
  44. package/src/jobs/permissions/decide-permission.ts +4 -4
  45. package/src/jobs/permissions/match-target.ts +24 -16
  46. package/src/jobs/permissions.ts +18 -7
  47. package/src/jobs/prompt.ts +31 -3
  48. package/src/jobs/schedule.ts +65 -0
  49. package/src/mod.ts +15 -1
  50. package/src/records.ts +32 -8
  51. package/src/register.ts +5 -2
  52. package/src/runtime/claude.ts +4 -1
  53. package/src/runtime/hooks.ts +5 -5
  54. package/src/runtime/lifecycle.ts +101 -15
  55. package/src/runtime/state.ts +13 -1
  56. package/src/testing/fake-claude.ts +40 -3
  57. package/src/testing.ts +1 -0
  58. package/src/utils/call-effects.ts +13 -11
  59. package/testing/fake-claude.d.ts +18 -0
  60. package/testing/fake-claude.d.ts.map +1 -1
  61. package/testing/fake-claude.js +32 -2
  62. package/testing.d.ts.map +1 -1
  63. package/testing.js +1 -0
  64. package/utils/call-effects.d.ts +4 -8
  65. package/utils/call-effects.d.ts.map +1 -1
  66. package/utils/call-effects.js +6 -3
package/src/mod.ts CHANGED
@@ -10,6 +10,9 @@ import type {
10
10
  HookStream,
11
11
  HttpInit,
12
12
  HttpResponse,
13
+ ModelCompleteOptions,
14
+ ModelCompleteRequest,
15
+ ModelCompleteResult,
13
16
  PaneOpenArgs,
14
17
  PermissionRequestDecision,
15
18
  ProcessRunInit,
@@ -20,6 +23,7 @@ import type {
20
23
  RenderElement,
21
24
  Settings,
22
25
  SettingsReadArgs,
26
+ Timer,
23
27
  ToastOptions,
24
28
  UiScrollArgs,
25
29
  UiScrollResult,
@@ -117,12 +121,17 @@ export type HookInput<E extends ModEvent> = HookInputs[E]
117
121
 
118
122
  export type ModHook<E extends ModEvent> = (input: HookInput<E>) => HookAnswer | void | Promise<HookAnswer | void>
119
123
 
124
+ export type HookOptions = {
125
+ readonly timeoutMs?: number
126
+ }
127
+
120
128
  export type Mod<State extends object = Record<never, never>> = {
121
129
  readonly name: string
122
130
  readonly state: Readonly<State>
123
131
  readonly dataFolder: string
124
- on<E extends ModEvent>(event: E, hook: ModHook<E>): void
132
+ on<E extends ModEvent>(event: E, hook: ModHook<E>, options?: HookOptions): void
125
133
  use<Handle>(job: Job<Handle, State>): Handle
134
+ every(ms: number, hook: () => unknown): Timer
126
135
  readonly ui: {
127
136
  pane(pane: Pane<State>): PaneHandle
128
137
  render<S extends Slot>(slot: S, Component: (props: SlotProps<S>) => RenderElement): void
@@ -150,10 +159,15 @@ export type Mod<State extends object = Record<never, never>> = {
150
159
  }
151
160
  readonly session: {
152
161
  messages: Claude['session']['messages']
162
+ append(text: string): Promise<void>
163
+ submit(text: string): Promise<void>
153
164
  }
154
165
  readonly agent: {
155
166
  spawn(args: AgentSpawnArgs): Promise<AgentSpawnResult>
156
167
  }
168
+ readonly model: {
169
+ complete(request: ModelCompleteRequest, options?: ModelCompleteOptions): Promise<ModelCompleteResult>
170
+ }
157
171
  readonly projectRoot: string
158
172
  readonly cwd: string
159
173
  readonly dependencies: CmodDependencies
package/src/records.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { messageOf } from './utils/text.js'
1
+ import { listed, messageOf } from './utils/text.js'
2
2
 
3
3
  export type InstallRecord = {
4
4
  name: string
@@ -8,16 +8,17 @@ export type InstallRecord = {
8
8
  scriptsSha256: string
9
9
  uninstall: string | null
10
10
  program: string | null
11
+ keys: Readonly<Record<string, string>>
11
12
  }
12
13
 
13
- export type Steps = { install?: string; uninstall?: string; program?: string }
14
+ export type Steps = { install?: string; uninstall?: string; program?: string; keys?: Readonly<Record<string, string>> }
14
15
 
15
16
  export type ReadFile = (path: string) => Promise<string | undefined>
16
17
 
17
18
  export type RunnerEvent =
18
19
  | { kind: 'progress'; done: number; total: number; label: string }
19
20
  | { kind: 'log'; text: string }
20
- | { kind: 'needs-consent'; sha256: string; install: string; uninstall: string }
21
+ | { kind: 'needs-consent'; sha256: string; install: string; uninstall: string; keys: string }
21
22
  | { kind: 'done'; name: string; version?: string }
22
23
  | { kind: 'missing'; name: string }
23
24
  | { kind: 'failed'; code: number; message: string }
@@ -89,6 +90,8 @@ function parseRecord(text: string, path: string): InstallRecord {
89
90
  if (uninstall !== null && typeof uninstall !== 'string') throw new Error(`${path} has an "uninstall" that is neither a path nor null. ${fix}`)
90
91
  const program = value['program']
91
92
  if (program !== null && typeof program !== 'string') throw new Error(`${path} has a "program" that is neither a command name nor null. ${fix}`)
93
+ const keys = value['keys'] ?? {}
94
+ if (!isObject(keys) || Object.values(keys).some((command) => typeof command !== 'string')) throw new Error(`${path} has "keys" that are not key bindings. ${fix}`)
92
95
  return {
93
96
  name: value['name'] as string,
94
97
  version: value['version'] as string,
@@ -97,6 +100,7 @@ function parseRecord(text: string, path: string): InstallRecord {
97
100
  scriptsSha256: value['scriptsSha256'] as string,
98
101
  uninstall,
99
102
  program,
103
+ keys: keys as Record<string, string>,
100
104
  }
101
105
  }
102
106
 
@@ -108,7 +112,27 @@ export function readSteps(pkg: unknown): Steps | undefined {
108
112
  if (!isObject(steps)) throw new Error('package.json has a "cmod" key that is not an object. Write "cmod": { "install": "./setup/install.sh", "uninstall": "./setup/uninstall.sh" }.')
109
113
  const program = steps['program']
110
114
  if (program !== undefined && (typeof program !== 'string' || !pluginName.test(program))) throw new Error('package.json "cmod.program" must be the program\'s command, such as "hello".')
111
- return { ...readCommand(steps, 'install'), ...readCommand(steps, 'uninstall'), ...(program === undefined ? {} : { program }) }
115
+ return { ...readCommand(steps, 'install'), ...readCommand(steps, 'uninstall'), ...(program === undefined ? {} : { program }), ...readKeys(steps['keys']) }
116
+ }
117
+
118
+ function readKeys(keys: unknown): Steps {
119
+ if (keys === undefined) return {}
120
+ if (!isObject(keys) || Object.keys(keys).length === 0) throw new Error('package.json "cmod.keys" must bind each key to a command, such as "keys": { "shift+tab": "/mode" }.')
121
+ const commands: Record<string, string> = {}
122
+ for (const [key, value] of Object.entries(keys)) {
123
+ const command = typeof value === 'string' ? /^\/([A-Za-z0-9][A-Za-z0-9._:-]*)$/.exec(value)?.[1] : undefined
124
+ if (key.trim() === '' || command === undefined) throw new Error(`package.json "cmod.keys" binds "${key}" to ${JSON.stringify(value)}. Bind each key to one of the mod's commands, such as "shift+tab": "/mode".`)
125
+ commands[key] = command
126
+ }
127
+ return { keys: commands }
128
+ }
129
+
130
+ export function oldestCmodFor(keys: Readonly<Record<string, string>>): string {
131
+ return Object.keys(keys).length > 0 ? '0.1.12' : '0.0.0'
132
+ }
133
+
134
+ export function keyWords(keys: Readonly<Record<string, string>> | undefined): string {
135
+ return listed(Object.entries(keys ?? {}).map(([key, command]) => `${key} to /${command}`))
112
136
  }
113
137
 
114
138
  function readCommand(steps: Record<string, unknown>, key: 'install' | 'uninstall'): Steps {
@@ -145,7 +169,7 @@ export async function scriptsSha256(steps: Steps, files: { read: ReadFile; list:
145
169
  if (scriptFolders.length === 0) throw new Error(`package.json "cmod.${key}" runs "${command}", which names no script file in the mod, so consent cannot cover what it runs. Put the commands in a script, such as ./setup/${key}.sh.`)
146
170
  for (const folder of scriptFolders) folders.add(folder)
147
171
  }
148
- let text = [steps.install ?? '', steps.uninstall ?? '', steps.program ?? ''].join('\0')
172
+ let text = [steps.install ?? '', steps.uninstall ?? '', steps.program ?? '', ...(steps.keys === undefined ? [] : [JSON.stringify(Object.entries(steps.keys).sort())])].join('\0')
149
173
  for (const folder of [...folders].sort()) {
150
174
  for (const name of (await files.list(folder)).sort()) {
151
175
  const path = `${folder}/${name}`
@@ -165,7 +189,7 @@ export function formatEvent(event: RunnerEvent): string {
165
189
  case 'log':
166
190
  return `log ${event.text}`
167
191
  case 'needs-consent':
168
- return `needs-consent ${event.sha256}\t${event.install}\t${event.uninstall}`
192
+ return `needs-consent ${event.sha256}\t${event.install}\t${event.uninstall}\t${event.keys}`
169
193
  case 'done':
170
194
  return event.version === undefined ? `done ${event.name}` : `done ${event.name} ${event.version}`
171
195
  case 'missing':
@@ -178,8 +202,8 @@ export function formatEvent(event: RunnerEvent): string {
178
202
  export function parseEvent(line: string): RunnerEvent {
179
203
  const progress = /^progress (\d+) (\d+)(?: (.*))?$/.exec(line)
180
204
  if (progress !== null) return { kind: 'progress', done: Number(progress[1]), total: Number(progress[2]), label: progress[3] ?? '' }
181
- const consent = /^needs-consent (\S+)\t([^\t]*)\t(.*)$/.exec(line)
182
- if (consent !== null) return { kind: 'needs-consent', sha256: consent[1] as string, install: consent[2] as string, uninstall: consent[3] as string }
205
+ const consent = /^needs-consent (\S+)\t([^\t]*)\t([^\t]*)(?:\t(.*))?$/.exec(line)
206
+ if (consent !== null) return { kind: 'needs-consent', sha256: consent[1] as string, install: consent[2] as string, uninstall: consent[3] as string, keys: consent[4] ?? '' }
183
207
  const done = /^done (\S+)(?: (\S+))?$/.exec(line)
184
208
  if (done !== null) return { kind: 'done', name: done[1] as string, ...(done[2] === undefined ? {} : { version: done[2] }) }
185
209
  const missing = /^missing (\S+)$/.exec(line)
package/src/register.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Args, EngineInterface, EventResult, Frozen, Next, On } from 'claude-code'
1
+ import type { Args, EngineInterface, EventResult, Frozen, Next, On, ToolCallArgs } from 'claude-code'
2
2
  import type { ModDefinition } from './mod.js'
3
3
  import type { Claude } from './runtime/claude.js'
4
4
  import type { RoutedEvent } from './runtime/hooks.js'
@@ -56,9 +56,12 @@ async function startMod($: EngineInterface, eventInput: Frozen<Args<'session.sta
56
56
  usage: () => $.session.usage(),
57
57
  surfaces: () => $.session.surfaces(),
58
58
  messages: ((args?: Parameters<Claude['session']['messages']>[0]) => (args === undefined ? $.session.messages() : $.session.messages(args))) as Claude['session']['messages'],
59
+ append: (args) => $.session.append(args),
59
60
  },
61
+ prompt: { submit: (args) => $.prompt.submit(args) },
62
+ model: { complete: (request, options) => $.model.complete(request, options) },
60
63
  command: { register: (command) => $.command.register(command) },
61
- tool: { register: (tool) => $.tool.register(tool) },
64
+ tool: { register: (tool) => $.tool.register(tool), call: ((input: ToolCallArgs) => $.tool.call(input)) as Claude['tool']['call'] },
62
65
  agent: {
63
66
  list: () => $.agent.list(),
64
67
  spawn: (args) => $.agent.spawn(args),
@@ -83,9 +83,12 @@ export type Claude = {
83
83
  usage(): Promise<SessionUsage>
84
84
  surfaces(): Promise<readonly RenderSurface[]>
85
85
  messages: EngineInterface['session']['messages']
86
+ append: EngineInterface['session']['append']
86
87
  }
88
+ readonly prompt: { submit: EngineInterface['prompt']['submit'] }
89
+ readonly model: { complete: EngineInterface['model']['complete'] }
87
90
  readonly command: { register(command: CommandSpec): Promise<{ command: string }> }
88
- readonly tool: { register(tool: ToolSpec): Promise<{ tool: string }> }
91
+ readonly tool: { register(tool: ToolSpec): Promise<{ tool: string }>; call: EngineInterface['tool']['call'] }
89
92
  readonly agent: {
90
93
  list(): Promise<AgentInfo[]>
91
94
  spawn(args: AgentSpawnArgs): Promise<AgentSpawnResult>
@@ -4,7 +4,7 @@ import { configFolders } from '../records.js'
4
4
  import type { Claude } from './claude.js'
5
5
  import type { RoutedHook } from './router.js'
6
6
  import { reservedKeys, toolInputOf, type ToolCalls } from './tool-calls.js'
7
- import { callEffects, type FileAccess, type ToolUse } from '../utils/call-effects.js'
7
+ import { callEffects, inputOf, type CallBase, type FileAccess } from '../utils/call-effects.js'
8
8
  import { dynamicPattern } from '../utils/parse-shell.js'
9
9
  import { messageOf } from '../utils/text.js'
10
10
 
@@ -53,14 +53,14 @@ const readFields: Record<ModEvent, readonly string[]> = {
53
53
  }
54
54
 
55
55
  export function classicHook<E extends Exclude<ModEvent, 'PreToolUse'>>(name: string, event: E, hook: ModHook<E>, claude: Claude, calls: ToolCalls): RoutedHook<RoutedEvent> {
56
- const inputOf = async (e: unknown): Promise<unknown> => {
56
+ const hookInputOf = async (e: unknown): Promise<unknown> => {
57
57
  if (event !== 'PostToolUse' && event !== 'PostToolUseFailure') return e
58
58
  const input = e as ClassicHookInputs['PostToolUse' | 'PostToolUseFailure']
59
59
  const cwd = calls.cwdOf(input.tool_use_id) ?? input.cwd
60
- return { ...input, files: await callFiles(name, claude, { tool: input.tool_name, input: input.tool_input }, cwd) }
60
+ return { ...input, files: await callFiles(name, claude, { tool: input.tool_name, input: inputOf(input.tool_input) }, cwd) }
61
61
  }
62
62
  const resultOf = async (e: unknown): Promise<ClassicFields | undefined> => {
63
- const answer = await hook((await inputOf(e)) as HookInput<E>)
63
+ const answer = await hook((await hookInputOf(e)) as HookInput<E>)
64
64
  if (answer === undefined) return undefined
65
65
  if (answer.systemMessage !== undefined) claude.ui.log(answer.systemMessage)
66
66
  return classicResult(event, answer)
@@ -165,7 +165,7 @@ async function preToolUseInput(name: string, envelope: Frozen<Args<'tool.call'>>
165
165
  }
166
166
  }
167
167
 
168
- async function callFiles(name: string, claude: Claude, use: ToolUse, cwd: string): Promise<CallFiles> {
168
+ async function callFiles(name: string, claude: Claude, use: CallBase, cwd: string): Promise<CallFiles> {
169
169
  try {
170
170
  const home = await claude.env.home()
171
171
  if (home === undefined) throw new Error('HOME is not set, so ~ in a path has no meaning.')
@@ -1,6 +1,6 @@
1
1
  import type { Args, EventResult, Frozen, HookBudget, HookStream, ProcessSpawnChunk, ProcessSpawnResult } from 'claude-code'
2
2
  import type { HookInput, Mod, ModDefinition, ModEvent, ModHook } from '../mod.js'
3
- import { dataFolder, isAtLeast, parseEvent, readRecord, readSteps, scriptsSha256, storeFolder, type ReadFile, type RunnerEvent } from '../records.js'
3
+ import { dataFolder, isAtLeast, oldestCmodFor, parseEvent, readRecord, readSteps, scriptsSha256, storeFolder, type ReadFile, type RunnerEvent } from '../records.js'
4
4
  import { relativePath } from '../utils/paths.js'
5
5
  import { formatExit, listed, messageOf } from '../utils/text.js'
6
6
  import type { Claude } from './claude.js'
@@ -19,6 +19,7 @@ export type Plugin = {
19
19
  readonly store: string
20
20
  readonly isInstalled: boolean
21
21
  readonly shouldRecord: boolean
22
+ readonly keys: Readonly<Record<string, string>>
22
23
  }
23
24
 
24
25
  export type Phase = 'starting' | 'installing' | 'waiting' | 'declined' | 'failed' | 'ready' | 'active'
@@ -36,6 +37,7 @@ type ModRuntime = {
36
37
  readonly router: Router
37
38
  readonly progress: Progress
38
39
  readonly dataFolder: string
40
+ readonly keys: Readonly<Record<string, string>>
39
41
  readonly checksPermissions: () => boolean
40
42
  }
41
43
 
@@ -74,12 +76,12 @@ export async function readPlugin(claude: Claude): Promise<Plugin> {
74
76
  const manifest = (await readJson(read, `${root}/.claude-plugin/plugin.json`)) as { version?: unknown } | undefined
75
77
  const version = typeof manifest?.version === 'string' ? manifest.version : undefined
76
78
  const store = storeFolder({ HOME: await claude.env.home(), XDG_DATA_HOME: await claude.env.dataHome() })
77
- const plugin = { name, root, version, store }
78
79
  if (name === cmodPluginName) {
79
80
  const installed = await cmodVersion(claude)
80
- return { ...plugin, isInstalled: version !== undefined && installed !== undefined && isAtLeast(installed, version), shouldRecord: false }
81
+ return { name, root, version, store, keys: {}, isInstalled: version !== undefined && installed !== undefined && isAtLeast(installed, version), shouldRecord: false }
81
82
  }
82
83
  const steps = readSteps(await readJson(read, `${root}/package.json`)) ?? {}
84
+ const plugin = { name, root, version, store, keys: steps.keys ?? {} }
83
85
  const record = await readRecord(read, store, name)
84
86
  if (Object.keys(steps).length === 0) return { ...plugin, isInstalled: true, shouldRecord: record?.version !== version }
85
87
  const scripts = await scriptsSha256(steps, {
@@ -181,7 +183,7 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
181
183
  const activate = async () => {
182
184
  if (runtime === undefined || plugin === undefined) return
183
185
  const activeRouter = createRouter()
184
- const active = await createMod(definition, { ...runtime, router: activeRouter, dataFolder: dataFolder(plugin.store, plugin.name), checksPermissions }).catch((error: unknown) => {
186
+ const active = await createMod(definition, { ...runtime, router: activeRouter, dataFolder: dataFolder(plugin.store, plugin.name), keys: plugin.keys, checksPermissions }).catch((error: unknown) => {
185
187
  fail(messageOf(error), 'Fix it, then run /reload-plugins.')
186
188
  throw error
187
189
  })
@@ -215,8 +217,12 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
215
217
  return
216
218
  }
217
219
  showLine().wait('Waiting for your answer')
218
- const removal = event.uninstall === '' ? '' : `, and ${event.uninstall} when you remove it`
219
- const answer = await claude().ui.ask(`${name} runs ${event.install} to install${removal}. Run it now?`, { options: ['Install', 'Not now'], header: 'Install' })
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' })
220
226
  if (answer === 'Install') return install(event.sha256)
221
227
  phase = 'declined'
222
228
  endLine()
@@ -227,7 +233,8 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
227
233
  if (plugin === undefined) return
228
234
  if (plugin.name === cmodPluginName) return bootstrap(plugin.root)
229
235
  phase = 'installing'
230
- if ((await cmodVersion(claude())) === undefined) return waitForcmod()
236
+ const found = await cmodVersion(claude())
237
+ if (!fits(found)) return waitForcmod(found)
231
238
  endLine()
232
239
  const progress = showLine()
233
240
  let outcome: RunnerEvent | undefined
@@ -243,9 +250,12 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
243
250
  fail(reason, `Fix the cause, then run: cmod install ${definition.name}`)
244
251
  }
245
252
 
246
- const waitForcmod = () => {
253
+ const fits = (found: string | undefined): found is string => found !== undefined && isAtLeast(found, oldestCmodFor(plugin?.keys ?? {}))
254
+
255
+ const waitForcmod = (found: string | undefined) => {
247
256
  phase = 'waiting'
248
257
  showLine().wait('Waiting for cmod')
258
+ let latest = found
249
259
  let check: Promise<void> | undefined
250
260
  const stop = () => {
251
261
  timer.cancel()
@@ -255,7 +265,8 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
255
265
  check ??= cmodVersion(claude())
256
266
  .then((version) => {
257
267
  check = undefined
258
- if (version === undefined) return
268
+ latest = version
269
+ if (!fits(version)) return
259
270
  stop()
260
271
  return install()
261
272
  })
@@ -264,7 +275,13 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
264
275
  report(error)
265
276
  })
266
277
  })
267
- const longWait = claude().clock.after(cmodWaitMs, () => showLine().wait("Still waiting for cmod to download cmod. See cmod's own line."))
278
+ const longWait = claude().clock.after(cmodWaitMs, () =>
279
+ showLine().wait(
280
+ latest === undefined
281
+ ? "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.`,
283
+ ),
284
+ )
268
285
  }
269
286
 
270
287
  const bootstrap = async (root: string) => {
@@ -350,7 +367,8 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
350
367
  }
351
368
 
352
369
  async function createMod<State extends object>(definition: ModDefinition<State>, runtime: ModRuntime): Promise<ActiveMod<State>> {
353
- const { claude, router, progress } = runtime
370
+ const { router, progress } = runtime
371
+ const claude = checkingKeys(runtime.claude, definition.name, runtime.keys)
354
372
  const added: string[] = []
355
373
  const hookEvents: ModEvent[] = []
356
374
  const announce = (feature: string) => {
@@ -367,12 +385,26 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
367
385
  name: definition.name,
368
386
  state: modState.state,
369
387
  dataFolder: runtime.dataFolder,
370
- on(event, hook) {
388
+ on(event, hook, options) {
389
+ const bounded = options?.timeoutMs === undefined ? hook : timedHook(definition.name, event, hook, checkedMs(definition.name, `the ${event} hook's timeoutMs`, options.timeoutMs), claude)
371
390
  if (!hookEvents.includes(event)) hookEvents.push(event)
372
- if (event === 'PreToolUse') router.add('tool.call', preToolUseHook(definition.name, hook as ModHook<'PreToolUse'>, claude, agents, held))
373
- else router.add(`classic.${event as Exclude<ModEvent, 'PreToolUse'>}`, classicHook(definition.name, event as Exclude<ModEvent, 'PreToolUse'>, hook as ModHook<Exclude<ModEvent, 'PreToolUse'>>, claude, agents))
391
+ if (event === 'PreToolUse') router.add('tool.call', preToolUseHook(definition.name, bounded as ModHook<'PreToolUse'>, claude, agents, held))
392
+ 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))
374
393
  },
375
394
  use: (job) => job({ mod, claude, on: (event, hook) => router.add(event, hook), announce, reserveName, toolCalls: agents }),
395
+ every(ms, hook) {
396
+ let isRunning = false
397
+ return claude.clock.every(checkedMs(definition.name, 'mod.every', ms), () => {
398
+ if (isRunning) return
399
+ isRunning = true
400
+ void Promise.resolve()
401
+ .then(hook)
402
+ .catch((error: unknown) => claude.ui.log(`${definition.name}: the every ${ms} ms hook failed: ${messageOf(error)}`))
403
+ .finally(() => {
404
+ isRunning = false
405
+ })
406
+ })
407
+ },
376
408
  ui: area.ui,
377
409
  process: {
378
410
  run: (argv, init) => claude.process.run(argv, init),
@@ -387,8 +419,19 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
387
419
  },
388
420
  http: { fetch: (url, init) => claude.http.fetch(url, init) },
389
421
  settings: { read: (args) => claude.settings.read(args) },
390
- session: { messages: claude.session.messages },
422
+ session: {
423
+ messages: claude.session.messages,
424
+ async append(text) {
425
+ const added = await claude.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
426
+ if (added.deny !== undefined) throw new Error(`${definition.name}: Claude Code refused the note: ${added.deny}`)
427
+ },
428
+ async submit(text) {
429
+ const submitted = await claude.prompt.submit({ text })
430
+ if ('drop' in submitted && typeof submitted.drop === 'string') throw new Error(`${definition.name}: Claude Code dropped the prompt: ${submitted.drop}`)
431
+ },
432
+ },
391
433
  agent: { spawn: (args) => claude.agent.spawn(args) },
434
+ model: { complete: (request, options) => claude.model.complete(request, options) },
392
435
  get projectRoot() {
393
436
  return modState.root
394
437
  },
@@ -464,6 +507,49 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
464
507
  return { mod, added }
465
508
  }
466
509
 
510
+ function checkingKeys(claude: Claude, name: string, keys: Readonly<Record<string, string>>): Claude {
511
+ const bound = Object.entries(keys)
512
+ if (bound.length === 0) return claude
513
+ const register: Claude['command']['register'] = (command) => {
514
+ for (const [key, commandName] of bound) {
515
+ if (commandName === command.name && command.immediate !== true) {
516
+ claude.ui.log(`${name}: ${key} runs /${command.name}, which waits for Claude's turn to end and adds a row to the conversation. Give /${command.name} immediate: true.`)
517
+ }
518
+ }
519
+ return claude.command.register(command)
520
+ }
521
+ return { ...claude, command: { ...claude.command, register } }
522
+ }
523
+
524
+ const longestTimerMs = 2_147_483_647
525
+
526
+ function checkedMs(name: string, subject: string, ms: number): number {
527
+ if (!Number.isInteger(ms) || ms <= 0 || ms > longestTimerMs) throw new Error(`${name}: ${subject} is ${ms}. Give a whole number of milliseconds above 0 and at most ${longestTimerMs}.`)
528
+ return ms
529
+ }
530
+
531
+ function timedHook<E extends ModEvent>(name: string, event: E, hook: ModHook<E>, ms: number, claude: Claude): ModHook<E> {
532
+ return (input) =>
533
+ new Promise((resolve, reject) => {
534
+ const timer = claude.clock.after(ms, () => {
535
+ claude.ui.log(`${name}: the ${event} hook passed its ${ms / 1000} s timeout`)
536
+ resolve(undefined)
537
+ })
538
+ Promise.resolve()
539
+ .then(() => hook(input))
540
+ .then(
541
+ (answer) => {
542
+ timer.cancel()
543
+ resolve(answer)
544
+ },
545
+ (error: unknown) => {
546
+ timer.cancel()
547
+ reject(error)
548
+ },
549
+ )
550
+ })
551
+ }
552
+
467
553
  async function failsAs(subject: string, step: () => unknown): Promise<void> {
468
554
  try {
469
555
  await step()
@@ -32,7 +32,19 @@ const savedLifetimes: readonly SavedLifetime[] = ['session', 'project', 'global'
32
32
 
33
33
  const keptPerValue = 20
34
34
 
35
- export function createState<State extends object>({ name, initial, session, root, claude, changed }: StateOptions): ModState<State> {
35
+ const listeners = new WeakMap<object, Set<() => void>>()
36
+
37
+ export function onStateChange(state: object, listener: () => void): void {
38
+ const own = listeners.get(state) ?? new Set()
39
+ own.add(listener)
40
+ listeners.set(state, own)
41
+ }
42
+
43
+ export function createState<State extends object>({ name, initial, session, root, claude, changed: redraw }: StateOptions): ModState<State> {
44
+ const changed = () => {
45
+ redraw()
46
+ for (const listener of listeners.get(state) ?? []) listener()
47
+ }
36
48
  const declared = declaredGroups(name, initial)
37
49
  const current = copied(declared)
38
50
  let defaults = declared
@@ -13,20 +13,28 @@ export type Fakes = {
13
13
  http: { fetch?: Claude['http']['fetch'] }
14
14
  settings: { read?: Claude['settings']['read'] }
15
15
  ui: { ask?: Claude['ui']['ask']; open?: Claude['ui']['open']; scroll?: Claude['ui']['scroll'] }
16
- session: { messages?: Claude['session']['messages'] }
16
+ session: { messages?: Claude['session']['messages']; append?: Claude['session']['append'] }
17
+ prompt: { submit?: Claude['prompt']['submit'] }
18
+ model: { complete?: Claude['model']['complete'] }
17
19
  agent: { list?: Claude['agent']['list']; spawn?: Claude['agent']['spawn'] }
18
20
  clock: { after?: Claude['clock']['after']; every?: Claude['clock']['every'] }
19
21
  cmod: { call?: Claude['cmod']['call'] }
22
+ tool: { call?: Claude['tool']['call'] }
20
23
  }
21
24
 
25
+ export type ShownSchedule = { readonly id: string; readonly cron: string; readonly prompt: string }
26
+
22
27
  export type Shown = {
23
28
  readonly toasts: string[]
29
+ readonly notes: string[]
30
+ readonly prompts: string[]
24
31
  readonly logs: string[]
25
32
  readonly debug: string[]
26
33
  readonly statuses: (string | undefined)[]
27
34
  readonly openPanes: Set<string>
28
35
  readonly commands: string[]
29
36
  readonly tools: string[]
37
+ readonly schedules: ShownSchedule[]
30
38
  }
31
39
 
32
40
  export type FakeClaude = {
@@ -40,8 +48,8 @@ export type FakeClaude = {
40
48
 
41
49
  export function fakeClaude(plugin: { readonly name: string; readonly root: string }): FakeClaude {
42
50
  const calls: TestCall[] = []
43
- const fakes: Fakes = { process: {}, fs: {}, http: {}, settings: {}, ui: {}, session: {}, agent: {}, clock: {}, cmod: {} }
44
- const shown: Shown = { toasts: [], logs: [], debug: [], statuses: [], openPanes: new Set(), commands: [], tools: [] }
51
+ const fakes: Fakes = { process: {}, fs: {}, http: {}, settings: {}, ui: {}, session: {}, prompt: {}, model: {}, agent: {}, clock: {}, cmod: {}, tool: {} }
52
+ const shown: Shown = { toasts: [], notes: [], prompts: [], logs: [], debug: [], statuses: [], openPanes: new Set(), commands: [], tools: [], schedules: [] }
45
53
  const unplacedPanes = new Set<string>()
46
54
  let focusedPane: string | undefined
47
55
  const store = new Map<string, unknown>()
@@ -61,6 +69,31 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
61
69
  return async (...args: Args): Promise<Result> => run(...args)
62
70
  }
63
71
 
72
+ const appendNote: Claude['session']['append'] = async ({ message }) => {
73
+ shown.notes.push(message.content.map((block) => (typeof block['text'] === 'string' ? block['text'] : '')).join(''))
74
+ return { message, uuid: `note-${shown.notes.length}` } as unknown as Awaited<ReturnType<Claude['session']['append']>>
75
+ }
76
+ const submitPrompt: Claude['prompt']['submit'] = async ({ text }) => {
77
+ shown.prompts.push(text)
78
+ return { text, origin: { kind: 'plugin', name: plugin.name } } as unknown as Awaited<ReturnType<Claude['prompt']['submit']>>
79
+ }
80
+ let scheduled = 0
81
+ const scheduleTools = async (input: Record<string, unknown>): Promise<unknown> => {
82
+ if (input['tool'] === 'CronCreate') {
83
+ const created = { id: `cron-${(scheduled += 1)}`, cron: String(input['cron']), prompt: String(input['prompt']) }
84
+ shown.schedules.push(created)
85
+ return { result: { id: created.id, humanSchedule: created.cron, recurring: input['recurring'] !== false } }
86
+ }
87
+ if (input['tool'] === 'CronDelete') {
88
+ const at = shown.schedules.findIndex((created) => created.id === input['id'])
89
+ if (at === -1) return { result: { id: input['id'] }, text: `No job ${String(input['id'])}`, isError: true }
90
+ shown.schedules.splice(at, 1)
91
+ return { result: { id: input['id'] }, text: `Cancelled job ${String(input['id'])}.` }
92
+ }
93
+ if (input['tool'] === 'CronList') return { result: { jobs: shown.schedules.map(({ id, cron, prompt }) => ({ id, cron, humanSchedule: cron, prompt })) } }
94
+ throw new Error(`tool.call(${JSON.stringify(input)}) has no fake answer. Set fakes.tool.call on the tested mod.`)
95
+ }
96
+
64
97
  const claude: Claude = {
65
98
  plugin,
66
99
  ui: {
@@ -142,7 +175,10 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
142
175
  usage: async () => ({ context: { window: 200000 }, rateLimits: {}, cost: { usd: 0 } }) as unknown as Awaited<ReturnType<Claude['session']['usage']>>,
143
176
  surfaces: async () => ['terminal'],
144
177
  messages: rejected('session.messages', () => fakes.session.messages as ((args?: unknown) => Promise<unknown>) | undefined) as Claude['session']['messages'],
178
+ append: rejected('session.append', () => fakes.session.append ?? appendNote),
145
179
  },
180
+ prompt: { submit: rejected('prompt.submit', () => fakes.prompt.submit ?? submitPrompt) },
181
+ model: { complete: rejected('model.complete', () => fakes.model.complete) },
146
182
  command: {
147
183
  register: async (command) => {
148
184
  shown.commands.push(command.name)
@@ -154,6 +190,7 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
154
190
  shown.tools.push(tool.name)
155
191
  return { tool: `mcp__${plugin.name}__${tool.name}` }
156
192
  },
193
+ call: rejected('tool.call', () => fakes.tool.call ?? scheduleTools) as Claude['tool']['call'],
157
194
  },
158
195
  env: {
159
196
  home: async () => '/test/home',
package/src/testing.ts CHANGED
@@ -108,6 +108,7 @@ export function testMod<State extends object>(definition: ModDefinition<State>,
108
108
  store: storeFolder({ HOME: await claude.env.home(), XDG_DATA_HOME: await claude.env.dataHome() }),
109
109
  isInstalled: true,
110
110
  shouldRecord: false,
111
+ keys: {},
111
112
  }))
112
113
  await started
113
114
  if (lifecycle.phase !== 'active') throw lifecycle.failure ?? new Error(`${name} did not start: the lifecycle is ${lifecycle.phase}.`)
@@ -2,9 +2,7 @@ import { parseShell, type ShellCommand } from './parse-shell.js'
2
2
  import { expandHome, type FileSystem } from './paths.js'
3
3
  import { resolve } from '../vendor.js'
4
4
 
5
- export type ToolUse = { tool: string; input: unknown; agentId?: string; agentType?: string }
6
-
7
- export type CallBase = { tool: string; agentId?: string; agentType?: string }
5
+ export type CallBase = { tool: string; input: Readonly<Record<string, unknown>>; agentId?: string; agentType?: string }
8
6
  export type CommandCall = CallBase & { commands: [string, ...string[]][]; isFullyParsed: boolean }
9
7
  export type FileCall = CallBase & { path: string; content?: string; previousContent?: string }
10
8
  export type FetchCall = CallBase & { url: string }
@@ -20,7 +18,7 @@ export type CallEffects = {
20
18
  subagent: string | undefined
21
19
  }
22
20
 
23
- export function callEffects(use: ToolUse, workspace: { cwd: string; home: string; fs: FileSystem }): CallEffects {
21
+ export function callEffects(use: CallBase, workspace: { cwd: string; home: string; fs: FileSystem }): CallEffects {
24
22
  const { fs } = workspace
25
23
  const absolute = (path: string, folder = workspace.cwd) => resolve(folder, expandHome(path, workspace.home))
26
24
  const access = (path: string): FileAccess => ({ path: absolute(path), contents: undefined })
@@ -60,11 +58,15 @@ export function callEffects(use: ToolUse, workspace: { cwd: string; home: string
60
58
  return effects
61
59
  }
62
60
 
63
- export function callBaseOf({ tool, agentId, agentType }: ToolUse): CallBase {
64
- return { tool, ...(agentId === undefined ? {} : { agentId }), ...(agentType === undefined ? {} : { agentType }) }
61
+ export function inputOf(value: unknown): Readonly<Record<string, unknown>> {
62
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
63
+ }
64
+
65
+ export function callBaseOf({ tool, input, agentId, agentType }: CallBase): CallBase {
66
+ return { tool, input, ...(agentId === undefined ? {} : { agentId }), ...(agentType === undefined ? {} : { agentType }) }
65
67
  }
66
68
 
67
- async function editedContents(use: ToolUse, path: string, fs: FileSystem): Promise<Contents> {
69
+ async function editedContents(use: CallBase, path: string, fs: FileSystem): Promise<Contents> {
68
70
  const previousContent = await previousContentOf(path, fs)
69
71
  const oldString = textField(use, 'old_string')
70
72
  const newString = textField(use, 'new_string')
@@ -90,16 +92,16 @@ function once<Value>(load: () => Promise<Value>): () => Promise<Value> {
90
92
  return () => (loaded ??= load())
91
93
  }
92
94
 
93
- function fieldOf(use: ToolUse, name: string): unknown {
94
- return typeof use.input === 'object' && use.input !== null ? (use.input as Record<string, unknown>)[name] : undefined
95
+ function fieldOf(use: CallBase, name: string): unknown {
96
+ return use.input[name]
95
97
  }
96
98
 
97
- function textField(use: ToolUse, name: string): string {
99
+ function textField(use: CallBase, name: string): string {
98
100
  const value = fieldOf(use, name)
99
101
  if (typeof value !== 'string') throw new Error(`The ${use.tool} call has no text field ${name}.`)
100
102
  return value
101
103
  }
102
104
 
103
- function optionalTextField(use: ToolUse, name: string): string | undefined {
105
+ function optionalTextField(use: CallBase, name: string): string | undefined {
104
106
  return fieldOf(use, name) === undefined ? undefined : textField(use, name)
105
107
  }