baychat 0.8.1 → 0.9.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/README.md CHANGED
@@ -66,6 +66,10 @@ per session, never one that another integration already uses.
66
66
  | `baychat search <query> [--limit <n>]` | Search the web through BayChat — ranked results with title, URL, and snippet (see [Tools](#tools)) |
67
67
  | `baychat fetch <url> [--max-chars <n>]` | Fetch one public `http(s)` page through BayChat and print its readable text (see [Tools](#tools)) |
68
68
  | `baychat watch <conv> [--interval <sec>] [--timeout <sec>]` | Block until new messages arrive (exit 0) or timeout (exit 2) |
69
+ | `baychat relay start [--foreground]` | Run the **relay**: one long-poll for this whole machine that wakes local sessions the moment a message arrives. Installs a systemd user unit so it returns after a reboot (see [Relay](#relay)) |
70
+ | `baychat relay status` | Sessions, cursor, and any **delivery pending** — messages that reached this box and that nothing answered (exit 2 if any) |
71
+ | `baychat relay stop` | Stop the relay and disable it at boot |
72
+ | `baychat relay attach --session <name> [--runtime claude\|codex\|hermes] [--resume-id <id>] [--conversation <id>] [--timeout <sec>]` | Register this session with the relay and block until it is woken (exit 0) or the wait lapses (exit 2) |
69
73
  | `baychat mcp` | Run a local **stdio MCP server** so MCP-aware clients (Claude Desktop, Claude Code, Cursor) get BayChat as native tools (see below) |
70
74
  | `baychat mcp-config [--client codex\|cursor\|desktop]` | Print a paste-ready config that points another MCP client at the **remote** BayChat server. No `--client` lists what's supported (see [Other MCP clients](#other-mcp-clients)) |
71
75
 
@@ -77,6 +81,45 @@ returning agent needs, in one command.
77
81
  `check`/`watch` skip your own and deleted messages. The first `check` on a
78
82
  conversation anchors its cursor to *now* (no history dump).
79
83
 
84
+ ## Relay
85
+
86
+ A Claude Code or Codex session has **no background listener**. It runs when a
87
+ human prompts it, so a message sent from a phone sits unread until someone
88
+ happens to type in the terminal — even though the server delivered it instantly
89
+ and flagged it correctly. WebSockets do not fix this: a socket still needs a
90
+ process holding it, and that process still has to wake the session.
91
+
92
+ `baychat relay` is that process.
93
+
94
+ ```
95
+ baychat relay start # systemd user unit, survives reboot
96
+ baychat relay attach --session my-sess --conversation <conv>
97
+ ```
98
+
99
+ `attach` blocks until a message lands, prints it, and **exits 0**. That exit is
100
+ the wake — a harness that launched it in the background re-invokes the session
101
+ with its full context. Run it as a background process from your session and you
102
+ get near-instant delivery.
103
+
104
+ When no session is attached, the relay falls back to a **headless resume**
105
+ (`claude -p --resume`, `codex exec resume`) if it has a resume id for that
106
+ session. Pass `--resume-id` on attach so it can.
107
+
108
+ Three things it will not do:
109
+
110
+ - **It never decides who replies.** The wake carries the messages and tells the
111
+ session to re-check `shouldRespond` itself. Reply policy stays server-side.
112
+ - **It never runs two turns for one session at once.** An interactive wake and a
113
+ headless resume answering the same room as the same identity is the failure
114
+ mode; a per-session lock makes it impossible. Messages arriving mid-turn
115
+ coalesce into one follow-up batch.
116
+ - **It never claims an answer it cannot evidence.** No resume id, a self-hosted
117
+ runtime with no local resume, or a headless turn that exited non-zero — all
118
+ are recorded as `DELIVERY PENDING` and shown by `relay status`, which exits 2.
119
+
120
+ `baychat connect` installs and starts the relay for you. Set
121
+ `BAYCHAT_NO_RELAY_AUTOSTART=1` to opt out.
122
+
80
123
  ## Group instructions
81
124
 
82
125
  Group conversations carry a short, server-authored **primer** — who's in the
@@ -0,0 +1,149 @@
1
+ "use strict";
2
+ // Writing BayChat into a client's MCP config file, in place, without breaking it.
3
+ //
4
+ // `baychat mcp-config` PRINTS a config and asks the user to paste it. That is not
5
+ // an install: it demands the user know what TOML is, where their client keeps its
6
+ // config, and which half of the printed text is the part to paste. `baychat
7
+ // connect` writes the file itself, and this module is the part that touches it.
8
+ //
9
+ // THE ONE RULE: NEVER DESTROY WHAT IS ALREADY THERE. These files hold the user's
10
+ // other MCP servers, their editor settings, and in Codex's case a lot more
11
+ // besides. A naive write costs someone their whole configuration, and they will
12
+ // discover it later, in another tool, with no idea what did it. Everything below
13
+ // exists to make that impossible:
14
+ //
15
+ // - The merge is a SPLICE, not a rewrite: exactly the `baychat` server entry is
16
+ // replaced, byte-for-byte around it.
17
+ // - It is IDEMPOTENT: running connect twice replaces the block, never appends a
18
+ // second one (a duplicate `[mcp_servers.baychat]` makes the file invalid TOML,
19
+ // so an append-only implementation would break the client on the second run).
20
+ // - JSON is parsed and re-serialised; a file that does not parse is REFUSED
21
+ // rather than overwritten, because the only safe response to "I do not
22
+ // understand this file" is to keep your hands off it.
23
+ // - Callers back up first (`backupPathFor`), so there is always a way back.
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.mergeTomlConfig = mergeTomlConfig;
26
+ exports.mergeJsonConfig = mergeJsonConfig;
27
+ exports.mergeClientConfig = mergeClientConfig;
28
+ exports.backupPathFor = backupPathFor;
29
+ exports.expandHome = expandHome;
30
+ /**
31
+ * The TOML table header this owns, and the only span it may replace.
32
+ *
33
+ * Sub-tables (`[mcp_servers.baychat.env]`) belong to the same server and are
34
+ * replaced with it; a header for any OTHER server ends the span.
35
+ */
36
+ const TOML_TABLE = "mcp_servers.baychat";
37
+ /** Matches a top-level TOML table header line, capturing its dotted key. */
38
+ const TOML_HEADER = /^\s*\[\s*([^\]]+?)\s*\]\s*$/;
39
+ /**
40
+ * Splice the BayChat server block into an existing TOML file.
41
+ *
42
+ * @param existing current file content, or null when the file does not exist yet
43
+ * @param body the `[mcp_servers.baychat]` block to install
44
+ */
45
+ function mergeTomlConfig(existing, body) {
46
+ const block = body.trimEnd();
47
+ if (existing === null || existing.trim() === "") {
48
+ return { content: `${block}\n`, action: "created" };
49
+ }
50
+ const lines = existing.split("\n");
51
+ let start = -1;
52
+ let end = lines.length;
53
+ for (let i = 0; i < lines.length; i += 1) {
54
+ const match = TOML_HEADER.exec(lines[i] ?? "");
55
+ if (!match)
56
+ continue;
57
+ const key = (match[1] ?? "").replace(/\s+/g, "");
58
+ const ours = key === TOML_TABLE || key.startsWith(`${TOML_TABLE}.`);
59
+ if (start === -1) {
60
+ if (ours)
61
+ start = i;
62
+ continue;
63
+ }
64
+ // Inside our span: a header that is not ours closes it.
65
+ if (!ours) {
66
+ end = i;
67
+ break;
68
+ }
69
+ }
70
+ if (start === -1) {
71
+ // Not present — append, keeping exactly one blank line as a separator so the
72
+ // result is readable whether or not the file ended with a newline.
73
+ const prefix = existing.endsWith("\n") ? existing.replace(/\n+$/, "\n") : `${existing}\n`;
74
+ return { content: `${prefix}\n${block}\n`, action: "appended" };
75
+ }
76
+ const before = lines.slice(0, start);
77
+ const after = lines.slice(end);
78
+ const merged = [...before, ...block.split("\n"), ...after].join("\n");
79
+ return { content: merged.endsWith("\n") ? merged : `${merged}\n`, action: "updated" };
80
+ }
81
+ /**
82
+ * Merge the BayChat server entry into a JSON MCP config (Cursor, Claude Desktop).
83
+ *
84
+ * @throws when the existing file is present but not valid JSON, or is not a JSON
85
+ * object. Overwriting in that case would silently discard whatever the user had —
86
+ * including a file that is merely mid-edit. Refusing lets the CLI say which file
87
+ * to look at.
88
+ */
89
+ function mergeJsonConfig(existing, body) {
90
+ // `body` is ours, produced by buildClientConfig — a parse failure here is a bug
91
+ // in this package, not user input, so it is allowed to throw plainly.
92
+ const incoming = JSON.parse(body);
93
+ const entry = incoming.mcpServers?.baychat;
94
+ if (entry === undefined) {
95
+ throw new Error("Generated config has no mcpServers.baychat entry — this is a bug.");
96
+ }
97
+ if (existing === null || existing.trim() === "") {
98
+ return { content: `${JSON.stringify(incoming, null, 2)}\n`, action: "created" };
99
+ }
100
+ let parsed;
101
+ try {
102
+ parsed = JSON.parse(existing);
103
+ }
104
+ catch {
105
+ throw new Error("Your existing config is not valid JSON, so it was left untouched. Fix or move it, then run this again.");
106
+ }
107
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
108
+ throw new Error("Your existing config is not a JSON object, so it was left untouched. Fix or move it, then run this again.");
109
+ }
110
+ const current = parsed;
111
+ const servers = { ...(current.mcpServers ?? {}) };
112
+ const had = Object.prototype.hasOwnProperty.call(servers, "baychat");
113
+ servers.baychat = entry;
114
+ const merged = { ...current, mcpServers: servers };
115
+ return {
116
+ content: `${JSON.stringify(merged, null, 2)}\n`,
117
+ action: had ? "updated" : "appended",
118
+ };
119
+ }
120
+ /** Merge by dialect. Keeps the format decision in one place. */
121
+ function mergeClientConfig(config, existing) {
122
+ return config.format === "toml"
123
+ ? mergeTomlConfig(existing, config.body)
124
+ : mergeJsonConfig(existing, config.body);
125
+ }
126
+ /**
127
+ * Where to copy a file before rewriting it.
128
+ *
129
+ * Deliberately a FIXED name rather than a timestamp: a user who runs connect ten
130
+ * times should end up with one backup they can find, not ten they have to date.
131
+ * The pre-edit state is the only one worth keeping.
132
+ */
133
+ function backupPathFor(path) {
134
+ return `${path}.baychat-backup`;
135
+ }
136
+ /**
137
+ * Expand a leading `~` to the given home directory.
138
+ *
139
+ * The dialect table stores display paths (`~/.codex/config.toml`) because that is
140
+ * what a human reads; writing needs a real path. Only a LEADING `~/` is expanded —
141
+ * a tilde anywhere else is a legitimate filename character.
142
+ */
143
+ function expandHome(path, home) {
144
+ if (path === "~")
145
+ return home;
146
+ if (path.startsWith("~/"))
147
+ return `${home}/${path.slice(2)}`;
148
+ return path;
149
+ }
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ // Where each MCP client actually keeps its config on disk.
3
+ //
4
+ // WHY THIS IS NOT `CLIENT_FILES`. That table in `mcp-dialects.ts` holds strings
5
+ // written for a human to read — `"~/.cursor/mcp.json (or a project
6
+ // .cursor/mcp.json)"`, `"claude_desktop_config.json (Settings → Developer → Edit
7
+ // Config)"`. Perfect for a printed instruction, useless for `writeFileSync`: one
8
+ // carries a parenthetical, the other is not a path at all. Treating them as paths
9
+ // would have created a file literally named `claude_desktop_config.json (Settings
10
+ // → Developer → Edit Config)` in the working directory, and the user's real
11
+ // config would never have been touched — an install that reports success and
12
+ // changes nothing.
13
+ //
14
+ // So writing gets its own table, resolved per platform, and returns null when we
15
+ // genuinely do not know. Guessing a path is worse than declining: a wrong guess
16
+ // writes a file no client reads, and the user is left debugging a config that
17
+ // looks correct.
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.configPathFor = configPathFor;
20
+ exports.currentPathEnv = currentPathEnv;
21
+ exports.needsRestart = needsRestart;
22
+ /**
23
+ * Claude Desktop's config location, which is the only genuinely
24
+ * platform-dependent one of the three.
25
+ *
26
+ * Returns null on an unrecognised platform, and on Windows without `APPDATA` —
27
+ * there is no sound fallback for either, and inventing one writes a file nothing
28
+ * will read.
29
+ */
30
+ function claudeDesktopPath(env) {
31
+ if (env.platform === "darwin") {
32
+ return `${env.home}/Library/Application Support/Claude/claude_desktop_config.json`;
33
+ }
34
+ if (env.platform === "win32") {
35
+ if (!env.appData)
36
+ return null;
37
+ return `${env.appData}\\Claude\\claude_desktop_config.json`;
38
+ }
39
+ if (env.platform === "linux") {
40
+ const base = env.xdgConfigHome && env.xdgConfigHome.trim() !== ""
41
+ ? env.xdgConfigHome
42
+ : `${env.home}/.config`;
43
+ return `${base}/Claude/claude_desktop_config.json`;
44
+ }
45
+ return null;
46
+ }
47
+ /**
48
+ * The absolute config path to write for a client, or null when it cannot be
49
+ * determined on this platform.
50
+ *
51
+ * Codex and Cursor keep theirs in the home directory on every platform. Cursor
52
+ * also supports a per-project `.cursor/mcp.json`; the global file is chosen
53
+ * deliberately, because `connect` is establishing this machine's identity, not
54
+ * one repository's.
55
+ */
56
+ function configPathFor(client, env) {
57
+ switch (client) {
58
+ case "codex":
59
+ return `${env.home}/.codex/config.toml`;
60
+ case "cursor":
61
+ return `${env.home}/.cursor/mcp.json`;
62
+ case "desktop":
63
+ return claudeDesktopPath(env);
64
+ }
65
+ }
66
+ /** Read the path environment from the current process. */
67
+ function currentPathEnv() {
68
+ return {
69
+ home: process.env.HOME || process.env.USERPROFILE || "",
70
+ platform: process.platform,
71
+ appData: process.env.APPDATA,
72
+ xdgConfigHome: process.env.XDG_CONFIG_HOME,
73
+ };
74
+ }
75
+ /**
76
+ * Whether this client needs a restart to pick up a changed config.
77
+ *
78
+ * Only stated where it is true, so the instruction carries weight. A CLI that
79
+ * tells everyone to restart trains them to ignore it — and Codex and Cursor read
80
+ * their MCP config per session, so most users need nothing.
81
+ */
82
+ function needsRestart(client) {
83
+ return client === "desktop";
84
+ }
package/dist/commands.js CHANGED
@@ -651,8 +651,10 @@ async function cmdLogin(opts = {}) {
651
651
  expiresAt: me.expiresAt,
652
652
  });
653
653
  console.log(`✓ Logged in as ${me.user.name} (${me.tenant.name})`);
654
- registerWithClaude(base, opts.token);
655
- console.log("\n Run /baychat <name> in any session.");
654
+ if (opts.registerClaude !== false)
655
+ registerWithClaude(base, opts.token);
656
+ if (opts.hint !== false)
657
+ console.log("\n Run /baychat <name> in any session.");
656
658
  return true;
657
659
  }
658
660
  // The hostname labels this laptop in the approve UI; the server caps the field
@@ -704,8 +706,10 @@ async function cmdLogin(opts = {}) {
704
706
  expiresAt: status.expiresAt,
705
707
  });
706
708
  console.log(`✓ Logged in as ${status.user.name}`);
707
- registerWithClaude(base, status.token);
708
- console.log("\n Run /baychat <name> in any session.");
709
+ if (opts.registerClaude !== false)
710
+ registerWithClaude(base, status.token);
711
+ if (opts.hint !== false)
712
+ console.log("\n Run /baychat <name> in any session.");
709
713
  return true;
710
714
  }
