@cmodjs/core 0.1.14 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/docs/commands.md +32 -21
  2. package/docs/dependencies.md +8 -0
  3. package/docs/index.md +12 -4
  4. package/docs/install-steps.md +36 -4
  5. package/docs/jobs.md +25 -9
  6. package/docs/mod.md +103 -37
  7. package/docs/options.md +93 -0
  8. package/docs/permissions.md +88 -0
  9. package/docs/state.md +4 -31
  10. package/docs/testing.md +13 -2
  11. package/installer.d.ts +10 -0
  12. package/installer.d.ts.map +1 -0
  13. package/installer.js +8 -0
  14. package/jobs/context.d.ts +2 -2
  15. package/jobs/context.d.ts.map +1 -1
  16. package/jobs/context.js +5 -3
  17. package/jobs/permissions.js +1 -1
  18. package/jobs/program.d.ts.map +1 -1
  19. package/jobs/program.js +5 -1
  20. package/jobs/prompt.js +5 -5
  21. package/jobs/schedule.d.ts.map +1 -1
  22. package/jobs/schedule.js +2 -1
  23. package/jobs/slash-command.d.ts.map +1 -1
  24. package/jobs/slash-command.js +3 -2
  25. package/jobs/status-line.d.ts.map +1 -1
  26. package/jobs/status-line.js +3 -2
  27. package/jobs/tool.d.ts.map +1 -1
  28. package/jobs/tool.js +3 -2
  29. package/mod.d.ts +48 -10
  30. package/mod.d.ts.map +1 -1
  31. package/mod.js +3 -0
  32. package/options.d.ts +58 -0
  33. package/options.d.ts.map +1 -0
  34. package/options.js +66 -0
  35. package/package.json +1 -1
  36. package/records.d.ts +16 -1
  37. package/records.d.ts.map +1 -1
  38. package/records.js +119 -7
  39. package/register.d.ts +4 -2
  40. package/register.d.ts.map +1 -1
  41. package/register.js +8 -2
  42. package/runtime/claude.d.ts +5 -1
  43. package/runtime/claude.d.ts.map +1 -1
  44. package/runtime/grants.d.ts +28 -0
  45. package/runtime/grants.d.ts.map +1 -0
  46. package/runtime/grants.js +286 -0
  47. package/runtime/installer.d.ts +24 -0
  48. package/runtime/installer.d.ts.map +1 -0
  49. package/runtime/installer.js +118 -0
  50. package/runtime/lifecycle.d.ts +6 -3
  51. package/runtime/lifecycle.d.ts.map +1 -1
  52. package/runtime/lifecycle.js +226 -36
  53. package/runtime/metadata.d.ts +20 -0
  54. package/runtime/metadata.d.ts.map +1 -0
  55. package/runtime/metadata.js +143 -0
  56. package/runtime/options.d.ts +21 -0
  57. package/runtime/options.d.ts.map +1 -0
  58. package/runtime/options.js +100 -0
  59. package/runtime/state.d.ts +1 -0
  60. package/runtime/state.d.ts.map +1 -1
  61. package/runtime/state.js +5 -75
  62. package/src/installer.ts +17 -0
  63. package/src/jobs/context.ts +5 -3
  64. package/src/jobs/permissions.ts +1 -1
  65. package/src/jobs/program.ts +4 -1
  66. package/src/jobs/prompt.ts +5 -5
  67. package/src/jobs/schedule.ts +2 -1
  68. package/src/jobs/slash-command.ts +3 -2
  69. package/src/jobs/status-line.ts +3 -2
  70. package/src/jobs/tool.ts +3 -2
  71. package/src/mod.ts +46 -11
  72. package/src/options.ts +91 -0
  73. package/src/records.ts +131 -8
  74. package/src/register.ts +12 -3
  75. package/src/runtime/claude.ts +2 -0
  76. package/src/runtime/grants.ts +306 -0
  77. package/src/runtime/installer.ts +169 -0
  78. package/src/runtime/lifecycle.ts +227 -45
  79. package/src/runtime/metadata.ts +153 -0
  80. package/src/runtime/options.ts +102 -0
  81. package/src/runtime/state.ts +7 -70
  82. package/src/testing/fake-claude.ts +6 -1
  83. package/src/testing/fake-files.ts +9 -3
  84. package/src/testing.ts +40 -15
  85. package/src/utils/metadata.ts +242 -0
  86. package/testing/fake-claude.d.ts +4 -0
  87. package/testing/fake-claude.d.ts.map +1 -1
  88. package/testing/fake-claude.js +5 -1
  89. package/testing/fake-files.d.ts.map +1 -1
  90. package/testing/fake-files.js +9 -3
  91. package/testing.d.ts +6 -2
  92. package/testing.d.ts.map +1 -1
  93. package/testing.js +29 -13
  94. package/utils/metadata.d.ts +21 -0
  95. package/utils/metadata.d.ts.map +1 -0
  96. package/utils/metadata.js +250 -0
@@ -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"}
@@ -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)