@cmodjs/core 0.1.14 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/docs/commands.md +31 -19
  2. package/docs/index.md +12 -4
  3. package/docs/install-steps.md +30 -4
  4. package/docs/jobs.md +25 -9
  5. package/docs/mod.md +103 -37
  6. package/docs/options.md +93 -0
  7. package/docs/permissions.md +88 -0
  8. package/docs/state.md +4 -31
  9. package/docs/testing.md +13 -2
  10. package/installer.d.ts +10 -0
  11. package/installer.d.ts.map +1 -0
  12. package/installer.js +8 -0
  13. package/jobs/context.d.ts +2 -2
  14. package/jobs/context.d.ts.map +1 -1
  15. package/jobs/context.js +5 -3
  16. package/jobs/permissions.js +1 -1
  17. package/jobs/program.d.ts.map +1 -1
  18. package/jobs/program.js +5 -1
  19. package/jobs/prompt.js +5 -5
  20. package/jobs/schedule.d.ts.map +1 -1
  21. package/jobs/schedule.js +2 -1
  22. package/jobs/slash-command.d.ts.map +1 -1
  23. package/jobs/slash-command.js +3 -2
  24. package/jobs/status-line.d.ts.map +1 -1
  25. package/jobs/status-line.js +3 -2
  26. package/jobs/tool.d.ts.map +1 -1
  27. package/jobs/tool.js +3 -2
  28. package/mod.d.ts +48 -10
  29. package/mod.d.ts.map +1 -1
  30. package/mod.js +3 -0
  31. package/options.d.ts +58 -0
  32. package/options.d.ts.map +1 -0
  33. package/options.js +66 -0
  34. package/package.json +1 -1
  35. package/records.d.ts +14 -1
  36. package/records.d.ts.map +1 -1
  37. package/records.js +106 -7
  38. package/register.d.ts +4 -2
  39. package/register.d.ts.map +1 -1
  40. package/register.js +8 -2
  41. package/runtime/claude.d.ts +5 -1
  42. package/runtime/claude.d.ts.map +1 -1
  43. package/runtime/grants.d.ts +28 -0
  44. package/runtime/grants.d.ts.map +1 -0
  45. package/runtime/grants.js +286 -0
  46. package/runtime/installer.d.ts +23 -0
  47. package/runtime/installer.d.ts.map +1 -0
  48. package/runtime/installer.js +113 -0
  49. package/runtime/lifecycle.d.ts +6 -3
  50. package/runtime/lifecycle.d.ts.map +1 -1
  51. package/runtime/lifecycle.js +220 -36
  52. package/runtime/metadata.d.ts +20 -0
  53. package/runtime/metadata.d.ts.map +1 -0
  54. package/runtime/metadata.js +143 -0
  55. package/runtime/options.d.ts +21 -0
  56. package/runtime/options.d.ts.map +1 -0
  57. package/runtime/options.js +100 -0
  58. package/runtime/state.d.ts +1 -0
  59. package/runtime/state.d.ts.map +1 -1
  60. package/runtime/state.js +5 -75
  61. package/src/installer.ts +17 -0
  62. package/src/jobs/context.ts +5 -3
  63. package/src/jobs/permissions.ts +1 -1
  64. package/src/jobs/program.ts +4 -1
  65. package/src/jobs/prompt.ts +5 -5
  66. package/src/jobs/schedule.ts +2 -1
  67. package/src/jobs/slash-command.ts +3 -2
  68. package/src/jobs/status-line.ts +3 -2
  69. package/src/jobs/tool.ts +3 -2
  70. package/src/mod.ts +46 -11
  71. package/src/options.ts +91 -0
  72. package/src/records.ts +117 -8
  73. package/src/register.ts +12 -3
  74. package/src/runtime/claude.ts +2 -0
  75. package/src/runtime/grants.ts +306 -0
  76. package/src/runtime/installer.ts +163 -0
  77. package/src/runtime/lifecycle.ts +220 -45
  78. package/src/runtime/metadata.ts +153 -0
  79. package/src/runtime/options.ts +102 -0
  80. package/src/runtime/state.ts +7 -70
  81. package/src/testing/fake-claude.ts +6 -1
  82. package/src/testing/fake-files.ts +9 -3
  83. package/src/testing.ts +40 -15
  84. package/src/utils/metadata.ts +242 -0
  85. package/testing/fake-claude.d.ts +4 -0
  86. package/testing/fake-claude.d.ts.map +1 -1
  87. package/testing/fake-claude.js +5 -1
  88. package/testing/fake-files.d.ts.map +1 -1
  89. package/testing/fake-files.js +9 -3
  90. package/testing.d.ts +6 -2
  91. package/testing.d.ts.map +1 -1
  92. package/testing.js +29 -13
  93. package/utils/metadata.d.ts +21 -0
  94. package/utils/metadata.d.ts.map +1 -0
  95. package/utils/metadata.js +250 -0
@@ -1,9 +1,14 @@
1
- import { dataFolder, isAtLeast, oldestCmodFor, parseEvent, readRecord, readSteps, scriptsSha256, storeFolder } from '../records.js';
1
+ import { consentPath, dataFolder, finishStepsMethod, isAtLeast, oldestCmodFor, openPageMethod, parseConsent, pendingStepsMethod, parseEvent, permissionWords, readRecord, readSteps, scriptsSha256, settingsPagesMethod, storeFolder } from '../records.js';
2
2
  import { relativePath } from '../utils/paths.js';