711
715
  }
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ // What `baychat connect <client>` should DO, decided before anything is done.
3
+ //
4
+ // ONE LOGIN PER LAPTOP, THEN NOTHING. `connect` shows the device-link QR once,
5
+ // writes the DEVICE credential into the client's MCP config, and stops. Every
6
+ // coding session afterwards joins a room by itself:
7
+ //
8
+ // /baychat <name> -> join_session({ session: "<name>" })
9
+ //
10
+ // which mints (or reattaches) that session's own agent and puts it in a chat.
11
+ // Same name, same agent, same history.
12
+ //
13
+ // WHY THE DEVICE CREDENTIAL AND NOT AN AGENT TOKEN. An agent token pins the
14
+ // client to ONE pre-made agent in ONE room, so every new session would need
15
+ // another trip to the phone for another code — a recurring chore dressed up as an
16
+ // install. The device credential represents the PERSON, and the session tools it
17
+ // unlocks let each session create its own identity on demand. That is the whole
18
+ // difference between setting something up once and setting it up forever.
19
+ //
20
+ // Agent tokens and pairing codes are not gone; they are the path for EXTERNAL and
21
+ // LEGACY runtimes (a self-hosted gateway, anything driving the Agent API itself),
22
+ // which have no MCP client to configure and no session concept. `baychat pair`
23
+ // still serves them.
24
+ //
25
+ // The two halves people conflate remain distinct, and this module keeps them so:
26
+ //
27
+ // 1. CONNECT THIS LAPTOP - a device credential. Authorising a machine to act as
28
+ // you, the way WhatsApp Web authorises a browser. Creates NO agent and puts
29
+ // nobody in a room. Lives under Account -> Devices in the app.
30
+ // 2. JOIN A SESSION - `/baychat <name>` inside the client, per session. This is
31
+ // what actually ends with something able to speak, and it needs no QR, no
32
+ // code, and no pre-created agent.
33
+ //
34
+ // Steps are computed from observed state here, as data, and the executor only
35
+ // carries them out - so the sequencing is testable without a network, a terminal,
36
+ // or a phone.
37
+ Object.defineProperty(exports, "__esModule", { value: true });
38
+ exports.deviceStateFrom = deviceStateFrom;
39
+ exports.planConnect = planConnect;
40
+ exports.planNeedsPhoneApproval = planNeedsPhoneApproval;
41
+ exports.expiryNudge = expiryNudge;
42
+ exports.describeConnection = describeConnection;
43
+ const THREE_DAYS_MS = 3 * 86_400_000;
44
+ /** Read the on-disk credential into a state, without judging what to do about it. */
45
+ function deviceStateFrom(device, now = Date.now()) {
46
+ if (!device)
47
+ return { kind: "absent" };
48
+ const expires = new Date(device.expiresAt).getTime();
49
+ // An unparseable expiry is treated as expired. Treating it as live would send
50
+ // the user into a flow that fails at the first authenticated call, with an
51
+ // error that points at the server rather than at their credential.
52
+ if (Number.isNaN(expires) || expires <= now) {
53
+ return { kind: "expired", userName: device.user.name };
54
+ }
55
+ return { kind: "live", userName: device.user.name, msLeft: expires - now };
56
+ }
57
+ /**
58
+ * The ordered steps for one `connect` run.
59
+ *
60
+ * `needsRestart` is injected rather than imported so the sequencing can be tested
61
+ * against both answers without reaching for a specific client.
62
+ */
63
+ function planConnect(input) {
64
+ const { client, device, needsRestart } = input;
65
+ const steps = [];
66
+ if (device.kind === "absent" || device.kind === "expired") {
67
+ steps.push({ kind: "device-login", reason: device.kind });
68
+ }
69
+ else {
70
+ steps.push({ kind: "device-ok", userName: device.userName, msLeft: device.msLeft });
71
+ }
72
+ // No code, no room, no agent. The config carries the device credential, and the
73
+ // session tools it unlocks let `/baychat <name>` do the rest, per session.
74
+ steps.push({ kind: "write-config", client });
75
+ if (needsRestart)
76
+ steps.push({ kind: "restart-note", client });
77
+ return steps;
78
+ }
79
+ /** Whether the plan will ask the user to approve a QR on their phone. */
80
+ function planNeedsPhoneApproval(steps) {
81
+ return steps.some((s) => s.kind === "device-login");
82
+ }
83
+ /** The expiry nudge for a live credential, or null when it is not due yet. */
84
+ function expiryNudge(device) {
85
+ if (device.kind !== "live")
86
+ return null;
87
+ if (device.msLeft >= THREE_DAYS_MS)
88
+ return null;
89
+ const hours = Math.max(1, Math.round(device.msLeft / 3_600_000));
90
+ return `Your laptop login expires in about ${hours}h — run \`npx baychat login\` to renew.`;
91
+ }
92
+ /**
93
+ * Describe the finished state.
94
+ *
95
+ * The note is the one thing a user cannot infer from a successful install: the
96
+ * command connected a LAPTOP, and nothing is in a room yet. Without that line the
97
+ * obvious reading of "✓ done" is that an agent is now listening somewhere, and the
98
+ * user waits for a reply that was never going to come.
99
+ *
100
+ * Revocation is coherent under this design, which is a reason to prefer it:
101
+ * revoking the laptop under Account → Devices cuts every session it opened,
102
+ * because they all speak through its credential.
103
+ */
104
+ function describeConnection(input) {
105
+ const { device, agentName, groupTitle } = input;
106
+ const laptop = device.kind === "live"
107
+ ? `connected as ${device.userName} (${Math.floor(device.msLeft / 86_400_000)}d left)`
108
+ : device.kind === "expired"
109
+ ? `login expired — run \`npx baychat connect\` again`
110
+ : "not connected";
111
+ const agent = agentName === null
112
+ ? "none yet — run /baychat <name> in a session"
113
+ : groupTitle
114
+ ? `${agentName} in ${groupTitle}`
115
+ : agentName;
116
+ return {
117
+ laptop,
118
+ agent,
119
+ note: "Each session makes its own agent: run /baychat <name> inside the client. " +
120
+ "The same name always reattaches to the same agent and history.",
121
+ };
122
+ }
@@ -0,0 +1,219 @@
1
+ "use strict";
2
+ // `baychat connect <client>` — one command, once per laptop.
3
+ //
4
+ // WHAT IT DOES. Shows the device-link QR, waits for the phone to approve it, and
5
+ // writes the DEVICE credential into the client's MCP config. Then it stops.
6
+ //
7
+ // WHAT IT DELIBERATELY DOES NOT DO. Ask for a code. Ask which room. Create an
8
+ // agent. All three used to be here, and all three were the same mistake: they
9
+ // bound the client to one pre-made agent in one conversation, so every new
10
+ // coding session needed another trip to the phone. Setup you repeat is not setup.
11
+ //
12
+ // Instead, each session names itself, inside the client:
13
+ //
14
+ // /baychat <name> -> join_session({ session: "<name>" })
15
+ //
16
+ // which mints or reattaches that session's own agent and puts it in a chat. The
17
+ // session tools that make this possible are exactly what a device credential
18
+ // unlocks — which is why the config carries that credential and not an agent
19
+ // token.
20
+ //
21
+ // Pairing codes still exist, for EXTERNAL and LEGACY runtimes: a self-hosted
22
+ // gateway, or anything driving the Agent API directly. Those have no MCP client
23
+ // to configure and no session concept, so `baychat pair <code>` remains their
24
+ // path. It is not this one.
25
+ //
26
+ // FILE SAFETY. This edits config a user already has, so the merge is a splice
27
+ // rather than a rewrite, it is idempotent, unparseable JSON is refused rather
28
+ // than overwritten, the previous file is backed up first, and the result is 0600
29
+ // because it holds a bearer token. See client-config-writer.ts.
30
+ var __importDefault = (this && this.__importDefault) || function (mod) {
31
+ return (mod && mod.__esModule) ? mod : { "default": mod };
32
+ };
33
+ Object.defineProperty(exports, "__esModule", { value: true });
34
+ exports.renderConnectMenu = renderConnectMenu;
35
+ exports.parseConnectClient = parseConnectClient;
36
+ exports.writeClientConfig = writeClientConfig;
37
+ exports.cmdConnect = cmdConnect;
38
+ const node_fs_1 = __importDefault(require("node:fs"));
39
+ const node_path_1 = __importDefault(require("node:path"));
40
+ const client_config_writer_1 = require("./client-config-writer");
41
+ const client_paths_1 = require("./client-paths");
42
+ const commands_1 = require("./commands");
43
+ const config_1 = require("./config");
44
+ const connect_plan_1 = require("./connect-plan");
45
+ const mcp_dialects_1 = require("./mcp-dialects");
46
+ const commands_2 = require("./relay/commands");
47
+ /** Clients `connect` can configure. `claude` is an alias users reach for. */
48
+ const CLIENT_ALIASES = {
49
+ codex: "codex",
50
+ cursor: "cursor",
51
+ desktop: "desktop",
52
+ "claude-desktop": "desktop",
53
+ };
54
+ /** What `baychat connect` prints with no client: the menu. Names no credential. */
55
+ function renderConnectMenu() {
56
+ const rows = mcp_dialects_1.MCP_CLIENTS.map((c) => ` npx baychat connect ${c.padEnd(8)}→ ${mcp_dialects_1.CLIENT_LABELS[c]}`);
57
+ return [
58
+ "Connect this laptop to BayChat, once, and configure your client:",
59
+ "",
60
+ ...rows,
61
+ "",
62
+ "You approve a QR on your phone. After that, in any coding session:",
63
+ "",
64
+ " /baychat <name> join BayChat as a session called <name>",
65
+ "",
66
+ "Claude Code needs no config — `npx baychat login` sets it up for you.",
67
+ ].join("\n");
68
+ }
69
+ /**
70
+ * Narrow a client argument.
71
+ *
72
+ * @throws on anything unsupported, listing what is supported. A typo must not
73
+ * fall through to a default and configure the wrong client.
74
+ */
75
+ function parseConnectClient(value) {
76
+ const match = CLIENT_ALIASES[value.trim().toLowerCase()];
77
+ if (!match) {
78
+ throw new Error(`Unknown client "${value}" — supported: ${mcp_dialects_1.MCP_CLIENTS.join(", ")}. ` +
79
+ "Claude Code is registered automatically by `npx baychat login`.");
80
+ }
81
+ return match;
82
+ }
83
+ /** Write the client config, backing up whatever was there first. */
84
+ function writeClientConfig(client, endpoint, io, env = (0, client_paths_1.currentPathEnv)()) {
85
+ const target = (0, client_paths_1.configPathFor)(client, env);
86
+ if (target === null) {
87
+ throw new Error(`Could not work out where ${mcp_dialects_1.CLIENT_LABELS[client]} keeps its config on this platform. ` +
88
+ "Run `npx baychat mcp-config --client " +
89
+ client +
90
+ "` and paste it yourself.");
91
+ }
92
+ const config = (0, mcp_dialects_1.buildClientConfig)(client, endpoint);
93
+ const existing = io.readFile(target);
94
+ const merged = (0, client_config_writer_1.mergeClientConfig)(config, existing);
95
+ // Back up BEFORE writing, and only when there was something to lose. A user who
96
+ // discovers a surprise in their config later needs a way back that does not
97
+ // depend on them having made one.
98
+ if (existing !== null && existing.trim() !== "") {
99
+ io.copyFile(target, (0, client_config_writer_1.backupPathFor)(target));
100
+ }
101
+ io.mkdirp(node_path_1.default.dirname(target));
102
+ io.writeFile(target, merged.content);
103
+ return { path: target, action: merged.action };
104
+ }
105
+ /** Real filesystem IO for `writeClientConfig`. */
106
+ const realIo = {
107
+ readFile: (p) => {
108
+ try {
109
+ return node_fs_1.default.readFileSync(p, "utf8");
110
+ }
111
+ catch (err) {
112
+ // ENOENT is the ordinary "first run" case. Anything else — a permission
113
+ // problem, a directory where a file should be — must not be silently read
114
+ // as "no config", because that would overwrite a file we simply could not
115
+ // open.
116
+ if (err.code === "ENOENT")
117
+ return null;
118
+ throw err;
119
+ }
120
+ },
121
+ writeFile: (p, content) => {
122
+ // 0600: the file holds a bearer token.
123
+ node_fs_1.default.writeFileSync(p, content, { mode: 0o600 });
124
+ },
125
+ copyFile: (from, to) => node_fs_1.default.copyFileSync(from, to),
126
+ mkdirp: (dir) => {
127
+ node_fs_1.default.mkdirSync(dir, { recursive: true, mode: 0o700 });
128
+ },
129
+ };
130
+ /**
131
+ * `baychat connect <client>`.
132
+ *
133
+ * @returns 0 on success, 2 when the laptop login expired without approval.
134
+ */
135
+ async function cmdConnect(clientArg, opts = {}) {
136
+ if (clientArg === undefined) {
137
+ console.log(renderConnectMenu());
138
+ return 0;
139
+ }
140
+ const client = parseConnectClient(clientArg);
141
+ const base = (opts.base || process.env.BAYCHAT_API_URL || config_1.DEFAULT_API_URL).replace(/\/$/, "");
142
+ const steps = (0, connect_plan_1.planConnect)({
143
+ client,
144
+ device: (0, connect_plan_1.deviceStateFrom)((0, config_1.loadDeviceCredentials)()),
145
+ needsRestart: (0, client_paths_1.needsRestart)(client),
146
+ });
147
+ for (const step of steps) {
148
+ const outcome = await runStep(step, { base, client });
149
+ if (outcome.kind === "abort")
150
+ return outcome.code;
151
+ }
152
+ const summary = (0, connect_plan_1.describeConnection)({
153
+ device: (0, connect_plan_1.deviceStateFrom)((0, config_1.loadDeviceCredentials)()),
154
+ agentName: null,
155
+ groupTitle: null,
156
+ });
157
+ console.log("");
158
+ console.log(` Laptop: ${summary.laptop}`);
159
+ console.log(` Sessions: ${summary.agent}`);
160
+ console.log("");
161
+ console.log(` ${summary.note}`);
162
+ // The relay is what makes a joined session hear messages without being
163
+ // prompted. Best-effort by design: it reports what it did (or didn't) and
164
+ // never fails the connect it is tacked onto.
165
+ console.log(` ${await (0, commands_2.ensureRelayInstalled)()}`);
166
+ return 0;
167
+ }
168
+ async function runStep(step, ctx) {
169
+ switch (step.kind) {
170
+ case "device-login": {
171
+ console.log(step.reason === "expired"
172
+ ? "Your laptop login has expired. Reconnecting this laptop…\n"
173
+ : "Connecting this laptop — approve the QR on your phone.\n");
174
+ // registerClaude is left ON only for the desktop/claude clients; for any
175
+ // other client, editing Claude's config unasked is a surprise.
176
+ const ok = await (0, commands_1.cmdLogin)({
177
+ base: ctx.base,
178
+ registerClaude: ctx.client === "desktop",
179
+ hint: false,
180
+ });
181
+ if (!ok) {
182
+ console.log("\nLaptop not connected — run `npx baychat connect` again when ready.");
183
+ return { kind: "abort", code: 2 };
184
+ }
185
+ console.log("✓ Laptop connected.\n");
186
+ return { kind: "ok" };
187
+ }
188
+ case "device-ok": {
189
+ console.log(`✓ Laptop already connected as ${step.userName}.`);
190
+ const nudge = (0, connect_plan_1.expiryNudge)({ kind: "live", userName: step.userName, msLeft: step.msLeft });
191
+ if (nudge)
192
+ console.log(` ${nudge}`);
193
+ console.log("");
194
+ return { kind: "ok" };
195
+ }
196
+ case "write-config": {
197
+ // The DEVICE credential, deliberately — it is what unlocks the session tools
198
+ // (`join_session`, `list_sessions`, `end_session`) that let each session
199
+ // create its own identity. An agent token would pin this client to one
200
+ // agent in one room forever.
201
+ const device = (0, config_1.loadDeviceCredentials)();
202
+ if (!device) {
203
+ // Unreachable via planConnect, which logs in first; a guard rather than a
204
+ // comment because writing an empty bearer fails silently inside a client.
205
+ throw new Error("Internal: no device credential to write — login must run first.");
206
+ }
207
+ const { path: written, action } = writeClientConfig(step.client, { url: `${ctx.base}/api/mcp`, token: device.token }, realIo);
208
+ const verb = action === "created" ? "Created" : action === "updated" ? "Updated" : "Added to";
209
+ console.log(`✓ ${verb} ${written}`);
210
+ if (action !== "created") {
211
+ console.log(` A copy of the previous file is at ${(0, client_config_writer_1.backupPathFor)(written)}`);
212
+ }
213
+ return { kind: "ok" };
214
+ }
215
+ case "restart-note":
216
+ console.log(`\n Quit and reopen ${mcp_dialects_1.CLIENT_LABELS[step.client]} — it reads its config at startup.`);
217
+ return { kind: "ok" };
218
+ }
219
+ }