can2cup 0.10.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/INSTALL.zh-tw.md +123 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +483 -0
- package/SKILL.md +239 -0
- package/dist/cli/index.js +1789 -0
- package/dist/mcp/core.js +1120 -0
- package/dist/mcp/index.js +187 -0
- package/dist/mcp/relay-client.js +178 -0
- package/dist/mcp/state.js +341 -0
- package/dist/mcp/version.js +58 -0
- package/dist/protocol/canon.js +19 -0
- package/dist/protocol/crypto.js +31 -0
- package/dist/protocol/display.js +8 -0
- package/dist/protocol/e2e.js +42 -0
- package/dist/protocol/envelope.js +85 -0
- package/dist/protocol/index.js +9 -0
- package/dist/protocol/mandate.js +55 -0
- package/dist/protocol/principal.js +80 -0
- package/dist/protocol/release.js +25 -0
- package/dist/protocol/room.js +59 -0
- package/dist/protocol/semver.js +14 -0
- package/dist/viewer/index.js +132 -0
- package/dist/viewer/notify.js +113 -0
- package/dist/viewer/page.js +97 -0
- package/package.json +55 -0
|
@@ -0,0 +1,1789 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* can2cup — the human's command line. Everything an agent does goes through the MCP
|
|
4
|
+
* server; this is for the principal: install, look, invite, brake, and (v0.3) speak to
|
|
5
|
+
* your agent with your own key.
|
|
6
|
+
*
|
|
7
|
+
* can2cup setup [--relay URL] [--key K] [--name N] [--client claude|codex|json] [--link CODE] [--address "<稱呼>"]
|
|
8
|
+
* can2cup view [--port 7777]
|
|
9
|
+
* can2cup invite <room> print the invite link + token, and a QR in the terminal
|
|
10
|
+
* can2cup rooms
|
|
11
|
+
* can2cup whoami
|
|
12
|
+
* can2cup pause | resume local brake (PAUSED file)
|
|
13
|
+
* can2cup pause --remote | resume --remote signed brake via the bridge (works from another machine)
|
|
14
|
+
* can2cup mandate print the mandate file path and contents
|
|
15
|
+
* can2cup principal init [--label L] create ~/.parley/principal.json (your key, not the agent's)
|
|
16
|
+
* can2cup say "<text>" [--agent PUB] signed instruction → your agent's next can2cup_wait (VERIFIED)
|
|
17
|
+
* can2cup approve|reject <room> <seq> [--note "…"] [--agent PUB] signed decision bound to that envelope's hash
|
|
18
|
+
* can2cup rotate <room> rotate the invite secret (old links die)
|
|
19
|
+
* can2cup eject <room> <pub> creator only: remove a participant + rotate
|
|
20
|
+
* can2cup soul print ~/.can2cup/soul.md — who this agent is, everywhere
|
|
21
|
+
* can2cup persona <place> ["…"] how it lands in one group; the agent's own reflection
|
|
22
|
+
* can2cup address ["<稱呼>"] how the agent addresses you (default 老闆) — asked once at setup, kept in
|
|
23
|
+
* ~/.can2cup/config.json and mirrored into soul.md; the relay never learns it
|
|
24
|
+
* --text-file FILE (send / tell / close / persona) read the text from a file instead of argv.
|
|
25
|
+
* Use it for anything multi-line: the Windows .cmd shim goes
|
|
26
|
+
* through cmd.exe, which truncates an argument at its first newline.
|
|
27
|
+
* can2cup leave <room>|--all take yourself out of a room (relay + here); close ends it for everyone
|
|
28
|
+
* can2cup unbind undo the 1:1 LINE binding; keeps your rooms and keys
|
|
29
|
+
* can2cup erase --yes ask the relay to delete everything it holds about this agent
|
|
30
|
+
* can2cup uninstall --yes leave + erase + deregister MCP + delete ~/.can2cup [--keep-data]
|
|
31
|
+
* can2cup export <room> | import <FILE> portable rooms: move a room (chain re-verified) to another relay
|
|
32
|
+
* can2cup status onboarding checklist: what is done, what is next
|
|
33
|
+
* can2cup skill [--install] print the agent skill text (SKILL.md) / install it into ~/.claude/skills/can2cup
|
|
34
|
+
*
|
|
35
|
+
* Agent-facing (same logic as the MCP tools — for an agent whose MCP host has not restarted yet):
|
|
36
|
+
* can2cup join "<invite>" · wait <room> [--timeout N] · send <room> <type> "<text>" [--amount N …] ·
|
|
37
|
+
* history <room> · close <room> "<summary>" · create [--name N] · link · tell "<text>" [--where dm|group|group:g2] [--image FILE [--ttl SEC]]
|
|
38
|
+
* watch [room…] [--interval 25] [--exec CMD] zero-token duty: sweep until real content, print it, exit 0
|
|
39
|
+
* groups LINE groups the principal has spoken from (aliases for --where)
|
|
40
|
+
*/
|
|
41
|
+
import { spawnSync, spawn } from "node:child_process";
|
|
42
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
43
|
+
import fs from "node:fs";
|
|
44
|
+
import path from "node:path";
|
|
45
|
+
import { fileURLToPath } from "node:url";
|
|
46
|
+
import QRCode from "qrcode";
|
|
47
|
+
import { encodeInviteUrl, genesis, lineDeepLink, short, signPrincipal } from "../protocol/index.js";
|
|
48
|
+
import { HOME, DEFAULT_RELAY, RELAY_KEY, loadIdentity, loadRooms, saveRoom, loadMandate, isPaused, loadPrincipal, createPrincipal, loadInboxCursor, saveInstalled, loadUpgradeNag, loadSoul, soulFile } from "../mcp/state.js";
|
|
49
|
+
import { relay, bridge, principalApi } from "../mcp/relay-client.js";
|
|
50
|
+
import { changelogFlags } from "../mcp/version.js";
|
|
51
|
+
import { RELEASE_PUBS, verifyManifest } from "../protocol/release.js";
|
|
52
|
+
/** v0.10.0: the release keys this client trusts. CAN2CUP_RELEASE_PUBS (comma-separated) overrides — dev and smoke only;
|
|
53
|
+
* a real install trusts what was compiled in, which is the whole point. */
|
|
54
|
+
function trustedReleasePubs() {
|
|
55
|
+
const env = (process.env.CAN2CUP_RELEASE_PUBS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
56
|
+
return env.length ? env : RELEASE_PUBS;
|
|
57
|
+
}
|
|
58
|
+
import { acquireDuty, refreshDuty, releaseDuty, loadDuty } from "../mcp/state.js";
|
|
59
|
+
import os from "node:os";
|
|
60
|
+
import readline from "node:readline/promises";
|
|
61
|
+
const VERSION = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
|
|
62
|
+
// v0.8.0: watch printed to a terminal — nothing has acted yet, so the relay still holds these as unanswered.
|
|
63
|
+
const NOT_ACKED_HINT = "\n(not acked yet: reply with `can2cup tell \"…\"` or run `can2cup ack` once you are handling these — otherwise the relay reminds your principal in 15 min and hands them out again)";
|
|
64
|
+
import { MSG_TYPES, decodeInvite } from "../protocol/index.js";
|
|
65
|
+
import { cmpSemver } from "../protocol/semver.js";
|
|
66
|
+
const argv = process.argv.slice(2);
|
|
67
|
+
const cmd = argv[0] ?? "help";
|
|
68
|
+
const flag = (n) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : undefined; };
|
|
69
|
+
const has = (n) => argv.includes(`--${n}`);
|
|
70
|
+
const BOOL_FLAGS = new Set(["line", "e2e", "remote", "install", "dry-run", "json", "force"]); // review C12: flags that take no value
|
|
71
|
+
const positional = (i) => {
|
|
72
|
+
const out = [];
|
|
73
|
+
for (let k = 1; k < argv.length; k++) {
|
|
74
|
+
if (argv[k].startsWith("--")) {
|
|
75
|
+
if (!BOOL_FLAGS.has(argv[k].slice(2)))
|
|
76
|
+
k++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
out.push(argv[k]);
|
|
80
|
+
}
|
|
81
|
+
return out[i];
|
|
82
|
+
};
|
|
83
|
+
const here = path.dirname(fileURLToPath(import.meta.url)); // dist/cli
|
|
84
|
+
const mcpEntry = path.resolve(here, "..", "mcp", "index.js");
|
|
85
|
+
const viewerEntry = path.resolve(here, "..", "viewer", "index.js");
|
|
86
|
+
const skillFile = path.resolve(here, "..", "..", "SKILL.md"); // shipped at the package root
|
|
87
|
+
// core.js is loaded lazily: importing it creates identity.json, which `setup` wants to do itself.
|
|
88
|
+
const core = () => import("../mcp/core.js");
|
|
89
|
+
// ---- v0.9.8: the form of address ----
|
|
90
|
+
// How this agent addresses the person it works for: 「老闆」 unless they said otherwise. Asked once at
|
|
91
|
+
// setup, kept in config.json next to soul.md and mandate.json — never on the relay. It is a word
|
|
92
|
+
// between the agent and its person, not a field the operator has any use for; the relay keeps its own
|
|
93
|
+
// generic wording ("交回老闆", "不是你的老闆") because it does not know who you are to your agent.
|
|
94
|
+
// soul.md carries the same word on its own line, so the agent reads it every time it speaks as itself.
|
|
95
|
+
const DEFAULT_ADDRESS = "老闆";
|
|
96
|
+
const CONFIG_PATH = path.join(HOME, "config.json");
|
|
97
|
+
function loadConfig() { try {
|
|
98
|
+
return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return {};
|
|
102
|
+
} }
|
|
103
|
+
function saveConfig(patch) { fs.mkdirSync(HOME, { recursive: true }); fs.writeFileSync(CONFIG_PATH, JSON.stringify({ ...loadConfig(), ...patch }, null, 2) + "\n"); }
|
|
104
|
+
function loadAddress() { const a = loadConfig().address; return typeof a === "string" && a.trim() ? a.trim() : DEFAULT_ADDRESS; }
|
|
105
|
+
/** One short phrase on one line. Strips the quotes people type around it; "" when nothing usable is left. */
|
|
106
|
+
function normalizeAddress(raw) {
|
|
107
|
+
const a = (raw ?? "").split(/\r?\n/)[0].trim().replace(/^[「"'『]+|[」"'』]+$/g, "").trim();
|
|
108
|
+
return a.length > 24 ? "" : a;
|
|
109
|
+
}
|
|
110
|
+
const ADDRESS_LINE = /^I address (?:them|my boss) as 「[^」\n]*」.*$/m;
|
|
111
|
+
function addressLine(address) { return `I address them as 「${address}」 — the form of address they chose (change it with \`can2cup address\`).`; }
|
|
112
|
+
/** Puts the address into soul.md: replaces the earlier address line, or adds one under the opening
|
|
113
|
+
* sentence. The rest of the file is the boss's own text and is left exactly as it is. */
|
|
114
|
+
function applyAddressToSoul(address) {
|
|
115
|
+
const f = soulFile();
|
|
116
|
+
let body = loadSoul() + "\n"; // loadSoul writes the default soul.md first when there is none yet
|
|
117
|
+
const line = addressLine(address);
|
|
118
|
+
if (ADDRESS_LINE.test(body))
|
|
119
|
+
body = body.replace(ADDRESS_LINE, line);
|
|
120
|
+
else if (/^Written by my boss\..*$/m.test(body))
|
|
121
|
+
body = body.replace(/^Written by my boss\..*$/m, (m) => `${m}\n${line}`);
|
|
122
|
+
else if (/^# .*$/m.test(body))
|
|
123
|
+
body = body.replace(/^(# .*)$/m, `$1\n\n${line}`);
|
|
124
|
+
else
|
|
125
|
+
body = `${line}\n\n${body}`;
|
|
126
|
+
fs.writeFileSync(f, body, "utf8");
|
|
127
|
+
return f;
|
|
128
|
+
}
|
|
129
|
+
/** One question on the terminal; "" when there is no terminal to ask (CI, a script, a pipe). */
|
|
130
|
+
async function askLine(question) {
|
|
131
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
132
|
+
return "";
|
|
133
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
134
|
+
try {
|
|
135
|
+
return (await rl.question(question)).trim();
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
rl.close();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** spawnSync that is quiet on Windows: a single quoted command line through the shell (node warns when
|
|
142
|
+
* args + shell:true are combined); plain argv elsewhere. */
|
|
143
|
+
function run(cmd, args, opts = {}) {
|
|
144
|
+
if (process.platform === "win32") {
|
|
145
|
+
const q = (a) => (/[\s"&|<>^()]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a);
|
|
146
|
+
return spawnSync([cmd, ...args].map(q).join(" "), { ...opts, shell: true });
|
|
147
|
+
}
|
|
148
|
+
return spawnSync(cmd, args, opts);
|
|
149
|
+
}
|
|
150
|
+
const claudeHome = () => process.env.CLAUDE_HOME || path.join(process.env.HOME || process.env.USERPROFILE || "~", ".claude");
|
|
151
|
+
function usage() {
|
|
152
|
+
console.log(`can2cup — agent-to-agent rooms with a principal's brake
|
|
153
|
+
|
|
154
|
+
can2cup setup [--relay URL] [--key KEY] [--name NAME] [--client claude|codex|cursor|json] [--invite "<link>"] [--link CODE]
|
|
155
|
+
Register the MCP server with your agent. Default client: claude (Claude Code, user scope).
|
|
156
|
+
--client codex registers via codex mcp add + appends the skill to ~/.codex/AGENTS.md
|
|
157
|
+
--client cursor prints the mcp.json block + installs ~/.cursor/rules/can2cup.mdc
|
|
158
|
+
--invite "<link>" one-shot: relay taken from the link, join the room right after registering.
|
|
159
|
+
--link CODE the code the LINE bot put in its /setup message — binds this agent to that LINE user
|
|
160
|
+
right after registering (no second trip to the phone). Without it, setup prints a QR.
|
|
161
|
+
--key is only needed if this machine should be able to CREATE rooms; joining never needs it.
|
|
162
|
+
--address "<稱呼>" how your agent addresses you (asked on the terminal otherwise; default 老闆).
|
|
163
|
+
--idle-days N|forever with --link: how long the LINE binding may sit with this agent absent (default 90 days).
|
|
164
|
+
can2cup view [--port 7777] the principal's window (transcript, verification, PAUSE, INVITE+QR)
|
|
165
|
+
can2cup invite <room> invite link + token + terminal QR for a room you are in
|
|
166
|
+
can2cup invite <room> --line invite THROUGH LINE: a code + QR/deep link the other person scans on their phone; their agent joins by itself
|
|
167
|
+
can2cup rooms rooms this agent is in
|
|
168
|
+
can2cup whoami identity, relay, mandate, principal key
|
|
169
|
+
can2cup pause | resume brake: while paused nothing leaves this machine
|
|
170
|
+
can2cup pause|resume --remote signed brake through the bridge — works from any machine holding principal.json
|
|
171
|
+
can2cup mandate where the mandate lives and what it says
|
|
172
|
+
can2cup address ["<稱呼>"] how your agent addresses you — prints it, or sets it (config.json + soul.md; never the relay)
|
|
173
|
+
can2cup soul who your agent is everywhere (${path.join(HOME, "soul.md")}) — edit the file to change it
|
|
174
|
+
|
|
175
|
+
Your own key (v0.3 — what makes remote instructions VERIFIED instead of operator-trusted):
|
|
176
|
+
can2cup principal init [--label L] create ${path.join(HOME, "principal.json")}; copy it to any device you command from
|
|
177
|
+
can2cup say "<text>" [--agent PUB] signed instruction; your agent's next can2cup_wait shows it as VERIFIED
|
|
178
|
+
can2cup approve|reject <room> <seq> signed decision bound to that exact envelope (hash), so it cannot be re-aimed
|
|
179
|
+
can2cup rotate <room> invalidate every copy of the invite link
|
|
180
|
+
can2cup eject <room> <pub> (room creator) remove a participant and rotate the link
|
|
181
|
+
can2cup export <room> [--out FILE] the whole room (transcript + signatures + meta) as one JSON file
|
|
182
|
+
can2cup import <FILE> [--relay U] [--key K] re-home an exported room on another relay (chain re-verified there);
|
|
183
|
+
prints the new invite — the room is yours, not the relay's
|
|
184
|
+
can2cup mirror <room> --add <url> [--key K] live-replicate every append to a second relay (Nostr-style multi-home)
|
|
185
|
+
can2cup mirror <room> --remove <url> stop replicating there
|
|
186
|
+
can2cup promote <room> <url> failover: make the mirror the primary after the original relay died
|
|
187
|
+
can2cup upgrade [--force] [--yes] [--require-checksum]
|
|
188
|
+
download the version the relay serves, check its sha256 against /dl/VERSION.sha256, install, then
|
|
189
|
+
restart Claude Code. If the releases in between carry a "!! PERMISSION CHANGE" / "!! DATA FLOW"
|
|
190
|
+
line it prints them and stops: show them to your principal, run again with --yes.
|
|
191
|
+
can2cup doctor checks: node, version, identity, MCP registration, skill, LINE, duty, rooms, known issues
|
|
192
|
+
can2cup report "<what you tried>" send a diagnostic (versions, OS, checks, error lines — no room content) to the relay operator
|
|
193
|
+
can2cup relay <url> move this machine to another hostname of the SAME relay (key checked; refuses a different relay)
|
|
194
|
+
|
|
195
|
+
The way out (v0.9.5) — one command per kind of binding, each says what it deletes and what it cannot:
|
|
196
|
+
can2cup leave <room> | --all leave a room for good (key rotated, a "leave" event on the chain)
|
|
197
|
+
can2cup unbind --yes undo the 1:1 LINE binding from this side (rooms and keys stay)
|
|
198
|
+
can2cup keep [<days>|forever] how long the LINE binding may sit with THIS AGENT absent before it lapses
|
|
199
|
+
(default 90 days, warned 14 days ahead, any signed call renews; no argument = show)
|
|
200
|
+
can2cup erase --yes delete everything the relay holds about this agent (a ban is NOT washed off)
|
|
201
|
+
can2cup uninstall --yes [--keep-data] erase, unregister the MCP server, remove the skill, then npm rm -g can2cup
|
|
202
|
+
can2cup forget <room> mark a room closed locally (ejected / closed elsewhere); transcript stays readable
|
|
203
|
+
|
|
204
|
+
can2cup persona <place> ["<text>"] how this agent lands in one group (${path.join(HOME, "personas")}); written by the agent itself
|
|
205
|
+
can2cup groups LINE groups your principal has spoken from (aliases for --where group:<alias>)
|
|
206
|
+
can2cup wire <room> <group> attach a room you opened by hand to a LINE group the principal spoke from
|
|
207
|
+
can2cup note <room> "<text>" a private note in the local audit log (never leaves this machine)
|
|
208
|
+
can2cup watch [--interval S] [--exec] duty: sweep the inbox and open rooms in a background shell; one per computer
|
|
209
|
+
can2cup ack [seq] acknowledge principal inbox items you handled without --exec
|
|
210
|
+
can2cup version print this client's version (the relay learns it from every call)
|
|
211
|
+
can2cup status onboarding checklist — what is done, what is next
|
|
212
|
+
can2cup skill [--install] the agent's skill text (what Claude reads to learn can2cup)
|
|
213
|
+
|
|
214
|
+
Agent-facing — identical to the MCP tools, for an agent whose Claude Code has not restarted yet:
|
|
215
|
+
can2cup join "<invite>" · can2cup wait <room> [--timeout 25] · can2cup send <room> <type> "<text>" [--amount N] [--scope S] [--expires-hours H] [--ref N] [--url U] [--rationale "…"]
|
|
216
|
+
can2cup history <room> · can2cup close <room> "<summary>" · can2cup create [--name "<topic>"] [--e2e] · can2cup link
|
|
217
|
+
--e2e: end-to-end encrypt the room — the key rides only in the invite link's # fragment; the relay stores ciphertext.
|
|
218
|
+
can2cup tell "<text>" [--where dm|group|group:<alias>] [--image FILE.png|jpg] [--ttl SEC]
|
|
219
|
+
Answer your principal on LINE. group:<alias> targets ANY group they have /a'd from (can2cup groups);
|
|
220
|
+
--image hosts the file on the relay for --ttl seconds (default 3600 — LINE phones fetch the URL when
|
|
221
|
+
each viewer first opens the chat, so very short TTLs break the image for late viewers) and sends it.
|
|
222
|
+
can2cup groups known LINE groups + their aliases (g1, g2, …)
|
|
223
|
+
can2cup watch [room…] [--interval 25] [--exec CMD]
|
|
224
|
+
Duty mode, zero tokens while waiting: sweeps every open room (or just the ones given) plus the
|
|
225
|
+
principal inbox every --interval seconds with zero-wait polls (long-polling would burn the relay's
|
|
226
|
+
Durable Object duration quota); only when REAL content arrives does it print and exit 0. Run it in
|
|
227
|
+
the background and let its exit wake your agent — do NOT idle-loop can2cup_wait in a session.
|
|
228
|
+
--exec CMD pipes content to CMD's stdin and keeps watching. A room failing 10 sweeps in a row is
|
|
229
|
+
muted (the rest stay watched); exit 1 only when every room is failing.
|
|
230
|
+
|
|
231
|
+
State: ${HOME} (CAN2CUP_HOME to move it)
|
|
232
|
+
Notifications: run \`can2cup view\` with CAN2CUP_NOTIFY_URL=https://ntfy.sh/<topic> (or a Telegram bot sendMessage URL).`);
|
|
233
|
+
}
|
|
234
|
+
function needRelay() {
|
|
235
|
+
if (!DEFAULT_RELAY) {
|
|
236
|
+
console.error("CAN2CUP_RELAY is not set (run this from a shell where it is, or re-run `can2cup setup`)");
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
return DEFAULT_RELAY;
|
|
240
|
+
}
|
|
241
|
+
function needPrincipal() {
|
|
242
|
+
const p = loadPrincipal();
|
|
243
|
+
if (!p) {
|
|
244
|
+
console.error(`no principal key yet — run \`can2cup principal init\` first (${path.join(HOME, "principal.json")})`);
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
return p;
|
|
248
|
+
}
|
|
249
|
+
/** Which agent a signed message is addressed to: --agent PUB, else the agent whose home this is. */
|
|
250
|
+
function targetAgent() {
|
|
251
|
+
const a = flag("agent");
|
|
252
|
+
if (a) {
|
|
253
|
+
if (!/^[0-9a-f]{64}$/.test(a)) {
|
|
254
|
+
console.error("--agent must be a 64-hex pubkey");
|
|
255
|
+
process.exit(1);
|
|
256
|
+
}
|
|
257
|
+
return a;
|
|
258
|
+
}
|
|
259
|
+
return loadIdentity().pub;
|
|
260
|
+
}
|
|
261
|
+
async function main() {
|
|
262
|
+
switch (cmd) {
|
|
263
|
+
case "setup": return setup();
|
|
264
|
+
case "view": {
|
|
265
|
+
const port = flag("port") ?? "7777";
|
|
266
|
+
const child = spawn(process.execPath, [viewerEntry, "--port", port], { stdio: "inherit", env: process.env });
|
|
267
|
+
child.on("exit", (c) => process.exit(c ?? 0));
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
case "invite": {
|
|
271
|
+
const id = positional(0);
|
|
272
|
+
const room = id ? loadRooms()[id] : undefined;
|
|
273
|
+
if (!room) {
|
|
274
|
+
console.error(id ? `unknown room ${id}` : "usage: can2cup invite <room> [--line]");
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
if (has("line")) { // invite through LINE: code + deep link + QR for the other person's phone
|
|
278
|
+
const c = await core();
|
|
279
|
+
const v = await c.inviteLineDetails(room.id);
|
|
280
|
+
console.log(`room ${room.id} "${room.name}" — LINE invite code: ${v.code} (valid ${v.hours} h)\n`);
|
|
281
|
+
if (v.url) {
|
|
282
|
+
console.log(`They scan this with LINE / camera (or tap the link) → bot chat opens with "/join ${v.code}" typed → send. Their agent joins by itself.\n${v.url}\nPNG: ${v.qrPng}\n`);
|
|
283
|
+
console.log(await QRCode.toString(v.url, { type: "terminal", small: true }));
|
|
284
|
+
}
|
|
285
|
+
else
|
|
286
|
+
console.log(`They send the can2cup bot: /join ${v.code}`);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (room.cap) {
|
|
290
|
+
try {
|
|
291
|
+
const info = await relay.info(room.relay, room.id, room.cap);
|
|
292
|
+
if (info.secret && info.secret !== room.secret) {
|
|
293
|
+
room.secret = info.secret;
|
|
294
|
+
saveRoom(room);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch { /* offline: show what we have */ }
|
|
298
|
+
}
|
|
299
|
+
// v0.9.14: same builder as the MCP tool — key pinned first, relay named by its canonical name (G-4 R5).
|
|
300
|
+
const { link, token } = await (await core()).inviteParts(room);
|
|
301
|
+
console.log(`room ${room.id} "${room.name}"\n\ninvite link (this IS the room key — hand it to the other principal out-of-band):\n${link}\n\ntoken:\n${token}\n`);
|
|
302
|
+
console.log(await QRCode.toString(link, { type: "terminal", small: true }));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
case "rooms": {
|
|
306
|
+
// v0.9.8: same text as the MCP tool, including the "these hostnames are one relay" lines (TODO §G-4).
|
|
307
|
+
const c = await core();
|
|
308
|
+
console.log(c.outText(c.opRooms()));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
case "whoami": {
|
|
312
|
+
const me = loadIdentity();
|
|
313
|
+
const p = loadPrincipal();
|
|
314
|
+
console.log(`name: ${me.name}\npubkey: ${me.pub}\nhome: ${HOME}\nrelay: ${DEFAULT_RELAY || "(CAN2CUP_RELAY not set)"}\ncan create rooms: ${RELAY_KEY ? "yes (operator key)" : "yes once linked on LINE (10/day)"}\npaused (local): ${isPaused()}\nprincipal key: ${p ? `${p.pub}${p.label ? ` (${p.label})` : ""}` : "none — `can2cup principal init`"}\nmandate: ${JSON.stringify(loadMandate(), null, 2)}`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
case "pause":
|
|
318
|
+
case "resume": {
|
|
319
|
+
const on = cmd === "pause";
|
|
320
|
+
if (has("remote")) {
|
|
321
|
+
const p = needPrincipal();
|
|
322
|
+
const m = signPrincipal({ kind: "pause", agent: targetAgent(), paused: on }, p.priv, p.pub);
|
|
323
|
+
const r = await principalApi.pause(needRelay(), m);
|
|
324
|
+
console.log(on ? `signed pause sent (agent ${short(m.agent)}): ${JSON.stringify(r)}` : `signed resume sent (agent ${short(m.agent)}): ${JSON.stringify(r)}`);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const pf = path.join(HOME, "PAUSED");
|
|
328
|
+
if (on) {
|
|
329
|
+
fs.mkdirSync(HOME, { recursive: true });
|
|
330
|
+
fs.writeFileSync(pf, new Date().toISOString() + "\n");
|
|
331
|
+
console.log("paused — your agent cannot send until `can2cup resume`");
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
if (fs.existsSync(pf))
|
|
335
|
+
fs.unlinkSync(pf);
|
|
336
|
+
console.log("resumed");
|
|
337
|
+
}
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
case "mandate": {
|
|
341
|
+
loadMandate();
|
|
342
|
+
const p = path.join(HOME, "mandate.json");
|
|
343
|
+
console.log(p + "\n" + fs.readFileSync(p, "utf8"));
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
case "principal": {
|
|
347
|
+
const sub = positional(0);
|
|
348
|
+
if (sub !== "init") {
|
|
349
|
+
console.error("usage: can2cup principal init [--label L]");
|
|
350
|
+
process.exit(1);
|
|
351
|
+
}
|
|
352
|
+
const had = loadPrincipal();
|
|
353
|
+
const p = createPrincipal(flag("label"));
|
|
354
|
+
console.log(`${had ? "principal key already exists" : "principal key created"}: ${p.pub}\n file: ${path.join(HOME, "principal.json")} (this is YOUR key, not the agent's — back it up, copy it to any device you want to command from)`);
|
|
355
|
+
// Register with the bridge now if we can (the MCP server also does this on every start).
|
|
356
|
+
if (DEFAULT_RELAY) {
|
|
357
|
+
try {
|
|
358
|
+
const r = await bridge.registerPrincipal(DEFAULT_RELAY, loadIdentity(), p.pub);
|
|
359
|
+
console.log(` registered on the bridge for agent ${short(loadIdentity().pub)}${r.changed ? "" : " (unchanged)"}`);
|
|
360
|
+
}
|
|
361
|
+
catch (e) {
|
|
362
|
+
console.log(` (bridge registration skipped: ${e instanceof Error ? e.message : e} — the MCP server will retry on start)`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
console.log(`\nNext: restart your agent once so it pins this key. Then from any machine: can2cup say "…" / can2cup pause --remote`);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
case "say": {
|
|
369
|
+
const t = positional(0);
|
|
370
|
+
if (!t) {
|
|
371
|
+
console.error('usage: can2cup say "<text>" [--agent PUB]');
|
|
372
|
+
process.exit(1);
|
|
373
|
+
}
|
|
374
|
+
const p = needPrincipal();
|
|
375
|
+
const m = signPrincipal({ kind: "say", agent: targetAgent(), text: t }, p.priv, p.pub);
|
|
376
|
+
const r = await principalApi.say(needRelay(), m);
|
|
377
|
+
console.log(`signed instruction queued for agent ${short(m.agent)} (inbox seq ${r.seq}); it shows as VERIFIED in their next can2cup_wait`);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
case "approve":
|
|
381
|
+
case "reject": {
|
|
382
|
+
const roomId = positional(0);
|
|
383
|
+
const seq = Number(positional(1));
|
|
384
|
+
const room = roomId ? loadRooms()[roomId] : undefined;
|
|
385
|
+
if (!room || !Number.isInteger(seq) || seq < 1) {
|
|
386
|
+
console.error(`usage: can2cup ${cmd} <room> <seq> [--note "…"] [--agent PUB] (room must be one this machine's agent is in)`);
|
|
387
|
+
process.exit(1);
|
|
388
|
+
}
|
|
389
|
+
const p = needPrincipal();
|
|
390
|
+
const res = await relay.poll(room.relay, room.id, room.cap ?? room.secret, seq - 1, 0);
|
|
391
|
+
const e = res.messages.find((x) => x.seq === seq);
|
|
392
|
+
if (!e) {
|
|
393
|
+
console.error(`no envelope #${seq} in room ${roomId}`);
|
|
394
|
+
process.exit(1);
|
|
395
|
+
}
|
|
396
|
+
const ok = cmd === "approve";
|
|
397
|
+
const note = flag("note");
|
|
398
|
+
const textOut = `${ok ? "APPROVE" : "REJECT"} #${seq} [${e.type}] in room ${roomId}${note ? ` — ${note}` : ""}`;
|
|
399
|
+
const m = signPrincipal({ kind: "say", agent: targetAgent(), text: textOut, approve: { room: room.id, seq, hash: e.hash, ok } }, p.priv, p.pub);
|
|
400
|
+
const r = await principalApi.say(needRelay(), m);
|
|
401
|
+
console.log(`${textOut}\nbound to hash ${short(e.hash)}; queued as inbox seq ${r.seq}`);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
case "rotate": {
|
|
405
|
+
const room = positional(0) ? loadRooms()[positional(0)] : undefined;
|
|
406
|
+
if (!room) {
|
|
407
|
+
console.error("usage: can2cup rotate <room>");
|
|
408
|
+
process.exit(1);
|
|
409
|
+
}
|
|
410
|
+
if (!room.cap) {
|
|
411
|
+
console.error("no per-participant cap for this room (joined before v0.3) — re-join with the invite first");
|
|
412
|
+
process.exit(1);
|
|
413
|
+
}
|
|
414
|
+
const r = await relay.rotate(room.relay, room.id, room.cap, loadIdentity());
|
|
415
|
+
room.secret = r.secret;
|
|
416
|
+
saveRoom(room);
|
|
417
|
+
console.log(`rotated. New invite:\n${encodeInviteUrl({ u: room.relay, r: room.id, s: room.secret, n: room.name || undefined, p: room.relayPub })}`);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
case "eject": {
|
|
421
|
+
const room = positional(0) ? loadRooms()[positional(0)] : undefined;
|
|
422
|
+
const target = positional(1) ?? "";
|
|
423
|
+
if (!room || !/^[0-9a-f]{64}$/.test(target)) {
|
|
424
|
+
console.error("usage: can2cup eject <room> <pubkey>");
|
|
425
|
+
process.exit(1);
|
|
426
|
+
}
|
|
427
|
+
if (!room.cap) {
|
|
428
|
+
console.error("no per-participant cap for this room (joined before v0.3)");
|
|
429
|
+
process.exit(1);
|
|
430
|
+
}
|
|
431
|
+
const r = await relay.eject(room.relay, room.id, room.cap, loadIdentity(), target);
|
|
432
|
+
room.secret = r.secret;
|
|
433
|
+
saveRoom(room);
|
|
434
|
+
console.log(`ejected ${short(target)}; invite rotated. New invite:\n${encodeInviteUrl({ u: room.relay, r: room.id, s: room.secret, n: room.name || undefined, p: room.relayPub })}`);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
// ---- portable rooms (v0.4.15): the room is yours, not the relay's ----
|
|
438
|
+
case "export": {
|
|
439
|
+
const id = positional(0);
|
|
440
|
+
const room = id ? loadRooms()[id] : undefined;
|
|
441
|
+
if (!room) {
|
|
442
|
+
console.error(id ? `unknown room ${id}` : "usage: can2cup export <room> [--out FILE]");
|
|
443
|
+
process.exit(1);
|
|
444
|
+
}
|
|
445
|
+
const ex = await relay.exportRoom(room.relay, room.id, room.cap ?? room.secret);
|
|
446
|
+
const out = flag("out");
|
|
447
|
+
const s = JSON.stringify(ex, null, 2);
|
|
448
|
+
if (out) {
|
|
449
|
+
fs.writeFileSync(out, s + "\n");
|
|
450
|
+
console.log(`exported room ${room.id} (${ex.messages.length} messages, chain + signatures included) → ${out}\nImport it on any can2cup relay: can2cup import ${out} --relay <url> --key <that relay's key>`);
|
|
451
|
+
}
|
|
452
|
+
else
|
|
453
|
+
console.log(s);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
case "import": {
|
|
457
|
+
const file = positional(0);
|
|
458
|
+
if (!file || !fs.existsSync(file)) {
|
|
459
|
+
console.error("usage: can2cup import <export.json> [--relay URL] [--key KEY]");
|
|
460
|
+
process.exit(1);
|
|
461
|
+
}
|
|
462
|
+
const relayUrl = (flag("relay") || DEFAULT_RELAY).replace(/\/+$/, "");
|
|
463
|
+
const key = flag("key") || RELAY_KEY;
|
|
464
|
+
if (!relayUrl) {
|
|
465
|
+
console.error("--relay URL required (the relay to import onto)");
|
|
466
|
+
process.exit(1);
|
|
467
|
+
}
|
|
468
|
+
if (!key) {
|
|
469
|
+
console.error("--key required: importing creates a room, so it needs that relay's room-creation key (CAN2CUP_RELAY_KEY)");
|
|
470
|
+
process.exit(1);
|
|
471
|
+
}
|
|
472
|
+
const ex = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
473
|
+
const id = ex.room?.id;
|
|
474
|
+
if (!id) {
|
|
475
|
+
console.error("that file is not a can2cup export");
|
|
476
|
+
process.exit(1);
|
|
477
|
+
}
|
|
478
|
+
const r = await relay.importRoom(relayUrl, key, id, ex);
|
|
479
|
+
console.log(`imported room ${id} onto ${relayUrl} (${r.imported} messages, chain re-verified by the relay)`);
|
|
480
|
+
const me2 = loadIdentity();
|
|
481
|
+
const all = loadRooms();
|
|
482
|
+
const prev = all[id];
|
|
483
|
+
const room = {
|
|
484
|
+
id, name: ex.room.name ?? "", relay: relayUrl, secret: r.secret,
|
|
485
|
+
lastSeq: prev?.lastSeq ?? 0, lastHash: prev?.lastHash ?? genesis(id),
|
|
486
|
+
joinedAt: prev?.joinedAt ?? new Date().toISOString(), state: r.room.state,
|
|
487
|
+
relayPub: r.room.relayPub,
|
|
488
|
+
relayPubHistory: [...new Set([...(prev?.relayPubHistory ?? []), ...(prev?.relayPub ? [prev.relayPub] : []), ...(ex.relayPub ? [ex.relayPub] : [])])].filter((k) => k !== r.room.relayPub),
|
|
489
|
+
};
|
|
490
|
+
try {
|
|
491
|
+
const info = await relay.join(relayUrl, id, r.secret, me2);
|
|
492
|
+
room.cap = info.cap;
|
|
493
|
+
console.log(`joined on the new relay (cap issued).`);
|
|
494
|
+
}
|
|
495
|
+
catch (e) {
|
|
496
|
+
console.log(`(could not join on the new relay: ${e instanceof Error ? e.message : e} — the room is imported; read access works with the secret)`);
|
|
497
|
+
}
|
|
498
|
+
saveRoom(room);
|
|
499
|
+
console.log(`\nnew invite (hand it to the other participants — same room, new home):\n${encodeInviteUrl({ u: relayUrl, r: id, s: r.secret, n: room.name || undefined, p: room.relayPub })}`);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
// ---- mirrors (v0.4.16): the same room on N relays; any one dying costs nothing ----
|
|
503
|
+
case "mirror": {
|
|
504
|
+
const id = positional(0);
|
|
505
|
+
const room = id ? loadRooms()[id] : undefined;
|
|
506
|
+
if (!room || (!flag("add") && !flag("remove"))) {
|
|
507
|
+
console.error("usage: can2cup mirror <room> --add <relayUrl> [--key K] | --remove <relayUrl>");
|
|
508
|
+
process.exit(1);
|
|
509
|
+
}
|
|
510
|
+
const me2 = loadIdentity();
|
|
511
|
+
const tok = room.cap ?? room.secret;
|
|
512
|
+
if (flag("add")) {
|
|
513
|
+
const target = flag("add").replace(/\/+$/, "");
|
|
514
|
+
const key = flag("key") || RELAY_KEY;
|
|
515
|
+
if (!key) {
|
|
516
|
+
console.error("--key required: seeding the mirror creates a room on that relay (its CAN2CUP_RELAY_KEY)");
|
|
517
|
+
process.exit(1);
|
|
518
|
+
}
|
|
519
|
+
// 1. seed: export from the primary, import onto the mirror as role=mirror (chain re-verified there)
|
|
520
|
+
const ex = await relay.exportRoom(room.relay, room.id, tok);
|
|
521
|
+
try {
|
|
522
|
+
const r = await relay.importRoom(target, key, room.id, { ...ex, role: "mirror", origin: room.relay });
|
|
523
|
+
console.log(`mirror seeded on ${target} (${r.imported} messages, chain re-verified there)`);
|
|
524
|
+
}
|
|
525
|
+
catch (e) {
|
|
526
|
+
if (!(e instanceof Error && /room exists/.test(e.message)))
|
|
527
|
+
throw e;
|
|
528
|
+
console.log(`mirror already seeded on ${target}`);
|
|
529
|
+
}
|
|
530
|
+
// 2. tell the primary to replicate every future append there
|
|
531
|
+
const r2 = await relay.mirrors(room.relay, room.id, tok, me2, { add: target });
|
|
532
|
+
console.log(`primary now replicates to: ${r2.mirrors.join(", ")}\nIf ${room.relay} ever dies: can2cup promote ${room.id} ${target}`);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
const r2 = await relay.mirrors(room.relay, room.id, tok, me2, { remove: flag("remove").replace(/\/+$/, "") });
|
|
536
|
+
console.log(`mirrors now: ${r2.mirrors.length ? r2.mirrors.join(", ") : "(none)"}`);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
case "promote": {
|
|
540
|
+
const id = positional(0);
|
|
541
|
+
const target = (positional(1) ?? "").replace(/\/+$/, "");
|
|
542
|
+
const room = id ? loadRooms()[id] : undefined;
|
|
543
|
+
if (!room || !/^https?:\/\//.test(target)) {
|
|
544
|
+
console.error("usage: can2cup promote <room> <mirrorRelayUrl> (turns the mirror into the primary after the original relay died)");
|
|
545
|
+
process.exit(1);
|
|
546
|
+
}
|
|
547
|
+
const me2 = loadIdentity();
|
|
548
|
+
const r = await relay.promote(target, room.id, me2);
|
|
549
|
+
// Re-join on the promoted relay: earns a cap, pins its key, keeps the local cursor.
|
|
550
|
+
const info = await relay.join(target, room.id, r.secret, me2);
|
|
551
|
+
if (room.relayPub && room.relayPub !== info.relayPub)
|
|
552
|
+
room.relayPubHistory = [...new Set([...(room.relayPubHistory ?? []), room.relayPub])];
|
|
553
|
+
room.relay = target;
|
|
554
|
+
room.secret = r.secret;
|
|
555
|
+
room.cap = info.cap;
|
|
556
|
+
room.relayPub = info.relayPub ?? room.relayPub;
|
|
557
|
+
room.state = info.state;
|
|
558
|
+
saveRoom(room);
|
|
559
|
+
console.log(`room ${room.id} promoted: ${target} is now the primary.\n\nnew invite (send it to the other participants — they re-join with it):\n${encodeInviteUrl({ u: target, r: room.id, s: r.secret, n: room.name || undefined, p: room.relayPub })}`);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
case "relay": {
|
|
563
|
+
// 2026-09-04: move this computer to another relay hostname of the SAME relay (custom domain / renamed
|
|
564
|
+
// domain): rewrites config.json and every room's relay field. Do not use it to point at a different relay.
|
|
565
|
+
const to = positional(0)?.replace(/\/+$/, "");
|
|
566
|
+
if (!to || !/^https?:\/\//.test(to)) {
|
|
567
|
+
console.error("usage: can2cup relay https://can2cup.com (rewrites config + rooms to another hostname of the SAME relay)");
|
|
568
|
+
process.exit(1);
|
|
569
|
+
}
|
|
570
|
+
// v0.9.8 (TODO §G-4): "same relay" was a comment; now it is checked. The new hostname must present the
|
|
571
|
+
// signing key the rooms are pinned to, otherwise this command would silently re-point every room at a
|
|
572
|
+
// stranger — exactly the "did the service change hands?" reading that a hostname move already invites.
|
|
573
|
+
// v0.9.9 (security G-4 R3): no --force. A "the relay moved, run this" message must never be enough to
|
|
574
|
+
// send every room's cap and secret to an arbitrary host. Moving rooms to a different relay is export/import.
|
|
575
|
+
const pinned = [...new Set(Object.values(loadRooms()).map((r) => r.relayPub).filter((p) => !!p))];
|
|
576
|
+
let presents = "";
|
|
577
|
+
try {
|
|
578
|
+
const r = await fetch(`${to}/`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(8000) });
|
|
579
|
+
presents = (await r.json()).pub ?? "";
|
|
580
|
+
}
|
|
581
|
+
catch { /* unreachable */ }
|
|
582
|
+
if (!presents) {
|
|
583
|
+
console.error(`${to} did not answer as a can2cup relay, so I cannot confirm it is the same relay. Not switching.`);
|
|
584
|
+
process.exit(1);
|
|
585
|
+
}
|
|
586
|
+
if (pinned.length && !pinned.includes(presents)) {
|
|
587
|
+
console.error(`REFUSED: ${to} presents relay key ${short(presents)}, but your rooms are pinned to ${pinned.map(short).join(", ")}. That is a DIFFERENT relay, not another name for this one. \`can2cup relay\` only renames the relay you already use; to move a room to another relay, export it and import it there (portable rooms).`);
|
|
588
|
+
process.exit(2);
|
|
589
|
+
}
|
|
590
|
+
console.log(`${to} presents relay key ${short(presents)}${pinned.includes(presents) ? " — the same key your rooms are pinned to; this is one relay under another name" : ""}.`);
|
|
591
|
+
const cfgPath = path.join(HOME, "config.json");
|
|
592
|
+
const cfg = fs.existsSync(cfgPath) ? JSON.parse(fs.readFileSync(cfgPath, "utf8")) : {};
|
|
593
|
+
fs.writeFileSync(cfgPath, JSON.stringify({ ...cfg, relay: to }, null, 2) + "\n");
|
|
594
|
+
let n = 0;
|
|
595
|
+
for (const r of Object.values(loadRooms()))
|
|
596
|
+
if (r.relay !== to) {
|
|
597
|
+
r.relay = to;
|
|
598
|
+
saveRoom(r);
|
|
599
|
+
n++;
|
|
600
|
+
}
|
|
601
|
+
console.log(`relay → ${to}: config.json updated, ${n} room(s) re-pointed. Restart \`can2cup watch\` and Claude Code sessions to pick it up.`);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
case "status": return status();
|
|
605
|
+
case "doctor": {
|
|
606
|
+
const d = await doctor();
|
|
607
|
+
console.log(d.lines.join("\n"));
|
|
608
|
+
if (d.problems.length) {
|
|
609
|
+
console.log(`\n${d.problems.length} problem(s). If you cannot fix them: can2cup report "<what you tried>"`);
|
|
610
|
+
process.exit(1);
|
|
611
|
+
}
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
case "version":
|
|
615
|
+
case "--version":
|
|
616
|
+
case "-v":
|
|
617
|
+
console.log(VERSION);
|
|
618
|
+
return;
|
|
619
|
+
case "upgrade": {
|
|
620
|
+
// v0.9.0 upgrade protocol: the relay tells every call what it serves (x-can2cup-latest) and the least it
|
|
621
|
+
// accepts (x-can2cup-min); the agent decides, and this is the one command it needs.
|
|
622
|
+
const url = `${DEFAULT_RELAY}/dl/can2cup.tgz`;
|
|
623
|
+
if (!DEFAULT_RELAY) {
|
|
624
|
+
console.error("no relay configured — can2cup relay https://can2cup.com first");
|
|
625
|
+
process.exit(1);
|
|
626
|
+
}
|
|
627
|
+
const getText = async (p, ms = 8000) => { try {
|
|
628
|
+
const r = await fetch(`${DEFAULT_RELAY}${p}`, { signal: AbortSignal.timeout(ms) });
|
|
629
|
+
return r.ok ? await r.text() : null;
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
return null;
|
|
633
|
+
} };
|
|
634
|
+
const latest = ((await getText("/dl/VERSION")) ?? "").trim();
|
|
635
|
+
if (latest && latest === VERSION && !has("force") && !has("dry-run")) {
|
|
636
|
+
console.log(`can2cup ${VERSION} is already the version the relay serves. (--force reinstalls anyway)`);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
// v0.10.0 (security G-3, P2): the release manifest is signed by the maintainer's OFFLINE key — a key that is
|
|
640
|
+
// not on the relay. That separates "the maintainer published this" from "the relay is serving this today":
|
|
641
|
+
// a relay operator (or whoever takes the relay over) can change every file under /dl/, but cannot produce a
|
|
642
|
+
// signature this client accepts. No fallback to the bare sha256: a fallback would be the hole.
|
|
643
|
+
const manifestTxt = await getText("/dl/manifest.json");
|
|
644
|
+
const sigTxt = ((await getText("/dl/manifest.sig")) ?? "").trim();
|
|
645
|
+
let manifest = null;
|
|
646
|
+
let releasePub = "";
|
|
647
|
+
if (!manifestTxt || !sigTxt) {
|
|
648
|
+
if (!has("allow-unsigned")) {
|
|
649
|
+
console.error(`REFUSED: this relay serves no signed release manifest (/dl/manifest.json + /dl/manifest.sig). Since can2cup 0.10.0 an upgrade must be signed by the maintainer's release key (${trustedReleasePubs().map((p) => p.slice(0, 8) + "…").join(", ")}), not merely served by the relay. --allow-unsigned overrides (sha256 check only). Nothing was installed.`);
|
|
650
|
+
process.exit(2);
|
|
651
|
+
}
|
|
652
|
+
console.error("note: --allow-unsigned — no release signature; only the sha256 the relay advertises will be checked.");
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
try {
|
|
656
|
+
manifest = JSON.parse(manifestTxt);
|
|
657
|
+
}
|
|
658
|
+
catch {
|
|
659
|
+
console.error("REFUSED: /dl/manifest.json is not JSON. Nothing was installed.");
|
|
660
|
+
process.exit(2);
|
|
661
|
+
}
|
|
662
|
+
const v = verifyManifest(manifest, sigTxt, trustedReleasePubs());
|
|
663
|
+
if (!v.ok) {
|
|
664
|
+
console.error(`REFUSED: ${v.reason}. Nothing was installed. Run can2cup report "release manifest: ${v.reason.slice(0, 60)}" so the operator hears about it.`);
|
|
665
|
+
process.exit(2);
|
|
666
|
+
}
|
|
667
|
+
releasePub = v.pub;
|
|
668
|
+
if (latest && manifest.version !== latest) {
|
|
669
|
+
console.error(`REFUSED: the signed manifest names ${manifest.version} but /dl/VERSION says ${latest} — staging on the relay is incomplete, or the two files are being swapped separately. Nothing was installed.`);
|
|
670
|
+
process.exit(2);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
// v0.9.11: a release that changes who may do what, or where data goes, says so on a `!!` line. Those lines
|
|
674
|
+
// go in front of the principal BEFORE the install — the agent shows them and comes back with --yes.
|
|
675
|
+
// v0.10.0: the signed manifest carries the same two flags, so a silent changelog cannot hide one.
|
|
676
|
+
let flags = [];
|
|
677
|
+
{
|
|
678
|
+
const t = await getText("/changelog.txt");
|
|
679
|
+
if (t)
|
|
680
|
+
flags = changelogFlags(t, VERSION, latest || null);
|
|
681
|
+
}
|
|
682
|
+
if (manifest?.permissionChange && !flags.some((f) => /PERMISSION CHANGE/.test(f)))
|
|
683
|
+
flags.push(`${manifest.version}: !! PERMISSION CHANGE (declared in the signed manifest)`);
|
|
684
|
+
if (manifest?.dataFlowChange && !flags.some((f) => /DATA FLOW/.test(f)))
|
|
685
|
+
flags.push(`${manifest.version}: !! DATA FLOW (declared in the signed manifest)`);
|
|
686
|
+
if (flags.length && !has("yes")) {
|
|
687
|
+
console.error(`Between can2cup ${VERSION} and ${latest || "the version the relay serves"}, these releases change who may do what, or where data goes:\n${flags.map((f) => " " + f).join("\n")}\nShow these lines to your principal (full text: ${DEFAULT_RELAY}/changelog.txt). Run can2cup upgrade --yes once they have seen them. Nothing was installed.`);
|
|
688
|
+
process.exit(3);
|
|
689
|
+
}
|
|
690
|
+
// v0.9.11 (security G-3, P1): download, check the sha256 the relay advertises, THEN hand the file to npm.
|
|
691
|
+
// Same origin as the tarball, so this does not defeat a hostile relay; it catches a swapped or corrupted
|
|
692
|
+
// file, and gives a person a number to compare out of band. --require-checksum refuses a relay without one.
|
|
693
|
+
let expected = "";
|
|
694
|
+
try {
|
|
695
|
+
const r = await fetch(`${DEFAULT_RELAY}/dl/VERSION.sha256`, { signal: AbortSignal.timeout(8000) });
|
|
696
|
+
if (r.ok)
|
|
697
|
+
expected = /^[0-9a-f]{64}/.exec((await r.text()).trim())?.[0] ?? "";
|
|
698
|
+
}
|
|
699
|
+
catch { /* relay predates P1 */ }
|
|
700
|
+
if (!expected && has("require-checksum")) {
|
|
701
|
+
console.error("this relay serves no /dl/VERSION.sha256 — refusing (--require-checksum). Nothing was installed.");
|
|
702
|
+
process.exit(2);
|
|
703
|
+
}
|
|
704
|
+
console.error(`upgrading can2cup ${VERSION} → ${latest || "?"} from ${url} …`);
|
|
705
|
+
let buf;
|
|
706
|
+
try {
|
|
707
|
+
const r = await fetch(url, { signal: AbortSignal.timeout(60000) });
|
|
708
|
+
if (!r.ok)
|
|
709
|
+
throw new Error(`HTTP ${r.status}`);
|
|
710
|
+
buf = Buffer.from(await r.arrayBuffer());
|
|
711
|
+
}
|
|
712
|
+
catch (e) {
|
|
713
|
+
console.error(`could not download ${url}: ${e instanceof Error ? e.message : e}. Nothing was installed.`);
|
|
714
|
+
process.exit(1);
|
|
715
|
+
}
|
|
716
|
+
const actual = createHash("sha256").update(buf).digest("hex");
|
|
717
|
+
if (manifest && !Object.values(manifest.files).includes(actual)) {
|
|
718
|
+
console.error(`REFUSED: the downloaded tarball's sha256 (${actual.slice(0, 16)}…) is not in the signed manifest for ${manifest.version} — the file the relay serves is not the one the maintainer signed.\nNothing was installed. Run can2cup report "upgrade tarball not in signed manifest" so the operator hears about it.`);
|
|
719
|
+
process.exit(2);
|
|
720
|
+
}
|
|
721
|
+
if (expected && actual !== expected) {
|
|
722
|
+
console.error(`REFUSED: the downloaded tarball's sha256 does not match what the relay advertises.\n expected ${expected}\n got ${actual}\nNothing was installed. Run can2cup report "upgrade sha256 mismatch" so the operator hears about it.`);
|
|
723
|
+
process.exit(2);
|
|
724
|
+
}
|
|
725
|
+
if (!expected && !manifest)
|
|
726
|
+
console.error("note: this relay serves no VERSION.sha256 either — installing unverified.");
|
|
727
|
+
if (has("dry-run")) {
|
|
728
|
+
console.log(`dry run: would install can2cup ${latest || "?"} — sha256 ${actual.slice(0, 16)}… ${manifest ? `signed by release key ${releasePub.slice(0, 8)}… (manifest ${manifest.version}, ${manifest.date})` : "UNSIGNED (--allow-unsigned)"}. Nothing was installed.`);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
const tmp = path.join(os.tmpdir(), `can2cup-${latest || "latest"}-${randomBytes(4).toString("hex")}.tgz`);
|
|
732
|
+
fs.writeFileSync(tmp, buf);
|
|
733
|
+
const win = process.platform === "win32";
|
|
734
|
+
// Windows: npm is npm.cmd, which needs a shell; hand cmd.exe one string (spawnSync with shell:true + args warns DEP0190).
|
|
735
|
+
const run = (cmd, args, capture = false) => win
|
|
736
|
+
? spawnSync("cmd.exe", ["/d", "/s", "/c", `${cmd} ${args.map((a) => `"${a}"`).join(" ")}`], { stdio: capture ? "pipe" : "inherit", encoding: "utf8", windowsVerbatimArguments: true })
|
|
737
|
+
: spawnSync(cmd, args, { stdio: capture ? "pipe" : "inherit", encoding: "utf8" });
|
|
738
|
+
const rr = run("npm", ["i", "-g", tmp]);
|
|
739
|
+
try {
|
|
740
|
+
fs.unlinkSync(tmp);
|
|
741
|
+
}
|
|
742
|
+
catch { /* best effort */ }
|
|
743
|
+
if (rr.status !== 0) {
|
|
744
|
+
console.error(`npm exited ${rr.status}. If it said EEXIST for an old \`parley\` command: npm rm -g parley, then run can2cup upgrade again.`);
|
|
745
|
+
process.exit(rr.status ?? 1);
|
|
746
|
+
}
|
|
747
|
+
const v = run("can2cup", ["--version"], true).stdout?.trim();
|
|
748
|
+
console.log(`installed: can2cup ${v || "(run can2cup --version)"} (this process was ${VERSION}); sha256 ${actual.slice(0, 16)}… ${manifest ? `signed by release key ${releasePub.slice(0, 8)}…` : expected ? "verified against the relay's VERSION.sha256 (UNSIGNED)" : "UNVERIFIED (relay served no checksum)"}.`);
|
|
749
|
+
saveInstalled((v || latest || VERSION).replace(/^can2cup\s+/, "").trim(), { sha256: actual, verified: !!expected || !!manifest, ...(manifest ? { manifestSig: sigTxt.slice(0, 16), releasePub: releasePub.slice(0, 8) } : {}) });
|
|
750
|
+
const d = loadDuty();
|
|
751
|
+
if (d)
|
|
752
|
+
console.log(`a can2cup watch (pid ${d.pid}) is on duty with the old code — it notices within a sweep, exits, and asks your session to start it again on the new code.`);
|
|
753
|
+
console.log("Restart Claude Code once so its MCP server loads the new code. Then: can2cup doctor");
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
case "report": return report();
|
|
757
|
+
case "forget": {
|
|
758
|
+
// v0.8.1: a room this agent can no longer reach (ejected / closed elsewhere / relay gone). Local only; the transcript stays.
|
|
759
|
+
const id = positional(0);
|
|
760
|
+
if (!id) {
|
|
761
|
+
console.error("usage: can2cup forget <room>");
|
|
762
|
+
process.exit(1);
|
|
763
|
+
}
|
|
764
|
+
const r = loadRooms()[id];
|
|
765
|
+
if (!r) {
|
|
766
|
+
console.error(`unknown room ${id}`);
|
|
767
|
+
process.exit(1);
|
|
768
|
+
}
|
|
769
|
+
r.state = "closed";
|
|
770
|
+
saveRoom(r);
|
|
771
|
+
console.log(`room ${id} marked closed locally. watch will skip it; history stays readable.`);
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
// ---- v0.9.5: the way out. Every one of these prints what it actually did. ----
|
|
775
|
+
case "leave": {
|
|
776
|
+
// Take this agent out of a room for real: the relay drops it from the participants and the
|
|
777
|
+
// invite secret rotates, so it cannot walk back in. `forget` only ever edited this machine.
|
|
778
|
+
const all = flag("all") !== undefined;
|
|
779
|
+
const id = positional(0);
|
|
780
|
+
if (!all && !id) {
|
|
781
|
+
console.error('usage: can2cup leave <room> | can2cup leave --all\n (leaves the room for you only. To end it for everyone: can2cup close <room> "<summary>")');
|
|
782
|
+
process.exit(1);
|
|
783
|
+
}
|
|
784
|
+
const rooms = loadRooms();
|
|
785
|
+
const targets = all ? Object.values(rooms).filter((r) => r.state === "open") : [rooms[id]].filter(Boolean);
|
|
786
|
+
if (!targets.length) {
|
|
787
|
+
console.error(all ? "no open rooms to leave" : `unknown room ${id}`);
|
|
788
|
+
process.exit(1);
|
|
789
|
+
}
|
|
790
|
+
const me = loadIdentity();
|
|
791
|
+
let left = 0;
|
|
792
|
+
for (const r of targets) {
|
|
793
|
+
if (!r.cap) {
|
|
794
|
+
console.log(`- ${r.id}: joined before v0.3, no per-participant cap — marking it closed here only`);
|
|
795
|
+
r.state = "closed";
|
|
796
|
+
saveRoom(r);
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
try {
|
|
800
|
+
const res = await relay.leave(r.relay, r.id, r.cap, me);
|
|
801
|
+
r.state = "closed";
|
|
802
|
+
saveRoom(r);
|
|
803
|
+
left++;
|
|
804
|
+
console.log(`- ${r.id} "${r.name || ""}": left${res.roomClosed ? " (nobody left in it — the room is closed)" : `, ${res.remaining} still in it`}`);
|
|
805
|
+
}
|
|
806
|
+
catch (e) {
|
|
807
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
808
|
+
console.log(`- ${r.id} "${r.name || ""}": ${msg}`);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
if (left)
|
|
812
|
+
console.log(`\nThe transcript stays readable here (can2cup history <room>), and the people still in those rooms keep their signed copy of what you wrote. Leaving does not retract it.`);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
case "unbind": {
|
|
816
|
+
// Undo the 1:1 LINE binding from this side. Rooms are untouched — a room belongs to the
|
|
817
|
+
// people in it, not to the phone that happened to open it.
|
|
818
|
+
if (!DEFAULT_RELAY) {
|
|
819
|
+
console.error("no relay configured");
|
|
820
|
+
process.exit(1);
|
|
821
|
+
}
|
|
822
|
+
// v0.9.11: like erase / uninstall / leave --all, say what it does and wait for --yes (docs review).
|
|
823
|
+
if (!has("yes")) {
|
|
824
|
+
console.error(`can2cup unbind — undoes the 1:1 LINE binding from this side.\n deletes on the relay: the binding, its inbox, the principal-key pin for that LINE user\n keeps: your rooms, your keys, this machine's files (${HOME}); group wires stay until /unmirror\n cannot delete: LINE pushes already sent, any ban\nRun again with --yes to do it.`);
|
|
825
|
+
process.exit(1);
|
|
826
|
+
}
|
|
827
|
+
const r = await relay.erase(DEFAULT_RELAY, loadIdentity(), "binding");
|
|
828
|
+
if (!r.wasBound) {
|
|
829
|
+
console.log("this agent was not bound to any LINE account.");
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
console.log(`unbound from LINE.\ndeleted on the relay: ${fmtDeleted(r.deleted)}`);
|
|
833
|
+
console.log(`\nkept: your rooms, your keys, this machine's files (${HOME}).\nto bind again: /setup on LINE, or can2cup link <code>`);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
case "keep": {
|
|
837
|
+
// v0.9.12: how long this binding may sit with the AGENT absent before the relay lets it lapse (default 90 days,
|
|
838
|
+
// warned 14 days ahead; any signed call renews). The clock is the agent's absence, never the principal's silence.
|
|
839
|
+
if (!DEFAULT_RELAY) {
|
|
840
|
+
console.error("no relay configured");
|
|
841
|
+
process.exit(1);
|
|
842
|
+
}
|
|
843
|
+
const arg = positional(0);
|
|
844
|
+
const body = arg === "forever" || arg === "永久" ? { forever: true } : arg && /^\d+$/.test(arg) ? { days: Number(arg) } : {};
|
|
845
|
+
if (arg && body.days === undefined && !body.forever) {
|
|
846
|
+
console.error("usage: can2cup keep [<days 7-365> | forever] (no argument: show the current setting)");
|
|
847
|
+
process.exit(1);
|
|
848
|
+
}
|
|
849
|
+
try {
|
|
850
|
+
const r = await bridge.keep(DEFAULT_RELAY, loadIdentity(), body);
|
|
851
|
+
const i = r.idle;
|
|
852
|
+
console.log(i.forever
|
|
853
|
+
? "idle expiry: never — this binding stays until somebody unbinds it. If you change computers, /setup again (or can2cup unbind here), or the old machine stays your agent."
|
|
854
|
+
: `idle expiry: after ${i.days} ${process.env.CAN2CUP_IDLE_UNIT ?? "days"} of agent absence → ${i.expiresAt} (last seen ${i.lastSeen ?? "never"}; warned 14 days ahead; any signed call renews).${body.forever || body.days ? "" : " Change: can2cup keep <days> | forever"}`);
|
|
855
|
+
}
|
|
856
|
+
catch (e) {
|
|
857
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
858
|
+
process.exit(1);
|
|
859
|
+
}
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
case "erase": {
|
|
863
|
+
// Everything the relay holds about this agent.
|
|
864
|
+
if (!DEFAULT_RELAY) {
|
|
865
|
+
console.error("no relay configured");
|
|
866
|
+
process.exit(1);
|
|
867
|
+
}
|
|
868
|
+
if (flag("yes") === undefined) {
|
|
869
|
+
console.error(`This asks ${DEFAULT_RELAY} to delete everything it holds about this agent:\n the LINE binding, your inbox, your group settings, the rooms registry,\n queued pushes, and the rooms where nobody but you is left.\n\nIt does NOT and cannot delete: messages other participants already received\n(they hold a signed copy), or pushes already delivered to LINE's servers.\n\nRe-run with --yes to do it.`);
|
|
870
|
+
process.exit(1);
|
|
871
|
+
}
|
|
872
|
+
const r = await relay.erase(DEFAULT_RELAY, loadIdentity(), "all");
|
|
873
|
+
console.log(`erased on ${DEFAULT_RELAY}: ${fmtDeleted(r.deleted)}`);
|
|
874
|
+
console.log(`\nStill on this machine: ${HOME} (can2cup uninstall removes it)`);
|
|
875
|
+
console.log(`Still with other people: whatever you sent into shared rooms. That cannot be recalled.`);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
case "uninstall": {
|
|
879
|
+
// The whole exit in one command, in the order that leaves nothing dangling: leave the rooms
|
|
880
|
+
// while the caps still work, then erase on the relay, then take the machine apart.
|
|
881
|
+
const keepData = flag("keep-data") !== undefined;
|
|
882
|
+
if (flag("yes") === undefined) {
|
|
883
|
+
console.error(`can2cup uninstall — removes this agent from this computer.\n\n 1. leaves every open room (the others are told; the transcript stays with them)\n 2. asks the relay to delete the LINE binding, the inbox and the rooms registry\n 3. removes the MCP registration from Claude Code\n 4. deletes ${HOME}${keepData ? " (skipped: --keep-data)" : ""}\n 5. prints the one command left for you: npm uninstall -g can2cup\n\nRe-run with --yes.`);
|
|
884
|
+
process.exit(1);
|
|
885
|
+
}
|
|
886
|
+
const me = loadIdentity();
|
|
887
|
+
console.log("1. leaving rooms");
|
|
888
|
+
for (const r of Object.values(loadRooms()).filter((x) => x.state === "open")) {
|
|
889
|
+
if (!r.cap) {
|
|
890
|
+
console.log(` - ${r.id}: no cap, skipped on the relay`);
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
try {
|
|
894
|
+
await relay.leave(r.relay, r.id, r.cap, me);
|
|
895
|
+
console.log(` - ${r.id} "${r.name || ""}": left`);
|
|
896
|
+
}
|
|
897
|
+
catch (e) {
|
|
898
|
+
console.log(` - ${r.id}: ${e instanceof Error ? e.message : e}`);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
console.log("2. asking the relay to forget this agent");
|
|
902
|
+
if (DEFAULT_RELAY) {
|
|
903
|
+
try {
|
|
904
|
+
const r = await relay.erase(DEFAULT_RELAY, me, "all");
|
|
905
|
+
console.log(` ${fmtDeleted(r.deleted)}`);
|
|
906
|
+
}
|
|
907
|
+
catch (e) {
|
|
908
|
+
console.log(` could not reach ${DEFAULT_RELAY}: ${e instanceof Error ? e.message : e}\n → the relay still holds your data. Re-run \`can2cup erase --yes\` when you are online, or type /forgetme to the LINE bot.`);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
else
|
|
912
|
+
console.log(" (no relay configured — nothing to erase)");
|
|
913
|
+
console.log("3. removing the MCP registration");
|
|
914
|
+
const rm = run("claude", ["mcp", "remove", "can2cup", "-s", "user"], { stdio: "ignore" });
|
|
915
|
+
console.log(rm.status === 0 ? " removed from Claude Code (user scope)" : " claude CLI not available — remove the can2cup entry from your MCP config by hand");
|
|
916
|
+
try {
|
|
917
|
+
fs.rmSync(path.join(claudeHome(), "skills", "can2cup"), { recursive: true, force: true });
|
|
918
|
+
console.log(" skill removed");
|
|
919
|
+
}
|
|
920
|
+
catch { /* best effort */ }
|
|
921
|
+
console.log(`4. ${keepData ? "keeping" : "deleting"} ${HOME}`);
|
|
922
|
+
if (!keepData) {
|
|
923
|
+
try {
|
|
924
|
+
fs.rmSync(HOME, { recursive: true, force: true });
|
|
925
|
+
console.log(" deleted (keys, rooms, transcripts, audit log — all of it)");
|
|
926
|
+
}
|
|
927
|
+
catch (e) {
|
|
928
|
+
console.log(` could not delete: ${e instanceof Error ? e.message : e}\n → delete the folder by hand.`);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
else
|
|
932
|
+
console.log(" kept — your keys and transcripts are still there");
|
|
933
|
+
console.log(`\n5. one command left, run it yourself (this process is the package):\n\n npm uninstall -g can2cup\n`);
|
|
934
|
+
console.log(`Also worth knowing:\n - a duty watch running in another window will exit on its next sweep.\n - Claude Code keeps the can2cup tools until you restart it.\n - other people's copies of what you wrote in shared rooms stay theirs.`);
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
case "skill": {
|
|
938
|
+
if (!fs.existsSync(skillFile)) {
|
|
939
|
+
console.error(`SKILL.md not found at ${skillFile}`);
|
|
940
|
+
process.exit(1);
|
|
941
|
+
}
|
|
942
|
+
if (has("install")) {
|
|
943
|
+
console.log(installSkill());
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
console.log(fs.readFileSync(skillFile, "utf8"));
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
// ---- agent-facing (core) ----
|
|
950
|
+
case "join": {
|
|
951
|
+
const inv = positional(0);
|
|
952
|
+
if (!inv) {
|
|
953
|
+
console.error('usage: can2cup join "<invite link or token>"');
|
|
954
|
+
process.exit(1);
|
|
955
|
+
}
|
|
956
|
+
const c = await core();
|
|
957
|
+
console.log(c.outText(await c.opJoin(inv)));
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
case "wait": {
|
|
961
|
+
const id = positional(0);
|
|
962
|
+
if (!id) {
|
|
963
|
+
console.error("usage: can2cup wait <room> [--timeout 25]");
|
|
964
|
+
process.exit(1);
|
|
965
|
+
}
|
|
966
|
+
const c = await core();
|
|
967
|
+
console.log(c.outText(await c.opWait(id, Number(flag("timeout") ?? 25) || 0)));
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
case "send": {
|
|
971
|
+
const id = positional(0);
|
|
972
|
+
const type = positional(1);
|
|
973
|
+
const t = msgText(positional(2));
|
|
974
|
+
if (!id || !type || t === undefined || !MSG_TYPES.includes(type) || type === "system") {
|
|
975
|
+
console.error('usage: can2cup send <room> <type> "<text>" | <room> <type> --text-file FILE [--amount N] [--scope S] [--expires-hours H] [--ref N] [--url U] [--sha256 H] [--name N] [--rationale "…"]\n types: ' + MSG_TYPES.filter((x) => x !== "system").join(" "));
|
|
976
|
+
process.exit(1);
|
|
977
|
+
}
|
|
978
|
+
const num = (n) => (flag(n) !== undefined ? Number(flag(n)) : undefined);
|
|
979
|
+
const c = await core();
|
|
980
|
+
console.log(c.outText(await c.opSend({ room: id, type, text: t, amount: num("amount"), scope: flag("scope"), expiresHours: num("expires-hours"), ref: num("ref"), url: flag("url"), sha256: flag("sha256"), name: flag("name"), rationale: flag("rationale") })));
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
case "history": {
|
|
984
|
+
const id = positional(0);
|
|
985
|
+
if (!id) {
|
|
986
|
+
console.error("usage: can2cup history <room>");
|
|
987
|
+
process.exit(1);
|
|
988
|
+
}
|
|
989
|
+
const c = await core();
|
|
990
|
+
console.log(c.outText(await c.opHistory(id)));
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
case "close": {
|
|
994
|
+
const id = positional(0);
|
|
995
|
+
const sum = msgText(positional(1));
|
|
996
|
+
if (!id || !sum) {
|
|
997
|
+
console.error('usage: can2cup close <room> "<summary>" | <room> --text-file FILE');
|
|
998
|
+
process.exit(1);
|
|
999
|
+
}
|
|
1000
|
+
const c = await core();
|
|
1001
|
+
console.log(c.outText(await c.opClose(id, sum)));
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
case "create": {
|
|
1005
|
+
const c = await core();
|
|
1006
|
+
console.log(c.outText(await c.opCreateRoom({ name: flag("name"), ttlHours: flag("ttl-hours") ? Number(flag("ttl-hours")) : undefined, e2e: has("e2e"), group: flag("group") })));
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
case "wire": {
|
|
1010
|
+
// v0.8.2: attach a room opened by hand to the LINE group it is for (join code posted there + mirror).
|
|
1011
|
+
const id = positional(0);
|
|
1012
|
+
const g = positional(1);
|
|
1013
|
+
if (!id || !g) {
|
|
1014
|
+
console.error("usage: can2cup wire <room> <group alias|id|name> (groups: can2cup groups)");
|
|
1015
|
+
process.exit(1);
|
|
1016
|
+
}
|
|
1017
|
+
const c = await core();
|
|
1018
|
+
console.log(c.outText(await c.opWire(id, g)));
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
case "link": {
|
|
1022
|
+
const c = await core();
|
|
1023
|
+
const claim = positional(0);
|
|
1024
|
+
if (claim) { // reverse flow: the human got a code from the bot first ("/link" with no code)
|
|
1025
|
+
console.log(c.outText(await c.opLink(claim)));
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
const l = await c.linkDetails();
|
|
1029
|
+
console.log(`/link ${l.code} (valid ${l.minutes} min)${l.alreadyBound ? " — already bound; re-binds" : ""}`);
|
|
1030
|
+
if (l.url) {
|
|
1031
|
+
console.log(`\nScan with your phone (LINE or camera) → opens the can2cup bot chat with "/link ${l.code}" typed; tap send.\n${l.url}\nPNG: ${l.qrPng}\n`);
|
|
1032
|
+
console.log(await QRCode.toString(l.url, { type: "terminal", small: true }));
|
|
1033
|
+
}
|
|
1034
|
+
else {
|
|
1035
|
+
console.log("\nSend that to the can2cup LINE bot within the time limit.");
|
|
1036
|
+
}
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
case "tell": {
|
|
1040
|
+
const t = msgText(positional(0)) ?? "";
|
|
1041
|
+
const img = flag("image");
|
|
1042
|
+
if (!t && !img) {
|
|
1043
|
+
console.error('usage: can2cup tell "<text>" | --text-file FILE [--where dm|group|group:<alias>] [--image FILE] [--ttl SEC] [--room ID]');
|
|
1044
|
+
process.exit(1);
|
|
1045
|
+
}
|
|
1046
|
+
const c = await core();
|
|
1047
|
+
console.log(c.outText(await c.opTell(t, flag("room"), flag("where"), img, flag("ttl") ? Number(flag("ttl")) : undefined)));
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
case "groups": {
|
|
1051
|
+
const c = await core();
|
|
1052
|
+
console.log(c.outText(await c.opGroups()));
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
case "ack": {
|
|
1056
|
+
// v0.8.0: "I (or my Claude) am handling the instructions watch printed" — otherwise the relay reminds the
|
|
1057
|
+
// principal after 15 min and hands them out again.
|
|
1058
|
+
const c = await core();
|
|
1059
|
+
const seq = positional(0) ? Number(positional(0)) : undefined;
|
|
1060
|
+
console.log(c.outText(await c.opAck(seq)));
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
case "note": {
|
|
1064
|
+
const id = positional(0);
|
|
1065
|
+
const t = positional(1);
|
|
1066
|
+
if (!id || !t) {
|
|
1067
|
+
console.error('usage: can2cup note <room> "<summary of where this room stands>"');
|
|
1068
|
+
process.exit(1);
|
|
1069
|
+
}
|
|
1070
|
+
const c = await core();
|
|
1071
|
+
console.log(c.outText(c.opNote(id, t)));
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
// ---- v0.9.6: who this agent is ----
|
|
1075
|
+
case "soul": {
|
|
1076
|
+
const f = soulFile();
|
|
1077
|
+
const body = loadSoul();
|
|
1078
|
+
if (positional(0) === "path") {
|
|
1079
|
+
console.log(f);
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
console.log(`${body}\n\n---\n${f} (edit this file to change how your agent comes across everywhere)`);
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
case "address": {
|
|
1086
|
+
// v0.9.8: how this agent addresses you. Local only — config.json + the line in soul.md.
|
|
1087
|
+
const want = positional(0);
|
|
1088
|
+
if (want === undefined) {
|
|
1089
|
+
console.log(`your agent addresses you as 「${loadAddress()}」\n stored in ${CONFIG_PATH}\n change it can2cup address "<稱呼>" (soul.md follows)`);
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
const a = normalizeAddress(want);
|
|
1093
|
+
if (!a) {
|
|
1094
|
+
console.error('usage: can2cup address "<稱呼>" — one short phrase, one line (up to 24 characters)');
|
|
1095
|
+
process.exit(1);
|
|
1096
|
+
}
|
|
1097
|
+
saveConfig({ address: a });
|
|
1098
|
+
const f = applyAddressToSoul(a);
|
|
1099
|
+
console.log(`from now on your agent addresses you as 「${a}」.\n ${CONFIG_PATH}\n ${f} (the line under the opening sentence; a session already running sees it the next time it reads soul.md)`);
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
case "persona": {
|
|
1103
|
+
const place = positional(0);
|
|
1104
|
+
if (!place) {
|
|
1105
|
+
console.error('usage: can2cup persona <group-alias|room|place> ["<what you learned about how you land here>"]\n with no text it prints what you recorded last.');
|
|
1106
|
+
process.exit(1);
|
|
1107
|
+
}
|
|
1108
|
+
const c = await core();
|
|
1109
|
+
console.log(c.outText(c.opPersona(place.startsWith("group-") || /^[0-9a-f]{12}$/.test(place) ? place : `group-${place}`, msgText(positional(1)))));
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
case "watch": {
|
|
1113
|
+
const c = await core();
|
|
1114
|
+
// v0.8.0: one inbox duty per computer. A second watch would race the first for the same instructions.
|
|
1115
|
+
const held = acquireDuty(flag("exec") ? "exec" : "stdout");
|
|
1116
|
+
if (held) {
|
|
1117
|
+
console.error(`watch: another can2cup watch (pid ${held.pid}, ${held.mode}, since ${held.at}) is already on duty on this computer. Stop it first, or let it work.`);
|
|
1118
|
+
process.exit(3);
|
|
1119
|
+
}
|
|
1120
|
+
const letGo = () => releaseDuty();
|
|
1121
|
+
process.on("exit", letGo);
|
|
1122
|
+
process.on("SIGINT", () => { letGo(); process.exit(130); });
|
|
1123
|
+
process.on("SIGTERM", () => { letGo(); process.exit(143); });
|
|
1124
|
+
// v0.4.6: zero-WAIT sweeps, not long-polls. A held long-poll keeps the relay's Durable Object
|
|
1125
|
+
// active the whole time and burned the free tier's daily duration quota in one day of duty
|
|
1126
|
+
// (2026-08-21, "Exceeded allowed duration in Durable Objects free tier"). wait=0 polls cost the
|
|
1127
|
+
// DO milliseconds; the waiting happens here, in this process, for free.
|
|
1128
|
+
const interval = Number(flag("interval") ?? flag("timeout") ?? 30) || 30; // --timeout kept as a legacy alias; 30 s (was 25)
|
|
1129
|
+
const exec = flag("exec");
|
|
1130
|
+
let rooms = [];
|
|
1131
|
+
for (let k = 1; k < argv.length; k++) {
|
|
1132
|
+
if (argv[k].startsWith("--")) {
|
|
1133
|
+
k++;
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
rooms.push(argv[k]);
|
|
1137
|
+
}
|
|
1138
|
+
if (!rooms.length)
|
|
1139
|
+
rooms = Object.values(loadRooms()).filter((r) => r.state === "open").map((r) => r.id);
|
|
1140
|
+
if (!rooms.length) {
|
|
1141
|
+
// Fresh install: no room yet, but a LINE-bound principal may /a us any minute. Inbox-only duty.
|
|
1142
|
+
console.error(`no open rooms — watching the principal inbox only (sweep every ${interval}s; content ${exec ? `→ ${exec}` : "→ stdout, then exit 0"}). Join a room and restart watch to cover it too.`);
|
|
1143
|
+
for (;;) {
|
|
1144
|
+
refreshDuty();
|
|
1145
|
+
{
|
|
1146
|
+
const inst = loadUpgradeNag()?.installed;
|
|
1147
|
+
if (inst && inst.version && cmpSemver(inst.version, VERSION) > 0) {
|
|
1148
|
+
console.log(`=== can2cup watch: a newer client is installed ===\nThis watch is running can2cup ${VERSION}; can2cup ${inst.version} was installed at ${inst.at}. Start duty again to come up on it, and restart Claude Code once.`);
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
try {
|
|
1153
|
+
const o = await c.opInboxPeek(false);
|
|
1154
|
+
if (!o.empty) {
|
|
1155
|
+
const text = `=== can2cup watch: principal instruction(s) ===\n${c.outText(o)}${exec ? "" : NOT_ACKED_HINT}`;
|
|
1156
|
+
if (exec) {
|
|
1157
|
+
console.error(text);
|
|
1158
|
+
const rr = spawnSync(exec, { shell: true, input: text, stdio: ["pipe", "inherit", "inherit"] });
|
|
1159
|
+
if (rr.status === 0)
|
|
1160
|
+
await c.opAck().catch(() => undefined);
|
|
1161
|
+
else
|
|
1162
|
+
console.error(`watch: --exec exited ${rr.status}; not acked`);
|
|
1163
|
+
}
|
|
1164
|
+
else {
|
|
1165
|
+
console.log(text);
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
// review C11: a room may have appeared (invite accepted, /room handled) — switch to the room loop
|
|
1170
|
+
if (Object.values(loadRooms()).some((r) => r.state === "open")) {
|
|
1171
|
+
console.error("watch: a room opened — restarting duty with rooms");
|
|
1172
|
+
releaseDuty();
|
|
1173
|
+
const rr = spawnSync(process.execPath, [process.argv[1], ...process.argv.slice(2)], { stdio: "inherit" });
|
|
1174
|
+
process.exit(rr.status ?? 0);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
catch (e) {
|
|
1178
|
+
console.error(`watch: inbox error: ${e instanceof Error ? e.message : e}`);
|
|
1179
|
+
}
|
|
1180
|
+
await new Promise((r) => setTimeout(r, interval * 1000));
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
console.error(`watching ${rooms.join(", ")} + principal inbox (sweep every ${interval}s, zero-wait polls; content ${exec ? `→ ${exec}` : "→ stdout, then exit 0"})`);
|
|
1184
|
+
const fails = new Map(); // one broken room must not kill the whole duty (it did, once)
|
|
1185
|
+
const MUTE_AT = 10;
|
|
1186
|
+
let inboxOnly = false; // v0.7.9: every room dead (e.g. rooms from a relay that no longer exists) ≠ off duty
|
|
1187
|
+
// v0.9.0: the relay's version headers arrive with the first sweep; say it once (stderr), and once more in
|
|
1188
|
+
// front of the first content this watch hands to the agent, so the agent — not the human — decides.
|
|
1189
|
+
let pendingUpgrade; // undefined = relay not asked yet this process
|
|
1190
|
+
let upgradeShown = false;
|
|
1191
|
+
const upgradeOnce = () => { if (pendingUpgrade === undefined) {
|
|
1192
|
+
pendingUpgrade = c.upgradeText();
|
|
1193
|
+
if (pendingUpgrade)
|
|
1194
|
+
console.error(`watch: ${pendingUpgrade}`);
|
|
1195
|
+
} return pendingUpgrade; };
|
|
1196
|
+
const withUpgrade = (t) => { const up = upgradeOnce(); if (!up || upgradeShown)
|
|
1197
|
+
return t; upgradeShown = true; return `${up}\n\n${t}`; };
|
|
1198
|
+
// v0.9.2: `can2cup upgrade` cannot replace the code inside a process that is already running.
|
|
1199
|
+
// This one exits instead, with the restart line as its content — the session that started it
|
|
1200
|
+
// reads that the way it reads any other watch output, and starts the new code.
|
|
1201
|
+
const installedElsewhere = () => {
|
|
1202
|
+
const inst = loadUpgradeNag()?.installed;
|
|
1203
|
+
// 只有「裝上來的比我新」才退場。反過來(我跑的是新的、upgrade.json 記著舊版)不是升級,
|
|
1204
|
+
// 是這台在跑開發版 —— 那樣還退場的話,值班會在每一輪自殺。
|
|
1205
|
+
return inst && inst.version && cmpSemver(inst.version, VERSION) > 0
|
|
1206
|
+
? `=== can2cup watch: a newer client is installed ===\nThis watch is running can2cup ${VERSION}; can2cup ${inst.version} was installed on this computer at ${inst.at}.\nA running process cannot swap its own code, so this one is standing down. Start duty again (same command) and it comes up on ${inst.version}.\nAlso restart Claude Code once, so its MCP server loads the new code too.`
|
|
1207
|
+
: null;
|
|
1208
|
+
};
|
|
1209
|
+
for (;;) {
|
|
1210
|
+
refreshDuty();
|
|
1211
|
+
{
|
|
1212
|
+
const drift = installedElsewhere();
|
|
1213
|
+
if (drift) {
|
|
1214
|
+
console.log(drift);
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
// review C11: rooms joined or created since we started (LINE invites, /room requests) get watched too.
|
|
1219
|
+
for (const r of Object.values(loadRooms()))
|
|
1220
|
+
if (r.state === "open" && !rooms.includes(r.id))
|
|
1221
|
+
rooms.push(r.id);
|
|
1222
|
+
// traffic fix: the principal inbox once per sweep, then each room without re-reading it
|
|
1223
|
+
try {
|
|
1224
|
+
const ib = await c.opInboxPeek(false);
|
|
1225
|
+
upgradeOnce();
|
|
1226
|
+
if (!ib.empty) {
|
|
1227
|
+
const text = withUpgrade(`=== can2cup watch: principal instruction(s) ===
|
|
1228
|
+
${c.outText(ib)}${exec ? "" : NOT_ACKED_HINT}`);
|
|
1229
|
+
if (exec) {
|
|
1230
|
+
console.error(text);
|
|
1231
|
+
const rr = spawnSync(exec, { shell: true, input: text, stdio: ["pipe", "inherit", "inherit"] });
|
|
1232
|
+
if (rr.status === 0)
|
|
1233
|
+
await c.opAck().catch(() => undefined);
|
|
1234
|
+
}
|
|
1235
|
+
else {
|
|
1236
|
+
console.log(text);
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
catch (e) {
|
|
1242
|
+
console.error(`watch: inbox error: ${e instanceof Error ? e.message : e}`);
|
|
1243
|
+
}
|
|
1244
|
+
for (const room of rooms) {
|
|
1245
|
+
if ((fails.get(room) ?? 0) >= MUTE_AT)
|
|
1246
|
+
continue;
|
|
1247
|
+
try {
|
|
1248
|
+
const o = await c.opWait(room, 0, false, true); // review R4: watch never acks by reading; inbox already read above
|
|
1249
|
+
fails.set(room, 0);
|
|
1250
|
+
if (o.empty)
|
|
1251
|
+
continue;
|
|
1252
|
+
const text = withUpgrade(`=== can2cup watch: content (while polling room ${room}) ===\n${c.outText(o)}${exec ? "" : NOT_ACKED_HINT}`);
|
|
1253
|
+
if (exec) {
|
|
1254
|
+
console.error(text);
|
|
1255
|
+
const rr = spawnSync(exec, { shell: true, input: text, stdio: ["pipe", "inherit", "inherit"] });
|
|
1256
|
+
if (rr.status === 0)
|
|
1257
|
+
await c.opAck().catch(() => undefined);
|
|
1258
|
+
else
|
|
1259
|
+
console.error(`watch: --exec exited ${rr.status}; not acked — the relay will remind your principal`);
|
|
1260
|
+
continue;
|
|
1261
|
+
}
|
|
1262
|
+
console.log(text);
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
catch (e) {
|
|
1266
|
+
const n = (fails.get(room) ?? 0) + 1;
|
|
1267
|
+
fails.set(room, n);
|
|
1268
|
+
console.error(`watch: room ${room} error ${n}/${MUTE_AT}: ${e instanceof Error ? e.message : e}`);
|
|
1269
|
+
if (n === MUTE_AT)
|
|
1270
|
+
console.error(`watch: room ${room} muted after ${MUTE_AT} consecutive errors — still watching the rest; restart watch to retry it`);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
if (rooms.every((r) => (fails.get(r) ?? 0) >= MUTE_AT)) {
|
|
1274
|
+
// v0.7.9: the rooms are dead, the principal is not. Mira's first day (2026-09-04): her only room lived on
|
|
1275
|
+
// the old relay, 10× 401 muted it, and watch exited — so her LINE /a went unread. Keep sweeping the inbox.
|
|
1276
|
+
if (!inboxOnly) {
|
|
1277
|
+
inboxOnly = true;
|
|
1278
|
+
console.error("watch: every room is failing — switching to principal-inbox-only duty (leave or close the dead rooms to silence this; restart watch to retry them)");
|
|
1279
|
+
}
|
|
1280
|
+
try {
|
|
1281
|
+
const o = await c.opInboxPeek(false);
|
|
1282
|
+
if (!o.empty) {
|
|
1283
|
+
const text = `=== can2cup watch: principal instruction(s) ===\n${c.outText(o)}${exec ? "" : NOT_ACKED_HINT}`;
|
|
1284
|
+
if (exec) {
|
|
1285
|
+
console.error(text);
|
|
1286
|
+
const rr = spawnSync(exec, { shell: true, input: text, stdio: ["pipe", "inherit", "inherit"] });
|
|
1287
|
+
if (rr.status === 0)
|
|
1288
|
+
await c.opAck().catch(() => undefined);
|
|
1289
|
+
}
|
|
1290
|
+
else {
|
|
1291
|
+
console.log(text);
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
catch (e) {
|
|
1297
|
+
console.error(`watch: inbox error: ${e instanceof Error ? e.message : e}`);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
await new Promise((r) => setTimeout(r, interval * 1000));
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
default: usage();
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
/** Copy SKILL.md into ~/.claude/skills/can2cup/ so every Claude Code session can learn can2cup by itself. */
|
|
1307
|
+
/** v0.9.5: text for a message, from --text-file if given, else the positional argument.
|
|
1308
|
+
*
|
|
1309
|
+
* On Windows the `can2cup.cmd` shim runs through cmd.exe, where a newline inside an argument ends
|
|
1310
|
+
* the command: a multi-line message is silently truncated to its first line and the send still
|
|
1311
|
+
* reports success. Anything longer than one line should come from a file. */
|
|
1312
|
+
function msgText(positionalText) {
|
|
1313
|
+
const f = flag("text-file");
|
|
1314
|
+
if (!f)
|
|
1315
|
+
return positionalText;
|
|
1316
|
+
try {
|
|
1317
|
+
return fs.readFileSync(f, "utf8").replace(/\r\n/g, "\n").trim();
|
|
1318
|
+
}
|
|
1319
|
+
catch (e) {
|
|
1320
|
+
console.error(`--text-file ${f}: ${e instanceof Error ? e.message : e}`);
|
|
1321
|
+
process.exit(1);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
/** v0.9.5: turn the relay's deletion manifest into something a human can check us on. */
|
|
1325
|
+
function fmtDeleted(d) {
|
|
1326
|
+
const label = {
|
|
1327
|
+
inbox: "inbox", inboxSeq: "inbox counter", read: "read cursor", seen: "seen cursor",
|
|
1328
|
+
groups: "known groups", galias: "group aliases", principal: "registered principal key",
|
|
1329
|
+
paused: "pause state", spause: "signed pause", lastGroup: "last group", offline: "presence",
|
|
1330
|
+
offpend: "presence", offtold: "presence", oldnag: "upgrade nag", stale: "presence",
|
|
1331
|
+
ver: "client version", pub: "binding (by agent)", user: "binding (by LINE account)",
|
|
1332
|
+
mirror: "wired groups", mirrors: "group→room links", quiet: "quiet settings",
|
|
1333
|
+
ctx: "group-context settings", roomreq: "pending room requests", room: "room registry",
|
|
1334
|
+
rooms: "your room list", recent: "recent-room cache", pq: "queued pushes", "push-log": "push log entries",
|
|
1335
|
+
};
|
|
1336
|
+
const parts = Object.entries(d).filter(([, n]) => n > 0).map(([k, n]) => `${label[k] ?? k} ×${n}`);
|
|
1337
|
+
return parts.length ? parts.join(", ") : "nothing was there";
|
|
1338
|
+
}
|
|
1339
|
+
function installSkill() {
|
|
1340
|
+
const dir = path.join(claudeHome(), "skills", "can2cup");
|
|
1341
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1342
|
+
fs.copyFileSync(skillFile, path.join(dir, "SKILL.md"));
|
|
1343
|
+
return `skill installed: ${path.join(dir, "SKILL.md")}`;
|
|
1344
|
+
}
|
|
1345
|
+
/** Onboarding checklist. Each line: done / next step with the exact command. */
|
|
1346
|
+
/** v0.8.1: one command that finds what is wrong and says how to fix it — so another person's agent can
|
|
1347
|
+
* repair its own install without us. Also the payload of `can2cup report`. Returns [lines, problems]. */
|
|
1348
|
+
async function doctor() {
|
|
1349
|
+
const lines = [];
|
|
1350
|
+
const problems = [];
|
|
1351
|
+
const ok = (t) => lines.push(`✅ ${t}`);
|
|
1352
|
+
const bad = (t, fix) => { lines.push(`❌ ${t}\n → ${fix}`); problems.push(t); };
|
|
1353
|
+
const warn = (t, fix) => lines.push(`⚠️ ${t}\n → ${fix}`);
|
|
1354
|
+
// 1. runtime
|
|
1355
|
+
const nodeMajor = Number(process.versions.node.split(".")[0]);
|
|
1356
|
+
if (nodeMajor >= 18)
|
|
1357
|
+
ok(`node ${process.versions.node}`);
|
|
1358
|
+
else
|
|
1359
|
+
bad(`node ${process.versions.node} is too old`, "install Node.js 18+ from https://nodejs.org and reinstall can2cup");
|
|
1360
|
+
// 2. version vs relay (+ v0.9.11: what the last `can2cup upgrade` installed, and whether its sha256 was verified)
|
|
1361
|
+
{
|
|
1362
|
+
const inst = loadUpgradeNag()?.installed;
|
|
1363
|
+
if (inst?.sha256)
|
|
1364
|
+
ok(`last upgrade installed ${inst.version} from sha256 ${inst.sha256.slice(0, 8)}… (${inst.releasePub ? `signed by release key ${inst.releasePub}…` : inst.verified ? "sha256 matched the relay's VERSION.sha256, but UNSIGNED" : "UNVERIFIED — the relay served no checksum"})`);
|
|
1365
|
+
}
|
|
1366
|
+
ok(`trusts release key(s): ${RELEASE_PUBS.map((p) => p.slice(0, 8) + "…").join(", ")}${process.env.CAN2CUP_RELEASE_PUBS ? " (!! overridden by CAN2CUP_RELEASE_PUBS in this environment)" : ""} — an upgrade must be signed by one of these`);
|
|
1367
|
+
let latest = "";
|
|
1368
|
+
if (DEFAULT_RELAY) {
|
|
1369
|
+
try {
|
|
1370
|
+
const r = await fetch(`${DEFAULT_RELAY}/dl/VERSION`, { signal: AbortSignal.timeout(8000) });
|
|
1371
|
+
if (r.ok)
|
|
1372
|
+
latest = (await r.text()).trim();
|
|
1373
|
+
}
|
|
1374
|
+
catch { /* offline */ }
|
|
1375
|
+
}
|
|
1376
|
+
if (!latest)
|
|
1377
|
+
warn(`can2cup ${VERSION} (relay unreachable, latest unknown)`, `check ${DEFAULT_RELAY || "CAN2CUP_RELAY"} is reachable`);
|
|
1378
|
+
else if (latest === VERSION)
|
|
1379
|
+
ok(`can2cup ${VERSION} (latest)`);
|
|
1380
|
+
else
|
|
1381
|
+
warn(`can2cup ${VERSION}, relay serves ${latest}`, `can2cup upgrade (= npm i -g ${DEFAULT_RELAY}/dl/can2cup.tgz; if npm says EEXIST for an old \`parley\` command: npm rm -g parley first)`);
|
|
1382
|
+
// 3. identity / mcp / skill
|
|
1383
|
+
const hasId = fs.existsSync(path.join(HOME, "identity.json"));
|
|
1384
|
+
if (hasId)
|
|
1385
|
+
ok(`identity ${loadIdentity().name} (${short(loadIdentity().pub)}) in ${HOME}`);
|
|
1386
|
+
else
|
|
1387
|
+
bad("no agent identity", `can2cup setup --relay ${DEFAULT_RELAY || "<relay>"} --name <name>`);
|
|
1388
|
+
let mcpOk = false;
|
|
1389
|
+
try {
|
|
1390
|
+
const r = run("claude", ["mcp", "get", "can2cup"], { encoding: "utf8" });
|
|
1391
|
+
mcpOk = r.status === 0 && /can2cup/.test(String(r.stdout ?? ""));
|
|
1392
|
+
}
|
|
1393
|
+
catch { /* no claude */ }
|
|
1394
|
+
if (mcpOk)
|
|
1395
|
+
ok("registered as a Claude Code MCP server");
|
|
1396
|
+
else
|
|
1397
|
+
warn("not registered with Claude Code (or `claude` not on PATH)", "can2cup setup … registers it; other clients: can2cup setup --client json");
|
|
1398
|
+
const skillPath = path.join(claudeHome(), "skills", "can2cup", "SKILL.md");
|
|
1399
|
+
if (fs.existsSync(skillPath))
|
|
1400
|
+
ok("agent skill installed");
|
|
1401
|
+
else
|
|
1402
|
+
warn("agent skill not installed", "can2cup skill --install");
|
|
1403
|
+
// 4. relay + LINE + inbox
|
|
1404
|
+
let st = null;
|
|
1405
|
+
if (DEFAULT_RELAY && hasId) {
|
|
1406
|
+
try {
|
|
1407
|
+
st = await bridge.state(DEFAULT_RELAY, loadIdentity());
|
|
1408
|
+
}
|
|
1409
|
+
catch (e) {
|
|
1410
|
+
bad(`relay ${DEFAULT_RELAY} not answering: ${e instanceof Error ? e.message : e}`, "check the network; if the relay moved, run can2cup setup --relay <new url> again");
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
if (st) {
|
|
1414
|
+
if (st.bound)
|
|
1415
|
+
ok("LINE linked");
|
|
1416
|
+
else
|
|
1417
|
+
warn("not linked to LINE", "in the 傳聲罐罐 bot type /setup (or /link) — instructions from LINE need this");
|
|
1418
|
+
const pending = Math.max(0, st.inboxSeq - loadInboxCursor());
|
|
1419
|
+
if (pending)
|
|
1420
|
+
warn(`${pending} instruction(s) from your principal not yet read`, "run `can2cup watch` (background) or have the agent call can2cup_wait");
|
|
1421
|
+
if (st.paused)
|
|
1422
|
+
warn("remote PAUSE is on", "your principal typed /pause on LINE — nothing goes out until they /resume");
|
|
1423
|
+
}
|
|
1424
|
+
// 5. duty
|
|
1425
|
+
const duty = loadDuty();
|
|
1426
|
+
if (duty)
|
|
1427
|
+
ok(`on duty: can2cup watch pid ${duty.pid} (${duty.mode}) since ${duty.at}`);
|
|
1428
|
+
else
|
|
1429
|
+
warn("nothing is on duty", "start `can2cup watch` in a background shell — LINE instructions are only answered while something listens");
|
|
1430
|
+
// 6. rooms: a room that answers 401/404 is dead for this agent (ejected, closed elsewhere, or a relay that no longer exists)
|
|
1431
|
+
const rooms = Object.values(loadRooms()).filter((r) => r.state === "open");
|
|
1432
|
+
for (const r of rooms) {
|
|
1433
|
+
try {
|
|
1434
|
+
const res = await fetch(`${r.relay}/rooms/${r.id}/info`, { headers: { authorization: `Bearer ${r.cap ?? r.secret}` }, signal: AbortSignal.timeout(8000) }); // review C9: /head is 404 on an unsigned relay
|
|
1435
|
+
if (res.ok)
|
|
1436
|
+
ok(`room ${r.id} "${r.name}" reachable`);
|
|
1437
|
+
else if (res.status === 401 || res.status === 404)
|
|
1438
|
+
bad(`room ${r.id} "${r.name}" answers ${res.status} — you were ejected, it was closed, or its relay is gone`, `can2cup forget ${r.id} (marks it closed locally; ask the other side for a new invite if you still need to talk)`);
|
|
1439
|
+
else
|
|
1440
|
+
warn(`room ${r.id} answers ${res.status}`, "transient? try again in a minute");
|
|
1441
|
+
}
|
|
1442
|
+
catch (e) {
|
|
1443
|
+
warn(`room ${r.id} relay ${r.relay} unreachable: ${e instanceof Error ? e.message : e}`, "network, or that relay no longer exists → can2cup forget " + r.id);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
if (!rooms.length)
|
|
1447
|
+
lines.push("ℹ️ no open rooms (fine — LINE /a still works without one)");
|
|
1448
|
+
// 7. known issues from the relay
|
|
1449
|
+
if (DEFAULT_RELAY) {
|
|
1450
|
+
try {
|
|
1451
|
+
const r = await fetch(`${DEFAULT_RELAY}/known-issues.json`, { signal: AbortSignal.timeout(8000) });
|
|
1452
|
+
if (r.ok) {
|
|
1453
|
+
const ki = (await r.json());
|
|
1454
|
+
const text = lines.join("\n");
|
|
1455
|
+
for (const i of ki.issues ?? [])
|
|
1456
|
+
if (new RegExp(i.match, "i").test(text))
|
|
1457
|
+
lines.push(`📌 known issue ${i.id}: ${i.title}\n → ${i.fix}`);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
catch { /* optional */ }
|
|
1461
|
+
}
|
|
1462
|
+
return { lines, problems };
|
|
1463
|
+
}
|
|
1464
|
+
/** v0.8.1: send the doctor output (+ a note) to the relay operator. No room content leaves the machine. */
|
|
1465
|
+
async function report() {
|
|
1466
|
+
if (!DEFAULT_RELAY) {
|
|
1467
|
+
console.error("CAN2CUP_RELAY not set");
|
|
1468
|
+
process.exit(1);
|
|
1469
|
+
}
|
|
1470
|
+
const d = await doctor();
|
|
1471
|
+
const errors = [];
|
|
1472
|
+
try {
|
|
1473
|
+
const audit = fs.readFileSync(path.join(HOME, "audit.jsonl"), "utf8").trim().split("\n").slice(-200);
|
|
1474
|
+
for (const l of audit) {
|
|
1475
|
+
try {
|
|
1476
|
+
const j = JSON.parse(l);
|
|
1477
|
+
if (j.kind === "blocked" || j.error || (j.status && /bad|replay|fail/i.test(j.status)))
|
|
1478
|
+
errors.push(`${j.at} ${j.kind} ${j.error ?? j.reason ?? j.status ?? ""}`);
|
|
1479
|
+
}
|
|
1480
|
+
catch { /* skip */ }
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
catch { /* no audit yet */ }
|
|
1484
|
+
const note = flag("note") ?? positional(0) ?? "";
|
|
1485
|
+
// review C15: what /privacy promises — no room names or ids leave the machine
|
|
1486
|
+
const redacted = d.lines.join("\n").replace(/room [0-9a-f]{12} "[^"]*"/g, "room ‹redacted›").replace(/room [0-9a-f]{12}/g, "room ‹redacted›").replace(/identity [^\n]*/g, "identity ‹redacted›");
|
|
1487
|
+
const body = { note, doctor: redacted, version: VERSION, platform: `${process.platform} ${os.release()} node ${process.versions.node}`, errors: errors.slice(-20) };
|
|
1488
|
+
if (flag("dry-run")) {
|
|
1489
|
+
console.log(JSON.stringify(body, null, 2));
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
const r = await bridge.report(DEFAULT_RELAY, loadIdentity(), body);
|
|
1493
|
+
if (!r.ok) {
|
|
1494
|
+
console.error(`report refused: ${r.reason ?? "?"}`);
|
|
1495
|
+
process.exit(2);
|
|
1496
|
+
}
|
|
1497
|
+
console.log(`report filed: ${r.id}${r.operatorNotified ? " — the relay operator was notified on LINE" : ""}. Tell your principal the id; keep working around it meanwhile.`);
|
|
1498
|
+
}
|
|
1499
|
+
async function status() {
|
|
1500
|
+
const lines = [];
|
|
1501
|
+
const hasId = fs.existsSync(path.join(HOME, "identity.json"));
|
|
1502
|
+
const id = hasId ? loadIdentity() : null;
|
|
1503
|
+
lines.push(`${hasId ? "✅" : "⬜"} 1. agent identity ${id ? `(${id.name}, ${short(id.pub)})` : "→ can2cup setup --relay <url> --name <name>"}`);
|
|
1504
|
+
let mcpOk = false;
|
|
1505
|
+
try {
|
|
1506
|
+
const r = run("claude", ["mcp", "get", "can2cup"], { encoding: "utf8" });
|
|
1507
|
+
mcpOk = r.status === 0 && /can2cup/.test(String(r.stdout ?? ""));
|
|
1508
|
+
}
|
|
1509
|
+
catch { /* no claude */ }
|
|
1510
|
+
lines.push(`${mcpOk ? "✅" : "⬜"} 2. registered with Claude Code ${mcpOk ? "(restart Claude Code once after setup; until then the agent can use `can2cup …` via Bash)" : "→ can2cup setup … (or --client json for other hosts)"}`);
|
|
1511
|
+
const p = loadPrincipal();
|
|
1512
|
+
lines.push(`${p ? "✅" : "⬜"} 3. principal key ${p ? `(${short(p.pub)}${p.label ? ", " + p.label : ""}) — can2cup say / approve / pause --remote are VERIFIED` : '→ can2cup principal init --label "<your name>" (recommended: makes remote instructions signed)'}`);
|
|
1513
|
+
const m = loadMandate();
|
|
1514
|
+
const touched = m.never_disclose.length || m.max_commit_amount != null || m.may_share.length || m.may_grant.length;
|
|
1515
|
+
lines.push(`${touched ? "✅" : "⬜"} 4. mandate ${touched ? `(never_disclose ${m.never_disclose.length}, max_commit_amount ${m.max_commit_amount}, may_grant ${m.may_grant.length})` : `→ edit ${path.join(HOME, "mandate.json")} (defaults are wide open)`}`);
|
|
1516
|
+
let bound = null;
|
|
1517
|
+
let pending = 0;
|
|
1518
|
+
let boundAt = "";
|
|
1519
|
+
let idleNote = "";
|
|
1520
|
+
if (DEFAULT_RELAY && id) {
|
|
1521
|
+
try {
|
|
1522
|
+
const st = await bridge.state(DEFAULT_RELAY, id);
|
|
1523
|
+
bound = st.bound;
|
|
1524
|
+
pending = Math.max(0, st.inboxSeq - loadInboxCursor());
|
|
1525
|
+
boundAt = st.boundAt ?? ""; // v0.9.8: "bound since" — shown once the relay sends it (HANDOFF interface → core)
|
|
1526
|
+
// v0.9.12: when the binding lapses if this agent stays away (can2cup keep <days>|forever)
|
|
1527
|
+
if (st.idle)
|
|
1528
|
+
idleNote = st.idle.forever ? "; never expires" : `; lapses ${st.idle.expiresAt?.slice(0, 10) ?? "?"} if this agent stays away (${st.idle.days} d idle; can2cup keep)`;
|
|
1529
|
+
}
|
|
1530
|
+
catch { /* offline */ }
|
|
1531
|
+
}
|
|
1532
|
+
lines.push(`${bound ? "✅" : "⬜"} 5. LINE linked ${bound === null ? "(relay unreachable or not configured)" : bound ? `(yes${boundAt ? `, since ${boundAt.slice(0, 16).replace("T", " ")}` : ""}${idleNote}${pending ? `; ${pending} instruction(s) waiting — have the agent call can2cup_wait` : ""}) — in LINE: /a <text> · /status · /pause · /resume` : "→ agent runs can2cup_link (or `can2cup link`); you send /link <code> to the can2cup LINE bot within 10 min (optional: remote control from your phone)"}`);
|
|
1533
|
+
const rooms = Object.values(loadRooms());
|
|
1534
|
+
const open = rooms.filter((r) => r.state === "open");
|
|
1535
|
+
lines.push(`${rooms.length ? "✅" : "⬜"} 6. rooms ${rooms.length ? `(${open.length} open / ${rooms.length} total — agent keeps can2cup_wait looping on ${open.map((r) => r.id).join(", ") || "—"})` : '→ paste an invite link to your agent: "join this can2cup room and keep waiting: <link>" (or `can2cup create --name …` if this machine has the relay key)'}`);
|
|
1536
|
+
const skillPath = path.join(claudeHome(), "skills", "can2cup", "SKILL.md");
|
|
1537
|
+
lines.push(`${fs.existsSync(skillPath) ? "✅" : "⬜"} 7. agent skill installed ${fs.existsSync(skillPath) ? `(${skillPath})` : "→ can2cup skill --install"}`);
|
|
1538
|
+
const duty = loadDuty();
|
|
1539
|
+
lines.push(`${duty ? "✅" : "⬜"} 8. on duty ${duty ? `(can2cup watch pid ${duty.pid}, ${duty.mode}, since ${duty.at})` : "→ start `can2cup watch` in a background shell (one per computer); instructions from LINE are only answered while something is on duty"}`);
|
|
1540
|
+
lines.push(` paused (local): ${isPaused()} addresses you as: 「${loadAddress()}」 (can2cup address) home: ${HOME} relay: ${DEFAULT_RELAY || "(CAN2CUP_RELAY not set)"}`);
|
|
1541
|
+
console.log(lines.join("\n"));
|
|
1542
|
+
}
|
|
1543
|
+
async function setup() {
|
|
1544
|
+
// --invite "<link>": one-shot onboarding — relay inferred from the link, join right after registering.
|
|
1545
|
+
const inviteArg = flag("invite");
|
|
1546
|
+
const linkArg = flag("link");
|
|
1547
|
+
let inviteRelay = "";
|
|
1548
|
+
if (inviteArg) {
|
|
1549
|
+
try {
|
|
1550
|
+
inviteRelay = decodeInvite(inviteArg).u;
|
|
1551
|
+
}
|
|
1552
|
+
catch (e) {
|
|
1553
|
+
console.error(`--invite: ${e instanceof Error ? e.message : e}`);
|
|
1554
|
+
process.exit(1);
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
// A machine that has run setup before knows its relay: config.json records it, and every
|
|
1558
|
+
// room in rooms.json carries the relay it lives on. Re-running setup for another client
|
|
1559
|
+
// (--client codex on a machine set up for claude) must not demand a URL the machine
|
|
1560
|
+
// already has. `||` throughout — inviteRelay/DEFAULT_RELAY are "" when absent, and ""
|
|
1561
|
+
// slipping through `??` was exactly the bug that made --relay look mandatory here.
|
|
1562
|
+
const rememberedRelay = (() => {
|
|
1563
|
+
try {
|
|
1564
|
+
const c = JSON.parse(fs.readFileSync(path.join(HOME, "config.json"), "utf8"));
|
|
1565
|
+
if (c.relay)
|
|
1566
|
+
return c.relay;
|
|
1567
|
+
}
|
|
1568
|
+
catch { /* no config yet */ }
|
|
1569
|
+
return Object.values(loadRooms()).map((r) => r.relay).filter(Boolean).pop() ?? "";
|
|
1570
|
+
})();
|
|
1571
|
+
const relayUrl = flag("relay") || inviteRelay || DEFAULT_RELAY || rememberedRelay;
|
|
1572
|
+
const key = flag("key") ?? RELAY_KEY;
|
|
1573
|
+
const name = flag("name") ?? (process.env.CAN2CUP_NAME ?? process.env.CAN2CAN_NAME ?? process.env.PARLEY_NAME) ?? "";
|
|
1574
|
+
const client = flag("client") ?? "claude";
|
|
1575
|
+
if (!relayUrl) {
|
|
1576
|
+
console.error("--relay URL is required (the person who invited you will tell you which relay)");
|
|
1577
|
+
process.exit(1);
|
|
1578
|
+
}
|
|
1579
|
+
if (!flag("relay") && !inviteRelay && !DEFAULT_RELAY)
|
|
1580
|
+
console.log(`relay inferred from this machine's earlier setup: ${relayUrl}`);
|
|
1581
|
+
const env = { CAN2CUP_RELAY: relayUrl };
|
|
1582
|
+
if (key)
|
|
1583
|
+
env.CAN2CUP_RELAY_KEY = key;
|
|
1584
|
+
if (name)
|
|
1585
|
+
env.CAN2CUP_NAME = name;
|
|
1586
|
+
const envArgs = Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]);
|
|
1587
|
+
if (client === "json" || client === "cursor") {
|
|
1588
|
+
const block = { mcpServers: { can2cup: { command: process.execPath, args: [mcpEntry], env } } };
|
|
1589
|
+
console.log(JSON.stringify(block, null, 2));
|
|
1590
|
+
if (client === "cursor") {
|
|
1591
|
+
const home = process.env.HOME || process.env.USERPROFILE || "~";
|
|
1592
|
+
console.log(`\nCursor: paste the block above into ${path.join(home, ".cursor", "mcp.json")} (global) or <project>/.cursor/mcp.json, then reload Cursor.`);
|
|
1593
|
+
// Cursor reads rules, not skills: drop the same text where its agent will see it.
|
|
1594
|
+
try {
|
|
1595
|
+
const rulesDir = path.join(home, ".cursor", "rules");
|
|
1596
|
+
fs.mkdirSync(rulesDir, { recursive: true });
|
|
1597
|
+
const body = fs.readFileSync(skillFile, "utf8").replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "");
|
|
1598
|
+
fs.writeFileSync(path.join(rulesDir, "can2cup.mdc"), `---\ndescription: can2cup — agent-to-agent rooms (install, join, LINE, behaviour in a room)\nalwaysApply: false\n---\n${body}`);
|
|
1599
|
+
console.log(`rule installed: ${path.join(rulesDir, "can2cup.mdc")}`);
|
|
1600
|
+
}
|
|
1601
|
+
catch { /* best effort */ }
|
|
1602
|
+
}
|
|
1603
|
+
else {
|
|
1604
|
+
console.log("\n(paste into Claude Desktop's claude_desktop_config.json, Cursor's mcp.json, or any MCP client config)");
|
|
1605
|
+
}
|
|
1606
|
+
await finishSetup(env, name, inviteArg, linkArg);
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
if (client === "codex") {
|
|
1610
|
+
const args = ["mcp", "add", "can2cup", ...Object.entries(env).flatMap(([k, v]) => ["--env", `${k}=${v}`]), "--", process.execPath, mcpEntry];
|
|
1611
|
+
console.log("$ codex " + args.join(" "));
|
|
1612
|
+
const r = run("codex", args, { stdio: "inherit" });
|
|
1613
|
+
if (r.status !== 0)
|
|
1614
|
+
console.error("codex CLI failed or missing — run the printed command yourself, or use --client json");
|
|
1615
|
+
// Codex reads AGENTS.md, not skills: the CLI fallback + behaviour rules still apply, so hand them over.
|
|
1616
|
+
try {
|
|
1617
|
+
const home = process.env.HOME || process.env.USERPROFILE || "~";
|
|
1618
|
+
const dir = path.join(home, ".codex");
|
|
1619
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1620
|
+
const f = path.join(dir, "AGENTS.md");
|
|
1621
|
+
const body = fs.readFileSync(skillFile, "utf8").replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "");
|
|
1622
|
+
const cur = fs.existsSync(f) ? fs.readFileSync(f, "utf8") : "";
|
|
1623
|
+
if (!cur.includes("# can2cup —"))
|
|
1624
|
+
fs.writeFileSync(f, cur + (cur ? "\n\n" : "") + body);
|
|
1625
|
+
console.log(`agent notes appended: ${f}`);
|
|
1626
|
+
}
|
|
1627
|
+
catch { /* best effort */ }
|
|
1628
|
+
await finishSetup(env, name, inviteArg, linkArg);
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
// Claude Code, user scope (every project). Remove first so re-running setup updates env.
|
|
1632
|
+
run("claude", ["mcp", "remove", "can2cup", "-s", "user"], { stdio: "ignore" });
|
|
1633
|
+
const args = ["mcp", "add", "can2cup", "-s", "user", ...envArgs, "--", process.execPath, mcpEntry];
|
|
1634
|
+
console.log("$ claude " + args.map((a) => (a.includes(" ") ? JSON.stringify(a) : a)).join(" "));
|
|
1635
|
+
const r = run("claude", args, { stdio: "inherit" });
|
|
1636
|
+
if (r.status !== 0) {
|
|
1637
|
+
console.error("\nclaude CLI failed or missing. Options: install Claude Code, or `can2cup setup --client json` and paste the config into your MCP client.");
|
|
1638
|
+
process.exit(1);
|
|
1639
|
+
}
|
|
1640
|
+
await finishSetup(env, name, inviteArg, linkArg);
|
|
1641
|
+
}
|
|
1642
|
+
/** Everything after the MCP registration: identity, principal key, safe mandate, skill, optional join, LINE binding. */
|
|
1643
|
+
async function finishSetup(env, name, inviteArg, linkArg) {
|
|
1644
|
+
// The MCP server reads CAN2CUP_RELAY from its own env; the CLI needs it in-process for the join + bridge calls.
|
|
1645
|
+
for (const [k, v] of Object.entries(env))
|
|
1646
|
+
if (!process.env[k])
|
|
1647
|
+
process.env[k] = v;
|
|
1648
|
+
// Remember the relay so the next `can2cup setup --client <other>` needs no --relay at all.
|
|
1649
|
+
try {
|
|
1650
|
+
saveConfig({ relay: env.CAN2CUP_RELAY });
|
|
1651
|
+
}
|
|
1652
|
+
catch { /* best effort — inference from rooms.json still works */ }
|
|
1653
|
+
const id = loadIdentity(); // create ~/.parley/identity.json now, with the chosen name
|
|
1654
|
+
// Safe-by-default mandate for a first run (wide-open defaults were the old behaviour): no money, no grants.
|
|
1655
|
+
const mandatePath = path.join(HOME, "mandate.json");
|
|
1656
|
+
if (!fs.existsSync(mandatePath)) {
|
|
1657
|
+
fs.writeFileSync(mandatePath, JSON.stringify({
|
|
1658
|
+
never_disclose: ["sk-live-", "sk-ant-", "ghp_", "glpat-", "-----BEGIN", "xoxb-"],
|
|
1659
|
+
may_share: [], may_grant: [], max_grant_hours: 2, max_commit_amount: 0, currency: "TWD", require_signed_principal: false,
|
|
1660
|
+
brief: "First-run defaults: low-stakes, human-reversible only. No money commitments (max_commit_amount 0), no grants (may_grant empty). Escalate when unsure. Edit this file to widen.",
|
|
1661
|
+
}, null, 2) + "\n");
|
|
1662
|
+
}
|
|
1663
|
+
else
|
|
1664
|
+
loadMandate();
|
|
1665
|
+
loadSoul(); // v0.9.6: write the default soul.md now, so the boss can find and edit it
|
|
1666
|
+
// v0.9.8: how the agent addresses you — asked once, on the terminal, and only when nothing is recorded yet.
|
|
1667
|
+
// --address answers it for scripts; no terminal and no flag → the default, which `can2cup address` can change later.
|
|
1668
|
+
const hadAddress = typeof loadConfig().address === "string";
|
|
1669
|
+
let address = normalizeAddress(flag("address"));
|
|
1670
|
+
if (!address && !hadAddress)
|
|
1671
|
+
address = normalizeAddress(await askLine(`\nHow should your agent address you? (稱呼 — Enter for 「${DEFAULT_ADDRESS}」, change later with \`can2cup address\`) > `)) || DEFAULT_ADDRESS;
|
|
1672
|
+
if (address) {
|
|
1673
|
+
saveConfig({ address });
|
|
1674
|
+
applyAddressToSoul(address);
|
|
1675
|
+
}
|
|
1676
|
+
const addressNow = loadAddress();
|
|
1677
|
+
const p = createPrincipal(name || id.name); // the human's own key — there is no reason to make them ask for it
|
|
1678
|
+
let skillLine = "";
|
|
1679
|
+
try {
|
|
1680
|
+
skillLine = installSkill();
|
|
1681
|
+
}
|
|
1682
|
+
catch (e) {
|
|
1683
|
+
skillLine = `(skill not installed: ${e instanceof Error ? e.message : e})`;
|
|
1684
|
+
}
|
|
1685
|
+
console.log(`\nregistered.
|
|
1686
|
+
agent identity ${id.name} (${short(id.pub)}) ${path.join(HOME, "identity.json")}
|
|
1687
|
+
your own key ${short(p.pub)} ${path.join(HOME, "principal.json")} (signs can2cup say / approve / pause --remote)
|
|
1688
|
+
mandate safe defaults: no money, no grants ${mandatePath} (edit to widen)
|
|
1689
|
+
addresses you as 「${addressNow}」 ${CONFIG_PATH} (can2cup address "<稱呼>" to change; soul.md says the same)
|
|
1690
|
+
${skillLine}
|
|
1691
|
+
|
|
1692
|
+
NEXT — \`can2cup status\` shows this checklist any time:
|
|
1693
|
+
1. Restart Claude Code when convenient so the can2cup_* tools appear. Until then your agent can use \`can2cup …\` directly via Bash (same thing).
|
|
1694
|
+
2. ${linkArg ? "LINE binding: claiming the code from --link below." : "LINE (drive your agent from your phone): scan the QR below with LINE — the bot chat opens with \"/link <code>\" typed; tap send."}
|
|
1695
|
+
3. ${inviteArg ? "joining the room from --invite now…" : 'paste an invite link to your agent: "join this can2cup room and keep waiting: <link>"'}`);
|
|
1696
|
+
// LINE binding, both directions, without a second trip to the phone:
|
|
1697
|
+
// --link CODE the bot's /setup already minted a code for this LINE user -> claim it now;
|
|
1698
|
+
// otherwise mint our own code and print the QR so the human just scans it.
|
|
1699
|
+
// Use env.CAN2CUP_RELAY explicitly here. DEFAULT_RELAY was initialised when this process started,
|
|
1700
|
+
// before setup wrote config.json, so a genuinely fresh process cannot rely on that imported value yet.
|
|
1701
|
+
const relayUrl = env.CAN2CUP_RELAY;
|
|
1702
|
+
const freshLinkDetails = async () => {
|
|
1703
|
+
const r = await bridge.link(relayUrl, id);
|
|
1704
|
+
const details = {
|
|
1705
|
+
code: r.code, minutes: Math.round(r.expiresInSec / 60),
|
|
1706
|
+
};
|
|
1707
|
+
try {
|
|
1708
|
+
const h = await relay.health(relayUrl);
|
|
1709
|
+
if (h.lineOa) {
|
|
1710
|
+
details.url = lineDeepLink(h.lineOa, `/link ${r.code}`);
|
|
1711
|
+
details.qrPng = path.join(HOME, "line-link-qr.png");
|
|
1712
|
+
await QRCode.toFile(details.qrPng, details.url, { margin: 1, width: 320 });
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
catch { /* code-only fallback remains usable */ }
|
|
1716
|
+
return details;
|
|
1717
|
+
};
|
|
1718
|
+
let linked = false;
|
|
1719
|
+
try {
|
|
1720
|
+
if (linkArg) {
|
|
1721
|
+
const r = await bridge.claim(relayUrl, id, linkArg);
|
|
1722
|
+
console.log(`\nlinked: this agent is now bound to LINE user ${r.userId}… (they got a ✅ in LINE). From now on their /a arrives in can2cup_wait; answer with can2cup_tell_principal.`);
|
|
1723
|
+
linked = true;
|
|
1724
|
+
// v0.9.14: --idle-days N|forever — the binding's lifetime (v0.9.12), set right here instead of a second command.
|
|
1725
|
+
const idleArg = flag("idle-days");
|
|
1726
|
+
if (idleArg) {
|
|
1727
|
+
try {
|
|
1728
|
+
const kr = await bridge.keep(relayUrl, id, idleArg === "forever" ? { forever: true } : { days: Number(idleArg) });
|
|
1729
|
+
console.log(kr.idle.forever ? "idle expiry: never — you asked for forever (remember /setup again when you change computers)." : `idle expiry: after ${kr.idle.days} days of agent absence (can2cup keep to change).`);
|
|
1730
|
+
}
|
|
1731
|
+
catch (e) {
|
|
1732
|
+
console.error(`--idle-days: ${e instanceof Error ? e.message : e} — later: can2cup keep <days>|forever`);
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
else {
|
|
1737
|
+
const l = await freshLinkDetails();
|
|
1738
|
+
if (l.url) {
|
|
1739
|
+
console.log(`\nLINE: scan this with your phone (valid ${l.minutes} min) — or send /link ${l.code} to the bot yourself.\n${l.url}\nPNG: ${l.qrPng}\n`);
|
|
1740
|
+
console.log(await QRCode.toString(l.url, { type: "terminal", small: true }));
|
|
1741
|
+
}
|
|
1742
|
+
else
|
|
1743
|
+
console.log(`\nLINE: send /link ${l.code} to the bot within ${l.minutes} min (optional).`);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
catch (e) {
|
|
1747
|
+
console.error(`\nLINE binding failed: ${e instanceof Error ? e.message : e}`);
|
|
1748
|
+
if (linkArg) {
|
|
1749
|
+
// A /setup code can expire while Node/npm is being installed. Do not send a fresh user back
|
|
1750
|
+
// through another copy/paste loop: mint an agent-side code and show the one-scan QR fallback.
|
|
1751
|
+
try {
|
|
1752
|
+
const l = await freshLinkDetails();
|
|
1753
|
+
if (l.url) {
|
|
1754
|
+
console.log(`\nQR FALLBACK: scan this once with your phone (valid ${l.minutes} min). LINE opens the bot chat with \"/link ${l.code}\" typed; tap send.\n${l.url}\nPNG: ${l.qrPng}\n`);
|
|
1755
|
+
console.log(await QRCode.toString(l.url, { type: "terminal", small: true }));
|
|
1756
|
+
}
|
|
1757
|
+
else
|
|
1758
|
+
console.log(`\nQR unavailable. Send /link ${l.code} to the LINE bot within ${l.minutes} min.`);
|
|
1759
|
+
}
|
|
1760
|
+
catch (fallbackError) {
|
|
1761
|
+
console.error(`QR fallback failed: ${fallbackError instanceof Error ? fallbackError.message : fallbackError} — later run: can2cup link`);
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
else
|
|
1765
|
+
console.error("Later run: can2cup link");
|
|
1766
|
+
}
|
|
1767
|
+
// Duty starts now, not "when the human remembers": the first thing a fresh principal does is
|
|
1768
|
+
// type "/a 你好" in LINE, and nothing answers unless something is polling the inbox.
|
|
1769
|
+
// A detached daemon would have nobody to hand the message to — the consumer is the agent
|
|
1770
|
+
// that is running this very command, so tell it plainly.
|
|
1771
|
+
console.log(`
|
|
1772
|
+
AGENT (the assistant running this setup): go on duty NOW.
|
|
1773
|
+
• run can2cup watch in a BACKGROUND shell (Claude Code: Bash with run_in_background). It sweeps the
|
|
1774
|
+
principal inbox${inviteArg ? " and the room" : ""} every 25 s at zero token cost and returns only when something real arrives.
|
|
1775
|
+
• when it returns, act on what it printed (a /a instruction -> do it, reply with can2cup tell "…"), then re-arm the watch.
|
|
1776
|
+
• ${linked ? "the principal is bound: expect their first /a within minutes." : "once the principal scans the QR, their /a lands there."}`);
|
|
1777
|
+
if (inviteArg) {
|
|
1778
|
+
try {
|
|
1779
|
+
const c = await core();
|
|
1780
|
+
const o = await c.opJoin(inviteArg);
|
|
1781
|
+
const room = /joined room ([0-9a-f]{12})/.exec(o.blocks[0]?.text ?? "")?.[1];
|
|
1782
|
+
console.log(`\n${o.blocks[0]?.text.split("\n")[0] ?? "joined"}\n→ now keep the agent on duty: can2cup wait ${room ?? "<room>"} (or can2cup_wait once Claude Code restarts)`);
|
|
1783
|
+
}
|
|
1784
|
+
catch (e) {
|
|
1785
|
+
console.error(`\njoin failed: ${e instanceof Error ? e.message : e} — you can retry with: can2cup join "<link>"`);
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
main().catch((e) => { console.error(e instanceof Error ? e.message : e); process.exit(1); });
|