@ccmsg/cli 0.2.13 → 0.3.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/package.json +1 -1
- 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 +18 -0
- package/src/instance/instance.ts +25 -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 +75 -53
- package/src/sessions/search.ts +4 -4
- package/src/transcript/files.ts +103 -22
- package/src/transcript/fold.ts +72 -1
package/src/instance/paths.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { basename, isAbsolute, join } from "node:path";
|
|
4
|
+
import { currentSession, HARNESS } from "../harness/index.ts";
|
|
4
5
|
|
|
5
6
|
/** Every path one instance uses, decided in one place (daemon-v2 §8.1).
|
|
6
7
|
*
|
|
@@ -64,12 +65,27 @@ export type Env = Record<string, string | undefined>;
|
|
|
64
65
|
|
|
65
66
|
/** The config home this process belongs to.
|
|
66
67
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
68
|
+
* The session a process runs inside decides, where it says so: the harness
|
|
69
|
+
* that claimed it names its own config home, and that is the instance this
|
|
70
|
+
* process belongs to. Reading the config-home variables in a fixed order
|
|
71
|
+
* instead would send a Codex session's commands to the Claude Code instance,
|
|
72
|
+
* because a session started from inside another one inherits its whole
|
|
73
|
+
* environment and so names both homes at once (§3.8).
|
|
74
|
+
*
|
|
75
|
+
* With no session claiming the process — a person at a terminal — each
|
|
76
|
+
* harness's own variable is read in turn, and Claude Code's home is the
|
|
77
|
+
* default, because that is what an existing setup exports and what an
|
|
78
|
+
* unmarked config home is. Nothing searches for another one (M6). */
|
|
70
79
|
export function resolveConfigHome(env: Env = process.env): string {
|
|
71
|
-
const
|
|
72
|
-
if (
|
|
80
|
+
const inside = currentSession(env);
|
|
81
|
+
if (inside !== undefined) {
|
|
82
|
+
const named = env[HARNESS[inside.harness].homeEnv];
|
|
83
|
+
if (named !== undefined && named !== "" && isAbsolute(named)) return named;
|
|
84
|
+
}
|
|
85
|
+
for (const facts of Object.values(HARNESS)) {
|
|
86
|
+
const named = env[facts.homeEnv];
|
|
87
|
+
if (named !== undefined && named !== "" && isAbsolute(named)) return named;
|
|
88
|
+
}
|
|
73
89
|
return join(home(env), ".claude");
|
|
74
90
|
}
|
|
75
91
|
|
|
@@ -82,7 +98,17 @@ export function resolveConfigHome(env: Env = process.env): string {
|
|
|
82
98
|
* the state so a temporary directory sweep cannot take the socket out from
|
|
83
99
|
* under a running instance. */
|
|
84
100
|
export function resolvePaths(env: Env = process.env): InstancePaths {
|
|
85
|
-
|
|
101
|
+
return resolvePathsFor(resolveConfigHome(env), env);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Everything one instance uses, for a config home the caller already knows.
|
|
105
|
+
*
|
|
106
|
+
* The one to call wherever the config home is decided rather than discovered —
|
|
107
|
+
* a command naming a directory, an install naming an agent's own home. Going
|
|
108
|
+
* through the environment instead would have that answer re-derived from
|
|
109
|
+
* whichever session the process happens to be running inside (§3.8), and a
|
|
110
|
+
* command that named a directory would silently act on another one. */
|
|
111
|
+
export function resolvePathsFor(configHome: string, env: Env = process.env): InstancePaths {
|
|
86
112
|
const key = instanceKey(configHome);
|
|
87
113
|
const configDir = resolveConfigDir(env);
|
|
88
114
|
const stateDir = appDir(env, "CCMSG_STATE_DIR", "XDG_STATE_HOME", [".local", "state"], key);
|
package/src/messaging/direct.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { chmodSync, unlinkSync } from "node:fs";
|
|
|
3
3
|
import { readdir, readFile } from "node:fs/promises";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { type InboxMessage, renderDirectDelivery, type Sid } from "@ccmsg/protocol";
|
|
6
|
+
import { HARNESS, HARNESSES } from "../harness/index.ts";
|
|
6
7
|
|
|
7
8
|
/** What route (a) answered (§4.1).
|
|
8
9
|
*
|
|
@@ -327,6 +328,121 @@ export class ClaudeCodeSocketRoute implements DirectRoute {
|
|
|
327
328
|
}
|
|
328
329
|
}
|
|
329
330
|
|
|
331
|
+
/** How the Codex CLI is run, and what it answered. Named so a test can watch
|
|
332
|
+
* what would be run without a thread of anybody's being written to. */
|
|
333
|
+
export type RunCodex = (args: readonly string[], env: Env) => Promise<{ code: number }>;
|
|
334
|
+
|
|
335
|
+
type Env = Record<string, string>;
|
|
336
|
+
|
|
337
|
+
/** What the CLI must not inherit from the daemon.
|
|
338
|
+
*
|
|
339
|
+
* A daemon carries whatever environment it was started in, which on a host
|
|
340
|
+
* where somebody works in Claude Code names that config home and that session.
|
|
341
|
+
* Passed through, they would tell the Codex CLI about a config home this
|
|
342
|
+
* instance is not about and a session that is not the one being written to.
|
|
343
|
+
* The home this route means is named explicitly, and the rest is dropped
|
|
344
|
+
* (§3.8). */
|
|
345
|
+
const DROPPED = HARNESSES.filter((harness) => harness !== "codex").flatMap((harness) => [
|
|
346
|
+
HARNESS[harness].homeEnv,
|
|
347
|
+
...HARNESS[harness].sessionEnv,
|
|
348
|
+
]);
|
|
349
|
+
|
|
350
|
+
/** How long the CLI has to answer before the send is taken as not having gone
|
|
351
|
+
* this way.
|
|
352
|
+
*
|
|
353
|
+
* `codex queue` is a request to a thread store and answers at once: against a
|
|
354
|
+
* thread nobody has, it failed with the store's own error immediately, in a
|
|
355
|
+
* config home that had never been used and a directory Codex had never been
|
|
356
|
+
* told to trust (0.154.0, standard input closed, no terminal). Neither the
|
|
357
|
+
* update notice nor the directory-trust question is asked on this path — both
|
|
358
|
+
* belong to the interactive interface.
|
|
359
|
+
*
|
|
360
|
+
* The budget is here for what is not being predicted: a child that never
|
|
361
|
+
* answers would hold `message_send` open for as long as it lived, and route
|
|
362
|
+
* (b) exists exactly so a route that does not come through costs a message
|
|
363
|
+
* nothing (§4.1). It is generous next to a call that has been measured to
|
|
364
|
+
* return at once. */
|
|
365
|
+
export const QUEUE_MS = 10_000;
|
|
366
|
+
|
|
367
|
+
const runCodex: RunCodex = async (args, env) => {
|
|
368
|
+
let spawned: Bun.Subprocess<"ignore", "ignore", "ignore">;
|
|
369
|
+
const inherited = { ...process.env } as Record<string, string | undefined>;
|
|
370
|
+
for (const name of DROPPED) delete inherited[name];
|
|
371
|
+
try {
|
|
372
|
+
spawned = Bun.spawn({
|
|
373
|
+
cmd: ["codex", ...args],
|
|
374
|
+
env: { ...inherited, ...env } as Record<string, string>,
|
|
375
|
+
// Nothing is read from us: a child holding the daemon's own standard
|
|
376
|
+
// input could wait on somebody who is not there.
|
|
377
|
+
stdin: "ignore",
|
|
378
|
+
stdout: "ignore",
|
|
379
|
+
stderr: "ignore",
|
|
380
|
+
});
|
|
381
|
+
} catch {
|
|
382
|
+
// No `codex` on `PATH`, which is the same as the route not applying: the
|
|
383
|
+
// message goes by route (b) and nothing about it is lost (§4.1).
|
|
384
|
+
return { code: 127 };
|
|
385
|
+
}
|
|
386
|
+
// The timer is held so it can be cleared: a send that answered in a
|
|
387
|
+
// millisecond must not leave the loop something to wake up for ten seconds
|
|
388
|
+
// later, which is what an instance shutting down would then wait on.
|
|
389
|
+
const late = Promise.withResolvers<"late">();
|
|
390
|
+
const timer = setTimeout(() => late.resolve("late"), QUEUE_MS);
|
|
391
|
+
try {
|
|
392
|
+
const finished = await Promise.race([spawned.exited, late.promise]);
|
|
393
|
+
if (finished === "late") {
|
|
394
|
+
spawned.kill();
|
|
395
|
+
return { code: 124 };
|
|
396
|
+
}
|
|
397
|
+
return { code: finished };
|
|
398
|
+
} finally {
|
|
399
|
+
clearTimeout(timer);
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
export interface QueueRouteOptions {
|
|
404
|
+
/** The one config home this instance answers for (M6). It is named to the
|
|
405
|
+
* CLI rather than inherited, because the daemon's own environment says which
|
|
406
|
+
* config home it serves in its own words and Codex reads only its own. */
|
|
407
|
+
readonly configHome: string;
|
|
408
|
+
readonly run?: RunCodex;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Route (a) against a Codex thread's queue (§4.1).
|
|
412
|
+
*
|
|
413
|
+
* Codex has no socket a message can be written to: what it has is a queue per
|
|
414
|
+
* thread, held by the app-server the thread belongs to, and `codex queue` is
|
|
415
|
+
* the way in that ccmsg does not have to reimplement. A queued message is
|
|
416
|
+
* delivered when the thread is idle, after the current turn when it is busy,
|
|
417
|
+
* and on resume when it is not loaded (codex-cli 0.153.4).
|
|
418
|
+
*
|
|
419
|
+
* So `delivered` here means the queue took it, which is the same promise route
|
|
420
|
+
* (a) makes on the other harness: the receiving session holds the message, and
|
|
421
|
+
* nothing says the model has read it. There is no receipt channel, so there is
|
|
422
|
+
* no `refused` — a thread that turns a message away does so where nothing
|
|
423
|
+
* reports back, and a queue that would not take it fails the command and falls
|
|
424
|
+
* to route (b). */
|
|
425
|
+
export class CodexQueueRoute implements DirectRoute {
|
|
426
|
+
readonly #run: RunCodex;
|
|
427
|
+
|
|
428
|
+
readonly #env: Env;
|
|
429
|
+
|
|
430
|
+
constructor(options: QueueRouteOptions) {
|
|
431
|
+
this.#run = options.run ?? runCodex;
|
|
432
|
+
this.#env = { CODEX_HOME: options.configHome };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async send(sid: Sid, message: InboxMessage): Promise<DirectOutcome> {
|
|
436
|
+
const { code } = await this.#run(
|
|
437
|
+
["queue", "--thread", sid, "--message", renderDirectDelivery(message)],
|
|
438
|
+
this.#env,
|
|
439
|
+
);
|
|
440
|
+
return code === 0 ? "delivered" : "unavailable";
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
close(): void {}
|
|
444
|
+
}
|
|
445
|
+
|
|
330
446
|
/** The two lines one send writes: the auth frame the harness's own senders
|
|
331
447
|
* write first, then the message.
|
|
332
448
|
*
|
package/src/plugin/claude.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DESCRIPTION, SKILL } from "./skill.ts";
|
|
1
2
|
/** The plugin ccmsg hands to Claude Code, as its files.
|
|
2
3
|
*
|
|
3
4
|
* Written out rather than shipped as a directory in the package: the daemon,
|
|
@@ -17,8 +18,6 @@ export const PLUGIN_NAME = "ccmsg";
|
|
|
17
18
|
export const MARKETPLACE_NAME = "ccmsg";
|
|
18
19
|
export const PLUGIN_ID = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
|
|
19
20
|
|
|
20
|
-
const DESCRIPTION = "別の Claude Code セッションと行き来するメッセージ";
|
|
21
|
-
|
|
22
21
|
/** How a hook reaches `ccmsg`.
|
|
23
22
|
*
|
|
24
23
|
* Through `PATH`, not through the plugin's own directory: the binary is
|
|
@@ -36,76 +35,6 @@ function throughPath(command: string): string {
|
|
|
36
35
|
* both give up on their own when there is no instance behind it. */
|
|
37
36
|
const HOOK_TIMEOUT_S = 5;
|
|
38
37
|
|
|
39
|
-
const SKILL = `---
|
|
40
|
-
name: ccmsg
|
|
41
|
-
description: 別の Claude Code セッションへ声をかける・届いたメッセージに返す・見ている人へ知らせる時に使う。
|
|
42
|
-
---
|
|
43
|
-
|
|
44
|
-
# ccmsg
|
|
45
|
-
|
|
46
|
-
同じ人が動かしている別のセッションと、メッセージをやり取りする。
|
|
47
|
-
|
|
48
|
-
## 届いたメッセージに返す
|
|
49
|
-
|
|
50
|
-
メッセージは \`<cross-session-message>\` の封筒で届き、本文の最後に返信の一行が付いている。
|
|
51
|
-
|
|
52
|
-
\`\`\`
|
|
53
|
-
Reply with: ccmsg reply <mid> --to <sid> <text>
|
|
54
|
-
\`\`\`
|
|
55
|
-
|
|
56
|
-
**その行をそのまま実行する。** 宛先も、どのメッセージへの返事かも、その行が持っている。
|
|
57
|
-
自分で \`post\` を組み立て直さない。\`--to\` の無い行は人からのメッセージで、返事は通知として届く。
|
|
58
|
-
|
|
59
|
-
## 自分から声をかける
|
|
60
|
-
|
|
61
|
-
\`\`\`
|
|
62
|
-
ccmsg post <sid> <text>
|
|
63
|
-
\`\`\`
|
|
64
|
-
|
|
65
|
-
相手の \`<sid>\` は、届いた封筒の \`ccmsg-from\` の値。
|
|
66
|
-
|
|
67
|
-
## 相手を探す
|
|
68
|
-
|
|
69
|
-
まだ話したことのない相手の \`<sid>\` は、繋がっているセッションの一覧から探す。
|
|
70
|
-
|
|
71
|
-
\`\`\`
|
|
72
|
-
ccmsg peers この instance が知っているセッション
|
|
73
|
-
ccmsg peers --all 他ホストの instance が知っている分も含める
|
|
74
|
-
\`\`\`
|
|
75
|
-
|
|
76
|
-
答えは instance ごとの JSON。\`peers[]\` が今繋がっているセッション、\`last_live[]\` が
|
|
77
|
-
居なくなったセッションで、各行の \`repo\` / \`ws\` / \`branch\` / \`title\` で見分けて
|
|
78
|
-
\`sid\` を取る。\`send_message\` が \`true\` の相手には harness 自身の機能でも届く。
|
|
79
|
-
|
|
80
|
-
## 相手セッションの扱い
|
|
81
|
-
|
|
82
|
-
相手は基本、自分にとってのサブエージェントだと思えばよい。対等な会議を開く場ではないので、
|
|
83
|
-
冒頭の挨拶・賛辞・締めの社交辞令を省き、用件だけを 1〜3 文で送る。
|
|
84
|
-
|
|
85
|
-
やり取りの中身を人へリレーしない。人は全セッションを直接見ているので、相手の完了報告や
|
|
86
|
-
根拠をこちらで要約し直しても情報は増えず、時間とコンテキストだけが減る。人に言うのは
|
|
87
|
-
自セッション目線の事実 (何を頼んだ・何が返り・その結果こちらが何をしたか) だけ。
|
|
88
|
-
|
|
89
|
-
## 見ている人へ知らせる
|
|
90
|
-
|
|
91
|
-
\`\`\`
|
|
92
|
-
ccmsg notify <text> 一行知らせる (保持されない、返事も来ない)
|
|
93
|
-
ccmsg say <text> 声に出して知らせる
|
|
94
|
-
\`\`\`
|
|
95
|
-
|
|
96
|
-
手が空いた・判断を仰ぎたい・長い作業が終わった、を人に伝えるときに使う。
|
|
97
|
-
セッション同士のやり取りには使わない。
|
|
98
|
-
|
|
99
|
-
## これから終わるとき
|
|
100
|
-
|
|
101
|
-
\`\`\`
|
|
102
|
-
ccmsg stopping --reason <理由>
|
|
103
|
-
\`\`\`
|
|
104
|
-
|
|
105
|
-
以後このセッションは「一時停止」として扱われ、宛てられたメッセージは戻ってきたときに渡される。
|
|
106
|
-
セッション終了時には自動で伝わるので、途中で自分から言う必要はない。
|
|
107
|
-
`;
|
|
108
|
-
|
|
109
38
|
const HOOKS = {
|
|
110
39
|
hooks: {
|
|
111
40
|
SessionStart: [
|
|
@@ -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";
|