3
3
  import { formatExit, listed, messageOf } from '../utils/text.js';
4
4
  import { beforeDeadline } from './deadline.js';
5
5
  import { answerCall, dependencyCalls, notInstalled } from './dependencies.js';
6
6
  import { classicHook, heldDecisionHook, permissionEvents, preToolUseHook, userSkillHook } from './hooks.js';
7
+ import { checkedAnswer, checkingGrants, checksAnswers, checkWrite, isCovered, itemOf, passedDown } from './grants.js';
8
+ import { createModFiles } from './metadata.js';
9
+ import { fitsOption } from '../options.js';
10
+ import { createInstaller } from './installer.js';
11
+ import { createOptions, MissingOptions } from './options.js';
7
12
  import { createRouter } from './router.js';
8
13
  import { createState } from './state.js';
9
14
  import { toolCalls } from './tool-calls.js';
@@ -30,12 +35,14 @@ export async function readPlugin(claude) {
30
35
  const manifest = (await readJson(read, `${root}/.claude-plugin/plugin.json`));
31
36
  const version = typeof manifest?.version === 'string' ? manifest.version : undefined;
32
37
  const store = storeFolder({ HOME: await claude.env.home(), XDG_DATA_HOME: await claude.env.dataHome() });
38
+ const steps = readSteps(await readJson(read, `${root}/package.json`)) ?? {};
39
+ const declared = steps.permissions ?? [];
33
40
  if (name === cmodPluginName) {
34
41
  const installed = await cmodVersion(claude);
35
- return { name, root, version, store, keys: {}, isInstalled: version !== undefined && installed !== undefined && isAtLeast(installed, version), shouldRecord: false };
42
+ return { name, root, version, store, steps, granted: declared, isInstalled: version !== undefined && installed !== undefined && isAtLeast(installed, version), shouldRecord: false };
36
43
  }
37
- const steps = readSteps(await readJson(read, `${root}/package.json`)) ?? {};
38
- const plugin = { name, root, version, store, keys: steps.keys ?? {} };
44
+ const approved = parseConsent(await readJson(read, consentPath(store)), consentPath(store))[name] ?? [];
45
+ const plugin = { name, root, version, store, steps, granted: declared.filter((item) => approved.includes(item)) };
39
46
  const record = await readRecord(read, store, name);
40
47
  if (Object.keys(steps).length === 0)
41
48
  return { ...plugin, isInstalled: true, shouldRecord: record?.version !== version };
@@ -79,15 +86,18 @@ async function cmodVersion(claude) {
79
86
  throw new Error(`cmod --version ${formatExit(result.exitCode, lastLineOf(result.stderr) ?? '')}`);
80
87
  return result.stdout.trim().split(/\s+/).at(-1);
81
88
  }
82
- export function createLifecycle(definition, checksPermissions = () => true) {
89
+ export function createLifecycle(definition, checksPermissions = () => true, options = {}, asksPerson = true) {
83
90
  let router = createRouter();
84
91
  let phase = 'starting';
85
92
  let runtime;
86
93
  let plugin;
94
+ const granted = new Set();
87
95
  let line;
88
96
  let activation;
89
97
  let mod;
90
98
  let failure;
99
+ let pluginOptions = options;
100
+ const installer = createInstaller(definition.name, () => claude());
91
101
  let shouldRecord = false;
92
102
  let recording;
93
103
  let missedSessionStart;
@@ -95,7 +105,7 @@ export function createLifecycle(definition, checksPermissions = () => true) {
95
105
  const startSettled = new Promise((resolve) => (settleStart = resolve));
96
106
  const claude = () => {
97
107
  if (runtime === undefined)
98
- throw new Error(`${definition.name}: the lifecycle has not started. registerMod(addHook, mod) starts it at session.start.`);
108
+ throw new Error(`${definition.name}: the lifecycle has not started. registerMod(addHook, mod, options) starts it at session.start.`);
99
109
  return runtime.claude;
100
110
  };
101
111
  const showLine = () => {
@@ -132,20 +142,71 @@ export function createLifecycle(definition, checksPermissions = () => true) {
132
142
  recording = undefined;
133
143
  });
134
144
  };
145
+ const saveOption = async (key, text) => {
146
+ const option = definition.options?.[key];
147
+ if (option === undefined || plugin === undefined)
148
+ return `${definition.name} has no option ${key}.`;
149
+ 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;
150
+ const misfit = option.kind === 'toggle' && !['true', 'false'].includes(text.trim()) ? 'takes true or false' : fitsOption(option, value);
151
+ if (misfit !== undefined)
152
+ return `${option.title} ${misfit}.`;
153
+ const saved = await claude().config.set({ key: `${plugin.name}.${key}`, value });
154
+ return saved.deny;
155
+ };
156
+ const askOptions = async (missing) => {
157
+ if (!asksPerson || plugin === undefined || (await claude().session.surfaces()).length === 0)
158
+ return false;
159
+ const declared = definition.options ?? {};
160
+ const asked = missing.keys.flatMap((key) => {
161
+ const option = declared[key];
162
+ return option === undefined ? [] : [[key, option]];
163
+ });
164
+ if (!(await installer.options(asked, saveOption)))
165
+ return false;
166
+ const prefix = `${plugin.name}.`;
167
+ const rows = await claude().config.list();
168
+ pluginOptions = { ...pluginOptions, ...Object.fromEntries(rows.filter((row) => row.key.startsWith(prefix)).map((row) => [row.key.slice(prefix.length), row.value])) };
169
+ return true;
170
+ };
171
+ const pendingSteps = async (active) => {
172
+ const pending = [];
173
+ for (const step of definition.installer ?? [])
174
+ if (!(await Promise.resolve(step.isDone(active)).catch(() => false)))
175
+ pending.push(step.title);
176
+ return pending;
177
+ };
178
+ const runSteps = async (active) => {
179
+ const steps = asksPerson ? (definition.installer ?? []) : [];
180
+ for (const [index, step] of steps.entries()) {
181
+ if (await Promise.resolve(step.isDone(active)).catch(() => false))
182
+ continue;
183
+ const isAnswered = (await claude().session.surfaces()).length > 0 && (await installer.step(active, step, index + 1, steps.length));
184
+ if (!isAnswered) {
185
+ 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.`);
186
+ break;
187
+ }
188
+ }
189
+ await installer.close();
190
+ };
135
191
  const activate = async () => {
136
192
  if (runtime === undefined || plugin === undefined)
137
193
  return;
138
194
  const activeRouter = createRouter();
139
- const active = await createMod(definition, { ...runtime, router: activeRouter, dataFolder: dataFolder(plugin.store, plugin.name), keys: plugin.keys, checksPermissions }).catch((error) => {
140
- fail(messageOf(error), 'Fix it, then run /reload-plugins.');
195
+ 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) => {
196
+ if (error instanceof MissingOptions && (await askOptions(error)))
197
+ return undefined;
198
+ fail(messageOf(error), error instanceof MissingOptions ? `Set ${error.keys.length === 1 ? 'it' : 'them'} in /config.` : 'Fix it, then run /reload-plugins.');
141
199
  throw error;
142
200
  });
201
+ if (active === undefined)
202
+ return activate();
143
203
  const { added } = active;
144
204
  mod = active.mod;
145
205
  router = activeRouter;
146
206
  phase = 'active';
147
207
  runtime.claude.ui.invalidate('ui.render');
148
208
  endLine();
209
+ void runSteps(active.mod).catch((error) => claude().ui.log(`${definition.name}: its installer stopped: ${messageOf(error)}`));
149
210
  const missed = missedSessionStart;
150
211
  missedSessionStart = undefined;
151
212
  const lateAnswer = missed === undefined ? {} : await activeRouter.dispatch('classic.SessionStart', missed, async () => ({})).catch((error) => {
@@ -171,14 +232,9 @@ export function createLifecycle(definition, checksPermissions = () => true) {
171
232
  return;
172
233
  }
173
234
  showLine().wait('Waiting for your answer');
174
- const actions = [
175
- ...(event.install === '' ? [] : [`runs ${event.install} to install`]),
176
- ...(event.uninstall === '' ? [] : [`runs ${event.uninstall} when you remove it`]),
177
- ...(event.keys === '' ? [] : [`binds ${event.keys}`]),
178
- ];
179
- const answer = await claude().ui.ask(`${name} ${listed(actions)}. Install it now?`, { options: ['Install', 'Not now'], header: 'Install' });
180
- if (answer === 'Install')
235
+ if (await installer.consent(event))
181
236
  return install(event.sha256);
237
+ await installer.close();
182
238
  phase = 'declined';
183
239
  endLine();
184
240
  claude().ui.log(`${name} is not installed. Run cmod install ${name} to install it.`);
@@ -203,14 +259,17 @@ export function createLifecycle(definition, checksPermissions = () => true) {
203
259
  if (event.kind === 'done' || event.kind === 'failed' || event.kind === 'needs-consent')
204
260
  outcome = event;
205
261
  });
206
- if (outcome?.kind === 'done')
262
+ if (outcome?.kind === 'done') {
263
+ for (const item of plugin.steps.permissions ?? [])
264
+ granted.add(item);
207
265
  return finish();
266
+ }
208
267
  if (outcome?.kind === 'needs-consent')
209
268
  return askConsent(outcome);
210
269
  const reason = outcome?.kind === 'failed' ? outcome.message : `cmod setup ${formatExit(code, lastError)}`;
211
270
  fail(reason, `Fix the cause, then run: cmod install ${definition.name}`);
212
271
  };
213
- const fits = (found) => found !== undefined && isAtLeast(found, oldestCmodFor(plugin?.keys ?? {}));
272
+ const fits = (found) => found !== undefined && isAtLeast(found, oldestCmodFor(plugin?.steps ?? {}));
214
273
  const waitForcmod = (found) => {
215
274
  phase = 'waiting';
216
275
  showLine().wait('Waiting for cmod');
@@ -237,7 +296,7 @@ export function createLifecycle(definition, checksPermissions = () => true) {
237
296
  });
238
297
  const longWait = claude().clock.after(cmodWaitMs, () => showLine().wait(latest === undefined
239
298
  ? "Still waiting for cmod to download cmod. See cmod's own line."
240
- : `PATH finds cmod ${latest}, and ${definition.name} needs cmod ${oldestCmodFor(plugin?.keys ?? {})} or later. Run npm i -g @cmodjs/cli, or put ~/.local/bin ahead of the old cmod on PATH.`));
299
+ : `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.`));
241
300
  };
242
301
  const bootstrap = async (root) => {
243
302
  const progress = showLine();
@@ -256,6 +315,47 @@ export function createLifecycle(definition, checksPermissions = () => true) {
256
315
  if (phase !== 'failed')
257
316
  fail(messageOf(error), `Run cmod install ${definition.name} in a terminal to see the whole log.`);
258
317
  };
318
+ let consentMs = 0;
319
+ const consentStamp = async (path) => {
320
+ const stat = await claude().fs.stat(path).catch(() => undefined);
321
+ return stat?.kind === 'file' ? stat.mtimeMs : 0;
322
+ };
323
+ const refreshGrants = async () => {
324
+ if (plugin === undefined || plugin.name === cmodPluginName)
325
+ return;
326
+ const path = consentPath(plugin.store);
327
+ const modifiedMs = await consentStamp(path);
328
+ if (modifiedMs === consentMs)
329
+ return;
330
+ consentMs = modifiedMs;
331
+ const approved = modifiedMs === 0 ? [] : (parseConsent(JSON.parse(await claude().fs.read(path)), path)[plugin.name] ?? []);
332
+ granted.clear();
333
+ for (const item of plugin.steps.permissions ?? []) {
334
+ if (approved.includes(item))
335
+ granted.add(item);
336
+ }
337
+ };
338
+ const droppedParts = new Set();
339
+ const answerCheck = {
340
+ get plugin() {
341
+ return plugin?.name ?? definition.name;
342
+ },
343
+ isGranted: (item) => isCovered(granted, item, undefined),
344
+ dropped(item, part) {
345
+ if (droppedParts.has(item))
346
+ return;
347
+ droppedParts.add(item);
348
+ const declared = plugin?.steps.permissions ?? [];
349
+ const fix = declared.includes(item) ? `Turn it on in /mods ${definition.name}.` : `It needs "permissions": { "${item}": true } in package.json "cmod".`;
350
+ claude().ui.log(`${definition.name} answered ${part} without your grant to "${permissionWords(item)}", so cmod dropped it. ${fix}`);
351
+ },
352
+ };
353
+ const checkedRoute = async (event, e, next) => {
354
+ let below;
355
+ const passOn = (passed) => (below ??= next(passedDown(event, e, passed, answerCheck)));
356
+ const answer = await router.dispatch(event, e, passOn);
357
+ return (await checkedAnswer(event, e, answer, () => passOn(e), answerCheck));
358
+ };
259
359
  const whenActive = async () => {
260
360
  if (phase === 'ready')
261
361
  activation ??= activate().catch(report);
@@ -291,6 +391,9 @@ export function createLifecycle(definition, checksPermissions = () => true) {
291
391
  if (started === undefined)
292
392
  return;
293
393
  plugin = started;
394
+ for (const item of started.granted)
395
+ granted.add(item);
396
+ consentMs = await consentStamp(consentPath(started.store));
294
397
  if (started.isInstalled) {
295
398
  shouldRecord = started.shouldRecord;
296
399
  if (shouldRecord)
@@ -306,6 +409,10 @@ export function createLifecycle(definition, checksPermissions = () => true) {
306
409
  }
307
410
  },
308
411
  async route(event, e, next) {
412
+ if (event === 'ui.render' && installer.draws(e))
413
+ return installer.draw(e);
414
+ if (event === 'ui.close')
415
+ installer.closed(e.id);
309
416
  if (event === 'classic.SessionStart')
310
417
  missedSessionStart = undefined;
311
418
  if (event === 'classic.SessionStart' && runtime === undefined) {
@@ -321,6 +428,17 @@ export function createLifecycle(definition, checksPermissions = () => true) {
321
428
  const isStopped = phase === 'declined' || phase === 'failed';
322
429
  return { deny: isStopped ? notInstalled(definition.name) : `${definition.name} is installing. Try again when it's ready.` };
323
430
  }
431
+ const call = e;
432
+ if (event === 'cmod.call' && call.to === definition.name && mod !== undefined) {
433
+ if (call.method === pendingStepsMethod)
434
+ return { value: await pendingSteps(mod) };
435
+ if (call.method === finishStepsMethod)
436
+ return (await runSteps(mod), { value: null });
437
+ }
438
+ if (event === 'classic.UserPromptSubmit' || event === 'classic.SessionStart')
439
+ await refreshGrants().catch((error) => claude().ui.log(`${definition.name} keeps its grants from before: ${messageOf(error)}`, { to: 'debug' }));
440
+ if (checksAnswers(event) && phase === 'active')
441
+ return checkedRoute(event, e, next);
324
442
  const answer = router.dispatch(event, e, next);
325
443
  const render = e;
326
444
  if (event !== 'ui.render' || render.component !== 'AbovePrompt' || runtime === undefined || !runtime.progress.isShown)
@@ -331,22 +449,63 @@ export function createLifecycle(definition, checksPermissions = () => true) {
331
449
  }
332
450
  async function createMod(definition, runtime) {
333
451
  const { router, progress } = runtime;
334
- const claude = checkingKeys(runtime.claude, definition.name, runtime.keys);
452
+ const claude = checkingKeys(runtime.claude, definition.name, runtime.steps.keys ?? {});
335
453
  const added = [];
336
454
  const hookEvents = [];
337
455
  const announce = (feature) => {
338
456
  added.push(feature);
339
457
  };
340
- const [session, root, startCwd] = await Promise.all([claude.session.id(), claude.session.root(), claude.session.cwd()]);
458
+ const [session, root, startCwd, home, configHome] = await Promise.all([claude.session.id(), claude.session.root(), claude.session.cwd(), claude.env.home(), claude.env.configHome()]);
459
+ const { granted } = runtime;
460
+ const configRoot = configHome ?? (home === undefined ? undefined : `${home}/.claude`);
461
+ const grants = {
462
+ name: definition.name,
463
+ declared: runtime.steps.permissions ?? [],
464
+ granted: () => granted,
465
+ refresh: runtime.refreshGrants,
466
+ home,
467
+ configRoot,
468
+ projectRoot: () => modState.root,
469
+ freeFolders: () => [modState.root, runtime.dataFolder],
470
+ };
471
+ const checked = checkingGrants(claude, grants);
472
+ const files = createModFiles({
473
+ name: definition.name,
474
+ claude,
475
+ places: { home, configRoot, projectRoot: () => modState.root },
476
+ checkWrite: (path) => checkWrite(grants, `metadata.update(${path})`, path),
477
+ });
478
+ const pages = new Map();
479
+ const settingsPages = {
480
+ page(page) {
481
+ if (pages.has(page.id))
482
+ throw new Error(`${definition.name}: the settings page "${page.id}" is already added. Give each page its own id.`);
483
+ pages.set(page.id, { title: page.title, handle: area.ui.pane(page) });
484
+ },
485
+ async open(pageId) {
486
+ if (pageId === undefined) {
487
+ await claude.cmod.call({ to: cmodPluginName, method: 'openSettings', input: { mod: definition.name } });
488
+ return;
489
+ }
490
+ const page = pages.get(pageId);
491
+ if (page === undefined)
492
+ throw new Error(`${definition.name} has no settings page "${pageId}". It has: ${[...pages.keys()].join(', ') || 'none'}.`);
493
+ await page.handle.open({ focus: true });
494
+ },
495
+ };
341
496
  let cwd = startCwd;
342
497
  let loadedCwd = startCwd;
343
498
  let staleCwd;
344
499
  const area = createUi({ name: definition.name, claude, router, progress, announce, mod: () => mod });
345
500
  const modState = createState({ name: definition.name, initial: definition.state ?? {}, session, root, claude, changed: area.changed });
501
+ const modOptions = createOptions({ name: definition.name, declared: definition.options ?? {}, fromClaude: runtime.options, claude, changed: modState.changed });
346
502
  const held = runtime.checksPermissions() ? new Map() : undefined;
347
503
  const mod = {
348
504
  name: definition.name,
349
505
  state: modState.state,
506
+ get options() {
507
+ return modOptions.values;
508
+ },
350
509
  dataFolder: runtime.dataFolder,
351
510
  on(event, hook, options) {
352
511
  const bounded = options?.timeoutMs === undefined ? hook : timedHook(definition.name, event, hook, checkedMs(definition.name, `the ${event} hook's timeoutMs`, options.timeoutMs), claude);
@@ -357,7 +516,7 @@ async function createMod(definition, runtime) {
357
516
  else
358
517
  router.add(`classic.${event}`, classicHook(definition.name, event, bounded, claude, agents));
359
518
  },
360
- use: (job) => job({ mod, claude, on: (event, hook) => router.add(event, hook), announce, reserveName, toolCalls: agents }),
519
+ use: (job) => job({ mod, announce, reserveName, toolCalls: agents }),
361
520
  every(ms, hook) {
362
521
  let isRunning = false;
363
522
  return claude.clock.every(checkedMs(definition.name, 'mod.every', ms), () => {
@@ -374,33 +533,42 @@ async function createMod(definition, runtime) {
374
533
  },
375
534
  ui: area.ui,
376
535
  process: {
377
- run: (argv, init) => claude.process.run(argv, init),
378
- spawn: (argv, init) => claude.process.spawn({ ...init, argv }),
536
+ run: (argv, init) => checked.process.run(argv, init),
537
+ spawn: (argv, init) => checked.process.spawn({ ...init, argv }),
379
538
  },
380
539
  fs: {
381
- read: (path) => claude.fs.read(path),
382
- write: (path, text) => claude.fs.write(path, text),
383
- list: (path) => claude.fs.list(path),
384
- exists: (path) => claude.fs.exists(path),
385
- stat: (path, options) => claude.fs.stat(path, options),
540
+ read: (path) => checked.fs.read(path),
541
+ write: (path, text) => checked.fs.write(path, text),
542
+ list: (path) => checked.fs.list(path),
543
+ exists: (path) => checked.fs.exists(path),
544
+ stat: (path, options) => checked.fs.stat(path, options),
545
+ find: (glob) => files.find(glob),
546
+ },
547
+ metadata: {
548
+ read: (path) => files.read(path),
549
+ update: (path, change) => files.update(path, change),
386
550
  },
387
- http: { fetch: (url, init) => claude.http.fetch(url, init) },
388
- settings: { read: (args) => claude.settings.read(args) },
551
+ http: { fetch: (url, init) => checked.http.fetch(url, init) },
552
+ settings: settingsPages,
389
553
  session: {
390
- messages: claude.session.messages,
554
+ messages: ((args) => (args === undefined ? checked.session.messages() : checked.session.messages({ agentId: args.agentId }))),
391
555
  async append(text) {
392
- const added = await claude.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } });
556
+ const added = await checked.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } });
393
557
  if (added.deny !== undefined)
394
558
  throw new Error(`${definition.name}: Claude Code refused the note: ${added.deny}`);
395
559
  },
396
560
  async submit(text) {
397
- const submitted = await claude.prompt.submit({ text });
561
+ const submitted = await checked.prompt.submit({ text });
398
562
  if ('drop' in submitted && typeof submitted.drop === 'string')
399
563
  throw new Error(`${definition.name}: Claude Code dropped the prompt: ${submitted.drop}`);
400
564
  },
401
565
  },
402
- agent: { spawn: (args) => claude.agent.spawn(args) },
403
- model: { complete: (request, options) => claude.model.complete(request, options) },
566
+ agent: { spawn: (args) => checked.agent.spawn(args) },
567
+ model: { complete: (completion, options) => checked.model.complete(completion, options) },
568
+ permissions: {
569
+ has: (name, value) => isCovered(granted, itemOf(name, value), home),
570
+ },
571
+ claude: { ...checked, on: (event, hook) => router.add(event, hook) },
404
572
  get projectRoot() {
405
573
  return modState.root;
406
574
  },
@@ -427,6 +595,8 @@ async function createMod(definition, runtime) {
427
595
  cwd = loadedCwd;
428
596
  throw error;
429
597
  });
598
+ if (hasMovedRoot)
599
+ await modOptions.load(nextRoot);
430
600
  loadedCwd = nextCwd;
431
601
  if (!hasMovedRoot)
432
602
  area.changed();
@@ -465,7 +635,17 @@ async function createMod(definition, runtime) {
465
635
  });
466
636
  router.add('skill.prompt', userSkillHook(claude));
467
637
  const api = Object.fromEntries(Object.entries(definition.api ?? {}).map(([method, run]) => [method, (input) => run(input, mod)]));
468
- router.add('cmod.call', (e, next) => (e.to === definition.name ? answerCall(definition.name, api, e) : next(e)));
638
+ router.add('cmod.call', async (e, next) => {
639
+ if (e.to !== definition.name)
640
+ return next(e);
641
+ if (e.method === settingsPagesMethod)
642
+ return { value: [...pages].map(([id, { title }]) => ({ id, title })) };
643
+ if (e.method === openPageMethod) {
644
+ await settingsPages.open(String(e.input));
645
+ return { value: null };
646
+ }
647
+ return answerCall(definition.name, api, e);
648
+ });
469
649
  const agents = toolCalls(claude, router);
470
650
  const names = new Set();
471
651
  const reserveName = (kind, name, taken) => {
@@ -475,6 +655,10 @@ async function createMod(definition, runtime) {
475
655
  names.add(key);
476
656
  };
477
657
  await failsAs('its state did not load', () => modState.load());
658
+ await failsAs('its options did not load', () => modOptions.load(root));
659
+ const missing = modOptions.missing;
660
+ if (missing.length > 0)
661
+ throw new MissingOptions(missing, missing.map((key) => definition.options?.[key]?.title ?? key));
478
662
  await failsAs('its setup function threw', () => definition.setup(mod));
479
663
  const permissionHooks = permissionEvents.filter((event) => router.has(event));
480
664
  if (permissionHooks.length > 0 && !runtime.checksPermissions()) {
@@ -0,0 +1,20 @@
1
+ import type { FileMatch, Metadata } from '../mod.js';
2
+ import type { Claude } from './claude.js';
3
+ export type Places = {
4
+ readonly home: string | undefined;
5
+ readonly configRoot: string | undefined;
6
+ readonly projectRoot: () => string;
7
+ };
8
+ export type ModFiles = {
9
+ find(glob: string | readonly string[]): Promise<readonly FileMatch[]>;
10
+ read(path: string): Promise<Metadata>;
11
+ update(path: string, change: (metadata: Record<string, string>) => void): Promise<void>;
12
+ };
13
+ export declare function resolvedPath(path: string, places: Places): string;
14
+ export declare function createModFiles({ name, claude, places, checkWrite }: {
15
+ readonly name: string;
16
+ readonly claude: Claude;
17
+ readonly places: Places;
18
+ readonly checkWrite: (path: string) => Promise<void>;
19
+ }): ModFiles;
20
+ //# sourceMappingURL=metadata.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metadata.d.ts","sourceRoot":"","sources":["../src/runtime/metadata.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAA;AAIpD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAEzC,MAAM,MAAM,MAAM,GAAG;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAA;IACjC,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,CAAA;IACvC,QAAQ,CAAC,WAAW,EAAE,MAAM,MAAM,CAAA;CACnC,CAAA;AAED,MAAM,MAAM,QAAQ,GAAG;IACrB,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,SAAS,SAAS,EAAE,CAAC,CAAA;IACrE,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;IACrC,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACxF,CAAA;AAUD,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAQjE;AAED,wBAAgB,cAAc,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,EAAE;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,GAAG,QAAQ,CAoHhN"}
@@ -0,0 +1,143 @@
1
+ import { formatOf, MetadataError, readMetadata, sidecarSuffix, writeMetadata } from '../utils/metadata.js';
2
+ import { messageOf } from '../utils/text.js';
3
+ import { picomatch, resolve } from '../vendor.js';
4
+ const skippedFolders = ['.git', 'node_modules'];
5
+ const ownKey = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
6
+ export function resolvedPath(path, places) {
7
+ const configured = /^~\/\.claude(?=\/|$)/.exec(path);
8
+ if (configured !== null && places.configRoot !== undefined)
9
+ return resolve(`${places.configRoot}${path.slice(configured[0].length)}`);
10
+ if (path === '~' || path.startsWith('~/')) {
11
+ if (places.home === undefined)
12
+ throw new Error(`${path} starts with ~, and HOME is not set. Start Claude Code with HOME set.`);
13
+ return resolve(`${places.home}${path.slice(1)}`);
14
+ }
15
+ return path.startsWith('/') ? resolve(path) : resolve(places.projectRoot(), path);
16
+ }
17
+ export function createModFiles({ name, claude, places, checkWrite }) {
18
+ const prefix = `${name}.`;
19
+ const cache = new Map();
20
+ const logged = new Set();
21
+ const own = (all) => Object.freeze(Object.fromEntries(Object.entries(all).flatMap(([key, value]) => (key.startsWith(prefix) ? [[key.slice(prefix.length), value]] : []))));
22
+ const locate = async (path) => {
23
+ const stat = await claude.fs.stat(path, { resolve: true });
24
+ if (stat.kind !== 'file')
25
+ throw new Error(`${path} is not a file, so it holds no metadata.`);
26
+ const realPath = stat.realPath ?? path;
27
+ const extensionless = !realPath.slice(realPath.lastIndexOf('/') + 1).includes('.');
28
+ const firstLine = extensionless ? (await claude.fs.read(realPath)).split('\n', 1)[0] : undefined;
29
+ const format = formatOf(realPath, firstLine);
30
+ const holder = format.kind === 'sidecar' ? `${realPath}${sidecarSuffix}` : realPath;
31
+ const sidecar = format.kind === 'sidecar' ? await claude.fs.stat(holder).catch(() => undefined) : undefined;
32
+ return { realPath, format, holder, signature: `${stat.mtimeMs}:${stat.size}:${sidecar?.kind === 'file' ? `${sidecar.mtimeMs}:${sidecar.size}` : ''}` };
33
+ };
34
+ const textOf = async (holder) => ((await claude.fs.stat(holder).catch(() => undefined))?.kind === 'file' ? claude.fs.read(holder) : '');
35
+ const readEntry = async (path) => {
36
+ const located = await locate(path);
37
+ const cached = cache.get(path);
38
+ if (cached?.signature === located.signature)
39
+ return cached.read;
40
+ let read;
41
+ try {
42
+ const { metadata, name: declared } = readMetadata(await textOf(located.holder), located.format);
43
+ read = { metadata, ...(declared === undefined ? {} : { name: declared }) };
44
+ }
45
+ catch (error) {
46
+ if (!(error instanceof MetadataError))
47
+ throw error;
48
+ const failure = `${located.holder} ${error.message}`;
49
+ read = { metadata: {}, error: failure };
50
+ if (!logged.has(failure))
51
+ claude.ui.log(`${name}: ${failure}`);
52
+ logged.add(failure);
53
+ }
54
+ cache.set(path, { signature: located.signature, read });
55
+ return read;
56
+ };
57
+ const matchesOf = async (pattern) => {
58
+ const absolute = resolvedPath(pattern, places);
59
+ const scanned = picomatch.scan(absolute);
60
+ if (!scanned.isGlob)
61
+ return (await claude.fs.stat(absolute).catch(() => undefined))?.kind === 'file' ? [absolute] : [];
62
+ const isMatch = picomatch(absolute, { dot: true });
63
+ const entersSkipped = skippedFolders.filter((folder) => scanned.glob.split('/').includes(folder));
64
+ const maxDepth = scanned.glob.includes('**') ? Number.POSITIVE_INFINITY : scanned.glob.split('/').length;
65
+ const found = [];
66
+ const visited = new Set();
67
+ const walk = async (folder, depth) => {
68
+ const real = (await claude.fs.stat(folder, { resolve: true }).catch(() => undefined))?.realPath ?? folder;
69
+ if (visited.has(real))
70
+ return;
71
+ visited.add(real);
72
+ const entries = await claude.fs.list(folder).catch(() => []);
73
+ await Promise.all(entries.map(async (entry) => {
74
+ const path = `${folder}/${entry.name}`;
75
+ const kind = entry.isLink === true ? (await claude.fs.stat(path).catch(() => undefined))?.kind : entry.kind;
76
+ if (kind === 'file' && isMatch(path))
77
+ found.push(path);
78
+ if (kind !== 'dir' || depth + 1 >= maxDepth)
79
+ return;
80
+ if (skippedFolders.includes(entry.name) && !entersSkipped.includes(entry.name))
81
+ return;
82
+ await walk(path, depth + 1);
83
+ }));
84
+ };
85
+ const base = scanned.base === '' ? '/' : scanned.base;
86
+ if ((await claude.fs.stat(base).catch(() => undefined))?.kind === 'dir')
87
+ await walk(base, 0);
88
+ return found;
89
+ };
90
+ return {
91
+ async find(glob) {
92
+ const patterns = typeof glob === 'string' ? [glob] : glob;
93
+ const paths = [...new Set((await Promise.all(patterns.map(matchesOf))).flat())].sort();
94
+ const kept = paths.filter((path) => !(path.endsWith(sidecarSuffix) && paths.includes(path.slice(0, -sidecarSuffix.length))));
95
+ return Promise.all(kept.map(async (path) => {
96
+ const read = await readEntry(path);
97
+ return { path, ...(read.name === undefined ? {} : { name: read.name }), metadata: own(read.metadata), ...(read.error === undefined ? {} : { error: read.error }) };
98
+ }));
99
+ },
100
+ async read(path) {
101
+ const read = await readEntry(resolvedPath(path, places));
102
+ if (read.error !== undefined)
103
+ throw new Error(`${name}: ${read.error}`);
104
+ return own(read.metadata);
105
+ },
106
+ async update(path, change) {
107
+ const given = resolvedPath(path, places);
108
+ await checkWrite(given);
109
+ const located = await locate(given);
110
+ const text = await textOf(located.holder);
111
+ let all;
112
+ try {
113
+ all = readMetadata(text, located.format).metadata;
114
+ }
115
+ catch (error) {
116
+ throw new Error(`${name}: ${located.holder} ${messageOf(error)}`, { cause: error });
117
+ }
118
+ const edited = { ...own(all) };
119
+ change(edited);
120
+ for (const [key, value] of Object.entries(edited)) {
121
+ if (!ownKey.test(key))
122
+ throw new Error(`${name}: the metadata key "${key}" is not a name. Use letters, digits, "_", ".", and "-".`);
123
+ if (typeof value !== 'string')
124
+ throw new Error(`${name}: the metadata key "${key}" holds ${JSON.stringify(value)}. Each value is text, and a list is one space-separated text.`);
125
+ if (value.includes('\n'))
126
+ throw new Error(`${name}: the metadata key "${key}" holds a line break. Each value is one line of text.`);
127
+ }
128
+ const next = {};
129
+ for (const [key, value] of Object.entries(all)) {
130
+ if (!key.startsWith(prefix))
131
+ next[key] = value;
132
+ else if (Object.hasOwn(edited, key.slice(prefix.length)))
133
+ next[key] = edited[key.slice(prefix.length)];
134
+ }
135
+ for (const [key, value] of Object.entries(edited))
136
+ next[`${prefix}${key}`] = value;
137
+ if (JSON.stringify(next) === JSON.stringify(all))
138
+ return;
139
+ await claude.fs.write(located.holder, writeMetadata(text, located.format, next));
140
+ cache.delete(given);
141
+ },
142
+ };
143
+ }
@@ -0,0 +1,21 @@
1
+ import type { PluginOptions } from 'claude-code';
2
+ import { type Options } from '../options.js';
3
+ import type { Claude } from './claude.js';
4
+ export declare class MissingOptions extends Error {
5
+ readonly keys: readonly string[];
6
+ readonly titles: readonly string[];
7
+ constructor(keys: readonly string[], titles: readonly string[]);
8
+ }
9
+ export type ModOptions = {
10
+ readonly values: Readonly<Record<string, unknown>>;
11
+ readonly missing: readonly string[];
12
+ load(root: string): Promise<void>;
13
+ };
14
+ export declare function createOptions({ name, declared, fromClaude, claude, changed }: {
15
+ readonly name: string;
16
+ readonly declared: Options;
17
+ readonly fromClaude: PluginOptions;
18
+ readonly claude: Claude;
19
+ readonly changed: () => void;
20
+ }): ModOptions;
21
+ //# sourceMappingURL=options.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"options.d.ts","sourceRoot":"","sources":["../src/runtime/options.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAChD,OAAO,EAAoC,KAAK,OAAO,EAAE,MAAM,eAAe,CAAA;AAG9E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAEzC,qBAAa,cAAe,SAAQ,KAAK;IACvC,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAA;IAChC,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;IAElC,YAAY,IAAI,EAAE,SAAS,MAAM,EAAE,EAAE,MAAM,EAAE,SAAS,MAAM,EAAE,EAI7D;CACF;AAED,MAAM,MAAM,UAAU,GAAG;IACvB,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;IAClD,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;IACnC,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CAClC,CAAA;AAED,wBAAgB,aAAa,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,UAAU,EAAE,aAAa,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,IAAI,CAAA;CAAE,GAAG,UAAU,CAmE3O"}