@ccmsg/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/package.json +32 -0
  4. package/src/cli.ts +1074 -0
  5. package/src/daemon/control.ts +88 -0
  6. package/src/daemon/index.ts +6 -0
  7. package/src/daemon/link.ts +93 -0
  8. package/src/daemon/log.ts +116 -0
  9. package/src/daemon/registry.ts +285 -0
  10. package/src/daemon/snapshot.ts +115 -0
  11. package/src/daemon/supervise.ts +446 -0
  12. package/src/dispatch/caller.ts +47 -0
  13. package/src/dispatch/dispatch.ts +128 -0
  14. package/src/dispatch/handler.ts +55 -0
  15. package/src/dispatch/identity.ts +22 -0
  16. package/src/dispatch/index.ts +5 -0
  17. package/src/dispatch/result.ts +58 -0
  18. package/src/files/containment.ts +263 -0
  19. package/src/files/files.ts +421 -0
  20. package/src/files/index.ts +14 -0
  21. package/src/files/sandbox.ts +0 -0
  22. package/src/greeting/hook.ts +48 -0
  23. package/src/greeting/index.ts +2 -0
  24. package/src/greeting/meta.ts +66 -0
  25. package/src/instance/config.ts +424 -0
  26. package/src/instance/handlers.ts +28 -0
  27. package/src/instance/identity.ts +44 -0
  28. package/src/instance/index.ts +8 -0
  29. package/src/instance/instance.ts +911 -0
  30. package/src/instance/lock.ts +108 -0
  31. package/src/instance/log.ts +30 -0
  32. package/src/instance/paths.ts +200 -0
  33. package/src/instance/socket.ts +62 -0
  34. package/src/kv/index.ts +2 -0
  35. package/src/kv/merge.ts +66 -0
  36. package/src/kv/store.ts +195 -0
  37. package/src/launcher/index.ts +4 -0
  38. package/src/launcher/launcher.ts +190 -0
  39. package/src/launcher/roots.ts +32 -0
  40. package/src/launcher/spawn.ts +81 -0
  41. package/src/launcher/tree.ts +80 -0
  42. package/src/mesh/index.ts +5 -0
  43. package/src/mesh/keys.ts +158 -0
  44. package/src/mesh/mesh.ts +1169 -0
  45. package/src/mesh/probe.ts +100 -0
  46. package/src/mesh/relay.ts +147 -0
  47. package/src/mesh/wire.ts +96 -0
  48. package/src/messaging/delivery.ts +375 -0
  49. package/src/messaging/direct.ts +433 -0
  50. package/src/messaging/handlers.ts +14 -0
  51. package/src/messaging/inbox.ts +191 -0
  52. package/src/messaging/index.ts +5 -0
  53. package/src/messaging/notify.ts +117 -0
  54. package/src/plugin/claude.ts +148 -0
  55. package/src/plugin/index.ts +13 -0
  56. package/src/plugin/install.ts +416 -0
  57. package/src/service/index.ts +1 -0
  58. package/src/service/service.ts +359 -0
  59. package/src/sessions/classify.ts +66 -0
  60. package/src/sessions/dump.ts +105 -0
  61. package/src/sessions/fork.ts +127 -0
  62. package/src/sessions/handlers.ts +158 -0
  63. package/src/sessions/harness.ts +167 -0
  64. package/src/sessions/index.ts +26 -0
  65. package/src/sessions/last-live.ts +111 -0
  66. package/src/sessions/processes.ts +413 -0
  67. package/src/sessions/registry.ts +785 -0
  68. package/src/sessions/search.ts +278 -0
  69. package/src/sessions/status.ts +209 -0
  70. package/src/sessions/terminals.ts +72 -0
  71. package/src/sessions/workspace.ts +140 -0
  72. package/src/topics/handlers.ts +42 -0
  73. package/src/topics/index.ts +2 -0
  74. package/src/topics/topics.ts +290 -0
  75. package/src/transcript/files.ts +201 -0
  76. package/src/transcript/fold.ts +833 -0
  77. package/src/transcript/index.ts +16 -0
  78. package/src/transcript/read.ts +82 -0
  79. package/src/transcript/tail.ts +195 -0
  80. package/src/transcript/transcripts.ts +162 -0
  81. package/src/translate/helper.ts +87 -0
  82. package/src/translate/index.ts +2 -0
  83. package/src/translate/translate.ts +127 -0
  84. package/src/transport/conn.ts +129 -0
  85. package/src/transport/dial.ts +65 -0
  86. package/src/transport/driver.ts +102 -0
  87. package/src/transport/entry.ts +39 -0
  88. package/src/transport/framing.ts +131 -0
  89. package/src/transport/index.ts +8 -0
  90. package/src/transport/listener.ts +39 -0
  91. package/src/transport/uds.ts +88 -0
  92. package/src/transport/ws.ts +170 -0
  93. package/src/upstream/events.ts +125 -0
  94. package/src/upstream/gateway.ts +275 -0
  95. package/src/upstream/index.ts +8 -0
  96. package/src/upstream/json.ts +81 -0
  97. package/src/upstream/requests.ts +234 -0
  98. package/src/upstream/stats.ts +99 -0
  99. package/src/upstream/status.ts +281 -0
  100. package/src/upstream/usage.ts +208 -0
  101. package/src/upstream/webhook.ts +141 -0
  102. package/src/version.ts +8 -0
