@cmodjs/core 0.1.14 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/commands.md +32 -21
- package/docs/dependencies.md +8 -0
- package/docs/index.md +12 -4
- package/docs/install-steps.md +36 -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 +16 -1
- package/records.d.ts.map +1 -1
- package/records.js +119 -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 +24 -0
- package/runtime/installer.d.ts.map +1 -0
- package/runtime/installer.js +118 -0
- package/runtime/lifecycle.d.ts +6 -3
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +226 -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 +131 -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 +169 -0
- package/src/runtime/lifecycle.ts +227 -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/mod.ts
CHANGED
|
@@ -21,13 +21,15 @@ import type {
|
|
|
21
21
|
ProcessSpawnRequest,
|
|
22
22
|
ProcessSpawnResult,
|
|
23
23
|
RenderElement,
|
|
24
|
-
|
|
25
|
-
SettingsReadArgs,
|
|
24
|
+
SessionMessage,
|
|
26
25
|
Timer,
|
|
27
26
|
ToastOptions,
|
|
28
27
|
UiScrollArgs,
|
|
29
28
|
UiScrollResult,
|
|
30
29
|
} from 'claude-code'
|
|
30
|
+
import type { Step } from './installer.js'
|
|
31
|
+
import { checkOptions, type Option, type Options, type OptionValues } from './options.js'
|
|
32
|
+
import type { FlagPermission, ListPermission } from './records.js'
|
|
31
33
|
import type { Claude } from './runtime/claude.js'
|
|
32
34
|
import type { RoutedEvent } from './runtime/hooks.js'
|
|
33
35
|
import type { RoutedHook } from './runtime/router.js'
|
|
@@ -125,9 +127,30 @@ export type HookOptions = {
|
|
|
125
127
|
readonly timeoutMs?: number
|
|
126
128
|
}
|
|
127
129
|
|
|
128
|
-
export type
|
|
130
|
+
export type ScrollArgs = Omit<UiScrollArgs, 'to'> & { readonly to: Exclude<UiScrollArgs['to'], { requestId: string }> }
|
|
131
|
+
|
|
132
|
+
export type ModClaude = Claude & {
|
|
133
|
+
on<N extends RoutedEvent>(event: N, hook: RoutedHook<N>): void
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type Metadata = Readonly<Record<string, string>>
|
|
137
|
+
|
|
138
|
+
export type FileMatch = {
|
|
139
|
+
readonly path: string
|
|
140
|
+
readonly name?: string
|
|
141
|
+
readonly metadata: Metadata
|
|
142
|
+
readonly error?: string
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
type SessionMessages = {
|
|
146
|
+
(): Promise<SessionMessage[]>
|
|
147
|
+
(args: { readonly agentId: string }): Promise<SessionMessage[] | { readonly deny: string }>
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export type Mod<State extends object = Record<never, never>, Declared extends Options = Options> = {
|
|
129
151
|
readonly name: string
|
|
130
152
|
readonly state: Readonly<State>
|
|
153
|
+
readonly options: OptionValues<Declared>
|
|
131
154
|
readonly dataFolder: string
|
|
132
155
|
on<E extends ModEvent>(event: E, hook: ModHook<E>, options?: HookOptions): void
|
|
133
156
|
use<Handle>(job: Job<Handle, State>): Handle
|
|
@@ -138,7 +161,7 @@ export type Mod<State extends object = Record<never, never>> = {
|
|
|
138
161
|
toast(text: string, options?: ToastOptions): void
|
|
139
162
|
progress<T>(title: string, task: (report: (step: ProgressStep) => void) => Promise<T>): Promise<T>
|
|
140
163
|
ask(question: string, options?: readonly string[] | AskOptions): Promise<string>
|
|
141
|
-
scroll(args:
|
|
164
|
+
scroll(args: ScrollArgs): Promise<UiScrollResult>
|
|
142
165
|
}
|
|
143
166
|
readonly process: {
|
|
144
167
|
run(argv: readonly string[], init?: ProcessRunInit): Promise<ProcessRunResult>
|
|
@@ -150,15 +173,21 @@ export type Mod<State extends object = Record<never, never>> = {
|
|
|
150
173
|
list(path?: string): Promise<FsEntry[]>
|
|
151
174
|
exists(path: string): Promise<boolean>
|
|
152
175
|
stat(path: string, options?: FsStatOptions): Promise<FsStat>
|
|
176
|
+
find(glob: string | readonly string[]): Promise<readonly FileMatch[]>
|
|
177
|
+
}
|
|
178
|
+
readonly metadata: {
|
|
179
|
+
read(path: string): Promise<Metadata>
|
|
180
|
+
update(path: string, change: (metadata: Record<string, string>) => void): Promise<void>
|
|
153
181
|
}
|
|
154
182
|
readonly http: {
|
|
155
183
|
fetch(url: string, init?: HttpInit): Promise<HttpResponse>
|
|
156
184
|
}
|
|
157
185
|
readonly settings: {
|
|
158
|
-
|
|
186
|
+
page(page: Pane<State>): void
|
|
187
|
+
open(pageId?: string): Promise<void>
|
|
159
188
|
}
|
|
160
189
|
readonly session: {
|
|
161
|
-
messages:
|
|
190
|
+
messages: SessionMessages
|
|
162
191
|
append(text: string): Promise<void>
|
|
163
192
|
submit(text: string): Promise<void>
|
|
164
193
|
}
|
|
@@ -168,9 +197,14 @@ export type Mod<State extends object = Record<never, never>> = {
|
|
|
168
197
|
readonly model: {
|
|
169
198
|
complete(request: ModelCompleteRequest, options?: ModelCompleteOptions): Promise<ModelCompleteResult>
|
|
170
199
|
}
|
|
200
|
+
readonly permissions: {
|
|
201
|
+
has(name: ListPermission, value: string): boolean
|
|
202
|
+
has(name: FlagPermission): boolean
|
|
203
|
+
}
|
|
171
204
|
readonly projectRoot: string
|
|
172
205
|
readonly cwd: string
|
|
173
206
|
readonly dependencies: CmodDependencies
|
|
207
|
+
readonly claude: ModClaude
|
|
174
208
|
}
|
|
175
209
|
|
|
176
210
|
type StateGroups = {
|
|
@@ -184,17 +218,17 @@ type Api<Contract, State extends object> = {
|
|
|
184
218
|
readonly [Method in keyof Contract]: Contract[Method] extends (input: infer Input) => infer Result ? (input: Input, mod: Mod<State>) => Result | Awaited<Result> : never
|
|
185
219
|
}
|
|
186
220
|
|
|
187
|
-
export type ModDefinition<State extends StateGroups = Record<never, never>, Name extends string = string
|
|
221
|
+
export type ModDefinition<State extends StateGroups = Record<never, never>, Name extends string = string, Declared extends Options = Record<never, Option>> = {
|
|
188
222
|
readonly name: Name
|
|
189
223
|
readonly state?: State
|
|
224
|
+
readonly options?: Declared
|
|
190
225
|
readonly api?: Name extends keyof CmodDependencies ? Api<CmodDependencies[Name], State> : { readonly [method: string]: (input: never, mod: Mod<State>) => unknown }
|
|
191
|
-
|
|
226
|
+
readonly installer?: readonly Step<State>[]
|
|
227
|
+
setup(mod: Mod<State, Declared>): void | Promise<void>
|
|
192
228
|
}
|
|
193
229
|
|
|
194
230
|
export type JobContext<State extends object = Record<never, never>> = {
|
|
195
231
|
readonly mod: Mod<State>
|
|
196
|
-
readonly claude: Claude
|
|
197
|
-
on<N extends RoutedEvent>(event: N, hook: RoutedHook<N>): void
|
|
198
232
|
announce(feature: string): void
|
|
199
233
|
reserveName(kind: string, name: string, taken: string): void
|
|
200
234
|
readonly toolCalls: ToolCalls
|
|
@@ -202,7 +236,8 @@ export type JobContext<State extends object = Record<never, never>> = {
|
|
|
202
236
|
|
|
203
237
|
export type Job<Handle, State extends object = Record<never, never>> = (context: JobContext<State>) => Handle
|
|
204
238
|
|
|
205
|
-
export function defineMod<State extends StateGroups = Record<never, never>, Name extends string = string
|
|
239
|
+
export function defineMod<State extends StateGroups = Record<never, never>, Name extends string = string, const Declared extends Options = Record<never, Option>>(definition: ModDefinition<State, Name, Declared>): ModDefinition<State, Name, Declared> {
|
|
206
240
|
if (definition.name.trim() === '') throw new Error('defineMod: the mod needs a name, such as the name in .claude-plugin/plugin.json.')
|
|
241
|
+
if (definition.options !== undefined) checkOptions(definition.name, definition.options)
|
|
207
242
|
return definition
|
|
208
243
|
}
|
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,106 @@ 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 marketplaceOf(root: string, name: string): string | undefined {
|
|
206
|
+
const installed = /\/cache\/([^/]+)\/([^/]+)\/[^/]+\/?$/.exec(root)
|
|
207
|
+
return installed?.[2] === name ? installed[1] : undefined
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function updatesToTurnOn(marketplace: string | undefined, settings: unknown, known: unknown): string | undefined {
|
|
211
|
+
if (marketplace === undefined) return undefined
|
|
212
|
+
const declared = isObject(settings) && isObject(settings['extraKnownMarketplaces']) ? settings['extraKnownMarketplaces'][marketplace] : undefined
|
|
213
|
+
const fetched = isObject(known) ? known[marketplace] : undefined
|
|
214
|
+
const isDecided = [declared, fetched].some((entry) => isObject(entry) && typeof entry['autoUpdate'] === 'boolean')
|
|
215
|
+
const hasSource = [declared, fetched].some((entry) => isObject(entry) && isObject(entry['source']))
|
|
216
|
+
return isDecided || !hasSource ? undefined : marketplace
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function consentPath(store: string): string {
|
|
220
|
+
return `${store}/consent.json`
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function parseConsent(value: unknown, path: string): Record<string, string[]> {
|
|
224
|
+
if (value === undefined) return Object.create(null)
|
|
225
|
+
if (!isObject(value) || Object.values(value).some((items) => !Array.isArray(items) || items.some((item) => typeof item !== 'string'))) {
|
|
226
|
+
throw new Error(`${path} is not a map of plugin names to what each was approved for. Delete it, and cmod asks again.`)
|
|
227
|
+
}
|
|
228
|
+
return Object.assign(Object.create(null), value) as Record<string, string[]>
|
|
116
229
|
}
|
|
117
230
|
|
|
118
231
|
function readKeys(keys: unknown): Steps {
|
|
@@ -127,8 +240,9 @@ function readKeys(keys: unknown): Steps {
|
|
|
127
240
|
return { keys: commands }
|
|
128
241
|
}
|
|
129
242
|
|
|
130
|
-
export function oldestCmodFor(
|
|
131
|
-
|
|
243
|
+
export function oldestCmodFor(steps: Steps): string {
|
|
244
|
+
if (steps.permissions !== undefined) return '0.2.0'
|
|
245
|
+
return Object.keys(steps.keys ?? {}).length > 0 ? '0.1.12' : '0.0.0'
|
|
132
246
|
}
|
|
133
247
|
|
|
134
248
|
export function keyWords(keys: Readonly<Record<string, string>> | undefined): string {
|
|
@@ -169,7 +283,13 @@ export async function scriptsSha256(steps: Steps, files: { read: ReadFile; list:
|
|
|
169
283
|
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
284
|
for (const folder of scriptFolders) folders.add(folder)
|
|
171
285
|
}
|
|
172
|
-
let text = [
|
|
286
|
+
let text = [
|
|
287
|
+
steps.install ?? '',
|
|
288
|
+
steps.uninstall ?? '',
|
|
289
|
+
steps.program ?? '',
|
|
290
|
+
...(steps.keys === undefined ? [] : [JSON.stringify(Object.entries(steps.keys).sort())]),
|
|
291
|
+
...(steps.permissions === undefined ? [] : [JSON.stringify([...steps.permissions].sort())]),
|
|
292
|
+
].join('\0')
|
|
173
293
|
for (const folder of [...folders].sort()) {
|
|
174
294
|
for (const name of (await files.list(folder)).sort()) {
|
|
175
295
|
const path = `${folder}/${name}`
|
|
@@ -189,7 +309,7 @@ export function formatEvent(event: RunnerEvent): string {
|
|
|
189
309
|
case 'log':
|
|
190
310
|
return `log ${event.text}`
|
|
191
311
|
case 'needs-consent':
|
|
192
|
-
return `needs-consent ${event.sha256
|
|
312
|
+
return `needs-consent ${[event.sha256, event.install, event.uninstall, event.keys, ...event.permissions].join('\t')}`
|
|
193
313
|
case 'done':
|
|
194
314
|
return event.version === undefined ? `done ${event.name}` : `done ${event.name} ${event.version}`
|
|
195
315
|
case 'missing':
|
|
@@ -203,7 +323,10 @@ export function parseEvent(line: string): RunnerEvent {
|
|
|
203
323
|
const progress = /^progress (\d+) (\d+)(?: (.*))?$/.exec(line)
|
|
204
324
|
if (progress !== null) return { kind: 'progress', done: Number(progress[1]), total: Number(progress[2]), label: progress[3] ?? '' }
|
|
205
325
|
const consent = /^needs-consent (\S+)\t([^\t]*)\t([^\t]*)(?:\t(.*))?$/.exec(line)
|
|
206
|
-
if (consent !== null)
|
|
326
|
+
if (consent !== null) {
|
|
327
|
+
const [keys = '', ...permissions] = (consent[4] ?? '').split('\t')
|
|
328
|
+
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 !== '') }
|
|
329
|
+
}
|
|
207
330
|
const done = /^done (\S+)(?: (\S+))?$/.exec(line)
|
|
208
331
|
if (done !== null) return { kind: 'done', name: done[1] as string, ...(done[2] === undefined ? {} : { version: done[2] }) }
|
|
209
332
|
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>
|