@cmodjs/core 0.1.13 → 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 +14 -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
@@ -0,0 +1,100 @@
1
+ import { fitsOption, isUnset } from '../options.js';
2
+ import { configFolders } from '../records.js';
3
+ import { listed, messageOf } from '../utils/text.js';
4
+ export class MissingOptions extends Error {
5
+ keys;
6
+ titles;
7
+ constructor(keys, titles) {
8
+ super(`it needs ${listed(titles)}`);
9
+ this.keys = keys;
10
+ this.titles = titles;
11
+ }
12
+ }
13
+ export function createOptions({ name, declared, fromClaude, claude, changed }) {
14
+ let values = merged(declared, fromClaude, {}, new Set());
15
+ let locked;
16
+ let loadedRoot;
17
+ const readLocked = async () => {
18
+ if (Object.keys(declared).length === 0)
19
+ return new Set();
20
+ const rows = await claude.config.list().catch((error) => {
21
+ claude.ui.log(`${name} could not read /config, so a project's options.json may override a value your organization set: ${messageOf(error)}`, { to: 'debug' });
22
+ return [];
23
+ });
24
+ return new Set(rows.filter((row) => row.isLocked && row.key.startsWith(`${name}.`)).map((row) => row.key.slice(name.length + 1)));
25
+ };
26
+ const readProject = async (root) => {
27
+ const env = { HOME: await claude.env.home(), CLAUDE_CONFIG_DIR: await claude.env.configHome() };
28
+ const project = configFolders(env, name, root).find(({ tier }) => tier === 'project');
29
+ if (project === undefined)
30
+ return {};
31
+ const path = `${project.folder}/options.json`;
32
+ if (!(await claude.fs.exists(path)))
33
+ return {};
34
+ const ignore = (reason) => claude.ui.log(`${path} ${reason}`);
35
+ let file;
36
+ try {
37
+ file = JSON.parse(await claude.fs.read(path));
38
+ }
39
+ catch (error) {
40
+ ignore(`is not JSON (${messageOf(error)}). Fix the file; ${name} uses its other options until then.`);
41
+ return {};
42
+ }
43
+ if (typeof file !== 'object' || file === null || Array.isArray(file)) {
44
+ ignore('is not a JSON object. Write one, such as { "branch": "main" }.');
45
+ return {};
46
+ }
47
+ const kept = {};
48
+ for (const [key, value] of Object.entries(file)) {
49
+ const option = declared[key];
50
+ if (option === undefined)
51
+ ignore(`sets ${key}, which ${name} does not declare. ${Object.keys(declared).length === 0 ? 'Remove it.' : `Remove it, or use one of: ${Object.keys(declared).join(', ')}.`}`);
52
+ else if (option.kind === 'secret')
53
+ ignore(`sets ${key}, a secret, and a project file is committed for everyone to read. Remove it, and set it in /config.`);
54
+ else {
55
+ const misfit = fitsOption(option, value);
56
+ if (misfit === undefined)
57
+ kept[key] = value;
58
+ else
59
+ ignore(`sets ${key} to ${JSON.stringify(value)}, and ${option.title} ${misfit}.`);
60
+ }
61
+ }
62
+ return kept;
63
+ };
64
+ return {
65
+ get values() {
66
+ return values;
67
+ },
68
+ get missing() {
69
+ return Object.entries(declared)
70
+ .filter(([key, option]) => option.default === undefined && isUnset(values[key]))
71
+ .map(([key]) => key);
72
+ },
73
+ async load(root) {
74
+ if (Object.keys(declared).length === 0)
75
+ return;
76
+ locked ??= await readLocked();
77
+ const project = await readProject(root);
78
+ const isFirst = loadedRoot === undefined;
79
+ loadedRoot = root;
80
+ const next = merged(declared, fromClaude, project, locked);
81
+ if (!isFirst && JSON.stringify(next) === JSON.stringify(values))
82
+ return;
83
+ values = next;
84
+ if (!isFirst)
85
+ changed();
86
+ },
87
+ };
88
+ }
89
+ function merged(declared, fromClaude, project, locked) {
90
+ return Object.freeze(Object.fromEntries(Object.entries(declared).map(([key, option]) => [key, valueOf(key, option, fromClaude[key], project, locked)])));
91
+ }
92
+ function valueOf(key, option, own, project, locked) {
93
+ if (locked.has(key))
94
+ return own;
95
+ if (Object.hasOwn(project, key))
96
+ return project[key];
97
+ if (!isUnset(own) && fitsOption(option, own) === undefined)
98
+ return Array.isArray(own) ? Object.freeze([...own]) : own;
99
+ return option.default ?? (option.kind === 'list' ? Object.freeze([]) : option.kind === 'number' ? undefined : '');
100
+ }
@@ -11,6 +11,7 @@ export type StateOptions = {
11
11
  export type ModState<State extends object> = {
12
12
  readonly state: State;
13
13
  readonly root: string;
14
+ changed(): void;
14
15
  load(): Promise<void>;
15
16
  moveTo(root: string): Promise<void>;
16
17
  switchSession(session: string, source: ClassicHookInputs['SessionStart']['source']): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../src/runtime/state.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AAGpD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAQzC,MAAM,MAAM,YAAY,GAAG;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,IAAI,CAAA;CAC7B,CAAA;AAED,MAAM,MAAM,QAAQ,CAAC,KAAK,SAAS,MAAM,IAAI;IAC3C,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACrB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACnC,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,CAAC,cAAc,CAAC,CAAC,QAAQ,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACnG,CAAA;AAUD,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI,CAIvE;AAED,wBAAgB,WAAW,CAAC,KAAK,SAAS,MAAM,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,YAAY,GAAG,QAAQ,CAAC,KAAK,CAAC,CAyI1I"}
1
+ {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../src/runtime/state.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AAEpD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAQzC,MAAM,MAAM,YAAY,GAAG;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,IAAI,CAAA;CAC7B,CAAA;AAED,MAAM,MAAM,QAAQ,CAAC,KAAK,SAAS,MAAM,IAAI;IAC3C,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,OAAO,IAAI,IAAI,CAAA;IACf,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACrB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACnC,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,CAAC,cAAc,CAAC,CAAC,QAAQ,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACnG,CAAA;AAQD,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI,CAIvE;AAED,wBAAgB,WAAW,CAAC,KAAK,SAAS,MAAM,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,YAAY,GAAG,QAAQ,CAAC,KAAK,CAAC,CAoF1I"}
package/runtime/state.js CHANGED
@@ -1,7 +1,5 @@
1
- import { configFolder, configFolders } from '../records.js';
2
1
  import { messageOf } from '../utils/text.js';
3
2
  const lifetimes = ['memory', 'session', 'project', 'global'];
4
- const savedLifetimes = ['session', 'project', 'global'];
5
3
  const keptPerValue = 20;
6
4
  const listeners = new WeakMap();
7
5
  export function onStateChange(state, listener) {
@@ -17,7 +15,6 @@ export function createState({ name, initial, session, root, claude, changed: red
17
15
  };
18
16
  const declared = declaredGroups(name, initial);
19
17
  const current = copied(declared);
20
- let defaults = declared;
21
18
  let projectRoot = root;
22
19
  let sessionId = session;
23
20
  let turn = Promise.resolve();
@@ -38,62 +35,6 @@ export function createState({ name, initial, session, root, claude, changed: red
38
35
  for (const oldest of owners.slice(0, -keptPerValue))
39
36
  await claude.store.delete(oldest);
40
37
  };
41
- const fileValues = (path, tier, text, systemPath) => {
42
- const values = [];
43
- const ignore = (reason) => claude.ui.log(`${path} ${reason}`);
44
- let file;
45
- try {
46
- file = JSON.parse(text);
47
- }
48
- catch (error) {
49
- ignore(`is not JSON (${messageOf(error)}). Fix the file, then run /reload-plugins.`);
50
- return values;
51
- }
52
- if (!isPlainObject(file)) {
53
- ignore('is not a JSON object. Write one, such as { "global": { "key": "value" } }.');
54
- return values;
55
- }
56
- for (const [group, entries] of Object.entries(file)) {
57
- if (!isLifetime(group)) {
58
- ignore(`sets ${group}, which ${name} does not declare. Remove it, or use one of: ${savedLifetimes.join(', ')}.`);
59
- continue;
60
- }
61
- if (group === 'memory') {
62
- ignore('sets memory, and memory values are never saved, so a file cannot set them. Remove it.');
63
- continue;
64
- }
65
- if (!isPlainObject(entries)) {
66
- ignore(`sets ${group} to ${typeOf(entries)}. Write an object of values, such as { "${group}": { "key": "value" } }.`);
67
- continue;
68
- }
69
- for (const [key, value] of Object.entries(entries)) {
70
- const choices = Object.keys(declared[group]);
71
- const wanted = typeOf(declared[group][key]);
72
- if (!Object.hasOwn(declared[group], key))
73
- ignore(`sets ${group}.${key}, which ${name} does not declare. ${choices.length === 0 ? 'Remove it.' : `Remove it, or use one of: ${choices.join(', ')}.`}`);
74
- else if (tier === 'project' && group === 'global')
75
- ignore(`sets ${group}.${key}, and one repository cannot change a value for every project. Move it to ${systemPath}.`);
76
- else if (typeOf(value) !== wanted)
77
- ignore(`sets ${group}.${key} to ${typeOf(value)}, and ${name} keeps ${wanted} there. Write ${wanted}, or remove it.`);
78
- else
79
- values.push([group, key, frozen(name, `${group}.${key}`, value)]);
80
- }
81
- }
82
- return values;
83
- };
84
- const readDefaults = async (nextRoot) => {
85
- const env = { HOME: await claude.env.home(), CLAUDE_CONFIG_DIR: await claude.env.configHome() };
86
- const systemPath = `${configFolder(env, name)}/state.json`;
87
- const next = copied(declared);
88
- for (const { tier, folder } of configFolders(env, name, nextRoot)) {
89
- const path = `${folder}/state.json`;
90
- if (!(await claude.fs.exists(path)))
91
- continue;
92
- for (const [lifetime, key, value] of fileValues(path, tier, await claude.fs.read(path), systemPath))
93
- next[lifetime][key] = value;
94
- }
95
- return next;
96
- };
97
38
  const loadGroups = async (only, next) => {
98
39
  const loaded = [];
99
40
  for (const lifetime of only) {
@@ -102,9 +43,8 @@ export function createState({ name, initial, session, root, claude, changed: red
102
43
  }
103
44
  projectRoot = next.root;
104
45
  sessionId = next.session;
105
- defaults = next.defaults;
106
46
  for (const [lifetime, key, saved] of loaded)
107
- current[lifetime][key] = saved === undefined ? defaults[lifetime][key] : frozen(name, `${lifetime}.${key}`, saved);
47
+ current[lifetime][key] = saved === undefined ? declared[lifetime][key] : frozen(name, `${lifetime}.${key}`, saved);
108
48
  changed();
109
49
  };
110
50
  const copySession = async (nextSession) => {
@@ -139,14 +79,15 @@ export function createState({ name, initial, session, root, claude, changed: red
139
79
  get root() {
140
80
  return projectRoot;
141
81
  },
142
- load: () => inTurn(async () => loadGroups(lifetimes, { root: projectRoot, session: sessionId, defaults: await readDefaults(projectRoot) })),
143
- moveTo: (nextRoot) => inTurn(async () => (nextRoot === projectRoot ? undefined : loadGroups(['project'], { root: nextRoot, session: sessionId, defaults: await readDefaults(nextRoot) }))),
82
+ changed,
83
+ load: () => inTurn(() => loadGroups(lifetimes, { root: projectRoot, session: sessionId })),
84
+ moveTo: (nextRoot) => inTurn(async () => (nextRoot === projectRoot ? undefined : loadGroups(['project'], { root: nextRoot, session: sessionId }))),
144
85
  switchSession: (nextSession, source) => inTurn(async () => {
145
86
  if (nextSession === sessionId)
146
87
  return;
147
88
  if (source === 'fork')
148
89
  await copySession(nextSession);
149
- return loadGroups(['memory', 'session'], { root: projectRoot, session: nextSession, defaults });
90
+ return loadGroups(['memory', 'session'], { root: projectRoot, session: nextSession });
150
91
  }),
151
92
  };
152
93
  }
@@ -168,17 +109,6 @@ function copied(groups) {
168
109
  function isLifetime(group) {
169
110
  return lifetimes.includes(group);
170
111
  }
171
- function typeOf(value) {
172
- if (value === null)
173
- return 'null';
174
- if (Array.isArray(value))
175
- return 'an array';
176
- if (typeof value === 'object')
177
- return 'an object';
178
- if (value === undefined)
179
- return 'nothing';
180
- return `a ${typeof value}`;
181
- }
182
112
  function frozen(name, path, value) {
183
113
  const isArray = Array.isArray(value);
184
114
  if (!isArray && !isPlainObject(value))
@@ -0,0 +1,17 @@
1
+ import type { Frozen, RenderElement, RenderPropsOf } from 'claude-code'
2
+ import type { Mod } from './mod.js'
3
+
4
+ export type Step<State extends object = Record<never, never>> = {
5
+ readonly id: string
6
+ readonly title: string
7
+ render(mod: Mod<State>, props: Frozen<RenderPropsOf['Pane']>): RenderElement
8
+ isDone(mod: Mod<State>): boolean | Promise<boolean>
9
+ }
10
+
11
+ const stepId = /^[A-Za-z0-9_-]{1,64}$/
12
+
13
+ export function defineStep<State extends object = Record<never, never>>(step: Step<State>): Step<State> {
14
+ if (!stepId.test(step.id)) throw new Error(`defineStep: "${step.id}" is not a step id. Use 1 to 64 letters, digits, "_", or "-".`)
15
+ if (step.title.trim() === '') throw new Error(`defineStep: the step "${step.id}" needs a title, the line the installer shows above it.`)
16
+ return step
17
+ }
@@ -14,7 +14,7 @@ type Session = Omit<Workspace, 'projectRoot' | 'cwd'>
14
14
 
15
15
  type Stream = HookStream<ProcessSpawnChunk, ProcessSpawnResult>
16
16
 
17
- export function workspaceReader({ claude }: JobContext): () => Promise<Workspace> {
17
+ export function workspaceReader({ mod: { claude } }: JobContext): () => Promise<Workspace> {
18
18
  let session: Promise<Session> | undefined
19
19
  return async () => {
20
20
  session ??= readSession(claude)
@@ -40,7 +40,7 @@ export function afterCall<State extends object>(
40
40
  failed: (error: unknown) => readonly string[],
41
41
  ): void {
42
42
  const readWorkspace = workspaceReader(job)
43
- job.on('tool.call', async (e, next) => {
43
+ job.mod.claude.on('tool.call', async (e, next) => {
44
44
  const before = Promise.all([useOf(job, e), readWorkspace()])
45
45
  await before.catch(() => undefined)
46
46
  const result = await next(e)
@@ -58,7 +58,8 @@ export function modOf<State extends object>(job: JobContext<State>, { call, fold
58
58
  return modWithin(job, deadline, async () => workspace.scope?.workTreeOf(call !== undefined && 'path' in call ? call.path : folder))
59
59
  }
60
60
 
61
- export function modWithin<State extends object>({ mod, claude }: JobContext<State>, deadline: Deadline, workTree: () => Promise<string | undefined> = async () => undefined): Mod<State> {
61
+ export function modWithin<State extends object>({ mod }: JobContext<State>, deadline: Deadline, workTree: () => Promise<string | undefined> = async () => undefined): Mod<State> {
62
+ const { claude } = mod
62
63
  const within = <Value>(call: string, task: Promise<Value>) => beforeDeadline(claude, deadline, call, task)
63
64
  const folderOf = async (cwd: string | undefined) => {
64
65
  const folder = cwd ?? (await workTree())
@@ -79,6 +80,7 @@ export function modWithin<State extends object>({ mod, claude }: JobContext<Stat
79
80
  list: (path) => within('mod.fs.list', mod.fs.list(path)),
80
81
  exists: (path) => within('mod.fs.exists', mod.fs.exists(path)),
81
82
  stat: (path, options) => within('mod.fs.stat', mod.fs.stat(path, options)),
83
+ find: (glob) => within('mod.fs.find', mod.fs.find(glob)),
82
84
  },
83
85
  http: { fetch: (url, init) => within('mod.http.fetch', mod.http.fetch(url, init)) },
84
86
  dependencies: dependencyCalls(claude, within),
@@ -37,7 +37,7 @@ export function permissions<State extends object = Record<never, never>>(rules:
37
37
  }
38
38
  }
39
39
 
40
- job.on('tool.check', async (e, next) => {
40
+ job.mod.claude.on('tool.check', async (e, next) => {
41
41
  const [below, ours] = await Promise.all([next(e), verdictOf(e)])
42
42
  return ours === undefined ? below : stricterVerdict<Verdict>(below, ours)
43
43
  })
@@ -1,4 +1,5 @@
1
1
  import type { Job } from '../mod.js'
2
+ import { PermissionRefused } from '../runtime/grants.js'
2
3
  import { messageOf } from '../utils/text.js'
3
4
 
4
5
  export type Program = {
@@ -12,7 +13,8 @@ const backoffSeconds = [1, 2, 4, 8, 16] as const
12
13
 
13
14
  export function program(options: { readonly command: readonly string[]; readonly environment?: Record<string, string> }): Job<Program> {
14
15
  const { command, environment } = options
15
- return ({ mod, claude, announce }) => {
16
+ return ({ mod, announce }) => {
17
+ const { claude } = mod
16
18
  const [executable] = command
17
19
  if (executable === undefined) throw new Error(`${mod.name}: the program has no command. Name the program on PATH and its arguments, such as ['preview-server', '--port', '0'].`)
18
20
  let state: Program['state'] = 'stopped'
@@ -53,6 +55,7 @@ export function program(options: { readonly command: readonly string[]; readonly
53
55
  for (const waiter of waiting.splice(0)) waiter.resolve(address)
54
56
  }
55
57
  } catch (error) {
58
+ if (error instanceof PermissionRefused) return stop(`${messageOf(error)} Add it, then run /reload-plugins.`)
56
59
  lastError = messageOf(error)
57
60
  }
58
61
  address = undefined
@@ -26,7 +26,7 @@ export function prompt<State extends object = Record<never, never>>({ name, prom
26
26
  return (job) => {
27
27
  job.reserveName('prompt', name, `prompt: ${job.mod.name} adds the name "${name}" two times. Give each prompt its own name.`)
28
28
 
29
- const log = (reason: string) => job.claude.ui.log(`prompt "${name}" added nothing: ${reason}`, { to: 'debug' })
29
+ const log = (reason: string) => job.mod.claude.ui.log(`prompt "${name}" added nothing: ${reason}`, { to: 'debug' })
30
30
  const textFor = async (input: PromptInput, mod: Mod<State>) => {
31
31
  try {
32
32
  const value = typeof text === 'string' ? text : await text(input, mod)
@@ -61,12 +61,12 @@ function addKeptCurrent<State extends object>(job: JobContext<State>, name: stri
61
61
  let isQueued = false
62
62
  let updates = Promise.resolve()
63
63
 
64
- job.on('classic.SessionStart', (e, next) => {
64
+ job.mod.claude.on('classic.SessionStart', (e, next) => {
65
65
  hasStarted = e.source === 'resume' || e.source === 'compact'
66
66
  if (!hasStarted) given = undefined
67
67
  return next(e)
68
68
  })
69
- job.on('prompt.context', async (e, next) => {
69
+ job.mod.claude.on('prompt.context', async (e, next) => {
70
70
  const below = await next(e)
71
71
  hasStarted = true
72
72
  if (below.blocks.some((block) => block.name === name)) {
@@ -83,7 +83,7 @@ function addKeptCurrent<State extends object>(job: JobContext<State>, name: stri
83
83
  const value = await textFor()
84
84
  if (value === undefined || value === given) return
85
85
  given = value
86
- await job.claude.session.append({ message: { type: 'user', content: [{ type: 'text', text: `# ${name}\n${value}` }] } }).then((added) => {
86
+ await job.mod.claude.session.append({ message: { type: 'user', content: [{ type: 'text', text: `# ${name}\n${value}` }] } }).then((added) => {
87
87
  if (added.deny !== undefined) log(`Claude Code refused the new text: ${added.deny}`)
88
88
  })
89
89
  }
@@ -95,7 +95,7 @@ function addKeptCurrent<State extends object>(job: JobContext<State>, name: stri
95
95
  }
96
96
 
97
97
  function addAfterUserPrompts(job: JobContext, when: RegExp | ((userPrompt: string) => boolean), textFor: (userPrompt: string) => Promise<string | undefined>, log: (reason: string) => void): void {
98
- job.on('prompt.submit', async (e, next) => {
98
+ job.mod.claude.on('prompt.submit', async (e, next) => {
99
99
  let value: string | undefined
100
100
  try {
101
101
  if (typeof when === 'function' ? when(e.text) : e.text.search(when) >= 0) value = await textFor(e.text)
@@ -10,7 +10,8 @@ export function schedule<State extends object = Record<never, never>>(entry: Sch
10
10
  if (typeof entry !== 'function') checked(entry)
11
11
 
12
12
  return (job) => {
13
- const { mod, claude } = job
13
+ const { mod } = job
14
+ const { claude } = mod
14
15
  const order = scheduleCounts.get(mod) ?? 0
15
16
  scheduleCounts.set(mod, order + 1)
16
17
  const storeKey = `cmod:schedule:${order}`
@@ -20,7 +20,8 @@ export function slashCommand<State extends object = Record<never, never>>(option
20
20
  }): Job<void, State> {
21
21
  const { name, description, argumentHint, immediate, reply } = options
22
22
  return (job) => {
23
- const { mod, claude, announce, reserveName } = job
23
+ const { mod, announce, reserveName } = job
24
+ const { claude } = mod
24
25
  if (!commandName.test(name)) throw new Error(`${mod.name}: "${name}" is not a slash command name. Use 1 to 64 letters, digits, "_", or "-", without the slash.`)
25
26
  reserveName('slashCommand', name, `${mod.name}: the slash command /${name} is already added. Give each slashCommand its own name.`)
26
27
 
@@ -36,7 +37,7 @@ export function slashCommand<State extends object = Record<never, never>>(option
36
37
  announce(`/${name}`)
37
38
 
38
39
  const replyMod = modWithin(job, deadline)
39
- job.on('command.run', async (e, next) => {
40
+ claude.on('command.run', async (e, next) => {
40
41
  if (e.command !== registered) return next(e)
41
42
  try {
42
43
  return answerOf(await reply({ args: e.args, positionals: splitWords(e.args) }, replyMod))
@@ -17,7 +17,8 @@ export function statusLine<State extends object = Record<never, never>>(options:
17
17
  }): Job<void, State> {
18
18
  const { text, interval = 10000 } = options
19
19
  return (job) => {
20
- const { mod, claude, announce, reserveName } = job
20
+ const { mod, announce, reserveName } = job
21
+ const { claude } = mod
21
22
  if (!(interval >= 1)) throw new Error(`${mod.name}: the status line interval is ${interval}. Give it in milliseconds, 1 or more.`)
22
23
  reserveName('statusLine', '', `${mod.name}: a status line is already added. A mod has one status line: join the texts in one statusLine.`)
23
24
 
@@ -48,7 +49,7 @@ export function statusLine<State extends object = Record<never, never>>(options:
48
49
  }
49
50
 
50
51
  announce('a status line')
51
- job.on('session.measure', (e, next) => {
52
+ claude.on('session.measure', (e, next) => {
52
53
  refresh()
53
54
  return next(e)
54
55
  })
package/src/jobs/tool.ts CHANGED
@@ -16,7 +16,8 @@ export function tool<const Schema extends object = { type: 'object' }, State ext
16
16
  }): Job<{ readonly name: string }, State> {
17
17
  const { name, description, inputSchema, execute } = options
18
18
  return (job) => {
19
- const { mod, claude, announce, reserveName } = job
19
+ const { mod, announce, reserveName } = job
20
+ const { claude } = mod
20
21
  if (!toolName.test(name)) throw new Error(`${mod.name}: "${name}" is not a tool name. Use 1 to 64 letters, digits, "_", or "-".`)
21
22
  const properties = (inputSchema as { properties?: object } | undefined)?.properties ?? {}
22
23
  const reserved = reservedKeys.filter((key) => Object.hasOwn(properties, key))
@@ -36,7 +37,7 @@ export function tool<const Schema extends object = { type: 'object' }, State ext
36
37
  announce(`the ${name} tool`)
37
38
 
38
39
  const executeMod = modWithin(job, toolDeadline)
39
- job.on('tool.call', async (e, next) => {
40
+ claude.on('tool.call', async (e, next) => {
40
41
  if (e.tool !== registered) return next(e)
41
42
  const input = toolInputOf(e)
42
43
  const { valid, errors } = validator.validate(input)
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
  }