@@ -0,0 +1,117 @@
1
+ import type {
2
+ InstanceId,
3
+ Notification,
4
+ NotifySendArgs,
5
+ NotifySendResult,
6
+ SayMarkReadArgs,
7
+ SayMarkReadResult,
8
+ SayPostArgs,
9
+ SayPostResult,
10
+ Sid,
11
+ Timestamp,
12
+ } from "@ccmsg/protocol";
13
+ import { type HandlerInput, OpError } from "../dispatch/index.ts";
14
+ import type { TopicValue, UpstreamResource } from "../topics/index.ts";
15
+
16
+ /** The one topic a notification reaches a watcher on. */
17
+ const NOTIFY = "notify";
18
+
19
+ export interface NotifyDeps {
20
+ readonly self: InstanceId;
21
+ /** How a session is shown. The contract has the issuing instance resolve it,
22
+ * so the label is decided here rather than carried in the arguments. */
23
+ readonly label: (sid: Sid) => string;
24
+ /** The one way a value reaches subscribers (§6.1). No `to`: a notification is
25
+ * for whoever is watching, not for one session. */
26
+ readonly publish: (topic: string, data: unknown, instance: InstanceId) => void;
27
+ }
28
+
29
+ /** The `notify` topic and the three ops that speak on it.
30
+ *
31
+ * One object for all three because they are one thing seen from two sides: a
32
+ * line reaching a person watching. `notify_send` is somebody telling a person
33
+ * about a session; `say_post` is a session saying that it just spoke. Both end
34
+ * as the same frame, which is what keeps "a notification" from meaning two
35
+ * shapes depending on which op raised it.
36
+ *
37
+ * The topic is `event` granularity (§6.2): nothing is held, so there is no
38
+ * snapshot and no suppression — two identical notifications are two things
39
+ * that happened. */
40
+ export class Notify implements UpstreamResource {
41
+ /** Sessions that have spoken and not been heard. Instance state the contract
42
+ * lets a restart forget, so it lives here and nowhere on disk. */
43
+ readonly #unread = new Set<Sid>();
44
+
45
+ constructor(private readonly deps: NotifyDeps) {}
46
+
47
+ /** `notify_send`. The subject is the argument when it names one and the
48
+ * caller otherwise, so a session notifying about itself says only the text. */
49
+ send = (input: HandlerInput): NotifySendResult => {
50
+ const args = input.args as unknown as NotifySendArgs;
51
+ this.#announce(args.sid ?? this.#caller(input), args.text);
52
+ return {};
53
+ };
54
+
55
+ /** `say_post`. What was said is already in the caller's transcript, so this
56
+ * pushes the occurrence and raises the unread mark; the instance keeps no log
57
+ * of its own. The op is open to sessions alone, so the subject is the caller
58
+ * and there is nothing to address. */
59
+ post = (input: HandlerInput): SayPostResult => {
60
+ const args = input.args as unknown as SayPostArgs;
61
+ const sid = this.#caller(input);
62
+ const posted_at = this.#announce(sid, args.text);
63
+ this.#unread.add(sid);
64
+ return { posted_at };
65
+ };
66
+
67
+ /** `say_mark_read`. One session's mark, or every one when none is named. */
68
+ markRead = (input: HandlerInput): SayMarkReadResult => {
69
+ const { sid } = input.args as unknown as SayMarkReadArgs;
70
+ if (sid === undefined) this.#unread.clear();
71
+ else this.#unread.delete(sid);
72
+ return {};
73
+ };
74
+
75
+ /** The sessions that have spoken unheard. Nothing in this generation of the
76
+ * contract carries the mark on the wire, so this is how the instance's own
77
+ * side reads what `say_mark_read` clears. */
78
+ unread(): readonly Sid[] {
79
+ return [...this.#unread];
80
+ }
81
+
82
+ // --- UpstreamResource (§6.3)
83
+
84
+ /** Nothing upstream to run: a notification exists because an op raised it. */
85
+ start(): void {}
86
+
87
+ stop(): void {}
88
+
89
+ /** An event topic has no current value, so a subscriber starts at the next
90
+ * thing that happens (§6.2). */
91
+ snapshot(): readonly TopicValue[] {
92
+ return [];
93
+ }
94
+
95
+ #announce(sid: Sid, text: string, now: Timestamp = Date.now()): Timestamp {
96
+ const notification: Notification = {
97
+ sid,
98
+ sid_label: this.deps.label(sid),
99
+ text,
100
+ sent_at: now,
101
+ };
102
+ this.deps.publish(NOTIFY, notification, this.deps.self);
103
+ return now;
104
+ }
105
+
106
+ /** The session the caller is. A person's connection names none, which is why
107
+ * `notify_send` takes the subject as an argument — one that omits it from a
108
+ * connection with no session has named nobody for the notification to be
109
+ * about. */
110
+ #caller(input: HandlerInput): Sid {
111
+ const sid = input.identity?.sid;
112
+ if (sid === undefined) {
113
+ throw new OpError("bad_request", "a notification is about a session, and this names none");
114
+ }
115
+ return sid;
116
+ }
117
+ }
@@ -0,0 +1,148 @@
1
+ /** The plugin ccmsg hands to Claude Code, as its files.
2
+ *
3
+ * Written out rather than shipped as a directory in the package: the daemon,
4
+ * the CLI and the plugin are one release, and generating the plugin from the
5
+ * running binary is what keeps the three from drifting into three versions of
6
+ * "what ccmsg is". `install` lays these down and registers them; nothing else
7
+ * writes into a config home.
8
+ *
9
+ * The plugin itself is thin on purpose. Messages reach a session through the
10
+ * harness's own socket, so nothing here listens, polls or holds a connection —
11
+ * the skill says how to speak, and the two hooks say hello and goodbye. */
12
+
13
+ /** The plugin's name, the marketplace's name, and therefore the id Claude Code
14
+ * knows it by. One word for all three: there is one plugin here and a
15
+ * marketplace that exists only to carry it. */
16
+ export const PLUGIN_NAME = "ccmsg";
17
+ export const MARKETPLACE_NAME = "ccmsg";
18
+ export const PLUGIN_ID = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
19
+
20
+ const DESCRIPTION = "別の Claude Code セッションと行き来するメッセージ";
21
+
22
+ /** How a hook reaches `ccmsg`.
23
+ *
24
+ * Through `PATH`, not through the plugin's own directory: the binary is
25
+ * installed by whoever installs ccmsg, and a plugin that carried its own copy
26
+ * would be a second version of it to keep current. A session whose `PATH` has
27
+ * no `ccmsg` is a session with nothing to say, so the hook leaves without
28
+ * saying it — silently, because a person who has not installed ccmsg has not
29
+ * asked to hear about it at every session start. */
30
+ function throughPath(command: string): string {
31
+ return `if command -v ccmsg >/dev/null 2>&1; then ccmsg ${command}; fi`;
32
+ }
33
+
34
+ /** How long a greeting or a departure may take before the harness stops
35
+ * waiting on it. Both are one connection to a socket on this same host, and
36
+ * both give up on their own when there is no instance behind it. */
37
+ const HOOK_TIMEOUT_S = 5;
38
+
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
+ ccmsg notify <text> 一行知らせる (保持されない、返事も来ない)
84
+ ccmsg say <text> 声に出して知らせる
85
+ \`\`\`
86
+
87
+ 手が空いた・判断を仰ぎたい・長い作業が終わった、を人に伝えるときに使う。
88
+ セッション同士のやり取りには使わない。
89
+
90
+ ## これから終わるとき
91
+
92
+ \`\`\`
93
+ ccmsg stopping --reason <理由>
94
+ \`\`\`
95
+
96
+ 以後このセッションは「一時停止」として扱われ、宛てられたメッセージは戻ってきたときに渡される。
97
+ セッション終了時には自動で伝わるので、途中で自分から言う必要はない。
98
+ `;
99
+
100
+ const HOOKS = {
101
+ hooks: {
102
+ SessionStart: [
103
+ {
104
+ matcher: "startup|resume|clear|compact",
105
+ hooks: [{ type: "command", command: throughPath("hello --hook"), timeout: HOOK_TIMEOUT_S }],
106
+ },
107
+ ],
108
+ SessionEnd: [
109
+ {
110
+ hooks: [
111
+ { type: "command", command: throughPath("stopping --hook"), timeout: HOOK_TIMEOUT_S },
112
+ ],
113
+ },
114
+ ],
115
+ },
116
+ };
117
+
118
+ /** Every file the plugin is made of, by its path under the plugin's root.
119
+ *
120
+ * A string is written as it is; anything else is the content of a JSON file
121
+ * and is serialized by whoever writes it, so this states shapes rather than
122
+ * text and there is one place that turns a value into bytes. */
123
+ export function claudePluginFiles(version: string): Map<string, string | object> {
124
+ return new Map<string, string | object>([
125
+ [
126
+ ".claude-plugin/marketplace.json",
127
+ {
128
+ name: MARKETPLACE_NAME,
129
+ owner: { name: "kawaz" },
130
+ metadata: { description: DESCRIPTION, version },
131
+ plugins: [{ name: PLUGIN_NAME, description: DESCRIPTION, source: "./" }],
132
+ },
133
+ ],
134
+ [
135
+ ".claude-plugin/plugin.json",
136
+ {
137
+ name: PLUGIN_NAME,
138
+ description: DESCRIPTION,
139
+ version,
140
+ author: { name: "kawaz" },
141
+ license: "MIT",
142
+ repository: "https://github.com/kawaz/ccmsg",
143
+ },
144
+ ],
145
+ ["skills/ccmsg/SKILL.md", SKILL],
146
+ ["hooks/hooks.json", HOOKS],
147
+ ]);
148
+ }
@@ -0,0 +1,13 @@
1
+ export { claudePluginFiles, MARKETPLACE_NAME, PLUGIN_ID, PLUGIN_NAME } from "./claude.ts";
2
+ export {
3
+ type Agent,
4
+ AGENTS,
5
+ install,
6
+ type Outcome,
7
+ type Ran,
8
+ type Receipt,
9
+ type Run,
10
+ runClaude,
11
+ status,
12
+ uninstall,
13
+ } from "./install.ts";
@@ -0,0 +1,416 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import type { InstancePaths } from "../instance/index.ts";
4
+ import { claudePluginFiles, MARKETPLACE_NAME, PLUGIN_ID } from "./claude.ts";
5
+
6
+ /** The agents ccmsg can install a plugin for. One so far; the word is in the
7
+ * command because the second one is what the shape is for. */
8
+ export const AGENTS = ["claude"] as const;
9
+ export type Agent = (typeof AGENTS)[number];
10
+
11
+ /** How the agent's own CLI is run. The environment is inherited, which is how
12
+ * the install lands in the config home this instance answers for and not in
13
+ * another one (M6). Named so a test can watch what would be run without a
14
+ * config home of a person's being touched. */
15
+ export type Run = (args: readonly string[]) => Promise<Ran>;
16
+
17
+ export interface Ran {
18
+ readonly code: number;
19
+ readonly stdout: string;
20
+ readonly stderr: string;
21
+ }
22
+
23
+ export const runClaude: Run = async (args) => {
24
+ let spawned: Bun.Subprocess<"ignore", "pipe", "pipe">;
25
+ try {
26
+ spawned = Bun.spawn({ cmd: ["claude", ...args], stdout: "pipe", stderr: "pipe" });
27
+ } catch {
28
+ return { code: 127, stdout: "", stderr: "claude が PATH にありません" };
29
+ }
30
+ const [stdout, stderr] = await Promise.all([
31
+ new Response(spawned.stdout).text(),
32
+ new Response(spawned.stderr).text(),
33
+ ]);
34
+ return { code: await spawned.exited, stdout, stderr };
35
+ };
36
+
37
+ /** Why one of these commands stopped where it did: the agent command that was
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.
46
+ *
47
+ * Fields rather than sentences: these commands are read by whatever runs them
48
+ * as much as by a person, and a line of prose is something a caller has to
49
+ * parse back into the facts it was built from. The words a person wants are in
50
+ * `--help`; what is here is what was found. */
51
+ interface Report {
52
+ readonly agent: Agent;
53
+ /** Whether the command did everything it set out to do. */
54
+ readonly ok: boolean;
55
+ /** The step that stopped it. Absent while `ok`. */
56
+ readonly refused?: Refusal;
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`);
156
+ }
157
+
158
+ async function readReceipt(paths: InstancePaths, agent: Agent): Promise<Receipt | undefined> {
159
+ try {
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
+ }
165
+ }
166
+
167
+ async function writeReceipt(paths: InstancePaths, receipt: Receipt): Promise<void> {
168
+ const file = receiptFile(paths, receipt.agent);
169
+ await mkdir(dirname(file), { recursive: true });
170
+ await writeFile(file, `${JSON.stringify(receipt, null, 2)}\n`);
171
+ }
172
+
173
+ /** Lay the plugin's files down under the instance's own state, register it
174
+ * with the agent, and write down what was done.
175
+ *
176
+ * The two agent commands are both safe to repeat — the agent answers "already
177
+ * there" and succeeds — so an install that is run twice is an install that
178
+ * finishes twice rather than one that fails the second time. What is not
179
+ * repeatable is the copy the agent takes: it snapshots the plugin at install,
180
+ * and a version already installed is not re-read. So an install that finds its
181
+ * own id already there removes it first, which is what makes "install" mean
182
+ * "what is running is what was just laid down". */
183
+ export async function install(
184
+ paths: InstancePaths,
185
+ version: string,
186
+ run: Run = runClaude,
187
+ ): Promise<InstallReport> {
188
+ const root = rootFor(paths, "claude");
189
+ const files = claudePluginFiles(version);
190
+ for (const [path, content] of files) {
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
+ }
198
+
199
+ // Kept as it grows rather than rebuilt per step: each step adds what it did
200
+ // to what the earlier ones did, and the file on disk is that running total.
201
+ // A receipt that named only the last step would undo only the last step.
202
+ let receipt: Receipt = {
203
+ agent: "claude",
204
+ version,
205
+ installed_at: new Date().toISOString(),
206
+ config_home: paths.configHome,
207
+ root,
208
+ files: [...files.keys()],
209
+ commands: [],
210
+ };
211
+ await writeReceipt(paths, receipt);
212
+
213
+ let replaced = false;
214
+ const so = (ok: boolean, refused?: Refusal): InstallReport => ({
215
+ agent: "claude",
216
+ ok,
217
+ version,
218
+ config_home: paths.configHome,
219
+ root,
220
+ files: receipt.files,
221
+ marketplace: { name: MARKETPLACE_NAME, registered: receipt.marketplace !== undefined },
222
+ plugin: { id: PLUGIN_ID, installed: receipt.plugin_id !== undefined, replaced },
223
+ commands: receipt.commands,
224
+ ...(refused === undefined ? {} : { refused }),
225
+ });
226
+
227
+ const step = async (args: string[], done: Partial<Receipt>): Promise<Refusal | undefined> => {
228
+ const ran = await run(args);
229
+ if (ran.code !== 0) return refusal(args, ran);
230
+ receipt = { ...receipt, ...done, commands: [...receipt.commands, args] };
231
+ await writeReceipt(paths, receipt);
232
+ return undefined;
233
+ };
234
+
235
+ const added = await step(["plugin", "marketplace", "add", root], {
236
+ marketplace: MARKETPLACE_NAME,
237
+ });
238
+ if (added !== undefined) return so(false, added);
239
+
240
+ if (await installed(run)) {
241
+ const args = ["plugin", "uninstall", PLUGIN_ID, "-y"];
242
+ const ran = await run(args);
243
+ if (ran.code !== 0) return so(false, refusal(args, ran));
244
+ replaced = true;
245
+ }
246
+
247
+ const put = await step(["plugin", "install", PLUGIN_ID], { plugin_id: PLUGIN_ID });
248
+ return put === undefined ? so(true) : so(false, put);
249
+ }
250
+
251
+ /** What the receipt says was done, beside what is actually there now.
252
+ *
253
+ * Reading it is what tells the two apart: every field the receipt states has
254
+ * the current reading of the same thing beside it, so drift — a file deleted, a
255
+ * marketplace pointed elsewhere, a version other than the one installed — is
256
+ * two fields that differ rather than a sentence about them. */
257
+ export async function status(paths: InstancePaths, run: Run = runClaude): Promise<StatusReport> {
258
+ const receipt = await readReceipt(paths, "claude");
259
+ const here = await installedRow(run);
260
+ const registered = await marketplaces(run);
261
+ if (receipt === undefined) {
262
+ // No receipt, so there is nothing of ccmsg's to compare against. A plugin
263
+ // of the same id is still worth naming: it is there, and taking it out is
264
+ // not this command's to do.
265
+ return {
266
+ agent: "claude",
267
+ ok: true,
268
+ marketplace: known(registered, MARKETPLACE_NAME),
269
+ plugin:
270
+ here === undefined
271
+ ? {}
272
+ : { id: PLUGIN_ID, installed_version: here.version, enabled: here.enabled },
273
+ };
274
+ }
275
+ const missing: string[] = [];
276
+ for (const path of receipt.files) {
277
+ if (!(await Bun.file(join(receipt.root, path)).exists())) missing.push(path);
278
+ }
279
+ const market = known(registered, receipt.marketplace, receipt.root);
280
+ return {
281
+ agent: "claude",
282
+ ok: true,
283
+ receipt: receiptFile(paths, "claude"),
284
+ installed_at: receipt.installed_at,
285
+ version: receipt.version,
286
+ config_home: receipt.config_home,
287
+ root: receipt.root,
288
+ files: {
289
+ expected: receipt.files.length,
290
+ present: receipt.files.length - missing.length,
291
+ missing,
292
+ },
293
+ marketplace: market,
294
+ plugin: {
295
+ ...(receipt.plugin_id === undefined ? {} : { id: receipt.plugin_id }),
296
+ ...(here === undefined ? {} : { installed_version: here.version, enabled: here.enabled }),
297
+ expected_version: receipt.version,
298
+ },
299
+ };
300
+ }
301
+
302
+ /** One marketplace as the agent has it, against where it was put.
303
+ *
304
+ * An agent that could not be asked leaves `registered` unsaid, because "we do
305
+ * not know" and "it is not registered" lead to different next steps. */
306
+ function known(
307
+ registered: Map<string, string> | undefined,
308
+ name: string | undefined,
309
+ root?: string,
310
+ ): StatusReport["marketplace"] {
311
+ const named = name === undefined ? {} : { name };
312
+ if (registered === undefined || name === undefined) return named;
313
+ const at = registered.get(name);
314
+ if (at === undefined) return { ...named, registered: false };
315
+ return {
316
+ ...named,
317
+ registered: true,
318
+ ...(root === undefined || at === root || at === "" ? {} : { points_at: at }),
319
+ };
320
+ }
321
+
322
+ /** Undo what the receipt says was done, and nothing else.
323
+ *
324
+ * In the order that leaves nothing dangling: the agent lets go of the plugin,
325
+ * then of the marketplace that offered it, and only then are the files it was
326
+ * reading taken away. A step the receipt does not name is a step that was
327
+ * never taken, so it is not undone. */
328
+ export async function uninstall(
329
+ paths: InstancePaths,
330
+ run: Run = runClaude,
331
+ ): Promise<UninstallReport> {
332
+ const receipt = await readReceipt(paths, "claude");
333
+ if (receipt === undefined) return { agent: "claude", ok: true, removed: {} };
334
+ const file = receiptFile(paths, "claude");
335
+ let removed: UninstallReport["removed"] = {};
336
+ if (receipt.plugin_id !== undefined) {
337
+ const args = ["plugin", "uninstall", receipt.plugin_id, "-y"];
338
+ const ran = await run(args);
339
+ if (ran.code !== 0) {
340
+ return { agent: "claude", ok: false, receipt: file, removed, refused: refusal(args, ran) };
341
+ }
342
+ removed = { ...removed, plugin: receipt.plugin_id };
343
+ }
344
+ if (receipt.marketplace !== undefined) {
345
+ const args = ["plugin", "marketplace", "remove", receipt.marketplace];
346
+ const ran = await run(args);
347
+ if (ran.code !== 0) {
348
+ return { agent: "claude", ok: false, receipt: file, removed, refused: refusal(args, ran) };
349
+ }
350
+ removed = { ...removed, marketplace: receipt.marketplace };
351
+ }
352
+ await rm(receipt.root, { recursive: true, force: true });
353
+ await rm(file, { force: true });
354
+ return { agent: "claude", ok: true, receipt: file, removed: { ...removed, root: receipt.root } };
355
+ }
356
+
357
+ /** One installed plugin, as the agent lists it. */
358
+ interface Installed {
359
+ readonly version: string;
360
+ readonly enabled: boolean;
361
+ }
362
+
363
+ async function installedRow(run: Run): Promise<Installed | undefined> {
364
+ const ran = await run(["plugin", "list", "--json"]);
365
+ if (ran.code !== 0) return undefined;
366
+ for (const row of rows(ran.stdout)) {
367
+ if (row["id"] !== PLUGIN_ID) continue;
368
+ const version = row["version"];
369
+ return {
370
+ version: typeof version === "string" ? version : "不明",
371
+ enabled: row["enabled"] !== false,
372
+ };
373
+ }
374
+ return undefined;
375
+ }
376
+
377
+ async function installed(run: Run): Promise<boolean> {
378
+ return (await installedRow(run)) !== undefined;
379
+ }
380
+
381
+ /** The marketplaces the agent knows, by name and by where each one points.
382
+ * Nothing when the agent could not be asked, which reads as "unknown" rather
383
+ * than as "none registered". */
384
+ async function marketplaces(run: Run): Promise<Map<string, string> | undefined> {
385
+ const ran = await run(["plugin", "marketplace", "list", "--json"]);
386
+ if (ran.code !== 0) return undefined;
387
+ const known = new Map<string, string>();
388
+ for (const row of rows(ran.stdout)) {
389
+ const name = row["name"];
390
+ const path = row["path"] ?? row["installLocation"];
391
+ if (typeof name === "string") known.set(name, typeof path === "string" ? path : "");
392
+ }
393
+ return known;
394
+ }
395
+
396
+ function rows(output: string): Record<string, unknown>[] {
397
+ let parsed: unknown;
398
+ try {
399
+ parsed = JSON.parse(output);
400
+ } catch {
401
+ return [];
402
+ }
403
+ return Array.isArray(parsed)
404
+ ? parsed.filter(
405
+ (row): row is Record<string, unknown> => typeof row === "object" && row !== null,
406
+ )
407
+ : [];
408
+ }
409
+
410
+ /** A command the agent refused, as the report carries it: what was run, what it
411
+ * exited with, and its first line of complaint. The exit code is stated apart
412
+ * from the words because a command that said nothing still failed. */
413
+ function refusal(command: readonly string[], ran: Ran): Refusal {
414
+ const said = `${ran.stderr}${ran.stdout}`.trim();
415
+ return { command: ["claude", ...command], code: ran.code, said: said.split("\n")[0] ?? "" };
416
+ }
@@ -0,0 +1 @@
1
+ export * from "./service.ts";