@cmodjs/core 0.1.14 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/docs/commands.md +32 -21
  2. package/docs/dependencies.md +8 -0
  3. package/docs/index.md +12 -4
  4. package/docs/install-steps.md +36 -4
  5. package/docs/jobs.md +25 -9
  6. package/docs/mod.md +103 -37
  7. package/docs/options.md +93 -0
  8. package/docs/permissions.md +88 -0
  9. package/docs/state.md +4 -31
  10. package/docs/testing.md +13 -2
  11. package/installer.d.ts +10 -0
  12. package/installer.d.ts.map +1 -0
  13. package/installer.js +8 -0
  14. package/jobs/context.d.ts +2 -2
  15. package/jobs/context.d.ts.map +1 -1
  16. package/jobs/context.js +5 -3
  17. package/jobs/permissions.js +1 -1
  18. package/jobs/program.d.ts.map +1 -1
  19. package/jobs/program.js +5 -1
  20. package/jobs/prompt.js +5 -5
  21. package/jobs/schedule.d.ts.map +1 -1
  22. package/jobs/schedule.js +2 -1
  23. package/jobs/slash-command.d.ts.map +1 -1
  24. package/jobs/slash-command.js +3 -2
  25. package/jobs/status-line.d.ts.map +1 -1
  26. package/jobs/status-line.js +3 -2
  27. package/jobs/tool.d.ts.map +1 -1
  28. package/jobs/tool.js +3 -2
  29. package/mod.d.ts +48 -10
  30. package/mod.d.ts.map +1 -1
  31. package/mod.js +3 -0
  32. package/options.d.ts +58 -0
  33. package/options.d.ts.map +1 -0
  34. package/options.js +66 -0
  35. package/package.json +1 -1
  36. package/records.d.ts +16 -1
  37. package/records.d.ts.map +1 -1
  38. package/records.js +119 -7
  39. package/register.d.ts +4 -2
  40. package/register.d.ts.map +1 -1
  41. package/register.js +8 -2
  42. package/runtime/claude.d.ts +5 -1
  43. package/runtime/claude.d.ts.map +1 -1
  44. package/runtime/grants.d.ts +28 -0
  45. package/runtime/grants.d.ts.map +1 -0
  46. package/runtime/grants.js +286 -0
  47. package/runtime/installer.d.ts +24 -0
  48. package/runtime/installer.d.ts.map +1 -0
  49. package/runtime/installer.js +118 -0
  50. package/runtime/lifecycle.d.ts +6 -3
  51. package/runtime/lifecycle.d.ts.map +1 -1
  52. package/runtime/lifecycle.js +226 -36
  53. package/runtime/metadata.d.ts +20 -0
  54. package/runtime/metadata.d.ts.map +1 -0
  55. package/runtime/metadata.js +143 -0
  56. package/runtime/options.d.ts +21 -0
  57. package/runtime/options.d.ts.map +1 -0
  58. package/runtime/options.js +100 -0
  59. package/runtime/state.d.ts +1 -0
  60. package/runtime/state.d.ts.map +1 -1
  61. package/runtime/state.js +5 -75
  62. package/src/installer.ts +17 -0
  63. package/src/jobs/context.ts +5 -3
  64. package/src/jobs/permissions.ts +1 -1
  65. package/src/jobs/program.ts +4 -1
  66. package/src/jobs/prompt.ts +5 -5
  67. package/src/jobs/schedule.ts +2 -1
  68. package/src/jobs/slash-command.ts +3 -2
  69. package/src/jobs/status-line.ts +3 -2
  70. package/src/jobs/tool.ts +3 -2
  71. package/src/mod.ts +46 -11
  72. package/src/options.ts +91 -0
  73. package/src/records.ts +131 -8
  74. package/src/register.ts +12 -3
  75. package/src/runtime/claude.ts +2 -0
  76. package/src/runtime/grants.ts +306 -0
  77. package/src/runtime/installer.ts +169 -0
  78. package/src/runtime/lifecycle.ts +227 -45
  79. package/src/runtime/metadata.ts +153 -0
  80. package/src/runtime/options.ts +102 -0
  81. package/src/runtime/state.ts +7 -70
  82. package/src/testing/fake-claude.ts +6 -1
  83. package/src/testing/fake-files.ts +9 -3
  84. package/src/testing.ts +40 -15
  85. package/src/utils/metadata.ts +242 -0
  86. package/testing/fake-claude.d.ts +4 -0
  87. package/testing/fake-claude.d.ts.map +1 -1
  88. package/testing/fake-claude.js +5 -1
  89. package/testing/fake-files.d.ts.map +1 -1
  90. package/testing/fake-files.js +9 -3
  91. package/testing.d.ts +6 -2
  92. package/testing.d.ts.map +1 -1
  93. package/testing.js +29 -13
  94. package/utils/metadata.d.ts +21 -0
  95. package/utils/metadata.d.ts.map +1 -0
  96. package/utils/metadata.js +250 -0
package/src/mod.ts CHANGED
@@ -21,13 +21,15 @@ import type {
21
21
  ProcessSpawnRequest,
22
22
  ProcessSpawnResult,
23
23
  RenderElement,
24
- Settings,
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 Mod<State extends object = Record<never, never>> = {
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: UiScrollArgs): Promise<UiScrollResult>
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
- read(args?: SettingsReadArgs): Promise<Settings>
186
+ page(page: Pane<State>): void
187
+ open(pageId?: string): Promise<void>
159
188
  }
160
189
  readonly session: {
161
- messages: Claude['session']['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
- setup(mod: Mod<State>): void | Promise<void>
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>(definition: ModDefinition<State, Name>): ModDefinition<State, Name> {
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 = { install?: string; uninstall?: string; program?: string; keys?: Readonly<Record<string, string>> }
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
- return { ...readCommand(steps, 'install'), ...readCommand(steps, 'uninstall'), ...(program === undefined ? {} : { program }), ...readKeys(steps['keys']) }
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(keys: Readonly<Record<string, string>>): string {
131
- return Object.keys(keys).length > 0 ? '0.1.12' : '0.0.0'
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 = [steps.install ?? '', steps.uninstall ?? '', steps.program ?? '', ...(steps.keys === undefined ? [] : [JSON.stringify(Object.entries(steps.keys).sort())])].join('\0')
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}\t${event.install}\t${event.uninstall}\t${event.keys}`
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) return { kind: 'needs-consent', sha256: consent[1] as string, install: consent[2] as string, uninstall: consent[3] as string, keys: consent[4] ?? '' }
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>): void {
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
- lifecycle = createLifecycle(definition, () => checksPermissions)
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)
@@ -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>