@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/runtime/lifecycle.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
import type { Args, EventResult, Frozen, HookBudget, HookStream, ProcessSpawnChunk, ProcessSpawnResult } from 'claude-code'
|
|
2
|
-
import type { HookInput, Mod, ModDefinition, ModEvent, ModHook } from '../mod.js'
|
|
3
|
-
import {
|
|
1
|
+
import type { Args, EventResult, Frozen, HookBudget, HookStream, PluginOptions, ProcessSpawnChunk, ProcessSpawnResult } from 'claude-code'
|
|
2
|
+
import type { HookInput, Mod, ModDefinition, ModEvent, ModHook, PaneHandle } from '../mod.js'
|
|
3
|
+
import type { Options, OptionValues } from '../options.js'
|
|
4
|
+
import { consentPath, dataFolder, finishStepsMethod, isAtLeast, marketplaceOf, oldestCmodFor, openPageMethod, parseConsent, pendingStepsMethod, parseEvent, permissionWords, readRecord, readSteps, scriptsSha256, settingsPagesMethod, storeFolder, updatesToTurnOn, type ReadFile, type RunnerEvent, type Steps } from '../records.js'
|
|
4
5
|
import { relativePath } from '../utils/paths.js'
|
|
5
6
|
import { formatExit, listed, messageOf } from '../utils/text.js'
|
|
6
7
|
import type { Claude } from './claude.js'
|
|
7
8
|
import { beforeDeadline } from './deadline.js'
|
|
8
9
|
import { answerCall, dependencyCalls, notInstalled } from './dependencies.js'
|
|
9
10
|
import { classicHook, heldDecisionHook, permissionEvents, preToolUseHook, userSkillHook, type HeldDecisions, type RoutedEvent } from './hooks.js'
|
|
11
|
+
import { checkedAnswer, checkingGrants, checksAnswers, checkWrite, isCovered, itemOf, passedDown, type AnswerCheck, type Grants } from './grants.js'
|
|
12
|
+
import { createModFiles } from './metadata.js'
|
|
13
|
+
import { fitsOption } from '../options.js'
|
|
14
|
+
import { createInstaller } from './installer.js'
|
|
15
|
+
import { createOptions, MissingOptions } from './options.js'
|
|
10
16
|
import { createRouter, type Router, type RouterNext } from './router.js'
|
|
11
17
|
import { createState } from './state.js'
|
|
12
18
|
import { toolCalls } from './tool-calls.js'
|
|
@@ -19,7 +25,8 @@ export type Plugin = {
|
|
|
19
25
|
readonly store: string
|
|
20
26
|
readonly isInstalled: boolean
|
|
21
27
|
readonly shouldRecord: boolean
|
|
22
|
-
readonly
|
|
28
|
+
readonly steps: Steps
|
|
29
|
+
readonly granted: readonly string[]
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
export type Phase = 'starting' | 'installing' | 'waiting' | 'declined' | 'failed' | 'ready' | 'active'
|
|
@@ -37,12 +44,15 @@ type ModRuntime = {
|
|
|
37
44
|
readonly router: Router
|
|
38
45
|
readonly progress: Progress
|
|
39
46
|
readonly dataFolder: string
|
|
40
|
-
readonly
|
|
47
|
+
readonly steps: Steps
|
|
48
|
+
readonly granted: ReadonlySet<string>
|
|
49
|
+
readonly refreshGrants: () => Promise<void>
|
|
41
50
|
readonly checksPermissions: () => boolean
|
|
51
|
+
readonly options: PluginOptions
|
|
42
52
|
}
|
|
43
53
|
|
|
44
|
-
type ActiveMod<State extends object> = {
|
|
45
|
-
readonly mod: Mod<State>
|
|
54
|
+
type ActiveMod<State extends object, Declared extends Options> = {
|
|
55
|
+
readonly mod: Mod<State, Declared>
|
|
46
56
|
readonly added: readonly string[]
|
|
47
57
|
}
|
|
48
58
|
|
|
@@ -76,12 +86,14 @@ export async function readPlugin(claude: Claude): Promise<Plugin> {
|
|
|
76
86
|
const manifest = (await readJson(read, `${root}/.claude-plugin/plugin.json`)) as { version?: unknown } | undefined
|
|
77
87
|
const version = typeof manifest?.version === 'string' ? manifest.version : undefined
|
|
78
88
|
const store = storeFolder({ HOME: await claude.env.home(), XDG_DATA_HOME: await claude.env.dataHome() })
|
|
89
|
+
const steps = readSteps(await readJson(read, `${root}/package.json`)) ?? {}
|
|
90
|
+
const declared = steps.permissions ?? []
|
|
79
91
|
if (name === cmodPluginName) {
|
|
80
92
|
const installed = await cmodVersion(claude)
|
|
81
|
-
return { name, root, version, store,
|
|
93
|
+
return { name, root, version, store, steps, granted: declared, isInstalled: version !== undefined && installed !== undefined && isAtLeast(installed, version), shouldRecord: false }
|
|
82
94
|
}
|
|
83
|
-
const
|
|
84
|
-
const plugin = { name, root, version, store,
|
|
95
|
+
const approved = parseConsent(await readJson(read, consentPath(store)), consentPath(store))[name] ?? []
|
|
96
|
+
const plugin = { name, root, version, store, steps, granted: declared.filter((item) => approved.includes(item)) }
|
|
85
97
|
const record = await readRecord(read, store, name)
|
|
86
98
|
if (Object.keys(steps).length === 0) return { ...plugin, isInstalled: true, shouldRecord: record?.version !== version }
|
|
87
99
|
const scripts = await scriptsSha256(steps, {
|
|
@@ -123,15 +135,23 @@ async function cmodVersion(claude: Claude): Promise<string | undefined> {
|
|
|
123
135
|
return result.stdout.trim().split(/\s+/).at(-1)
|
|
124
136
|
}
|
|
125
137
|
|
|
126
|
-
export function createLifecycle<State extends object
|
|
138
|
+
export function createLifecycle<State extends object, Declared extends Options = Options>(
|
|
139
|
+
definition: ModDefinition<State, string, Declared>,
|
|
140
|
+
checksPermissions: () => boolean = () => true,
|
|
141
|
+
options: PluginOptions = {},
|
|
142
|
+
asksPerson = true,
|
|
143
|
+
): Lifecycle<State> {
|
|
127
144
|
let router: Router = createRouter()
|
|
128
145
|
let phase: Phase = 'starting'
|
|
129
146
|
let runtime: Pick<ModRuntime, 'claude' | 'progress'> | undefined
|
|
130
147
|
let plugin: Plugin | undefined
|
|
148
|
+
const granted = new Set<string>()
|
|
131
149
|
let line: ProgressLine | undefined
|
|
132
150
|
let activation: Promise<void> | undefined
|
|
133
151
|
let mod: Mod<State> | undefined
|
|
134
152
|
let failure: unknown
|
|
153
|
+
let pluginOptions = options
|
|
154
|
+
const installer = createInstaller(definition.name, () => claude())
|
|
135
155
|
let shouldRecord = false
|
|
136
156
|
let recording: Promise<void> | undefined
|
|
137
157
|
let missedSessionStart: Frozen<Args<'classic.SessionStart'>> | undefined
|
|
@@ -139,7 +159,7 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
139
159
|
const startSettled = new Promise<void>((resolve) => (settleStart = resolve))
|
|
140
160
|
|
|
141
161
|
const claude = () => {
|
|
142
|
-
if (runtime === undefined) throw new Error(`${definition.name}: the lifecycle has not started. registerMod(addHook, mod) starts it at session.start.`)
|
|
162
|
+
if (runtime === undefined) throw new Error(`${definition.name}: the lifecycle has not started. registerMod(addHook, mod, options) starts it at session.start.`)
|
|
143
163
|
return runtime.claude
|
|
144
164
|
}
|
|
145
165
|
|
|
@@ -180,19 +200,65 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
180
200
|
})
|
|
181
201
|
}
|
|
182
202
|
|
|
183
|
-
const
|
|
203
|
+
const saveOption = async (key: string, text: string): Promise<string | undefined> => {
|
|
204
|
+
const option = (definition.options as Options | undefined)?.[key]
|
|
205
|
+
if (option === undefined || plugin === undefined) return `${definition.name} has no option ${key}.`
|
|
206
|
+
const value = option.kind === 'number' ? Number(text) : option.kind === 'toggle' ? text.trim() === 'true' : option.kind === 'list' ? text.split(',').map((item) => item.trim()).filter((item) => item !== '') : text
|
|
207
|
+
const misfit = option.kind === 'toggle' && !['true', 'false'].includes(text.trim()) ? 'takes true or false' : fitsOption(option, value)
|
|
208
|
+
if (misfit !== undefined) return `${option.title} ${misfit}.`
|
|
209
|
+
const saved = await claude().config.set({ key: `${plugin.name}.${key}`, value })
|
|
210
|
+
return saved.deny
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const askOptions = async (missing: MissingOptions): Promise<boolean> => {
|
|
214
|
+
if (!asksPerson || plugin === undefined || (await claude().session.surfaces()).length === 0) return false
|
|
215
|
+
const declared: Options = definition.options ?? {}
|
|
216
|
+
const asked = missing.keys.flatMap((key) => {
|
|
217
|
+
const option = declared[key]
|
|
218
|
+
return option === undefined ? [] : [[key, option] as const]
|
|
219
|
+
})
|
|
220
|
+
if (!(await installer.options(asked, saveOption))) return false
|
|
221
|
+
const prefix = `${plugin.name}.`
|
|
222
|
+
const rows = await claude().config.list()
|
|
223
|
+
pluginOptions = { ...pluginOptions, ...Object.fromEntries(rows.filter((row) => row.key.startsWith(prefix)).map((row) => [row.key.slice(prefix.length), row.value])) }
|
|
224
|
+
return true
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const pendingSteps = async (active: Mod<State>) => {
|
|
228
|
+
const pending: string[] = []
|
|
229
|
+
for (const step of definition.installer ?? []) if (!(await Promise.resolve(step.isDone(active)).catch(() => false))) pending.push(step.title)
|
|
230
|
+
return pending
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const runSteps = async (active: Mod<State>) => {
|
|
234
|
+
const steps = asksPerson ? (definition.installer ?? []) : []
|
|
235
|
+
for (const [index, step] of steps.entries()) {
|
|
236
|
+
if (await Promise.resolve(step.isDone(active)).catch(() => false)) continue
|
|
237
|
+
const isAnswered = (await claude().session.surfaces()).length > 0 && (await installer.step(active, step, index + 1, steps.length))
|
|
238
|
+
if (!isAnswered) {
|
|
239
|
+
claude().ui.log(`${definition.name} needs ${steps.length - index === 1 ? 'one more step' : `${steps.length - index} more steps`}, starting with ${step.title}. Run /mods ${definition.name} to finish.`)
|
|
240
|
+
break
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
await installer.close()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const activate = async (): Promise<void> => {
|
|
184
247
|
if (runtime === undefined || plugin === undefined) return
|
|
185
248
|
const activeRouter = createRouter()
|
|
186
|
-
const active = await createMod(definition, { ...runtime, router: activeRouter, dataFolder: dataFolder(plugin.store, plugin.name),
|
|
187
|
-
|
|
249
|
+
const active = await createMod(definition, { ...runtime, router: activeRouter, dataFolder: dataFolder(plugin.store, plugin.name), steps: plugin.steps, granted, refreshGrants, checksPermissions, options: pluginOptions }).catch(async (error: unknown) => {
|
|
250
|
+
if (error instanceof MissingOptions && (await askOptions(error))) return undefined
|
|
251
|
+
fail(messageOf(error), error instanceof MissingOptions ? `Set ${error.keys.length === 1 ? 'it' : 'them'} in /config.` : 'Fix it, then run /reload-plugins.')
|
|
188
252
|
throw error
|
|
189
253
|
})
|
|
254
|
+
if (active === undefined) return activate()
|
|
190
255
|
const { added } = active
|
|
191
256
|
mod = active.mod
|
|
192
257
|
router = activeRouter
|
|
193
258
|
phase = 'active'
|
|
194
259
|
runtime.claude.ui.invalidate('ui.render')
|
|
195
260
|
endLine()
|
|
261
|
+
void runSteps(active.mod).catch((error: unknown) => claude().ui.log(`${definition.name}: its installer stopped: ${messageOf(error)}`))
|
|
196
262
|
const missed = missedSessionStart
|
|
197
263
|
missedSessionStart = undefined
|
|
198
264
|
const lateAnswer = missed === undefined ? {} : await activeRouter.dispatch('classic.SessionStart', missed, async () => ({})).catch((error: unknown) => {
|
|
@@ -209,6 +275,13 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
209
275
|
if (added.length > 0) runtime.claude.ui.log(`${definition.name} added ${listed(added)}.`)
|
|
210
276
|
}
|
|
211
277
|
|
|
278
|
+
const updatesToShow = async () => {
|
|
279
|
+
const [home, configHome] = await Promise.all([claude().env.home(), claude().env.configHome()])
|
|
280
|
+
const configRoot = configHome ?? `${home ?? ''}/.claude`
|
|
281
|
+
const known = await claude().fs.read(`${configRoot}/plugins/known_marketplaces.json`).then((text): unknown => JSON.parse(text), () => undefined)
|
|
282
|
+
return updatesToTurnOn(marketplaceOf(claude().plugin.root, claude().plugin.name), await claude().settings.read({ source: 'user' }), known)
|
|
283
|
+
}
|
|
284
|
+
|
|
212
285
|
const askConsent = async (event: Extract<RunnerEvent, { kind: 'needs-consent' }>) => {
|
|
213
286
|
const name = definition.name
|
|
214
287
|
if ((await claude().session.surfaces()).length === 0) {
|
|
@@ -217,13 +290,8 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
217
290
|
return
|
|
218
291
|
}
|
|
219
292
|
showLine().wait('Waiting for your answer')
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
...(event.uninstall === '' ? [] : [`runs ${event.uninstall} when you remove it`]),
|
|
223
|
-
...(event.keys === '' ? [] : [`binds ${event.keys}`]),
|
|
224
|
-
]
|
|
225
|
-
const answer = await claude().ui.ask(`${name} ${listed(actions)}. Install it now?`, { options: ['Install', 'Not now'], header: 'Install' })
|
|
226
|
-
if (answer === 'Install') return install(event.sha256)
|
|
293
|
+
if (await installer.consent({ ...event, updates: await updatesToShow().catch(() => undefined) })) return install(event.sha256)
|
|
294
|
+
await installer.close()
|
|
227
295
|
phase = 'declined'
|
|
228
296
|
endLine()
|
|
229
297
|
claude().ui.log(`${name} is not installed. Run cmod install ${name} to install it.`)
|
|
@@ -244,13 +312,16 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
244
312
|
if (event.kind === 'progress') progress.report(event)
|
|
245
313
|
if (event.kind === 'done' || event.kind === 'failed' || event.kind === 'needs-consent') outcome = event
|
|
246
314
|
})
|
|
247
|
-
if (outcome?.kind === 'done')
|
|
315
|
+
if (outcome?.kind === 'done') {
|
|
316
|
+
for (const item of plugin.steps.permissions ?? []) granted.add(item)
|
|
317
|
+
return finish()
|
|
318
|
+
}
|
|
248
319
|
if (outcome?.kind === 'needs-consent') return askConsent(outcome)
|
|
249
320
|
const reason = outcome?.kind === 'failed' ? outcome.message : `cmod setup ${formatExit(code, lastError)}`
|
|
250
321
|
fail(reason, `Fix the cause, then run: cmod install ${definition.name}`)
|
|
251
322
|
}
|
|
252
323
|
|
|
253
|
-
const fits = (found: string | undefined): found is string => found !== undefined && isAtLeast(found, oldestCmodFor(plugin?.
|
|
324
|
+
const fits = (found: string | undefined): found is string => found !== undefined && isAtLeast(found, oldestCmodFor(plugin?.steps ?? {}))
|
|
254
325
|
|
|
255
326
|
const waitForcmod = (found: string | undefined) => {
|
|
256
327
|
phase = 'waiting'
|
|
@@ -279,7 +350,7 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
279
350
|
showLine().wait(
|
|
280
351
|
latest === undefined
|
|
281
352
|
? "Still waiting for cmod to download cmod. See cmod's own line."
|
|
282
|
-
: `PATH finds cmod ${latest}, and ${definition.name} needs cmod ${oldestCmodFor(plugin?.
|
|
353
|
+
: `PATH finds cmod ${latest}, and ${definition.name} needs cmod ${oldestCmodFor(plugin?.steps ?? {})} or later. Run npm i -g @cmodjs/cli, or put ~/.local/bin ahead of the old cmod on PATH.`,
|
|
283
354
|
),
|
|
284
355
|
)
|
|
285
356
|
}
|
|
@@ -300,6 +371,46 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
300
371
|
if (phase !== 'failed') fail(messageOf(error), `Run cmod install ${definition.name} in a terminal to see the whole log.`)
|
|
301
372
|
}
|
|
302
373
|
|
|
374
|
+
let consentMs = 0
|
|
375
|
+
const consentStamp = async (path: string) => {
|
|
376
|
+
const stat = await claude().fs.stat(path).catch(() => undefined)
|
|
377
|
+
return stat?.kind === 'file' ? stat.mtimeMs : 0
|
|
378
|
+
}
|
|
379
|
+
const refreshGrants = async () => {
|
|
380
|
+
if (plugin === undefined || plugin.name === cmodPluginName) return
|
|
381
|
+
const path = consentPath(plugin.store)
|
|
382
|
+
const modifiedMs = await consentStamp(path)
|
|
383
|
+
if (modifiedMs === consentMs) return
|
|
384
|
+
consentMs = modifiedMs
|
|
385
|
+
const approved = modifiedMs === 0 ? [] : (parseConsent(JSON.parse(await claude().fs.read(path)), path)[plugin.name] ?? [])
|
|
386
|
+
granted.clear()
|
|
387
|
+
for (const item of plugin.steps.permissions ?? []) {
|
|
388
|
+
if (approved.includes(item)) granted.add(item)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const droppedParts = new Set<string>()
|
|
393
|
+
const answerCheck: AnswerCheck = {
|
|
394
|
+
get plugin() {
|
|
395
|
+
return plugin?.name ?? definition.name
|
|
396
|
+
},
|
|
397
|
+
isGranted: (item) => isCovered(granted, item, undefined),
|
|
398
|
+
dropped(item, part) {
|
|
399
|
+
if (droppedParts.has(item)) return
|
|
400
|
+
droppedParts.add(item)
|
|
401
|
+
const declared = plugin?.steps.permissions ?? []
|
|
402
|
+
const fix = declared.includes(item) ? `Turn it on in /mods ${definition.name}.` : `It needs "permissions": { "${item}": true } in package.json "cmod".`
|
|
403
|
+
claude().ui.log(`${definition.name} answered ${part} without your grant to "${permissionWords(item)}", so cmod dropped it. ${fix}`)
|
|
404
|
+
},
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const checkedRoute = async <N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>> => {
|
|
408
|
+
let below: Promise<EventResult<N>> | undefined
|
|
409
|
+
const passOn: RouterNext<N> = (passed) => (below ??= next(passedDown(event, e, passed, answerCheck) as Frozen<Args<N>>))
|
|
410
|
+
const answer = await router.dispatch(event, e, passOn)
|
|
411
|
+
return (await checkedAnswer(event, e, answer, () => passOn(e), answerCheck)) as EventResult<N>
|
|
412
|
+
}
|
|
413
|
+
|
|
303
414
|
const whenActive = async () => {
|
|
304
415
|
if (phase === 'ready') activation ??= activate().catch(report)
|
|
305
416
|
if (activation !== undefined && phase !== 'active') await activation
|
|
@@ -334,6 +445,8 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
334
445
|
})
|
|
335
446
|
if (started === undefined) return
|
|
336
447
|
plugin = started
|
|
448
|
+
for (const item of started.granted) granted.add(item)
|
|
449
|
+
consentMs = await consentStamp(consentPath(started.store))
|
|
337
450
|
if (started.isInstalled) {
|
|
338
451
|
shouldRecord = started.shouldRecord
|
|
339
452
|
if (shouldRecord) record()
|
|
@@ -346,6 +459,8 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
346
459
|
}
|
|
347
460
|
},
|
|
348
461
|
async route<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>> {
|
|
462
|
+
if (event === 'ui.render' && installer.draws(e as Frozen<Args<'ui.render'>>)) return installer.draw(e as Frozen<Args<'ui.render'>>) as EventResult<N>
|
|
463
|
+
if (event === 'ui.close') installer.closed((e as Frozen<Args<'ui.close'>>).id)
|
|
349
464
|
if (event === 'classic.SessionStart') missedSessionStart = undefined
|
|
350
465
|
if (event === 'classic.SessionStart' && runtime === undefined) {
|
|
351
466
|
missedSessionStart = e as Frozen<Args<'classic.SessionStart'>>
|
|
@@ -358,6 +473,13 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
358
473
|
const isStopped = phase === 'declined' || phase === 'failed'
|
|
359
474
|
return { deny: isStopped ? notInstalled(definition.name) : `${definition.name} is installing. Try again when it's ready.` } as EventResult<N>
|
|
360
475
|
}
|
|
476
|
+
const call = e as Frozen<Args<'cmod.call'>>
|
|
477
|
+
if (event === 'cmod.call' && call.to === definition.name && mod !== undefined) {
|
|
478
|
+
if (call.method === pendingStepsMethod) return { value: await pendingSteps(mod) } as EventResult<N>
|
|
479
|
+
if (call.method === finishStepsMethod) return (await runSteps(mod), { value: null }) as EventResult<N>
|
|
480
|
+
}
|
|
481
|
+
if (event === 'classic.UserPromptSubmit' || event === 'classic.SessionStart') await refreshGrants().catch((error: unknown) => claude().ui.log(`${definition.name} keeps its grants from before: ${messageOf(error)}`, { to: 'debug' }))
|
|
482
|
+
if (checksAnswers(event) && phase === 'active') return checkedRoute(event, e, next)
|
|
361
483
|
const answer = router.dispatch(event, e, next)
|
|
362
484
|
const render = e as Frozen<Args<'ui.render'>>
|
|
363
485
|
if (event !== 'ui.render' || render.component !== 'AbovePrompt' || runtime === undefined || !runtime.progress.isShown) return answer as Promise<EventResult<N>>
|
|
@@ -366,24 +488,63 @@ export function createLifecycle<State extends object>(definition: ModDefinition<
|
|
|
366
488
|
}
|
|
367
489
|
}
|
|
368
490
|
|
|
369
|
-
async function createMod<State extends object>(definition: ModDefinition<State>, runtime: ModRuntime): Promise<ActiveMod<State>> {
|
|
491
|
+
async function createMod<State extends object, Declared extends Options>(definition: ModDefinition<State, string, Declared>, runtime: ModRuntime): Promise<ActiveMod<State, Declared>> {
|
|
370
492
|
const { router, progress } = runtime
|
|
371
|
-
const claude = checkingKeys(runtime.claude, definition.name, runtime.keys)
|
|
493
|
+
const claude = checkingKeys(runtime.claude, definition.name, runtime.steps.keys ?? {})
|
|
372
494
|
const added: string[] = []
|
|
373
495
|
const hookEvents: ModEvent[] = []
|
|
374
496
|
const announce = (feature: string) => {
|
|
375
497
|
added.push(feature)
|
|
376
498
|
}
|
|
377
|
-
const [session, root, startCwd] = await Promise.all([claude.session.id(), claude.session.root(), claude.session.cwd()])
|
|
499
|
+
const [session, root, startCwd, home, configHome] = await Promise.all([claude.session.id(), claude.session.root(), claude.session.cwd(), claude.env.home(), claude.env.configHome()])
|
|
500
|
+
const { granted } = runtime
|
|
501
|
+
const configRoot = configHome ?? (home === undefined ? undefined : `${home}/.claude`)
|
|
502
|
+
const grants: Grants = {
|
|
503
|
+
name: definition.name,
|
|
504
|
+
declared: runtime.steps.permissions ?? [],
|
|
505
|
+
granted: () => granted,
|
|
506
|
+
refresh: runtime.refreshGrants,
|
|
507
|
+
home,
|
|
508
|
+
configRoot,
|
|
509
|
+
projectRoot: () => modState.root,
|
|
510
|
+
freeFolders: () => [modState.root, runtime.dataFolder],
|
|
511
|
+
}
|
|
512
|
+
const checked = checkingGrants(claude, grants)
|
|
513
|
+
const files = createModFiles({
|
|
514
|
+
name: definition.name,
|
|
515
|
+
claude,
|
|
516
|
+
places: { home, configRoot, projectRoot: () => modState.root },
|
|
517
|
+
checkWrite: (path) => checkWrite(grants, `metadata.update(${path})`, path),
|
|
518
|
+
})
|
|
519
|
+
const pages = new Map<string, { readonly title: string; readonly handle: PaneHandle }>()
|
|
520
|
+
const settingsPages: Mod<State>['settings'] = {
|
|
521
|
+
page(page) {
|
|
522
|
+
if (pages.has(page.id)) throw new Error(`${definition.name}: the settings page "${page.id}" is already added. Give each page its own id.`)
|
|
523
|
+
pages.set(page.id, { title: page.title, handle: area.ui.pane(page) })
|
|
524
|
+
},
|
|
525
|
+
async open(pageId) {
|
|
526
|
+
if (pageId === undefined) {
|
|
527
|
+
await claude.cmod.call({ to: cmodPluginName, method: 'openSettings', input: { mod: definition.name } })
|
|
528
|
+
return
|
|
529
|
+
}
|
|
530
|
+
const page = pages.get(pageId)
|
|
531
|
+
if (page === undefined) throw new Error(`${definition.name} has no settings page "${pageId}". It has: ${[...pages.keys()].join(', ') || 'none'}.`)
|
|
532
|
+
await page.handle.open({ focus: true })
|
|
533
|
+
},
|
|
534
|
+
}
|
|
378
535
|
let cwd = startCwd
|
|
379
536
|
let loadedCwd = startCwd
|
|
380
537
|
let staleCwd: string | undefined
|
|
381
538
|
const area = createUi<State>({ name: definition.name, claude, router, progress, announce, mod: () => mod })
|
|
382
539
|
const modState = createState<State>({ name: definition.name, initial: definition.state ?? {}, session, root, claude, changed: area.changed })
|
|
540
|
+
const modOptions = createOptions({ name: definition.name, declared: definition.options ?? {}, fromClaude: runtime.options, claude, changed: modState.changed })
|
|
383
541
|
const held: HeldDecisions | undefined = runtime.checksPermissions() ? new Map() : undefined
|
|
384
|
-
const mod: Mod<State> = {
|
|
542
|
+
const mod: Mod<State, Declared> = {
|
|
385
543
|
name: definition.name,
|
|
386
544
|
state: modState.state,
|
|
545
|
+
get options() {
|
|
546
|
+
return modOptions.values as OptionValues<Declared>
|
|
547
|
+
},
|
|
387
548
|
dataFolder: runtime.dataFolder,
|
|
388
549
|
on(event, hook, options) {
|
|
389
550
|
const bounded = options?.timeoutMs === undefined ? hook : timedHook(definition.name, event, hook, checkedMs(definition.name, `the ${event} hook's timeoutMs`, options.timeoutMs), claude)
|
|
@@ -391,7 +552,7 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
|
|
|
391
552
|
if (event === 'PreToolUse') router.add('tool.call', preToolUseHook(definition.name, bounded as ModHook<'PreToolUse'>, claude, agents, held))
|
|
392
553
|
else router.add(`classic.${event as Exclude<ModEvent, 'PreToolUse'>}`, classicHook(definition.name, event as Exclude<ModEvent, 'PreToolUse'>, bounded as ModHook<Exclude<ModEvent, 'PreToolUse'>>, claude, agents))
|
|
393
554
|
},
|
|
394
|
-
use: (job) => job({ mod,
|
|
555
|
+
use: (job) => job({ mod, announce, reserveName, toolCalls: agents }),
|
|
395
556
|
every(ms, hook) {
|
|
396
557
|
let isRunning = false
|
|
397
558
|
return claude.clock.every(checkedMs(definition.name, 'mod.every', ms), () => {
|
|
@@ -407,31 +568,40 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
|
|
|
407
568
|
},
|
|
408
569
|
ui: area.ui,
|
|
409
570
|
process: {
|
|
410
|
-
run: (argv, init) =>
|
|
411
|
-
spawn: (argv, init) =>
|
|
571
|
+
run: (argv, init) => checked.process.run(argv, init),
|
|
572
|
+
spawn: (argv, init) => checked.process.spawn({ ...init, argv }),
|
|
412
573
|
},
|
|
413
574
|
fs: {
|
|
414
|
-
read: (path) =>
|
|
415
|
-
write: (path, text) =>
|
|
416
|
-
list: (path) =>
|
|
417
|
-
exists: (path) =>
|
|
418
|
-
stat: (path, options) =>
|
|
575
|
+
read: (path) => checked.fs.read(path),
|
|
576
|
+
write: (path, text) => checked.fs.write(path, text),
|
|
577
|
+
list: (path) => checked.fs.list(path),
|
|
578
|
+
exists: (path) => checked.fs.exists(path),
|
|
579
|
+
stat: (path, options) => checked.fs.stat(path, options),
|
|
580
|
+
find: (glob) => files.find(glob),
|
|
581
|
+
},
|
|
582
|
+
metadata: {
|
|
583
|
+
read: (path) => files.read(path),
|
|
584
|
+
update: (path, change) => files.update(path, change),
|
|
419
585
|
},
|
|
420
|
-
http: { fetch: (url, init) =>
|
|
421
|
-
settings:
|
|
586
|
+
http: { fetch: (url, init) => checked.http.fetch(url, init) },
|
|
587
|
+
settings: settingsPages,
|
|
422
588
|
session: {
|
|
423
|
-
messages:
|
|
589
|
+
messages: ((args?: { readonly agentId: string }) => (args === undefined ? checked.session.messages() : checked.session.messages({ agentId: args.agentId }))) as Mod<State>['session']['messages'],
|
|
424
590
|
async append(text) {
|
|
425
|
-
const added = await
|
|
591
|
+
const added = await checked.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
|
|
426
592
|
if (added.deny !== undefined) throw new Error(`${definition.name}: Claude Code refused the note: ${added.deny}`)
|
|
427
593
|
},
|
|
428
594
|
async submit(text) {
|
|
429
|
-
const submitted = await
|
|
595
|
+
const submitted = await checked.prompt.submit({ text })
|
|
430
596
|
if ('drop' in submitted && typeof submitted.drop === 'string') throw new Error(`${definition.name}: Claude Code dropped the prompt: ${submitted.drop}`)
|
|
431
597
|
},
|
|
432
598
|
},
|
|
433
|
-
agent: { spawn: (args) =>
|
|
434
|
-
model: { complete: (
|
|
599
|
+
agent: { spawn: (args) => checked.agent.spawn(args) },
|
|
600
|
+
model: { complete: (completion, options) => checked.model.complete(completion, options) },
|
|
601
|
+
permissions: {
|
|
602
|
+
has: (name: string, value?: string) => isCovered(granted, itemOf(name, value), home),
|
|
603
|
+
},
|
|
604
|
+
claude: { ...checked, on: (event, hook) => router.add(event, hook) },
|
|
435
605
|
get projectRoot() {
|
|
436
606
|
return modState.root
|
|
437
607
|
},
|
|
@@ -454,6 +624,7 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
|
|
|
454
624
|
if (cwd === nextCwd) cwd = loadedCwd
|
|
455
625
|
throw error
|
|
456
626
|
})
|
|
627
|
+
if (hasMovedRoot) await modOptions.load(nextRoot)
|
|
457
628
|
loadedCwd = nextCwd
|
|
458
629
|
if (!hasMovedRoot) area.changed()
|
|
459
630
|
if (nextCwd === oldCwd) return
|
|
@@ -487,7 +658,15 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
|
|
|
487
658
|
})
|
|
488
659
|
router.add('skill.prompt', userSkillHook(claude))
|
|
489
660
|
const api = Object.fromEntries(Object.entries(definition.api ?? {}).map(([method, run]) => [method, (input: never) => run(input, mod)]))
|
|
490
|
-
router.add('cmod.call', (e, next) =>
|
|
661
|
+
router.add('cmod.call', async (e, next) => {
|
|
662
|
+
if (e.to !== definition.name) return next(e)
|
|
663
|
+
if (e.method === settingsPagesMethod) return { value: [...pages].map(([id, { title }]) => ({ id, title })) }
|
|
664
|
+
if (e.method === openPageMethod) {
|
|
665
|
+
await settingsPages.open(String(e.input))
|
|
666
|
+
return { value: null }
|
|
667
|
+
}
|
|
668
|
+
return answerCall(definition.name, api, e)
|
|
669
|
+
})
|
|
491
670
|
const agents = toolCalls(claude, router)
|
|
492
671
|
const names = new Set<string>()
|
|
493
672
|
const reserveName = (kind: string, name: string, taken: string) => {
|
|
@@ -496,6 +675,9 @@ async function createMod<State extends object>(definition: ModDefinition<State>,
|
|
|
496
675
|
names.add(key)
|
|
497
676
|
}
|
|
498
677
|
await failsAs('its state did not load', () => modState.load())
|
|
678
|
+
await failsAs('its options did not load', () => modOptions.load(root))
|
|
679
|
+
const missing = modOptions.missing
|
|
680
|
+
if (missing.length > 0) throw new MissingOptions(missing, missing.map((key) => definition.options?.[key]?.title ?? key))
|
|
499
681
|
await failsAs('its setup function threw', () => definition.setup(mod))
|
|
500
682
|
const permissionHooks = permissionEvents.filter((event) => router.has(event))
|
|
501
683
|
if (permissionHooks.length > 0 && !runtime.checksPermissions()) {
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { FileMatch, Metadata } from '../mod.js'
|
|
2
|
+
import { formatOf, MetadataError, readMetadata, sidecarSuffix, writeMetadata, type MetadataFormat } from '../utils/metadata.js'
|
|
3
|
+
import { messageOf } from '../utils/text.js'
|
|
4
|
+
import { picomatch, resolve } from '../vendor.js'
|
|
5
|
+
import type { Claude } from './claude.js'
|
|
6
|
+
|
|
7
|
+
export type Places = {
|
|
8
|
+
readonly home: string | undefined
|
|
9
|
+
readonly configRoot: string | undefined
|
|
10
|
+
readonly projectRoot: () => string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type ModFiles = {
|
|
14
|
+
find(glob: string | readonly string[]): Promise<readonly FileMatch[]>
|
|
15
|
+
read(path: string): Promise<Metadata>
|
|
16
|
+
update(path: string, change: (metadata: Record<string, string>) => void): Promise<void>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type Read = { readonly metadata: Metadata; readonly name?: string; readonly error?: string }
|
|
20
|
+
|
|
21
|
+
type Located = { readonly realPath: string; readonly format: MetadataFormat; readonly holder: string; readonly signature: string }
|
|
22
|
+
|
|
23
|
+
const skippedFolders: readonly string[] = ['.git', 'node_modules']
|
|
24
|
+
|
|
25
|
+
const ownKey = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/
|
|
26
|
+
|
|
27
|
+
export function resolvedPath(path: string, places: Places): string {
|
|
28
|
+
const configured = /^~\/\.claude(?=\/|$)/.exec(path)
|
|
29
|
+
if (configured !== null && places.configRoot !== undefined) return resolve(`${places.configRoot}${path.slice(configured[0].length)}`)
|
|
30
|
+
if (path === '~' || path.startsWith('~/')) {
|
|
31
|
+
if (places.home === undefined) throw new Error(`${path} starts with ~, and HOME is not set. Start Claude Code with HOME set.`)
|
|
32
|
+
return resolve(`${places.home}${path.slice(1)}`)
|
|
33
|
+
}
|
|
34
|
+
return path.startsWith('/') ? resolve(path) : resolve(places.projectRoot(), path)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createModFiles({ name, claude, places, checkWrite }: { readonly name: string; readonly claude: Claude; readonly places: Places; readonly checkWrite: (path: string) => Promise<void> }): ModFiles {
|
|
38
|
+
const prefix = `${name}.`
|
|
39
|
+
const cache = new Map<string, { readonly signature: string; readonly read: Read }>()
|
|
40
|
+
const logged = new Set<string>()
|
|
41
|
+
|
|
42
|
+
const own = (all: Metadata): Metadata => Object.freeze(Object.fromEntries(Object.entries(all).flatMap(([key, value]) => (key.startsWith(prefix) ? [[key.slice(prefix.length), value]] : []))))
|
|
43
|
+
|
|
44
|
+
const locate = async (path: string): Promise<Located> => {
|
|
45
|
+
const stat = await claude.fs.stat(path, { resolve: true })
|
|
46
|
+
if (stat.kind !== 'file') throw new Error(`${path} is not a file, so it holds no metadata.`)
|
|
47
|
+
const realPath = stat.realPath ?? path
|
|
48
|
+
const extensionless = !realPath.slice(realPath.lastIndexOf('/') + 1).includes('.')
|
|
49
|
+
const firstLine = extensionless ? (await claude.fs.read(realPath)).split('\n', 1)[0] : undefined
|
|
50
|
+
const format = formatOf(realPath, firstLine)
|
|
51
|
+
const holder = format.kind === 'sidecar' ? `${realPath}${sidecarSuffix}` : realPath
|
|
52
|
+
const sidecar = format.kind === 'sidecar' ? await claude.fs.stat(holder).catch(() => undefined) : undefined
|
|
53
|
+
return { realPath, format, holder, signature: `${stat.mtimeMs}:${stat.size}:${sidecar?.kind === 'file' ? `${sidecar.mtimeMs}:${sidecar.size}` : ''}` }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const textOf = async (holder: string): Promise<string> => ((await claude.fs.stat(holder).catch(() => undefined))?.kind === 'file' ? claude.fs.read(holder) : '')
|
|
57
|
+
|
|
58
|
+
const readEntry = async (path: string): Promise<Read> => {
|
|
59
|
+
const located = await locate(path)
|
|
60
|
+
const cached = cache.get(path)
|
|
61
|
+
if (cached?.signature === located.signature) return cached.read
|
|
62
|
+
let read: Read
|
|
63
|
+
try {
|
|
64
|
+
const { metadata, name: declared } = readMetadata(await textOf(located.holder), located.format)
|
|
65
|
+
read = { metadata, ...(declared === undefined ? {} : { name: declared }) }
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (!(error instanceof MetadataError)) throw error
|
|
68
|
+
const failure = `${located.holder} ${error.message}`
|
|
69
|
+
read = { metadata: {}, error: failure }
|
|
70
|
+
if (!logged.has(failure)) claude.ui.log(`${name}: ${failure}`)
|
|
71
|
+
logged.add(failure)
|
|
72
|
+
}
|
|
73
|
+
cache.set(path, { signature: located.signature, read })
|
|
74
|
+
return read
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const matchesOf = async (pattern: string): Promise<string[]> => {
|
|
78
|
+
const absolute = resolvedPath(pattern, places)
|
|
79
|
+
const scanned = picomatch.scan(absolute)
|
|
80
|
+
if (!scanned.isGlob) return (await claude.fs.stat(absolute).catch(() => undefined))?.kind === 'file' ? [absolute] : []
|
|
81
|
+
const isMatch = picomatch(absolute, { dot: true })
|
|
82
|
+
const entersSkipped = skippedFolders.filter((folder) => scanned.glob.split('/').includes(folder))
|
|
83
|
+
const maxDepth = scanned.glob.includes('**') ? Number.POSITIVE_INFINITY : scanned.glob.split('/').length
|
|
84
|
+
const found: string[] = []
|
|
85
|
+
const visited = new Set<string>()
|
|
86
|
+
const walk = async (folder: string, depth: number): Promise<void> => {
|
|
87
|
+
const real = (await claude.fs.stat(folder, { resolve: true }).catch(() => undefined))?.realPath ?? folder
|
|
88
|
+
if (visited.has(real)) return
|
|
89
|
+
visited.add(real)
|
|
90
|
+
const entries = await claude.fs.list(folder).catch(() => [])
|
|
91
|
+
await Promise.all(
|
|
92
|
+
entries.map(async (entry) => {
|
|
93
|
+
const path = `${folder}/${entry.name}`
|
|
94
|
+
const kind = entry.isLink === true ? (await claude.fs.stat(path).catch(() => undefined))?.kind : entry.kind
|
|
95
|
+
if (kind === 'file' && isMatch(path)) found.push(path)
|
|
96
|
+
if (kind !== 'dir' || depth + 1 >= maxDepth) return
|
|
97
|
+
if (skippedFolders.includes(entry.name) && !entersSkipped.includes(entry.name)) return
|
|
98
|
+
await walk(path, depth + 1)
|
|
99
|
+
}),
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
const base = scanned.base === '' ? '/' : scanned.base
|
|
103
|
+
if ((await claude.fs.stat(base).catch(() => undefined))?.kind === 'dir') await walk(base, 0)
|
|
104
|
+
return found
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
async find(glob) {
|
|
109
|
+
const patterns = typeof glob === 'string' ? [glob] : glob
|
|
110
|
+
const paths = [...new Set((await Promise.all(patterns.map(matchesOf))).flat())].sort()
|
|
111
|
+
const kept = paths.filter((path) => !(path.endsWith(sidecarSuffix) && paths.includes(path.slice(0, -sidecarSuffix.length))))
|
|
112
|
+
return Promise.all(
|
|
113
|
+
kept.map(async (path): Promise<FileMatch> => {
|
|
114
|
+
const read = await readEntry(path)
|
|
115
|
+
return { path, ...(read.name === undefined ? {} : { name: read.name }), metadata: own(read.metadata), ...(read.error === undefined ? {} : { error: read.error }) }
|
|
116
|
+
}),
|
|
117
|
+
)
|
|
118
|
+
},
|
|
119
|
+
async read(path) {
|
|
120
|
+
const read = await readEntry(resolvedPath(path, places))
|
|
121
|
+
if (read.error !== undefined) throw new Error(`${name}: ${read.error}`)
|
|
122
|
+
return own(read.metadata)
|
|
123
|
+
},
|
|
124
|
+
async update(path, change) {
|
|
125
|
+
const given = resolvedPath(path, places)
|
|
126
|
+
await checkWrite(given)
|
|
127
|
+
const located = await locate(given)
|
|
128
|
+
const text = await textOf(located.holder)
|
|
129
|
+
let all: Metadata
|
|
130
|
+
try {
|
|
131
|
+
all = readMetadata(text, located.format).metadata
|
|
132
|
+
} catch (error) {
|
|
133
|
+
throw new Error(`${name}: ${located.holder} ${messageOf(error)}`, { cause: error })
|
|
134
|
+
}
|
|
135
|
+
const edited: Record<string, string> = { ...own(all) }
|
|
136
|
+
change(edited)
|
|
137
|
+
for (const [key, value] of Object.entries(edited)) {
|
|
138
|
+
if (!ownKey.test(key)) throw new Error(`${name}: the metadata key "${key}" is not a name. Use letters, digits, "_", ".", and "-".`)
|
|
139
|
+
if (typeof value !== 'string') throw new Error(`${name}: the metadata key "${key}" holds ${JSON.stringify(value)}. Each value is text, and a list is one space-separated text.`)
|
|
140
|
+
if (value.includes('\n')) throw new Error(`${name}: the metadata key "${key}" holds a line break. Each value is one line of text.`)
|
|
141
|
+
}
|
|
142
|
+
const next: Record<string, string> = {}
|
|
143
|
+
for (const [key, value] of Object.entries(all)) {
|
|
144
|
+
if (!key.startsWith(prefix)) next[key] = value
|
|
145
|
+
else if (Object.hasOwn(edited, key.slice(prefix.length))) next[key] = edited[key.slice(prefix.length)] as string
|
|
146
|
+
}
|
|
147
|
+
for (const [key, value] of Object.entries(edited)) next[`${prefix}${key}`] = value
|
|
148
|
+
if (JSON.stringify(next) === JSON.stringify(all)) return
|
|
149
|
+
await claude.fs.write(located.holder, writeMetadata(text, located.format, next))
|
|
150
|
+
cache.delete(given)
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
}
|