@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.
- package/docs/commands.md +21 -14
- package/docs/dependencies.md +8 -0
- package/docs/hooks.md +10 -1
- package/docs/index.md +7 -4
- package/docs/install-steps.md +22 -4
- package/docs/jobs.md +55 -8
- package/docs/mod.md +58 -3
- package/docs/testing.md +29 -3
- package/jobs/context.d.ts +2 -2
- package/jobs/context.d.ts.map +1 -1
- package/jobs/context.js +2 -2
- package/jobs/permissions/decide-permission.d.ts +2 -2
- package/jobs/permissions/decide-permission.d.ts.map +1 -1
- package/jobs/permissions/match-target.d.ts +3 -2
- package/jobs/permissions/match-target.d.ts.map +1 -1
- package/jobs/permissions/match-target.js +24 -15
- package/jobs/permissions.d.ts +1 -1
- package/jobs/permissions.d.ts.map +1 -1
- package/jobs/permissions.js +18 -6
- package/jobs/prompt.d.ts.map +1 -1
- package/jobs/prompt.js +34 -3
- package/jobs/schedule.d.ts +7 -0
- package/jobs/schedule.d.ts.map +1 -0
- package/jobs/schedule.js +66 -0
- package/mod.d.ts +11 -2
- package/mod.d.ts.map +1 -1
- package/package.json +1 -1
- package/records.d.ts +5 -0
- package/records.d.ts.map +1 -1
- package/records.js +30 -6
- package/register.d.ts.map +1 -1
- package/register.js +4 -1
- package/runtime/claude.d.ts +8 -0
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/hooks.js +4 -4
- package/runtime/lifecycle.d.ts +1 -0
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +92 -16
- package/runtime/state.d.ts +2 -1
- package/runtime/state.d.ts.map +1 -1
- package/runtime/state.js +12 -1
- package/src/jobs/check.ts +2 -2
- package/src/jobs/context.ts +5 -5
- package/src/jobs/permissions/decide-permission.ts +4 -4
- package/src/jobs/permissions/match-target.ts +24 -16
- package/src/jobs/permissions.ts +18 -7
- package/src/jobs/prompt.ts +31 -3
- package/src/jobs/schedule.ts +65 -0
- package/src/mod.ts +15 -1
- package/src/records.ts +32 -8
- package/src/register.ts +5 -2
- package/src/runtime/claude.ts +4 -1
- package/src/runtime/hooks.ts +5 -5
- package/src/runtime/lifecycle.ts +101 -15
- package/src/runtime/state.ts +13 -1
- package/src/testing/fake-claude.ts +40 -3
- package/src/testing.ts +1 -0
- package/src/utils/call-effects.ts +13 -11
- package/testing/fake-claude.d.ts +18 -0
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +32 -2
- package/testing.d.ts.map +1 -1
- package/testing.js +1 -0
- package/utils/call-effects.d.ts +4 -8
- package/utils/call-effects.d.ts.map +1 -1
- 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
|
|
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(.*)
|
|
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),
|
package/src/runtime/claude.ts
CHANGED
|
@@ -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>
|
package/src/runtime/hooks.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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:
|
|
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.')
|
package/src/runtime/lifecycle.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
219
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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, () =>
|
|
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 {
|
|
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,
|
|
373
|
-
else router.add(`classic.${event as Exclude<ModEvent, 'PreToolUse'>}`, classicHook(definition.name, event as Exclude<ModEvent, 'PreToolUse'>,
|
|
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: {
|
|
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()
|
package/src/runtime/state.ts
CHANGED
|
@@ -32,7 +32,19 @@ const savedLifetimes: readonly SavedLifetime[] = ['session', 'project', 'global'
|
|
|
32
32
|
|
|
33
33
|
const keptPerValue = 20
|
|
34
34
|
|
|
35
|
-
|
|
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
|
|
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:
|
|
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
|
|
64
|
-
return
|
|
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:
|
|
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:
|
|
94
|
-
return
|
|
95
|
+
function fieldOf(use: CallBase, name: string): unknown {
|
|
96
|
+
return use.input[name]
|
|
95
97
|
}
|
|
96
98
|
|
|
97
|
-
function textField(use:
|
|
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:
|
|
105
|
+
function optionalTextField(use: CallBase, name: string): string | undefined {
|
|
104
106
|
return fieldOf(use, name) === undefined ? undefined : textField(use, name)
|
|
105
107
|
}
|