@ccmsg/cli 0.2.13 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/cli.ts +96 -29
- package/src/daemon/registry.ts +41 -14
- package/src/daemon/supervise.ts +2 -1
- package/src/harness/index.ts +96 -0
- package/src/instance/config.ts +36 -0
- package/src/instance/instance.ts +28 -5
- package/src/instance/paths.ts +32 -6
- package/src/messaging/direct.ts +116 -0
- package/src/plugin/claude.ts +1 -72
- package/src/plugin/codex.ts +332 -0
- package/src/plugin/index.ts +7 -5
- package/src/plugin/install.ts +41 -161
- package/src/plugin/receipt.ts +202 -0
- package/src/plugin/skill.ts +84 -0
- package/src/sessions/harness.ts +166 -31
- package/src/sessions/registry.ts +84 -53
- package/src/sessions/search.ts +4 -4
- package/src/transcript/files.ts +103 -22
- package/src/transcript/fold.ts +72 -1
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/** The plugin ccmsg hands to Codex, as its files.
|
|
2
|
+
*
|
|
3
|
+
* Nothing here goes through Codex's own plugin system. A Codex plugin can
|
|
4
|
+
* carry skills but not hooks — `plugin_hooks` is a removed feature of
|
|
5
|
+
* codex-cli 0.153.4 — and hooks are the whole point: they are how a session
|
|
6
|
+
* says hello and goodbye. So what is laid down is what Codex reads out of its
|
|
7
|
+
* config home directly: `hooks.json` beside its settings, and one skill under
|
|
8
|
+
* `skills/`.
|
|
9
|
+
*
|
|
10
|
+
* That makes the install different in kind from Claude Code's. There is no
|
|
11
|
+
* agent command to run and nothing to register: the files are the install, and
|
|
12
|
+
* uninstall is taking back exactly the files that were put there — which is
|
|
13
|
+
* why `hooks.json` is merged rather than written over, and unmerged rather than
|
|
14
|
+
* deleted. */
|
|
15
|
+
|
|
16
|
+
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { HARNESS, HARNESSES } from "../harness/index.ts";
|
|
19
|
+
import type { InstancePaths } from "../instance/index.ts";
|
|
20
|
+
import {
|
|
21
|
+
type InstallReport,
|
|
22
|
+
place,
|
|
23
|
+
readReceipt,
|
|
24
|
+
type Receipt,
|
|
25
|
+
receiptFile,
|
|
26
|
+
rootFor,
|
|
27
|
+
type Run,
|
|
28
|
+
type StatusReport,
|
|
29
|
+
type UninstallReport,
|
|
30
|
+
writeReceipt,
|
|
31
|
+
} from "./receipt.ts";
|
|
32
|
+
import { SKILL } from "./skill.ts";
|
|
33
|
+
|
|
34
|
+
/** How Codex's own CLI is run, for the one question this asks it. */
|
|
35
|
+
export const runCodex: Run = async (args) => {
|
|
36
|
+
let spawned: Bun.Subprocess<"ignore", "pipe", "pipe">;
|
|
37
|
+
try {
|
|
38
|
+
spawned = Bun.spawn({ cmd: ["codex", ...args], stdout: "pipe", stderr: "pipe" });
|
|
39
|
+
} catch {
|
|
40
|
+
return { code: 127, stdout: "", stderr: "codex が PATH にありません" };
|
|
41
|
+
}
|
|
42
|
+
const [stdout, stderr] = await Promise.all([
|
|
43
|
+
new Response(spawned.stdout).text(),
|
|
44
|
+
new Response(spawned.stderr).text(),
|
|
45
|
+
]);
|
|
46
|
+
return { code: await spawned.exited, stdout, stderr };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** The two events a session's life is read from, and the file each hook is.
|
|
50
|
+
*
|
|
51
|
+
* `SessionStart` and `SessionEnd` are what Codex fires around a thread, and
|
|
52
|
+
* both hand the hook the thread's id and its rollout path on standard input
|
|
53
|
+
* (codex-cli 0.153.4) — which is what `ccmsg hello --hook` and
|
|
54
|
+
* `ccmsg stopping --hook` already read. The legacy `notify` command is not
|
|
55
|
+
* used: it reports a finished turn, which is not a session's life. */
|
|
56
|
+
const EVENTS = [
|
|
57
|
+
["SessionStart", "session-start", "hello"],
|
|
58
|
+
["SessionEnd", "session-end", "stopping"],
|
|
59
|
+
] as const;
|
|
60
|
+
|
|
61
|
+
/** How long a greeting or a departure may take before Codex stops waiting on
|
|
62
|
+
* it. Both are one connection to a socket on this same host, and both give up
|
|
63
|
+
* on their own when there is no instance behind it. */
|
|
64
|
+
const HOOK_TIMEOUT_S = 5;
|
|
65
|
+
|
|
66
|
+
/** What Codex still asks of the person before the hooks fire.
|
|
67
|
+
*
|
|
68
|
+
* Both are Codex's own questions, asked in its interface: a hook runs once it
|
|
69
|
+
* has been reviewed there, and a directory Codex has not been told to trust
|
|
70
|
+
* does not load project-local hooks at all. Said rather than answered — what
|
|
71
|
+
* may run on somebody's machine is theirs to decide. */
|
|
72
|
+
const TRUST =
|
|
73
|
+
"hooks の trust が要ります (codex の hooks 画面で ccmsg の 2 つを trust。作業ディレクトリの信頼確認にも一度答えておく)";
|
|
74
|
+
|
|
75
|
+
export const HOOKS_FILE = "hooks.json";
|
|
76
|
+
const SKILL_FILE = join("skills", "ccmsg", "SKILL.md");
|
|
77
|
+
|
|
78
|
+
/** One hook, as a program of its own rather than as a command line.
|
|
79
|
+
*
|
|
80
|
+
* Codex states a hook as one `command` string, and whether it reaches a shell
|
|
81
|
+
* is not something a config file says. A script settles it: the path is what
|
|
82
|
+
* Codex runs, and everything that needs a shell — finding `ccmsg`, naming the
|
|
83
|
+
* config home — happens inside it where a shell is certain.
|
|
84
|
+
*
|
|
85
|
+
* The config home is named, and every other harness's is dropped. A session
|
|
86
|
+
* started against the default home has no variable saying so, and a Codex
|
|
87
|
+
* session started from inside a Claude Code session inherits that session's
|
|
88
|
+
* `CLAUDE_CONFIG_DIR` and session id — so a hook that only added its own would
|
|
89
|
+
* still greet the other instance, as the other session (§3.8, measured). What
|
|
90
|
+
* is dropped is named here rather than left to the shell: the hook has to
|
|
91
|
+
* speak for the session it fired for.
|
|
92
|
+
*
|
|
93
|
+
* `env` is spelled absolutely because `PATH` is what the hook is about to
|
|
94
|
+
* search and not something it can lean on before it has. `ccmsg` itself is
|
|
95
|
+
* reached through `PATH`:
|
|
96
|
+
* the binary belongs to whoever installed ccmsg, and a plugin carrying its own
|
|
97
|
+
* copy would be a second version of it to keep current. A session whose `PATH`
|
|
98
|
+
* has no `ccmsg` leaves without saying anything, because a person who has not
|
|
99
|
+
* installed ccmsg has not asked to hear about it at every session start. */
|
|
100
|
+
function hookScript(configHome: string, command: string): string {
|
|
101
|
+
const dropped = HARNESSES.filter((harness) => harness !== "codex").flatMap((harness) => [
|
|
102
|
+
HARNESS[harness].homeEnv,
|
|
103
|
+
...HARNESS[harness].sessionEnv,
|
|
104
|
+
]);
|
|
105
|
+
return `#!/bin/sh
|
|
106
|
+
command -v ccmsg >/dev/null 2>&1 || exit 0
|
|
107
|
+
exec /usr/bin/env ${dropped.map((name) => `-u ${name}`).join(" ")} CODEX_HOME=${shellQuoted(configHome)} ccmsg ${command} --hook
|
|
108
|
+
`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** One value as a POSIX shell reads it literally: single quotes, and the one
|
|
112
|
+
* escape those admit for a single quote of their own. A config home is a path
|
|
113
|
+
* a person chose, so it is quoted rather than assumed to hold nothing. */
|
|
114
|
+
function shellQuoted(value: string): string {
|
|
115
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Every file the plugin is made of, by its path under the plugin's root. */
|
|
119
|
+
export function codexPluginFiles(configHome: string): Map<string, string> {
|
|
120
|
+
return new Map(
|
|
121
|
+
EVENTS.map(([, file, command]) => [join("hooks", file), hookScript(configHome, command)]),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** What ccmsg adds to the config home's `hooks.json`. */
|
|
126
|
+
function hookEntries(root: string): Record<string, unknown[]> {
|
|
127
|
+
const entries: Record<string, unknown[]> = {};
|
|
128
|
+
for (const [event, file] of EVENTS) {
|
|
129
|
+
entries[event] = [
|
|
130
|
+
{
|
|
131
|
+
hooks: [
|
|
132
|
+
{ type: "command", command: join(root, "hooks", file), timeoutSec: HOOK_TIMEOUT_S },
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
}
|
|
137
|
+
return entries;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Lay the files down, add the hooks to the config home's own file, and write
|
|
141
|
+
* down what was done.
|
|
142
|
+
*
|
|
143
|
+
* Repeating it is laying the same files down again and replacing the same
|
|
144
|
+
* hooks: what an earlier install of ccmsg put in `hooks.json` is taken out
|
|
145
|
+
* before ours goes in, so an install run twice leaves one of each rather than
|
|
146
|
+
* two. */
|
|
147
|
+
export async function install(
|
|
148
|
+
paths: InstancePaths,
|
|
149
|
+
version: string,
|
|
150
|
+
run: Run = runCodex,
|
|
151
|
+
): Promise<InstallReport> {
|
|
152
|
+
const root = rootFor(paths, "codex");
|
|
153
|
+
const files = codexPluginFiles(paths.configHome);
|
|
154
|
+
// Executable, because Codex runs the path rather than passing it to a shell.
|
|
155
|
+
await place(root, files, 0o755);
|
|
156
|
+
await place(paths.configHome, new Map([[SKILL_FILE, SKILL]]));
|
|
157
|
+
|
|
158
|
+
const hooksFile = join(paths.configHome, HOOKS_FILE);
|
|
159
|
+
const held = await readHooks(hooksFile);
|
|
160
|
+
await writeFile(hooksFile, `${JSON.stringify(withHooks(held, root), null, 2)}\n`);
|
|
161
|
+
|
|
162
|
+
const receipt: Receipt = {
|
|
163
|
+
agent: "codex",
|
|
164
|
+
version,
|
|
165
|
+
installed_at: new Date().toISOString(),
|
|
166
|
+
config_home: paths.configHome,
|
|
167
|
+
root,
|
|
168
|
+
files: [...files.keys()],
|
|
169
|
+
placed: [join(paths.configHome, SKILL_FILE), hooksFile],
|
|
170
|
+
commands: [],
|
|
171
|
+
};
|
|
172
|
+
await writeReceipt(paths, receipt);
|
|
173
|
+
|
|
174
|
+
// Codex will not run a command hook it has not been shown: the person has to
|
|
175
|
+
// trust it once, in the session picker's hooks view. Said rather than worked
|
|
176
|
+
// around — trust is Codex asking whether this program may run, and answering
|
|
177
|
+
// it on their behalf is not an install's business.
|
|
178
|
+
const enabled = await hooksEnabled(run);
|
|
179
|
+
return {
|
|
180
|
+
agent: "codex",
|
|
181
|
+
ok: true,
|
|
182
|
+
version,
|
|
183
|
+
config_home: paths.configHome,
|
|
184
|
+
root,
|
|
185
|
+
files: receipt.files,
|
|
186
|
+
placed: receipt.placed,
|
|
187
|
+
commands: [],
|
|
188
|
+
needs:
|
|
189
|
+
enabled === false
|
|
190
|
+
? `codex の features.hooks が off です (codex features enable hooks で入れてから、${TRUST})`
|
|
191
|
+
: `codex 側で ${TRUST}`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** What the receipt says was done, beside what is actually there now. */
|
|
196
|
+
export async function status(paths: InstancePaths, run: Run = runCodex): Promise<StatusReport> {
|
|
197
|
+
const receipt = await readReceipt(paths, "codex");
|
|
198
|
+
const enabled = await hooksEnabled(run);
|
|
199
|
+
const hooks = enabled === undefined ? {} : { hooks_enabled: enabled };
|
|
200
|
+
if (receipt === undefined) return { agent: "codex", ok: true, ...hooks };
|
|
201
|
+
const missing: string[] = [];
|
|
202
|
+
for (const path of receipt.files) {
|
|
203
|
+
if (!(await Bun.file(join(receipt.root, path)).exists())) missing.push(path);
|
|
204
|
+
}
|
|
205
|
+
for (const path of receipt.placed ?? []) {
|
|
206
|
+
if (!(await Bun.file(path).exists())) missing.push(path);
|
|
207
|
+
}
|
|
208
|
+
const declared = await readHooks(join(receipt.config_home, HOOKS_FILE));
|
|
209
|
+
return {
|
|
210
|
+
agent: "codex",
|
|
211
|
+
ok: true,
|
|
212
|
+
receipt: receiptFile(paths, "codex"),
|
|
213
|
+
installed_at: receipt.installed_at,
|
|
214
|
+
version: receipt.version,
|
|
215
|
+
config_home: receipt.config_home,
|
|
216
|
+
root: receipt.root,
|
|
217
|
+
files: {
|
|
218
|
+
expected: receipt.files.length + (receipt.placed?.length ?? 0),
|
|
219
|
+
present: receipt.files.length + (receipt.placed?.length ?? 0) - missing.length,
|
|
220
|
+
missing,
|
|
221
|
+
},
|
|
222
|
+
...hooks,
|
|
223
|
+
...(hooksOf(declared, receipt.root).length === EVENTS.length
|
|
224
|
+
? {}
|
|
225
|
+
: {
|
|
226
|
+
needs: `${HOOKS_FILE} に ccmsg の hook がありません (plugin install codex で入れ直せます)`,
|
|
227
|
+
}),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Undo what the receipt says was done, and nothing else.
|
|
232
|
+
*
|
|
233
|
+
* `hooks.json` is the config home's own file and may hold hooks that are
|
|
234
|
+
* nobody's business but the person's, so what is taken out of it is the
|
|
235
|
+
* entries pointing at the scripts this receipt names — and the file goes only
|
|
236
|
+
* when nothing is left in it. */
|
|
237
|
+
export async function uninstall(paths: InstancePaths): Promise<UninstallReport> {
|
|
238
|
+
const receipt = await readReceipt(paths, "codex");
|
|
239
|
+
if (receipt === undefined) return { agent: "codex", ok: true, removed: {} };
|
|
240
|
+
const file = receiptFile(paths, "codex");
|
|
241
|
+
const hooksFile = join(receipt.config_home, HOOKS_FILE);
|
|
242
|
+
const left = withoutHooks(await readHooks(hooksFile), receipt.root);
|
|
243
|
+
if (Object.keys(left).length === 0) await rm(hooksFile, { force: true });
|
|
244
|
+
else await writeFile(hooksFile, `${JSON.stringify({ hooks: left }, null, 2)}\n`);
|
|
245
|
+
|
|
246
|
+
const placed = (receipt.placed ?? []).filter((path) => path !== hooksFile);
|
|
247
|
+
for (const path of placed) await rm(path, { force: true });
|
|
248
|
+
// The skill's own directory, which held nothing else.
|
|
249
|
+
await rm(join(receipt.config_home, "skills", "ccmsg"), { recursive: true, force: true });
|
|
250
|
+
await rm(receipt.root, { recursive: true, force: true });
|
|
251
|
+
await rm(file, { force: true });
|
|
252
|
+
return {
|
|
253
|
+
agent: "codex",
|
|
254
|
+
ok: true,
|
|
255
|
+
receipt: file,
|
|
256
|
+
removed: { root: receipt.root, placed: [...placed, hooksFile] },
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Whether Codex has hooks switched on at all, or nothing when it could not be
|
|
261
|
+
* asked. Its own answer rather than a reading of `config.toml`, because the
|
|
262
|
+
* effective state is the feature's stage and the config together. */
|
|
263
|
+
async function hooksEnabled(run: Run): Promise<boolean | undefined> {
|
|
264
|
+
const ran = await run(["features", "list"]);
|
|
265
|
+
if (ran.code !== 0) return undefined;
|
|
266
|
+
for (const line of ran.stdout.split("\n")) {
|
|
267
|
+
const fields = line.trim().split(/\s+/);
|
|
268
|
+
if (fields[0] !== "hooks") continue;
|
|
269
|
+
return fields[fields.length - 1] === "true";
|
|
270
|
+
}
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The `hooks` object of a config home's file, or nothing where there is no
|
|
275
|
+
* file or it says something else. A file that cannot be read is treated as
|
|
276
|
+
* holding nothing, which is what makes the merge below additive. */
|
|
277
|
+
async function readHooks(file: string): Promise<Record<string, unknown[]>> {
|
|
278
|
+
let parsed: unknown;
|
|
279
|
+
try {
|
|
280
|
+
parsed = JSON.parse(await readFile(file, "utf8"));
|
|
281
|
+
} catch {
|
|
282
|
+
return {};
|
|
283
|
+
}
|
|
284
|
+
const hooks = (parsed as { hooks?: unknown } | null)?.hooks;
|
|
285
|
+
if (typeof hooks !== "object" || hooks === null) return {};
|
|
286
|
+
const held: Record<string, unknown[]> = {};
|
|
287
|
+
for (const [event, entries] of Object.entries(hooks as Record<string, unknown>)) {
|
|
288
|
+
if (Array.isArray(entries)) held[event] = entries;
|
|
289
|
+
}
|
|
290
|
+
return held;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function withHooks(held: Record<string, unknown[]>, root: string): { hooks: object } {
|
|
294
|
+
const ours = hookEntries(root);
|
|
295
|
+
const merged = withoutHooks(held, root);
|
|
296
|
+
for (const [event, entries] of Object.entries(ours)) {
|
|
297
|
+
merged[event] = [...(merged[event] ?? []), ...entries];
|
|
298
|
+
}
|
|
299
|
+
return { hooks: merged };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The file's hooks with every entry that runs one of ours taken out, and
|
|
303
|
+
* every event left empty by that taken out with it. */
|
|
304
|
+
function withoutHooks(held: Record<string, unknown[]>, root: string): Record<string, unknown[]> {
|
|
305
|
+
const left: Record<string, unknown[]> = {};
|
|
306
|
+
for (const [event, entries] of Object.entries(held)) {
|
|
307
|
+
const kept = entries.filter((entry) => !runsOurs(entry, root));
|
|
308
|
+
if (kept.length > 0) left[event] = kept;
|
|
309
|
+
}
|
|
310
|
+
return left;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Whether one entry of a file's hooks runs a script of this install's. */
|
|
314
|
+
function runsOurs(entry: unknown, root: string): boolean {
|
|
315
|
+
return hooksIn(entry).some((command) => command.startsWith(join(root, "hooks")));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Which of ccmsg's own hook scripts a file's hooks name. */
|
|
319
|
+
function hooksOf(held: Record<string, unknown[]>, root: string): string[] {
|
|
320
|
+
return Object.values(held)
|
|
321
|
+
.flat()
|
|
322
|
+
.flatMap((entry) => hooksIn(entry))
|
|
323
|
+
.filter((command) => command.startsWith(join(root, "hooks")));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function hooksIn(entry: unknown): string[] {
|
|
327
|
+
const hooks = (entry as { hooks?: unknown } | null)?.hooks;
|
|
328
|
+
if (!Array.isArray(hooks)) return [];
|
|
329
|
+
return hooks
|
|
330
|
+
.map((hook) => (hook as { command?: unknown } | null)?.command)
|
|
331
|
+
.filter((command): command is string => typeof command === "string");
|
|
332
|
+
}
|
package/src/plugin/index.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
export { claudePluginFiles, MARKETPLACE_NAME, PLUGIN_ID, PLUGIN_NAME } from "./claude.ts";
|
|
2
|
+
export { codexPluginFiles, HOOKS_FILE, runCodex } from "./codex.ts";
|
|
3
|
+
export { install, runClaude, status, uninstall } from "./install.ts";
|
|
2
4
|
export {
|
|
3
5
|
type Agent,
|
|
4
6
|
AGENTS,
|
|
5
|
-
|
|
7
|
+
type InstallReport,
|
|
6
8
|
type Outcome,
|
|
7
9
|
type Ran,
|
|
8
10
|
type Receipt,
|
|
9
11
|
type Run,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
} from "./
|
|
12
|
+
type StatusReport,
|
|
13
|
+
type UninstallReport,
|
|
14
|
+
} from "./receipt.ts";
|
|
15
|
+
export { DESCRIPTION, SKILL } from "./skill.ts";
|
package/src/plugin/install.ts
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
3
|
import type { InstancePaths } from "../instance/index.ts";
|
|
4
4
|
import { claudePluginFiles, MARKETPLACE_NAME, PLUGIN_ID } from "./claude.ts";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
5
|
+
import * as codex from "./codex.ts";
|
|
6
|
+
import {
|
|
7
|
+
type Agent,
|
|
8
|
+
type InstallReport,
|
|
9
|
+
place,
|
|
10
|
+
type Ran,
|
|
11
|
+
readReceipt,
|
|
12
|
+
type Receipt,
|
|
13
|
+
receiptFile,
|
|
14
|
+
refusal as refusalOf,
|
|
15
|
+
type Refusal,
|
|
16
|
+
rootFor,
|
|
17
|
+
type Run,
|
|
18
|
+
type StatusReport,
|
|
19
|
+
type UninstallReport,
|
|
20
|
+
writeReceipt,
|
|
21
|
+
} from "./receipt.ts";
|
|
22
22
|
|
|
23
23
|
export const runClaude: Run = async (args) => {
|
|
24
24
|
let spawned: Bun.Subprocess<"ignore", "pipe", "pipe">;
|
|
@@ -34,140 +34,28 @@ export const runClaude: Run = async (args) => {
|
|
|
34
34
|
return { code: await spawned.exited, stdout, stderr };
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
-
/**
|
|
38
|
-
* refused, and what it said. */
|
|
39
|
-
export interface Refusal {
|
|
40
|
-
readonly command: readonly string[];
|
|
41
|
-
readonly code: number;
|
|
42
|
-
readonly said: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** What the three commands answer with.
|
|
37
|
+
/** The three commands, dispatched to the agent they are about.
|
|
46
38
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
export interface InstallReport extends Report {
|
|
60
|
-
readonly version: string;
|
|
61
|
-
readonly config_home: string;
|
|
62
|
-
readonly root: string;
|
|
63
|
-
/** The files laid down, by their path under `root`. */
|
|
64
|
-
readonly files: readonly string[];
|
|
65
|
-
readonly marketplace: { readonly name: string; readonly registered: boolean };
|
|
66
|
-
readonly plugin: {
|
|
67
|
-
readonly id: string;
|
|
68
|
-
readonly installed: boolean;
|
|
69
|
-
/** Whether a copy of the same id was taken out first, which is what makes
|
|
70
|
-
* a repeated install run what was just laid down. */
|
|
71
|
-
readonly replaced: boolean;
|
|
72
|
-
};
|
|
73
|
-
/** The agent commands that were run, as they were run. */
|
|
74
|
-
readonly commands: readonly (readonly string[])[];
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export interface StatusReport extends Report {
|
|
78
|
-
/** Where the receipt is. Absent when ccmsg installed nothing here, which is
|
|
79
|
-
* what makes every field below it absent too. */
|
|
80
|
-
readonly receipt?: string;
|
|
81
|
-
readonly installed_at?: string;
|
|
82
|
-
/** What the receipt says was installed. */
|
|
83
|
-
readonly version?: string;
|
|
84
|
-
readonly config_home?: string;
|
|
85
|
-
readonly root?: string;
|
|
86
|
-
/** The receipt's files, counted against what is under `root` now. */
|
|
87
|
-
readonly files?: {
|
|
88
|
-
readonly expected: number;
|
|
89
|
-
readonly present: number;
|
|
90
|
-
readonly missing: readonly string[];
|
|
91
|
-
};
|
|
92
|
-
readonly marketplace: {
|
|
93
|
-
readonly name?: string;
|
|
94
|
-
/** Whether the agent has it. Absent when the agent could not be asked,
|
|
95
|
-
* which is a different thing from it not being registered. */
|
|
96
|
-
readonly registered?: boolean;
|
|
97
|
-
/** Where the agent thinks it points, when that is not where the receipt
|
|
98
|
-
* put it. */
|
|
99
|
-
readonly points_at?: string;
|
|
100
|
-
};
|
|
101
|
-
readonly plugin: {
|
|
102
|
-
readonly id?: string;
|
|
103
|
-
/** What the agent reports having, and whether it has it switched on.
|
|
104
|
-
* Present with no `expected_version` beside it means something other than
|
|
105
|
-
* ccmsg installed it. */
|
|
106
|
-
readonly installed_version?: string;
|
|
107
|
-
readonly enabled?: boolean;
|
|
108
|
-
/** What the receipt says should be there. */
|
|
109
|
-
readonly expected_version?: string;
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export interface UninstallReport extends Report {
|
|
114
|
-
readonly receipt?: string;
|
|
115
|
-
/** What was actually taken back out. A step the receipt does not name was
|
|
116
|
-
* never taken, so it is not undone and does not appear here. */
|
|
117
|
-
readonly removed: {
|
|
118
|
-
readonly plugin?: string;
|
|
119
|
-
readonly marketplace?: string;
|
|
120
|
-
readonly root?: string;
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export type Outcome = InstallReport | StatusReport | UninstallReport;
|
|
125
|
-
|
|
126
|
-
/** What one install did, so that uninstall can undo exactly that.
|
|
127
|
-
*
|
|
128
|
-
* Everything reversible is written down before the next step is taken: the
|
|
129
|
-
* files that were laid down, the commands that were run against the agent, and
|
|
130
|
-
* the id the agent now knows the plugin by. Undoing reads this and nothing
|
|
131
|
-
* else — an install that half-finished leaves a receipt for the half that
|
|
132
|
-
* happened, and a plugin somebody else installed is not in it and is left
|
|
133
|
-
* alone. */
|
|
134
|
-
export interface Receipt {
|
|
135
|
-
readonly agent: Agent;
|
|
136
|
-
readonly version: string;
|
|
137
|
-
readonly installed_at: string;
|
|
138
|
-
/** The config home the agent was asked to install into. */
|
|
139
|
-
readonly config_home: string;
|
|
140
|
-
/** Where the plugin's own files were laid down. */
|
|
141
|
-
readonly root: string;
|
|
142
|
-
/** Their paths under that root, in the order they were written. */
|
|
143
|
-
readonly files: readonly string[];
|
|
144
|
-
/** The agent commands that were run, as they were run. */
|
|
145
|
-
readonly commands: readonly (readonly string[])[];
|
|
146
|
-
readonly marketplace?: string;
|
|
147
|
-
readonly plugin_id?: string;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function rootFor(paths: InstancePaths, agent: Agent): string {
|
|
151
|
-
return join(paths.pluginsDir, agent);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function receiptFile(paths: InstancePaths, agent: Agent): string {
|
|
155
|
-
return join(paths.pluginsDir, `${agent}.receipt.json`);
|
|
39
|
+
* What each one installs differs in kind — Claude Code takes a plugin through
|
|
40
|
+
* its own CLI, Codex reads files out of its config home — so the two are
|
|
41
|
+
* written apart and only the shapes they answer with are shared. */
|
|
42
|
+
export function install(
|
|
43
|
+
paths: InstancePaths,
|
|
44
|
+
agent: Agent,
|
|
45
|
+
version: string,
|
|
46
|
+
run?: Run,
|
|
47
|
+
): Promise<InstallReport> {
|
|
48
|
+
return agent === "codex"
|
|
49
|
+
? codex.install(paths, version, run)
|
|
50
|
+
: installClaude(paths, version, run);
|
|
156
51
|
}
|
|
157
52
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const parsed: unknown = JSON.parse(await readFile(receiptFile(paths, agent), "utf8"));
|
|
161
|
-
return typeof parsed === "object" && parsed !== null ? (parsed as Receipt) : undefined;
|
|
162
|
-
} catch {
|
|
163
|
-
return undefined;
|
|
164
|
-
}
|
|
53
|
+
export function status(paths: InstancePaths, agent: Agent, run?: Run): Promise<StatusReport> {
|
|
54
|
+
return agent === "codex" ? codex.status(paths, run) : statusClaude(paths, run);
|
|
165
55
|
}
|
|
166
56
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
await mkdir(dirname(file), { recursive: true });
|
|
170
|
-
await writeFile(file, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
57
|
+
export function uninstall(paths: InstancePaths, agent: Agent, run?: Run): Promise<UninstallReport> {
|
|
58
|
+
return agent === "codex" ? codex.uninstall(paths) : uninstallClaude(paths, run);
|
|
171
59
|
}
|
|
172
60
|
|
|
173
61
|
/** Lay the plugin's files down under the instance's own state, register it
|
|
@@ -180,21 +68,14 @@ async function writeReceipt(paths: InstancePaths, receipt: Receipt): Promise<voi
|
|
|
180
68
|
* and a version already installed is not re-read. So an install that finds its
|
|
181
69
|
* own id already there removes it first, which is what makes "install" mean
|
|
182
70
|
* "what is running is what was just laid down". */
|
|
183
|
-
|
|
71
|
+
async function installClaude(
|
|
184
72
|
paths: InstancePaths,
|
|
185
73
|
version: string,
|
|
186
74
|
run: Run = runClaude,
|
|
187
75
|
): Promise<InstallReport> {
|
|
188
76
|
const root = rootFor(paths, "claude");
|
|
189
77
|
const files = claudePluginFiles(version);
|
|
190
|
-
|
|
191
|
-
const file = join(root, path);
|
|
192
|
-
await mkdir(dirname(file), { recursive: true });
|
|
193
|
-
await writeFile(
|
|
194
|
-
file,
|
|
195
|
-
typeof content === "string" ? content : `${JSON.stringify(content, null, 2)}\n`,
|
|
196
|
-
);
|
|
197
|
-
}
|
|
78
|
+
await place(root, files);
|
|
198
79
|
|
|
199
80
|
// Kept as it grows rather than rebuilt per step: each step adds what it did
|
|
200
81
|
// to what the earlier ones did, and the file on disk is that running total.
|
|
@@ -254,7 +135,7 @@ export async function install(
|
|
|
254
135
|
* the current reading of the same thing beside it, so drift — a file deleted, a
|
|
255
136
|
* marketplace pointed elsewhere, a version other than the one installed — is
|
|
256
137
|
* two fields that differ rather than a sentence about them. */
|
|
257
|
-
|
|
138
|
+
async function statusClaude(paths: InstancePaths, run: Run = runClaude): Promise<StatusReport> {
|
|
258
139
|
const receipt = await readReceipt(paths, "claude");
|
|
259
140
|
const here = await installedRow(run);
|
|
260
141
|
const registered = await marketplaces(run);
|
|
@@ -325,7 +206,7 @@ function known(
|
|
|
325
206
|
* then of the marketplace that offered it, and only then are the files it was
|
|
326
207
|
* reading taken away. A step the receipt does not name is a step that was
|
|
327
208
|
* never taken, so it is not undone. */
|
|
328
|
-
|
|
209
|
+
async function uninstallClaude(
|
|
329
210
|
paths: InstancePaths,
|
|
330
211
|
run: Run = runClaude,
|
|
331
212
|
): Promise<UninstallReport> {
|
|
@@ -411,6 +292,5 @@ function rows(output: string): Record<string, unknown>[] {
|
|
|
411
292
|
* exited with, and its first line of complaint. The exit code is stated apart
|
|
412
293
|
* from the words because a command that said nothing still failed. */
|
|
413
294
|
function refusal(command: readonly string[], ran: Ran): Refusal {
|
|
414
|
-
|
|
415
|
-
return { command: ["claude", ...command], code: ran.code, said: said.split("\n")[0] ?? "" };
|
|
295
|
+
return refusalOf("claude", command, ran);
|
|
416
296
|
}
|