@cmodjs/core 0.1.14 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/commands.md +31 -19
- package/docs/index.md +12 -4
- package/docs/install-steps.md +30 -4
- package/docs/jobs.md +25 -9
- package/docs/mod.md +103 -37
- package/docs/options.md +93 -0
- package/docs/permissions.md +88 -0
- package/docs/state.md +4 -31
- package/docs/testing.md +13 -2
- package/installer.d.ts +10 -0
- package/installer.d.ts.map +1 -0
- package/installer.js +8 -0
- package/jobs/context.d.ts +2 -2
- package/jobs/context.d.ts.map +1 -1
- package/jobs/context.js +5 -3
- package/jobs/permissions.js +1 -1
- package/jobs/program.d.ts.map +1 -1
- package/jobs/program.js +5 -1
- package/jobs/prompt.js +5 -5
- package/jobs/schedule.d.ts.map +1 -1
- package/jobs/schedule.js +2 -1
- package/jobs/slash-command.d.ts.map +1 -1
- package/jobs/slash-command.js +3 -2
- package/jobs/status-line.d.ts.map +1 -1
- package/jobs/status-line.js +3 -2
- package/jobs/tool.d.ts.map +1 -1
- package/jobs/tool.js +3 -2
- package/mod.d.ts +48 -10
- package/mod.d.ts.map +1 -1
- package/mod.js +3 -0
- package/options.d.ts +58 -0
- package/options.d.ts.map +1 -0
- package/options.js +66 -0
- package/package.json +1 -1
- package/records.d.ts +14 -1
- package/records.d.ts.map +1 -1
- package/records.js +106 -7
- package/register.d.ts +4 -2
- package/register.d.ts.map +1 -1
- package/register.js +8 -2
- package/runtime/claude.d.ts +5 -1
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/grants.d.ts +28 -0
- package/runtime/grants.d.ts.map +1 -0
- package/runtime/grants.js +286 -0
- package/runtime/installer.d.ts +23 -0
- package/runtime/installer.d.ts.map +1 -0
- package/runtime/installer.js +113 -0
- package/runtime/lifecycle.d.ts +6 -3
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +220 -36
- package/runtime/metadata.d.ts +20 -0
- package/runtime/metadata.d.ts.map +1 -0
- package/runtime/metadata.js +143 -0
- package/runtime/options.d.ts +21 -0
- package/runtime/options.d.ts.map +1 -0
- package/runtime/options.js +100 -0
- package/runtime/state.d.ts +1 -0
- package/runtime/state.d.ts.map +1 -1
- package/runtime/state.js +5 -75
- package/src/installer.ts +17 -0
- package/src/jobs/context.ts +5 -3
- package/src/jobs/permissions.ts +1 -1
- package/src/jobs/program.ts +4 -1
- package/src/jobs/prompt.ts +5 -5
- package/src/jobs/schedule.ts +2 -1
- package/src/jobs/slash-command.ts +3 -2
- package/src/jobs/status-line.ts +3 -2
- package/src/jobs/tool.ts +3 -2
- package/src/mod.ts +46 -11
- package/src/options.ts +91 -0
- package/src/records.ts +117 -8
- package/src/register.ts +12 -3
- package/src/runtime/claude.ts +2 -0
- package/src/runtime/grants.ts +306 -0
- package/src/runtime/installer.ts +163 -0
- package/src/runtime/lifecycle.ts +220 -45
- package/src/runtime/metadata.ts +153 -0
- package/src/runtime/options.ts +102 -0
- package/src/runtime/state.ts +7 -70
- package/src/testing/fake-claude.ts +6 -1
- package/src/testing/fake-files.ts +9 -3
- package/src/testing.ts +40 -15
- package/src/utils/metadata.ts +242 -0
- package/testing/fake-claude.d.ts +4 -0
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +5 -1
- package/testing/fake-files.d.ts.map +1 -1
- package/testing/fake-files.js +9 -3
- package/testing.d.ts +6 -2
- package/testing.d.ts.map +1 -1
- package/testing.js +29 -13
- package/utils/metadata.d.ts +21 -0
- package/utils/metadata.d.ts.map +1 -0
- package/utils/metadata.js +250 -0
package/src/options.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export type OptionKind = 'text' | 'secret' | 'number' | 'toggle' | 'choice' | 'folder' | 'file' | 'list'
|
|
2
|
+
|
|
3
|
+
export type OptionValue = string | number | boolean | readonly string[]
|
|
4
|
+
|
|
5
|
+
type Described = { readonly title: string; readonly description: string }
|
|
6
|
+
|
|
7
|
+
export type Option<Value extends OptionValue = OptionValue> = Described & {
|
|
8
|
+
readonly kind: OptionKind
|
|
9
|
+
readonly default?: Value
|
|
10
|
+
readonly min?: number
|
|
11
|
+
readonly max?: number
|
|
12
|
+
readonly choices?: readonly string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type Options = Readonly<Record<string, Option>>
|
|
16
|
+
|
|
17
|
+
export type OptionValues<Declared extends Options> = { readonly [Key in keyof Declared]: Declared[Key] extends Option<infer Value> ? Value : never }
|
|
18
|
+
|
|
19
|
+
export type UserConfigField = Described & {
|
|
20
|
+
readonly type: 'string' | 'number' | 'boolean' | 'directory' | 'file'
|
|
21
|
+
readonly sensitive?: true
|
|
22
|
+
readonly multiple?: true
|
|
23
|
+
readonly default?: OptionValue
|
|
24
|
+
readonly min?: number
|
|
25
|
+
readonly max?: number
|
|
26
|
+
readonly options?: readonly string[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const option = {
|
|
30
|
+
text: (field: Described & { readonly default?: string }): Option<string> => ({ ...field, kind: 'text' }),
|
|
31
|
+
secret: (field: Described): Option<string> => ({ ...field, kind: 'secret' }),
|
|
32
|
+
number: (field: Described & { readonly default?: number; readonly min?: number; readonly max?: number }): Option<number> => ({ ...field, kind: 'number' }),
|
|
33
|
+
toggle: (field: Described & { readonly default?: boolean }): Option<boolean> => ({ ...field, kind: 'toggle', default: field.default ?? false }),
|
|
34
|
+
choice: <const Choice extends string>(choices: readonly Choice[], field: Described & { readonly default?: Choice }): Option<Choice> => ({ ...field, kind: 'choice', choices }),
|
|
35
|
+
folder: (field: Described & { readonly default?: string }): Option<string> => ({ ...field, kind: 'folder' }),
|
|
36
|
+
file: (field: Described & { readonly default?: string }): Option<string> => ({ ...field, kind: 'file' }),
|
|
37
|
+
list: (field: Described & { readonly default?: readonly string[] }): Option<readonly string[]> => ({ ...field, kind: 'list' }),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const types: Readonly<Record<OptionKind, UserConfigField['type']>> = { text: 'string', secret: 'string', number: 'number', toggle: 'boolean', choice: 'string', folder: 'directory', file: 'file', list: 'string' }
|
|
41
|
+
|
|
42
|
+
const optionKey = /^[A-Za-z_]\w*$/
|
|
43
|
+
|
|
44
|
+
export function checkOptions(name: string, options: Options): Options {
|
|
45
|
+
for (const [key, declared] of Object.entries(options)) {
|
|
46
|
+
const at = `${name}: options.${key}`
|
|
47
|
+
if (!optionKey.test(key)) throw new Error(`${at} is not an option name. Use letters, digits, and _, starting with a letter, as Claude Code passes each option on as CLAUDE_PLUGIN_OPTION_${key.toUpperCase()}.`)
|
|
48
|
+
if (declared.title.trim() === '') throw new Error(`${at} needs a title, the label /config shows.`)
|
|
49
|
+
if (declared.kind === 'choice' && (declared.choices ?? []).length === 0) throw new Error(`${at} is a choice with no choices. Give option.choice(['a', 'b'], { ... }).`)
|
|
50
|
+
if (declared.kind === 'choice' && declared.default !== undefined && !(declared.choices ?? []).includes(declared.default as string)) throw new Error(`${at} defaults to ${String(declared.default)}, which is not one of its choices: ${(declared.choices ?? []).join(', ')}.`)
|
|
51
|
+
}
|
|
52
|
+
return options
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function userConfigOf(options: Options): Record<string, UserConfigField> {
|
|
56
|
+
return Object.fromEntries(
|
|
57
|
+
Object.entries(options).map(([key, declared]) => [
|
|
58
|
+
key,
|
|
59
|
+
{
|
|
60
|
+
type: types[declared.kind],
|
|
61
|
+
title: declared.title,
|
|
62
|
+
description: declared.description,
|
|
63
|
+
...(declared.kind === 'secret' ? { sensitive: true } : {}),
|
|
64
|
+
...(declared.kind === 'list' ? { multiple: true } : {}),
|
|
65
|
+
...(declared.kind === 'choice' ? { options: declared.choices } : {}),
|
|
66
|
+
...(declared.default === undefined ? {} : { default: declared.default }),
|
|
67
|
+
...(declared.min === undefined ? {} : { min: declared.min }),
|
|
68
|
+
...(declared.max === undefined ? {} : { max: declared.max }),
|
|
69
|
+
} satisfies UserConfigField,
|
|
70
|
+
]),
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isUnset(value: unknown): boolean {
|
|
75
|
+
return value === undefined || value === '' || (Array.isArray(value) && value.length === 0)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function fitsOption(declared: Option, value: unknown): string | undefined {
|
|
79
|
+
const kind = declared.kind
|
|
80
|
+
if (kind === 'toggle') return typeof value === 'boolean' ? undefined : 'takes true or false'
|
|
81
|
+
if (kind === 'number') {
|
|
82
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return 'takes a number'
|
|
83
|
+
if (declared.min !== undefined && value < declared.min) return `takes ${declared.min} or more`
|
|
84
|
+
if (declared.max !== undefined && value > declared.max) return `takes ${declared.max} or less`
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
if (kind === 'list') return Array.isArray(value) && value.every((item) => typeof item === 'string') ? undefined : 'takes a list of text'
|
|
88
|
+
if (typeof value !== 'string') return 'takes text'
|
|
89
|
+
if (kind === 'choice' && !(declared.choices ?? []).includes(value)) return `takes one of: ${(declared.choices ?? []).join(', ')}`
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
package/src/records.ts
CHANGED
|
@@ -11,14 +11,28 @@ export type InstallRecord = {
|
|
|
11
11
|
keys: Readonly<Record<string, string>>
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
export type Steps = {
|
|
14
|
+
export type Steps = {
|
|
15
|
+
install?: string
|
|
16
|
+
uninstall?: string
|
|
17
|
+
program?: string
|
|
18
|
+
keys?: Readonly<Record<string, string>>
|
|
19
|
+
permissions?: readonly string[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const listPermissions = ['network', 'run', 'files'] as const
|
|
23
|
+
export const flagPermissions = ['conversation', 'prompt', 'model', 'agents', 'tools', 'config', 'approve'] as const
|
|
24
|
+
export type ListPermission = (typeof listPermissions)[number]
|
|
25
|
+
export type FlagPermission = (typeof flagPermissions)[number]
|
|
26
|
+
const permissionNames: readonly string[] = [...listPermissions, ...flagPermissions]
|
|
27
|
+
const hostName = /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/
|
|
28
|
+
const programName = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/
|
|
15
29
|
|
|
16
30
|
export type ReadFile = (path: string) => Promise<string | undefined>
|
|
17
31
|
|
|
18
32
|
export type RunnerEvent =
|
|
19
33
|
| { kind: 'progress'; done: number; total: number; label: string }
|
|
20
34
|
| { kind: 'log'; text: string }
|
|
21
|
-
| { kind: 'needs-consent'; sha256: string; install: string; uninstall: string; keys: string }
|
|
35
|
+
| { kind: 'needs-consent'; sha256: string; install: string; uninstall: string; keys: string; permissions: readonly string[] }
|
|
22
36
|
| { kind: 'done'; name: string; version?: string }
|
|
23
37
|
| { kind: 'missing'; name: string }
|
|
24
38
|
| { kind: 'failed'; code: number; message: string }
|
|
@@ -112,7 +126,92 @@ export function readSteps(pkg: unknown): Steps | undefined {
|
|
|
112
126
|
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" }.')
|
|
113
127
|
const program = steps['program']
|
|
114
128
|
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".')
|
|
115
|
-
|
|
129
|
+
const permissions = readPermissions(steps)
|
|
130
|
+
return {
|
|
131
|
+
...readCommand(steps, 'install'),
|
|
132
|
+
...readCommand(steps, 'uninstall'),
|
|
133
|
+
...(program === undefined ? {} : { program }),
|
|
134
|
+
...readKeys(steps['keys']),
|
|
135
|
+
...(permissions.length === 0 ? {} : { permissions }),
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function readPermissions(steps: Record<string, unknown>): string[] {
|
|
140
|
+
const declared = steps['permissions']
|
|
141
|
+
if (declared === undefined) return []
|
|
142
|
+
const at = 'package.json "cmod.permissions"'
|
|
143
|
+
if (!isObject(declared)) throw new Error(`${at} must be an object, such as { "network": ["api.github.com"], "prompt": true }.`)
|
|
144
|
+
const items: string[] = []
|
|
145
|
+
for (const [name, value] of Object.entries(declared)) {
|
|
146
|
+
if (!permissionNames.includes(name)) throw new Error(`${at} names "${name}", which is not a permission. Use ${listed(permissionNames.map((each) => `"${each}"`))}.`)
|
|
147
|
+
if ((flagPermissions as readonly string[]).includes(name)) {
|
|
148
|
+
if (value !== true) throw new Error(`${at} sets "${name}" to ${JSON.stringify(value)}. Write "${name}": true, or leave it out.`)
|
|
149
|
+
items.push(name)
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
if (name === 'run' && value === '*') {
|
|
153
|
+
items.push('run:*')
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
const example = name === 'network' ? '["api.github.com"]' : name === 'run' ? '["gh"], or "*" for any program' : '["~/.zshrc"]'
|
|
157
|
+
if (!Array.isArray(value) || value.length === 0 || !value.every((each) => typeof each === 'string')) throw new Error(`${at} sets "${name}" to ${JSON.stringify(value)}. Write a list, such as "${name}": ${example}.`)
|
|
158
|
+
for (const each of value as string[]) {
|
|
159
|
+
const isPath = /^(~\/|\/)/.test(each) && !each.split('/').includes('..')
|
|
160
|
+
const isValid = name === 'network' ? hostName.test(each) || isPath : name === 'run' ? programName.test(each) : isPath
|
|
161
|
+
if (!isValid) throw new Error(`${at} lists "${each}" under "${name}". Write ${name === 'network' ? 'a host alone, such as "api.github.com", or a socket path, such as "/var/run/docker.sock"' : name === 'run' ? 'a program\'s command, such as "gh"' : 'a path that starts with ~/ or /, such as "~/.zshrc"'}.`)
|
|
162
|
+
items.push(`${name}:${each}`)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return items
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function permissionWords(item: string): string {
|
|
169
|
+
const split = item.indexOf(':')
|
|
170
|
+
const target = item.slice(split + 1)
|
|
171
|
+
switch (split === -1 ? item : item.slice(0, split)) {
|
|
172
|
+
case 'network':
|
|
173
|
+
return `Connect to ${target}`
|
|
174
|
+
case 'run':
|
|
175
|
+
return target === '*' ? 'Run any program on your computer' : `Run ${target} on your computer`
|
|
176
|
+
case 'files':
|
|
177
|
+
return `Change ${target}`
|
|
178
|
+
case 'conversation':
|
|
179
|
+
return 'Read this conversation'
|
|
180
|
+
case 'prompt':
|
|
181
|
+
return 'Add text Claude reads and start turns'
|
|
182
|
+
case 'model':
|
|
183
|
+
return 'Ask a model, which uses your plan'
|
|
184
|
+
case 'agents':
|
|
185
|
+
return 'Start agents'
|
|
186
|
+
case 'tools':
|
|
187
|
+
return "Use and change Claude's tool calls"
|
|
188
|
+
case 'config':
|
|
189
|
+
return 'Change your Claude Code settings'
|
|
190
|
+
case 'approve':
|
|
191
|
+
return "Approve Claude's tool calls for you"
|
|
192
|
+
default:
|
|
193
|
+
return item
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export const settingsPagesMethod = 'cmod:settingsPages'
|
|
198
|
+
|
|
199
|
+
export const openPageMethod = 'cmod:openPage'
|
|
200
|
+
|
|
201
|
+
export const pendingStepsMethod = 'cmod:pendingSteps'
|
|
202
|
+
|
|
203
|
+
export const finishStepsMethod = 'cmod:finishSteps'
|
|
204
|
+
|
|
205
|
+
export function consentPath(store: string): string {
|
|
206
|
+
return `${store}/consent.json`
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function parseConsent(value: unknown, path: string): Record<string, string[]> {
|
|
210
|
+
if (value === undefined) return Object.create(null)
|
|
211
|
+
if (!isObject(value) || Object.values(value).some((items) => !Array.isArray(items) || items.some((item) => typeof item !== 'string'))) {
|
|
212
|
+
throw new Error(`${path} is not a map of plugin names to what each was approved for. Delete it, and cmod asks again.`)
|
|
213
|
+
}
|
|
214
|
+
return Object.assign(Object.create(null), value) as Record<string, string[]>
|
|
116
215
|
}
|
|
117
216
|
|
|
118
217
|
function readKeys(keys: unknown): Steps {
|
|
@@ -127,8 +226,9 @@ function readKeys(keys: unknown): Steps {
|
|
|
127
226
|
return { keys: commands }
|
|
128
227
|
}
|
|
129
228
|
|
|
130
|
-
export function oldestCmodFor(
|
|
131
|
-
|
|
229
|
+
export function oldestCmodFor(steps: Steps): string {
|
|
230
|
+
if (steps.permissions !== undefined) return '0.2.0'
|
|
231
|
+
return Object.keys(steps.keys ?? {}).length > 0 ? '0.1.12' : '0.0.0'
|
|
132
232
|
}
|
|
133
233
|
|
|
134
234
|
export function keyWords(keys: Readonly<Record<string, string>> | undefined): string {
|
|
@@ -169,7 +269,13 @@ export async function scriptsSha256(steps: Steps, files: { read: ReadFile; list:
|
|
|
169
269
|
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.`)
|
|
170
270
|
for (const folder of scriptFolders) folders.add(folder)
|
|
171
271
|
}
|
|
172
|
-
let text = [
|
|
272
|
+
let text = [
|
|
273
|
+
steps.install ?? '',
|
|
274
|
+
steps.uninstall ?? '',
|
|
275
|
+
steps.program ?? '',
|
|
276
|
+
...(steps.keys === undefined ? [] : [JSON.stringify(Object.entries(steps.keys).sort())]),
|
|
277
|
+
...(steps.permissions === undefined ? [] : [JSON.stringify([...steps.permissions].sort())]),
|
|
278
|
+
].join('\0')
|
|
173
279
|
for (const folder of [...folders].sort()) {
|
|
174
280
|
for (const name of (await files.list(folder)).sort()) {
|
|
175
281
|
const path = `${folder}/${name}`
|
|
@@ -189,7 +295,7 @@ export function formatEvent(event: RunnerEvent): string {
|
|
|
189
295
|
case 'log':
|
|
190
296
|
return `log ${event.text}`
|
|
191
297
|
case 'needs-consent':
|
|
192
|
-
return `needs-consent ${event.sha256
|
|
298
|
+
return `needs-consent ${[event.sha256, event.install, event.uninstall, event.keys, ...event.permissions].join('\t')}`
|
|
193
299
|
case 'done':
|
|
194
300
|
return event.version === undefined ? `done ${event.name}` : `done ${event.name} ${event.version}`
|
|
195
301
|
case 'missing':
|
|
@@ -203,7 +309,10 @@ export function parseEvent(line: string): RunnerEvent {
|
|
|
203
309
|
const progress = /^progress (\d+) (\d+)(?: (.*))?$/.exec(line)
|
|
204
310
|
if (progress !== null) return { kind: 'progress', done: Number(progress[1]), total: Number(progress[2]), label: progress[3] ?? '' }
|
|
205
311
|
const consent = /^needs-consent (\S+)\t([^\t]*)\t([^\t]*)(?:\t(.*))?$/.exec(line)
|
|
206
|
-
if (consent !== null)
|
|
312
|
+
if (consent !== null) {
|
|
313
|
+
const [keys = '', ...permissions] = (consent[4] ?? '').split('\t')
|
|
314
|
+
return { kind: 'needs-consent', sha256: consent[1] as string, install: consent[2] as string, uninstall: consent[3] as string, keys, permissions: permissions.filter((item) => item !== '') }
|
|
315
|
+
}
|
|
207
316
|
const done = /^done (\S+)(?: (\S+))?$/.exec(line)
|
|
208
317
|
if (done !== null) return { kind: 'done', name: done[1] as string, ...(done[2] === undefined ? {} : { version: done[2] }) }
|
|
209
318
|
const missing = /^missing (\S+)$/.exec(line)
|
package/src/register.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { Args, EngineInterface, EventResult, Frozen, Next, On, ToolCallArgs } from 'claude-code'
|
|
1
|
+
import type { Args, EngineInterface, EventResult, Frozen, Next, On, PluginOptions, ToolCallArgs } from 'claude-code'
|
|
2
2
|
import type { ModDefinition } from './mod.js'
|
|
3
|
+
import type { Options } from './options.js'
|
|
3
4
|
import type { Claude } from './runtime/claude.js'
|
|
4
5
|
import type { RoutedEvent } from './runtime/hooks.js'
|
|
5
6
|
import { createLifecycle, readPlugin, type Lifecycle } from './runtime/lifecycle.js'
|
|
@@ -8,6 +9,12 @@ let lifecycle: Lifecycle<object>
|
|
|
8
9
|
|
|
9
10
|
let checksPermissions = false
|
|
10
11
|
|
|
12
|
+
let registered: ModDefinition<object> | undefined
|
|
13
|
+
|
|
14
|
+
export function registeredMod(): ModDefinition<object> | undefined {
|
|
15
|
+
return registered
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
async function startMod($: EngineInterface, eventInput: Frozen<Args<'session.start'>>, passOn: Next<'session.start'>): Promise<EventResult<'session.start'>> {
|
|
12
19
|
const claude: Claude = {
|
|
13
20
|
plugin: { name: $.plugin.name, root: $.plugin.root },
|
|
@@ -37,6 +44,7 @@ async function startMod($: EngineInterface, eventInput: Frozen<Args<'session.sta
|
|
|
37
44
|
},
|
|
38
45
|
http: { fetch: (url, init) => $.http.fetch(url, init) },
|
|
39
46
|
settings: { read: (args) => $.settings.read(args) },
|
|
47
|
+
config: { list: () => $.config.list(), set: (args) => $.config.set(args) },
|
|
40
48
|
store: {
|
|
41
49
|
get: (key) => $.store.get(key),
|
|
42
50
|
set: (key, value) => $.store.set(key, value),
|
|
@@ -81,9 +89,10 @@ function routeToMod<N extends RoutedEvent>(_$: EngineInterface, eventInput: Froz
|
|
|
81
89
|
return lifecycle.route(passOn.event, eventInput, (passed: unknown) => passOn(passed as Args<N>) as Promise<EventResult<N>>)
|
|
82
90
|
}
|
|
83
91
|
|
|
84
|
-
export function registerMod<State extends object>(addHook: On, definition: ModDefinition<State
|
|
92
|
+
export function registerMod<State extends object, Declared extends Options>(addHook: On, definition: ModDefinition<State, string, Declared>, options: PluginOptions): void {
|
|
85
93
|
checksPermissions = false
|
|
86
|
-
|
|
94
|
+
registered = definition as ModDefinition<object>
|
|
95
|
+
lifecycle = createLifecycle(definition, () => checksPermissions, options)
|
|
87
96
|
addHook('session.start', startMod)
|
|
88
97
|
addHook('classic.SessionStart', routeToMod)
|
|
89
98
|
addHook('classic.SessionEnd', routeToMod)
|
package/src/runtime/claude.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
EngineInterface,
|
|
7
7
|
AskOptions,
|
|
8
8
|
CommandSpec,
|
|
9
|
+
ConfigRow,
|
|
9
10
|
ElementTable,
|
|
10
11
|
FsEntry,
|
|
11
12
|
FsStat,
|
|
@@ -64,6 +65,7 @@ export type Claude = {
|
|
|
64
65
|
}
|
|
65
66
|
readonly http: { fetch(url: string, init?: HttpInit): Promise<HttpResponse> }
|
|
66
67
|
readonly settings: { read(args?: SettingsReadArgs): Promise<Settings> }
|
|
68
|
+
readonly config: { list(): Promise<ConfigRow[]>; set: EngineInterface['config']['set'] }
|
|
67
69
|
readonly store: {
|
|
68
70
|
get(key: string): Promise<unknown>
|
|
69
71
|
set(key: string, value: unknown): Promise<void>
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { permissionWords } from '../records.js'
|
|
2
|
+
import { relativePath } from '../utils/paths.js'
|
|
3
|
+
import type { Claude } from './claude.js'
|
|
4
|
+
import type { RoutedEvent } from './hooks.js'
|
|
5
|
+
|
|
6
|
+
export type Grants = {
|
|
7
|
+
readonly name: string
|
|
8
|
+
readonly declared: readonly string[]
|
|
9
|
+
readonly granted: () => ReadonlySet<string>
|
|
10
|
+
readonly refresh: () => Promise<void>
|
|
11
|
+
readonly home: string | undefined
|
|
12
|
+
readonly configRoot: string | undefined
|
|
13
|
+
readonly projectRoot: () => string
|
|
14
|
+
readonly freeFolders: () => readonly string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class PermissionRefused extends Error {}
|
|
18
|
+
|
|
19
|
+
const configFiles: readonly string[] = ['.claude/settings.json', '.claude/settings.local.json', '.mcp.json']
|
|
20
|
+
|
|
21
|
+
const startsTurns: readonly string[] = ['CronCreate']
|
|
22
|
+
|
|
23
|
+
const ownSchedule: readonly string[] = ['CronDelete', 'CronList']
|
|
24
|
+
|
|
25
|
+
async function checkGrant(grants: Grants, call: string, item: string | undefined): Promise<void> {
|
|
26
|
+
if (item === undefined) return
|
|
27
|
+
await grants.refresh()
|
|
28
|
+
if (!isCovered(grants.granted(), item, grants.home)) throw new PermissionRefused(refusal(grants, call, item))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function checkWrite(grants: Grants, call: string, path: string): Promise<void> {
|
|
32
|
+
return checkGrant(grants, call, writeItem(grants, path))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function checkingGrants(claude: Claude, grants: Grants): Claude {
|
|
36
|
+
const gate = (call: string, item: string | undefined) => checkGrant(grants, call, item)
|
|
37
|
+
return {
|
|
38
|
+
...claude,
|
|
39
|
+
process: {
|
|
40
|
+
async run(argv, init) {
|
|
41
|
+
await gate(`process.run(${argv.join(' ')})`, `run:${programOf(argv)}`)
|
|
42
|
+
return claude.process.run(argv, init)
|
|
43
|
+
},
|
|
44
|
+
spawn(request) {
|
|
45
|
+
const started = gate(`process.spawn(${request.argv.join(' ')})`, `run:${programOf(request.argv)}`).then(() => claude.process.spawn(request))
|
|
46
|
+
return spawned(started)
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
fs: {
|
|
50
|
+
...claude.fs,
|
|
51
|
+
async read(path) {
|
|
52
|
+
await gate(`fs.read(${path})`, readItem(grants, path))
|
|
53
|
+
return claude.fs.read(path)
|
|
54
|
+
},
|
|
55
|
+
async write(path, text) {
|
|
56
|
+
await gate(`fs.write(${path})`, writeItem(grants, path))
|
|
57
|
+
return claude.fs.write(path, text)
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
http: {
|
|
61
|
+
async fetch(url, init) {
|
|
62
|
+
await gate(`http.fetch(${url})`, `network:${init?.socketPath ?? hostOf(url)}`)
|
|
63
|
+
return claude.http.fetch(url, init)
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
config: {
|
|
67
|
+
...claude.config,
|
|
68
|
+
set: (async (args: Parameters<Claude['config']['set']>[0]) => {
|
|
69
|
+
await gate('config.set', 'config')
|
|
70
|
+
return claude.config.set(args)
|
|
71
|
+
}) as Claude['config']['set'],
|
|
72
|
+
},
|
|
73
|
+
session: {
|
|
74
|
+
...claude.session,
|
|
75
|
+
messages: (async (...args: Parameters<Claude['session']['messages']>) => {
|
|
76
|
+
await gate('session.messages', 'conversation')
|
|
77
|
+
return claude.session.messages(...args)
|
|
78
|
+
}) as Claude['session']['messages'],
|
|
79
|
+
append: (async (args: Parameters<Claude['session']['append']>[0]) => {
|
|
80
|
+
await gate('session.append', 'prompt')
|
|
81
|
+
return claude.session.append(args)
|
|
82
|
+
}) as Claude['session']['append'],
|
|
83
|
+
},
|
|
84
|
+
prompt: {
|
|
85
|
+
submit: (async (args: Parameters<Claude['prompt']['submit']>[0]) => {
|
|
86
|
+
await gate('session.submit', 'prompt')
|
|
87
|
+
return claude.prompt.submit(args)
|
|
88
|
+
}) as Claude['prompt']['submit'],
|
|
89
|
+
},
|
|
90
|
+
model: {
|
|
91
|
+
complete: (async (...args: Parameters<Claude['model']['complete']>) => {
|
|
92
|
+
await gate('model.complete', 'model')
|
|
93
|
+
return claude.model.complete(...args)
|
|
94
|
+
}) as Claude['model']['complete'],
|
|
95
|
+
},
|
|
96
|
+
agent: {
|
|
97
|
+
...claude.agent,
|
|
98
|
+
async spawn(args) {
|
|
99
|
+
await gate('agent.spawn', 'agents')
|
|
100
|
+
return claude.agent.spawn(args)
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
tool: {
|
|
104
|
+
...claude.tool,
|
|
105
|
+
call: (async (input: Parameters<Claude['tool']['call']>[0]) => {
|
|
106
|
+
const item = startsTurns.includes(input.tool) ? 'prompt' : ownSchedule.includes(input.tool) ? undefined : 'tools'
|
|
107
|
+
await gate(`tool.call(${input.tool})`, item)
|
|
108
|
+
return claude.tool.call(input)
|
|
109
|
+
}) as Claude['tool']['call'],
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type Stream = ReturnType<Claude['process']['spawn']>
|
|
115
|
+
|
|
116
|
+
function spawned(started: Promise<Stream>): Stream {
|
|
117
|
+
const result = started.then((stream) => stream.result)
|
|
118
|
+
result.catch(() => undefined)
|
|
119
|
+
const stream = {
|
|
120
|
+
next: async () => (await started).next(),
|
|
121
|
+
async return(value: never) {
|
|
122
|
+
const running = await started.catch(() => undefined)
|
|
123
|
+
return (await running?.return?.(value)) ?? { done: true as const, value }
|
|
124
|
+
},
|
|
125
|
+
[Symbol.asyncIterator]: () => stream,
|
|
126
|
+
result,
|
|
127
|
+
}
|
|
128
|
+
return stream as unknown as Stream
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function itemOf(name: string, value?: string): string {
|
|
132
|
+
return value === undefined ? name : `${name}:${value}`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function isCovered(granted: ReadonlySet<string>, item: string, home: string | undefined): boolean {
|
|
136
|
+
if (granted.has(item)) return true
|
|
137
|
+
const split = item.indexOf(':')
|
|
138
|
+
if (split === -1) return false
|
|
139
|
+
const name = item.slice(0, split)
|
|
140
|
+
const target = expanded(item.slice(split + 1), home)
|
|
141
|
+
const values = [...granted].filter((each) => each.startsWith(`${name}:`)).map((each) => expanded(each.slice(name.length + 1), home))
|
|
142
|
+
if (name === 'run') return values.includes('*') || values.includes(target)
|
|
143
|
+
if (name === 'files') return values.some((each) => relativePath(each, target) !== undefined)
|
|
144
|
+
return values.includes(target)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function readItem(grants: Grants, path: string): string | undefined {
|
|
148
|
+
if (grants.configRoot === undefined) return undefined
|
|
149
|
+
return relativePath(`${grants.configRoot}/projects`, path) === undefined ? undefined : 'conversation'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function writeItem(grants: Grants, path: string): string | undefined {
|
|
153
|
+
const root = grants.projectRoot()
|
|
154
|
+
if (configFiles.some((file) => `${root}/${file}` === path)) return 'config'
|
|
155
|
+
if (grants.freeFolders().some((folder) => relativePath(folder, path) !== undefined)) return undefined
|
|
156
|
+
return `files:${path}`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function refusal(grants: Pick<Grants, 'name' | 'declared' | 'home'>, call: string, item: string): string {
|
|
160
|
+
const { name } = grants
|
|
161
|
+
if (isCovered(new Set(grants.declared), item, grants.home)) return `${name} calls ${call} without your grant to "${permissionWords(item)}". Turn it on in /mods ${name}.`
|
|
162
|
+
return `${name} calls ${call}, which needs ${declaration(item, grants.home)} in package.json "cmod".`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function declaration(item: string, home: string | undefined): string {
|
|
166
|
+
const split = item.indexOf(':')
|
|
167
|
+
if (split === -1) return `"permissions": { "${item}": true }`
|
|
168
|
+
const name = item.slice(0, split)
|
|
169
|
+
const target = item.slice(split + 1)
|
|
170
|
+
const fromHome = home === undefined ? undefined : relativePath(home, target)
|
|
171
|
+
const shown = name !== 'run' && fromHome !== undefined ? `~/${fromHome}` : target
|
|
172
|
+
return `"permissions": { "${name}": ["${shown}"] }`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
type Fields = Record<string, unknown>
|
|
176
|
+
|
|
177
|
+
const classicParts: Readonly<Record<string, string>> = {
|
|
178
|
+
additionalContext: 'prompt',
|
|
179
|
+
initialUserMessage: 'prompt',
|
|
180
|
+
suppressOriginalPrompt: 'prompt',
|
|
181
|
+
updatedToolOutput: 'prompt',
|
|
182
|
+
updatedMCPToolOutput: 'prompt',
|
|
183
|
+
updatedInput: 'tools',
|
|
184
|
+
retry: 'approve',
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const blocksKeepWorking: readonly RoutedEvent[] = ['classic.Stop', 'classic.PostToolUse']
|
|
188
|
+
|
|
189
|
+
const reservedToolKeys: readonly string[] = ['tool', 'tool_use_id']
|
|
190
|
+
|
|
191
|
+
export type AnswerCheck = {
|
|
192
|
+
readonly plugin: string
|
|
193
|
+
isGranted(item: string): boolean
|
|
194
|
+
dropped(item: string, part: string): void
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function checksAnswers(event: RoutedEvent): boolean {
|
|
198
|
+
return event.startsWith('classic.') || event === 'tool.check' || event === 'tool.call' || event === 'prompt.context' || event === 'prompt.submit'
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function passedDown(event: RoutedEvent, original: unknown, passed: unknown, check: AnswerCheck): unknown {
|
|
202
|
+
if (passed === original) return passed
|
|
203
|
+
if (event === 'tool.call') {
|
|
204
|
+
if (sameExcept(original, passed, reservedToolKeys) || check.isGranted('tools')) return passed
|
|
205
|
+
check.dropped('tools', 'a changed tool input')
|
|
206
|
+
return original
|
|
207
|
+
}
|
|
208
|
+
if (event === 'prompt.context' || event === 'prompt.submit') {
|
|
209
|
+
if (same(original, passed) || check.isGranted('prompt')) return passed
|
|
210
|
+
check.dropped('prompt', event === 'prompt.submit' ? 'a rewritten prompt' : 'changed context')
|
|
211
|
+
return original
|
|
212
|
+
}
|
|
213
|
+
return passed
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function checkedAnswer(event: RoutedEvent, e: unknown, answer: unknown, below: () => Promise<unknown>, check: AnswerCheck): Promise<unknown> {
|
|
217
|
+
if (!isFields(answer)) return answer
|
|
218
|
+
if (event === 'tool.check') return decided(answer['decision'], answer, below, check)
|
|
219
|
+
if (event === 'classic.PermissionRequest') {
|
|
220
|
+
const decision = isFields(answer['decision']) ? answer['decision']['behavior'] : undefined
|
|
221
|
+
return decided(decision, answer, below, check)
|
|
222
|
+
}
|
|
223
|
+
if (event.startsWith('classic.')) return classicAnswer(event, answer, below, check)
|
|
224
|
+
if (event === 'tool.call') {
|
|
225
|
+
if (answer['deny'] !== undefined || check.isGranted('prompt')) return answer
|
|
226
|
+
const tool = isFields(e) ? e['tool'] : undefined
|
|
227
|
+
if (typeof tool === 'string' && tool.startsWith(`mcp__${check.plugin}__`)) {
|
|
228
|
+
if (answer['context'] === undefined) return answer
|
|
229
|
+
check.dropped('prompt', 'context after a tool call')
|
|
230
|
+
const { context: _context, ...rest } = answer
|
|
231
|
+
return rest
|
|
232
|
+
}
|
|
233
|
+
const theirs = await below()
|
|
234
|
+
if (same(answer, theirs)) return answer
|
|
235
|
+
check.dropped('prompt', same(contextOf(answer), contextOf(theirs)) ? 'a changed tool result' : 'context after a tool call')
|
|
236
|
+
return theirs
|
|
237
|
+
}
|
|
238
|
+
if (event === 'prompt.submit' && answer['drop'] !== undefined) return answer
|
|
239
|
+
if (event === 'prompt.context' || event === 'prompt.submit') {
|
|
240
|
+
if (check.isGranted('prompt')) return answer
|
|
241
|
+
const theirs = await below()
|
|
242
|
+
if (same(answer, theirs)) return answer
|
|
243
|
+
check.dropped('prompt', event === 'prompt.submit' ? 'a rewritten prompt' : 'changed context')
|
|
244
|
+
return theirs
|
|
245
|
+
}
|
|
246
|
+
return answer
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function decided(decision: unknown, answer: Fields, below: () => Promise<unknown>, check: AnswerCheck): Promise<unknown> {
|
|
250
|
+
if ((decision !== 'allow' && decision !== 'ask') || check.isGranted('approve')) return answer
|
|
251
|
+
const theirs = await below()
|
|
252
|
+
if (same(answer, theirs)) return answer
|
|
253
|
+
check.dropped('approve', `${decision} on a tool call`)
|
|
254
|
+
return theirs
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function classicAnswer(event: RoutedEvent, answer: Fields, below: () => Promise<unknown>, check: AnswerCheck): Promise<unknown> {
|
|
258
|
+
const blocks = blocksKeepWorking.includes(event) ? ([['block', 'prompt']] as const) : []
|
|
259
|
+
const parts = [...Object.entries(classicParts), ...blocks].filter(([part, item]) => answer[part] !== undefined && !check.isGranted(item))
|
|
260
|
+
if (parts.length === 0) return answer
|
|
261
|
+
const theirs = await below()
|
|
262
|
+
const kept: Fields = { ...answer }
|
|
263
|
+
const theirFields = isFields(theirs) ? theirs : {}
|
|
264
|
+
for (const [part, item] of parts) {
|
|
265
|
+
if (same(answer[part], theirFields[part])) continue
|
|
266
|
+
check.dropped(item, part)
|
|
267
|
+
if (theirFields[part] === undefined) delete kept[part]
|
|
268
|
+
else kept[part] = theirFields[part]
|
|
269
|
+
}
|
|
270
|
+
return kept
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function contextOf(answer: unknown): unknown {
|
|
274
|
+
return isFields(answer) ? answer['context'] : undefined
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function same(left: unknown, right: unknown): boolean {
|
|
278
|
+
return left === right || JSON.stringify(left) === JSON.stringify(right)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function sameExcept(left: unknown, right: unknown, keys: readonly string[]): boolean {
|
|
282
|
+
if (!isFields(left) || !isFields(right)) return same(left, right)
|
|
283
|
+
const without = (fields: Fields) => Object.fromEntries(Object.entries(fields).filter(([key]) => !keys.includes(key)))
|
|
284
|
+
return same(without(left), without(right))
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function isFields(value: unknown): value is Fields {
|
|
288
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function programOf(argv: readonly string[]): string {
|
|
292
|
+
const command = argv[0] ?? ''
|
|
293
|
+
return command.slice(command.lastIndexOf('/') + 1)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function hostOf(url: string): string {
|
|
297
|
+
try {
|
|
298
|
+
return new URL(url).hostname
|
|
299
|
+
} catch {
|
|
300
|
+
return url
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function expanded(path: string, home: string | undefined): string {
|
|
305
|
+
return path.startsWith('~/') && home !== undefined ? `${home}${path.slice(1)}` : path
|
|
306
|
+
}
|