@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.
- package/docs/commands.md +31 -19
- package/docs/index.md +14 -4
- package/docs/install-steps.md +30 -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 +14 -1
- package/records.d.ts.map +1 -1
- package/records.js +106 -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 +23 -0
- package/runtime/installer.d.ts.map +1 -0
- package/runtime/installer.js +113 -0
- package/runtime/lifecycle.d.ts +6 -3
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +220 -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 +117 -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 +163 -0
- package/src/runtime/lifecycle.ts +220 -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
|
@@ -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
|
+
}
|
package/runtime/state.d.ts
CHANGED
|
@@ -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>;
|
package/runtime/state.d.ts.map
CHANGED
|
@@ -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;
|
|
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 ?
|
|
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
|
-
|
|
143
|
-
|
|
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
|
|
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))
|
package/src/installer.ts
ADDED
|
@@ -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
|
+
}
|
package/src/jobs/context.ts
CHANGED
|
@@ -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
|
|
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),
|
package/src/jobs/permissions.ts
CHANGED
|
@@ -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
|
})
|
package/src/jobs/program.ts
CHANGED
|
@@ -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,
|
|
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
|
package/src/jobs/prompt.ts
CHANGED
|
@@ -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)
|
package/src/jobs/schedule.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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))
|
package/src/jobs/status-line.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
SettingsReadArgs,
|
|
24
|
+
SessionMessage,
|
|
26
25
|
Timer,
|
|
27
26
|
ToastOptions,
|
|
28
27
|
UiScrollArgs,
|
|
29
28
|
UiScrollResult,
|
|
30
29
|
} from 'claude-code'
|
|
30
|
+
import type { Step } from './installer.js'
|
|
31
|
+
import { checkOptions, type Option, type Options, type OptionValues } from './options.js'
|
|
32
|
+
import type { FlagPermission, ListPermission } from './records.js'
|
|
31
33
|
import type { Claude } from './runtime/claude.js'
|
|
32
34
|
import type { RoutedEvent } from './runtime/hooks.js'
|
|
33
35
|
import type { RoutedHook } from './runtime/router.js'
|
|
@@ -125,9 +127,30 @@ export type HookOptions = {
|
|
|
125
127
|
readonly timeoutMs?: number
|
|
126
128
|
}
|
|
127
129
|
|
|
128
|
-
export type
|
|
130
|
+
export type ScrollArgs = Omit<UiScrollArgs, 'to'> & { readonly to: Exclude<UiScrollArgs['to'], { requestId: string }> }
|
|
131
|
+
|
|
132
|
+
export type ModClaude = Claude & {
|
|
133
|
+
on<N extends RoutedEvent>(event: N, hook: RoutedHook<N>): void
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type Metadata = Readonly<Record<string, string>>
|
|
137
|
+
|
|
138
|
+
export type FileMatch = {
|
|
139
|
+
readonly path: string
|
|
140
|
+
readonly name?: string
|
|
141
|
+
readonly metadata: Metadata
|
|
142
|
+
readonly error?: string
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
type SessionMessages = {
|
|
146
|
+
(): Promise<SessionMessage[]>
|
|
147
|
+
(args: { readonly agentId: string }): Promise<SessionMessage[] | { readonly deny: string }>
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export type Mod<State extends object = Record<never, never>, Declared extends Options = Options> = {
|
|
129
151
|
readonly name: string
|
|
130
152
|
readonly state: Readonly<State>
|
|
153
|
+
readonly options: OptionValues<Declared>
|
|
131
154
|
readonly dataFolder: string
|
|
132
155
|
on<E extends ModEvent>(event: E, hook: ModHook<E>, options?: HookOptions): void
|
|
133
156
|
use<Handle>(job: Job<Handle, State>): Handle
|
|
@@ -138,7 +161,7 @@ export type Mod<State extends object = Record<never, never>> = {
|
|
|
138
161
|
toast(text: string, options?: ToastOptions): void
|
|
139
162
|
progress<T>(title: string, task: (report: (step: ProgressStep) => void) => Promise<T>): Promise<T>
|
|
140
163
|
ask(question: string, options?: readonly string[] | AskOptions): Promise<string>
|
|
141
|
-
scroll(args:
|
|
164
|
+
scroll(args: ScrollArgs): Promise<UiScrollResult>
|
|
142
165
|
}
|
|
143
166
|
readonly process: {
|
|
144
167
|
run(argv: readonly string[], init?: ProcessRunInit): Promise<ProcessRunResult>
|
|
@@ -150,15 +173,21 @@ export type Mod<State extends object = Record<never, never>> = {
|
|
|
150
173
|
list(path?: string): Promise<FsEntry[]>
|
|
151
174
|
exists(path: string): Promise<boolean>
|
|
152
175
|
stat(path: string, options?: FsStatOptions): Promise<FsStat>
|
|
176
|
+
find(glob: string | readonly string[]): Promise<readonly FileMatch[]>
|
|
177
|
+
}
|
|
178
|
+
readonly metadata: {
|
|
179
|
+
read(path: string): Promise<Metadata>
|
|
180
|
+
update(path: string, change: (metadata: Record<string, string>) => void): Promise<void>
|
|
153
181
|
}
|
|
154
182
|
readonly http: {
|
|
155
183
|
fetch(url: string, init?: HttpInit): Promise<HttpResponse>
|
|
156
184
|
}
|
|
157
185
|
readonly settings: {
|
|
158
|
-
|
|
186
|
+
page(page: Pane<State>): void
|
|
187
|
+
open(pageId?: string): Promise<void>
|
|
159
188
|
}
|
|
160
189
|
readonly session: {
|
|
161
|
-
messages:
|
|
190
|
+
messages: SessionMessages
|
|
162
191
|
append(text: string): Promise<void>
|
|
163
192
|
submit(text: string): Promise<void>
|
|
164
193
|
}
|
|
@@ -168,9 +197,14 @@ export type Mod<State extends object = Record<never, never>> = {
|
|
|
168
197
|
readonly model: {
|
|
169
198
|
complete(request: ModelCompleteRequest, options?: ModelCompleteOptions): Promise<ModelCompleteResult>
|
|
170
199
|
}
|
|
200
|
+
readonly permissions: {
|
|
201
|
+
has(name: ListPermission, value: string): boolean
|
|
202
|
+
has(name: FlagPermission): boolean
|
|
203
|
+
}
|
|
171
204
|
readonly projectRoot: string
|
|
172
205
|
readonly cwd: string
|
|
173
206
|
readonly dependencies: CmodDependencies
|
|
207
|
+
readonly claude: ModClaude
|
|
174
208
|
}
|
|
175
209
|
|
|
176
210
|
type StateGroups = {
|
|
@@ -184,17 +218,17 @@ type Api<Contract, State extends object> = {
|
|
|
184
218
|
readonly [Method in keyof Contract]: Contract[Method] extends (input: infer Input) => infer Result ? (input: Input, mod: Mod<State>) => Result | Awaited<Result> : never
|
|
185
219
|
}
|
|
186
220
|
|
|
187
|
-
export type ModDefinition<State extends StateGroups = Record<never, never>, Name extends string = string
|
|
221
|
+
export type ModDefinition<State extends StateGroups = Record<never, never>, Name extends string = string, Declared extends Options = Record<never, Option>> = {
|
|
188
222
|
readonly name: Name
|
|
189
223
|
readonly state?: State
|
|
224
|
+
readonly options?: Declared
|
|
190
225
|
readonly api?: Name extends keyof CmodDependencies ? Api<CmodDependencies[Name], State> : { readonly [method: string]: (input: never, mod: Mod<State>) => unknown }
|
|
191
|
-
|
|
226
|
+
readonly installer?: readonly Step<State>[]
|
|
227
|
+
setup(mod: Mod<State, Declared>): void | Promise<void>
|
|
192
228
|
}
|
|
193
229
|
|
|
194
230
|
export type JobContext<State extends object = Record<never, never>> = {
|
|
195
231
|
readonly mod: Mod<State>
|
|
196
|
-
readonly claude: Claude
|
|
197
|
-
on<N extends RoutedEvent>(event: N, hook: RoutedHook<N>): void
|
|
198
232
|
announce(feature: string): void
|
|
199
233
|
reserveName(kind: string, name: string, taken: string): void
|
|
200
234
|
readonly toolCalls: ToolCalls
|
|
@@ -202,7 +236,8 @@ export type JobContext<State extends object = Record<never, never>> = {
|
|
|
202
236
|
|
|
203
237
|
export type Job<Handle, State extends object = Record<never, never>> = (context: JobContext<State>) => Handle
|
|
204
238
|
|
|
205
|
-
export function defineMod<State extends StateGroups = Record<never, never>, Name extends string = string
|
|
239
|
+
export function defineMod<State extends StateGroups = Record<never, never>, Name extends string = string, const Declared extends Options = Record<never, Option>>(definition: ModDefinition<State, Name, Declared>): ModDefinition<State, Name, Declared> {
|
|
206
240
|
if (definition.name.trim() === '') throw new Error('defineMod: the mod needs a name, such as the name in .claude-plugin/plugin.json.')
|
|
241
|
+
if (definition.options !== undefined) checkOptions(definition.name, definition.options)
|
|
207
242
|
return definition
|
|
208
243
|
}
|