@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.
- package/LICENSE +21 -0
- package/README.md +23 -0
- package/package.json +32 -0
- package/src/cli.ts +1074 -0
- package/src/daemon/control.ts +88 -0
- package/src/daemon/index.ts +6 -0
- package/src/daemon/link.ts +93 -0
- package/src/daemon/log.ts +116 -0
- package/src/daemon/registry.ts +285 -0
- package/src/daemon/snapshot.ts +115 -0
- package/src/daemon/supervise.ts +446 -0
- package/src/dispatch/caller.ts +47 -0
- package/src/dispatch/dispatch.ts +128 -0
- package/src/dispatch/handler.ts +55 -0
- package/src/dispatch/identity.ts +22 -0
- package/src/dispatch/index.ts +5 -0
- package/src/dispatch/result.ts +58 -0
- package/src/files/containment.ts +263 -0
- package/src/files/files.ts +421 -0
- package/src/files/index.ts +14 -0
- package/src/files/sandbox.ts +0 -0
- package/src/greeting/hook.ts +48 -0
- package/src/greeting/index.ts +2 -0
- package/src/greeting/meta.ts +66 -0
- package/src/instance/config.ts +424 -0
- package/src/instance/handlers.ts +28 -0
- package/src/instance/identity.ts +44 -0
- package/src/instance/index.ts +8 -0
- package/src/instance/instance.ts +911 -0
- package/src/instance/lock.ts +108 -0
- package/src/instance/log.ts +30 -0
- package/src/instance/paths.ts +200 -0
- package/src/instance/socket.ts +62 -0
- package/src/kv/index.ts +2 -0
- package/src/kv/merge.ts +66 -0
- package/src/kv/store.ts +195 -0
- package/src/launcher/index.ts +4 -0
- package/src/launcher/launcher.ts +190 -0
- package/src/launcher/roots.ts +32 -0
- package/src/launcher/spawn.ts +81 -0
- package/src/launcher/tree.ts +80 -0
- package/src/mesh/index.ts +5 -0
- package/src/mesh/keys.ts +158 -0
- package/src/mesh/mesh.ts +1169 -0
- package/src/mesh/probe.ts +100 -0
- package/src/mesh/relay.ts +147 -0
- package/src/mesh/wire.ts +96 -0
- package/src/messaging/delivery.ts +375 -0
- package/src/messaging/direct.ts +433 -0
- package/src/messaging/handlers.ts +14 -0
- package/src/messaging/inbox.ts +191 -0
- package/src/messaging/index.ts +5 -0
- package/src/messaging/notify.ts +117 -0
- package/src/plugin/claude.ts +148 -0
- package/src/plugin/index.ts +13 -0
- package/src/plugin/install.ts +416 -0
- package/src/service/index.ts +1 -0
- package/src/service/service.ts +359 -0
- package/src/sessions/classify.ts +66 -0
- package/src/sessions/dump.ts +105 -0
- package/src/sessions/fork.ts +127 -0
- package/src/sessions/handlers.ts +158 -0
- package/src/sessions/harness.ts +167 -0
- package/src/sessions/index.ts +26 -0
- package/src/sessions/last-live.ts +111 -0
- package/src/sessions/processes.ts +413 -0
- package/src/sessions/registry.ts +785 -0
- package/src/sessions/search.ts +278 -0
- package/src/sessions/status.ts +209 -0
- package/src/sessions/terminals.ts +72 -0
- package/src/sessions/workspace.ts +140 -0
- package/src/topics/handlers.ts +42 -0
- package/src/topics/index.ts +2 -0
- package/src/topics/topics.ts +290 -0
- package/src/transcript/files.ts +201 -0
- package/src/transcript/fold.ts +833 -0
- package/src/transcript/index.ts +16 -0
- package/src/transcript/read.ts +82 -0
- package/src/transcript/tail.ts +195 -0
- package/src/transcript/transcripts.ts +162 -0
- package/src/translate/helper.ts +87 -0
- package/src/translate/index.ts +2 -0
- package/src/translate/translate.ts +127 -0
- package/src/transport/conn.ts +129 -0
- package/src/transport/dial.ts +65 -0
- package/src/transport/driver.ts +102 -0
- package/src/transport/entry.ts +39 -0
- package/src/transport/framing.ts +131 -0
- package/src/transport/index.ts +8 -0
- package/src/transport/listener.ts +39 -0
- package/src/transport/uds.ts +88 -0
- package/src/transport/ws.ts +170 -0
- package/src/upstream/events.ts +125 -0
- package/src/upstream/gateway.ts +275 -0
- package/src/upstream/index.ts +8 -0
- package/src/upstream/json.ts +81 -0
- package/src/upstream/requests.ts +234 -0
- package/src/upstream/stats.ts +99 -0
- package/src/upstream/status.ts +281 -0
- package/src/upstream/usage.ts +208 -0
- package/src/upstream/webhook.ts +141 -0
- 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";
|