baychat 0.8.1 → 0.8.2

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.
@@ -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,214 @@
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
+ /** Clients `connect` can configure. `claude` is an alias users reach for. */
47
+ const CLIENT_ALIASES = {
48
+ codex: "codex",
49
+ cursor: "cursor",
50
+ desktop: "desktop",
51
+ "claude-desktop": "desktop",
52
+ };
53
+ /** What `baychat connect` prints with no client: the menu. Names no credential. */
54
+ function renderConnectMenu() {
55
+ const rows = mcp_dialects_1.MCP_CLIENTS.map((c) => ` npx baychat connect ${c.padEnd(8)}→ ${mcp_dialects_1.CLIENT_LABELS[c]}`);
56
+ return [
57
+ "Connect this laptop to BayChat, once, and configure your client:",
58
+ "",
59
+ ...rows,
60
+ "",
61
+ "You approve a QR on your phone. After that, in any coding session:",
62
+ "",
63
+ " /baychat <name> join BayChat as a session called <name>",
64
+ "",
65
+ "Claude Code needs no config — `npx baychat login` sets it up for you.",
66
+ ].join("\n");
67
+ }
68
+ /**
69
+ * Narrow a client argument.
70
+ *
71
+ * @throws on anything unsupported, listing what is supported. A typo must not
72
+ * fall through to a default and configure the wrong client.
73
+ */
74
+ function parseConnectClient(value) {
75
+ const match = CLIENT_ALIASES[value.trim().toLowerCase()];
76
+ if (!match) {
77
+ throw new Error(`Unknown client "${value}" — supported: ${mcp_dialects_1.MCP_CLIENTS.join(", ")}. ` +
78
+ "Claude Code is registered automatically by `npx baychat login`.");
79
+ }
80
+ return match;
81
+ }
82
+ /** Write the client config, backing up whatever was there first. */
83
+ function writeClientConfig(client, endpoint, io, env = (0, client_paths_1.currentPathEnv)()) {
84
+ const target = (0, client_paths_1.configPathFor)(client, env);
85
+ if (target === null) {
86
+ throw new Error(`Could not work out where ${mcp_dialects_1.CLIENT_LABELS[client]} keeps its config on this platform. ` +
87
+ "Run `npx baychat mcp-config --client " +
88
+ client +
89
+ "` and paste it yourself.");
90
+ }
91
+ const config = (0, mcp_dialects_1.buildClientConfig)(client, endpoint);
92
+ const existing = io.readFile(target);
93
+ const merged = (0, client_config_writer_1.mergeClientConfig)(config, existing);
94
+ // Back up BEFORE writing, and only when there was something to lose. A user who
95
+ // discovers a surprise in their config later needs a way back that does not
96
+ // depend on them having made one.
97
+ if (existing !== null && existing.trim() !== "") {
98
+ io.copyFile(target, (0, client_config_writer_1.backupPathFor)(target));
99
+ }
100
+ io.mkdirp(node_path_1.default.dirname(target));
101
+ io.writeFile(target, merged.content);
102
+ return { path: target, action: merged.action };
103
+ }
104
+ /** Real filesystem IO for `writeClientConfig`. */
105
+ const realIo = {
106
+ readFile: (p) => {
107
+ try {
108
+ return node_fs_1.default.readFileSync(p, "utf8");
109
+ }
110
+ catch (err) {
111
+ // ENOENT is the ordinary "first run" case. Anything else — a permission
112
+ // problem, a directory where a file should be — must not be silently read
113
+ // as "no config", because that would overwrite a file we simply could not
114
+ // open.
115
+ if (err.code === "ENOENT")
116
+ return null;
117
+ throw err;
118
+ }
119
+ },
120
+ writeFile: (p, content) => {
121
+ // 0600: the file holds a bearer token.
122
+ node_fs_1.default.writeFileSync(p, content, { mode: 0o600 });
123
+ },
124
+ copyFile: (from, to) => node_fs_1.default.copyFileSync(from, to),
125
+ mkdirp: (dir) => {
126
+ node_fs_1.default.mkdirSync(dir, { recursive: true, mode: 0o700 });
127
+ },
128
+ };
129
+ /**
130
+ * `baychat connect <client>`.
131
+ *
132
+ * @returns 0 on success, 2 when the laptop login expired without approval.
133
+ */
134
+ async function cmdConnect(clientArg, opts = {}) {
135
+ if (clientArg === undefined) {
136
+ console.log(renderConnectMenu());
137
+ return 0;
138
+ }
139
+ const client = parseConnectClient(clientArg);
140
+ const base = (opts.base || process.env.BAYCHAT_API_URL || config_1.DEFAULT_API_URL).replace(/\/$/, "");
141
+ const steps = (0, connect_plan_1.planConnect)({
142
+ client,
143
+ device: (0, connect_plan_1.deviceStateFrom)((0, config_1.loadDeviceCredentials)()),
144
+ needsRestart: (0, client_paths_1.needsRestart)(client),
145
+ });
146
+ for (const step of steps) {
147
+ const outcome = await runStep(step, { base, client });
148
+ if (outcome.kind === "abort")
149
+ return outcome.code;
150
+ }
151
+ const summary = (0, connect_plan_1.describeConnection)({
152
+ device: (0, connect_plan_1.deviceStateFrom)((0, config_1.loadDeviceCredentials)()),
153
+ agentName: null,
154
+ groupTitle: null,
155
+ });
156
+ console.log("");
157
+ console.log(` Laptop: ${summary.laptop}`);
158
+ console.log(` Sessions: ${summary.agent}`);
159
+ console.log("");
160
+ console.log(` ${summary.note}`);
161
+ return 0;
162
+ }
163
+ async function runStep(step, ctx) {
164
+ switch (step.kind) {
165
+ case "device-login": {
166
+ console.log(step.reason === "expired"
167
+ ? "Your laptop login has expired. Reconnecting this laptop…\n"
168
+ : "Connecting this laptop — approve the QR on your phone.\n");
169
+ // registerClaude is left ON only for the desktop/claude clients; for any
170
+ // other client, editing Claude's config unasked is a surprise.
171
+ const ok = await (0, commands_1.cmdLogin)({
172
+ base: ctx.base,
173
+ registerClaude: ctx.client === "desktop",
174
+ hint: false,
175
+ });
176
+ if (!ok) {
177
+ console.log("\nLaptop not connected — run `npx baychat connect` again when ready.");
178
+ return { kind: "abort", code: 2 };
179
+ }
180
+ console.log("✓ Laptop connected.\n");
181
+ return { kind: "ok" };
182
+ }
183
+ case "device-ok": {
184
+ console.log(`✓ Laptop already connected as ${step.userName}.`);
185
+ const nudge = (0, connect_plan_1.expiryNudge)({ kind: "live", userName: step.userName, msLeft: step.msLeft });
186
+ if (nudge)
187
+ console.log(` ${nudge}`);
188
+ console.log("");
189
+ return { kind: "ok" };
190
+ }
191
+ case "write-config": {
192
+ // The DEVICE credential, deliberately — it is what unlocks the session tools
193
+ // (`join_session`, `list_sessions`, `end_session`) that let each session
194
+ // create its own identity. An agent token would pin this client to one
195
+ // agent in one room forever.
196
+ const device = (0, config_1.loadDeviceCredentials)();
197
+ if (!device) {
198
+ // Unreachable via planConnect, which logs in first; a guard rather than a
199
+ // comment because writing an empty bearer fails silently inside a client.
200
+ throw new Error("Internal: no device credential to write — login must run first.");
201
+ }
202
+ const { path: written, action } = writeClientConfig(step.client, { url: `${ctx.base}/api/mcp`, token: device.token }, realIo);
203
+ const verb = action === "created" ? "Created" : action === "updated" ? "Updated" : "Added to";
204
+ console.log(`✓ ${verb} ${written}`);
205
+ if (action !== "created") {
206
+ console.log(` A copy of the previous file is at ${(0, client_config_writer_1.backupPathFor)(written)}`);
207
+ }
208
+ return { kind: "ok" };
209
+ }
210
+ case "restart-note":
211
+ console.log(`\n Quit and reopen ${mcp_dialects_1.CLIENT_LABELS[step.client]} — it reads its config at startup.`);
212
+ return { kind: "ok" };
213
+ }
214
+ }
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  "use strict";
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  const commands_1 = require("./commands");
5
+ const connect_1 = require("./connect");
5
6
  const mcp_1 = require("./mcp");
6
7
  const mcp_config_1 = require("./mcp-config");
7
8
  const HELP = `baychat — BayChat connector CLI for agent sessions (Claude Code, Codex)
@@ -12,9 +13,15 @@ Usage:
12
13
  live identity, conversations, and room context.
13
14
  --catch-up also appends the rolling summary +
14
15
  the messages after its boundary
16
+ baychat connect [codex|cursor|desktop] [--base <url>]
17
+ START HERE, once per laptop. Approve a QR on your
18
+ phone, and your client's MCP config is written for
19
+ you. Then run /baychat <name> in any session to
20
+ join as that session. No config blocks, no editing
15
21
  baychat login [--token <PAT>] [--base <url>]
16
22
  Log this laptop in to BayChat (QR) and add the
17
- BayChat MCP server to Claude Code
23
+ BayChat MCP server to Claude Code. Creates a
24
+ device credential only — no agent, no room
18
25
  baychat pair <code> [--base <url>] Redeem a pairing code from the BayChat app
19
26
  baychat link [--name <n>] [--base <url>]
20
27
  Link this session via a QR you scan with your phone
@@ -150,6 +157,11 @@ async function main() {
150
157
  });
151
158
  return got ? 0 : 2;
152
159
  }
160
+ case "connect": {
161
+ // A bare `connect` prints the client menu; positional() skips a leading flag
162
+ // so `connect --base x codex` still finds the client.
163
+ return await (0, connect_1.cmdConnect)(positional(args), { base: flag(args, "--base") });
164
+ }
153
165
  case "mcp-config": {
154
166
  // `--client` with no value is a typo, not a request for the menu: pass the
155
167
  // empty string so it is rejected by name rather than silently listing.
@@ -6,6 +6,13 @@
6
6
  // their client's config dialect, and dig the device token out of
7
7
  // ~/.baychat/credentials.json by hand. This module turns that into one command.
8
8
  //
9
+ // THE DIALECTS LIVE IN `./mcp-dialects`, not here — the mobile app renders the
10
+ // same configs on its "Connect an MCP client" screen, and a second hand-written
11
+ // copy would drift silently. That module imports no Node built-ins so it can be
12
+ // bundled by React Native. THIS file is the impure half: reading the credentials
13
+ // file, resolving the base url, and printing. Everything from `mcp-dialects` is
14
+ // re-exported below, so importers of this module see no difference.
15
+ //
9
16
  // Two rules shape everything below.
10
17
  //
11
18
  // 1. THE TOKEN IS A PASSWORD. It is read only when a config is actually being
@@ -17,23 +24,19 @@
17
24
  // usable token must produce an actionable error, not a config with an empty
18
25
  // bearer that fails later inside a GUI client with no visible reason.
19
26
  Object.defineProperty(exports, "__esModule", { value: true });
20
- exports.MCP_CLIENTS = void 0;
27
+ exports.renderClientList = exports.parseClient = exports.buildClientConfig = exports.MCP_CLIENTS = exports.CLIENT_LABELS = exports.CLIENT_FILES = exports.AUTH_ENV = void 0;
21
28
  exports.resolveMcpEndpoint = resolveMcpEndpoint;
22
- exports.parseClient = parseClient;
23
- exports.buildClientConfig = buildClientConfig;
24
- exports.renderClientList = renderClientList;
25
29
  exports.cmdMcpConfig = cmdMcpConfig;
26
30
  const config_1 = require("./config");
27
- exports.MCP_CLIENTS = ["codex", "cursor", "desktop"];
28
- /** The env var the stdio bridge expands the Authorization header from. */
29
- const AUTH_ENV = "BAYCHAT_AUTH_HEADER";
30
- /** Where each client keeps its MCP config. Named once so the menu and the
31
- * generated config can never disagree about where the paste goes. */
32
- const CLIENT_FILES = {
33
- codex: "~/.codex/config.toml",
34
- cursor: "~/.cursor/mcp.json (or a project .cursor/mcp.json)",
35
- desktop: "claude_desktop_config.json (Settings Developer Edit Config)",
36
- };
31
+ const mcp_dialects_1 = require("./mcp-dialects");
32
+ var mcp_dialects_2 = require("./mcp-dialects");
33
+ Object.defineProperty(exports, "AUTH_ENV", { enumerable: true, get: function () { return mcp_dialects_2.AUTH_ENV; } });
34
+ Object.defineProperty(exports, "CLIENT_FILES", { enumerable: true, get: function () { return mcp_dialects_2.CLIENT_FILES; } });
35
+ Object.defineProperty(exports, "CLIENT_LABELS", { enumerable: true, get: function () { return mcp_dialects_2.CLIENT_LABELS; } });
36
+ Object.defineProperty(exports, "MCP_CLIENTS", { enumerable: true, get: function () { return mcp_dialects_2.MCP_CLIENTS; } });
37
+ Object.defineProperty(exports, "buildClientConfig", { enumerable: true, get: function () { return mcp_dialects_2.buildClientConfig; } });
38
+ Object.defineProperty(exports, "parseClient", { enumerable: true, get: function () { return mcp_dialects_2.parseClient; } });
39
+ Object.defineProperty(exports, "renderClientList", { enumerable: true, get: function () { return mcp_dialects_2.renderClientList; } });
37
40
  const LOGIN_HINT = "run `baychat login` first";
38
41
  /**
39
42
  * The endpoint + token for the logged-in device.
@@ -86,105 +89,6 @@ function resolveBaseUrl(raw) {
86
89
  }
87
90
  return parsed.toString().replace(/\/+$/, "");
88
91
  }
89
- /**
90
- * Narrow a `--client` value.
91
- *
92
- * @throws on anything unsupported, listing what IS supported — a typo must not
93
- * silently fall through to a default client and produce a config for the wrong
94
- * one.
95
- */
96
- function parseClient(value) {
97
- const match = exports.MCP_CLIENTS.find((c) => c === value);
98
- if (!match) {
99
- throw new Error(`Unknown --client "${value}" — supported: ${exports.MCP_CLIENTS.join(", ")}. ` +
100
- "Claude Code is registered automatically by `baychat login`.");
101
- }
102
- return match;
103
- }
104
- /** A TOML basic string. Backslash first, then quote — the other order would
105
- * double-escape the backslashes it just introduced. */
106
- function tomlString(value) {
107
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
108
- }
109
- /** The mcp-remote argv, used by stdio-only clients.
110
- *
111
- * `Authorization:${VAR}` with NO space is deliberate: mcp-remote expands the
112
- * `${VAR}` from its own environment, and Claude Desktop mangles a header
113
- * argument that contains a space. Passing the value by env rather than inline
114
- * also keeps the credential out of the process listing. */
115
- function bridgeArgs(url) {
116
- return ["-y", "mcp-remote", url, "--header", `Authorization:\${${AUTH_ENV}}`];
117
- }
118
- /** The config for one client, ready to paste. Pure — no I/O, no logging. */
119
- function buildClientConfig(client, endpoint) {
120
- const header = `Bearer ${endpoint.token}`;
121
- if (client === "cursor") {
122
- // Cursor speaks Streamable HTTP natively — no bridge needed.
123
- const body = JSON.stringify({ mcpServers: { baychat: { url: endpoint.url, headers: { Authorization: header } } } }, null, 2);
124
- return {
125
- client,
126
- file: CLIENT_FILES[client],
127
- format: "json",
128
- body,
129
- notes: ["Cursor connects to the remote server directly — restart Cursor after saving."],
130
- };
131
- }
132
- if (client === "desktop") {
133
- // Claude Desktop's config file launches stdio servers only, so the remote
134
- // endpoint is fronted by the community mcp-remote bridge.
135
- const body = JSON.stringify({
136
- mcpServers: {
137
- baychat: {
138
- command: "npx",
139
- args: bridgeArgs(endpoint.url),
140
- env: { [AUTH_ENV]: header },
141
- },
142
- },
143
- }, null, 2);
144
- return {
145
- client,
146
- file: CLIENT_FILES[client],
147
- format: "json",
148
- body,
149
- notes: [
150
- "Claude Desktop launches stdio servers, so this bridges through `npx mcp-remote`.",
151
- "Quit and reopen Claude Desktop after saving — it only reads the file at startup.",
152
- ],
153
- };
154
- }
155
- // Codex speaks Streamable HTTP natively. Keep this paste-ready by storing the
156
- // bearer as a static header, matching Cursor's native configuration. The
157
- // command already warns that the generated config contains a live secret.
158
- const body = [
159
- "[mcp_servers.baychat]",
160
- `url = ${tomlString(endpoint.url)}`,
161
- `http_headers = { Authorization = ${tomlString(header)} }`,
162
- ].join("\n");
163
- return {
164
- client,
165
- file: CLIENT_FILES[client],
166
- format: "toml",
167
- body,
168
- notes: [
169
- "Codex reads TOML — append this to the file, do not replace it.",
170
- "Codex connects to the remote Streamable HTTP server directly.",
171
- ],
172
- };
173
- }
174
- /** What `baychat mcp-config` prints with no `--client`: the menu. It names no
175
- * credential, so it works — and is safe — when logged out. */
176
- function renderClientList() {
177
- const rows = exports.MCP_CLIENTS.map((client) => ` baychat mcp-config --client ${client.padEnd(8)}→ ${CLIENT_FILES[client]}`);
178
- return [
179
- "BayChat exposes a remote MCP server (Streamable HTTP + a bearer header).",
180
- "Pick your client and paste the config it prints:",
181
- "",
182
- ...rows,
183
- "",
184
- "Claude Code needs nothing — `baychat login` registers it for you.",
185
- "The printed config embeds your device token: treat it like a password.",
186
- ].join("\n");
187
- }
188
92
  /**
189
93
  * `baychat mcp-config [--client codex|cursor|desktop]`.
190
94
  *
@@ -199,11 +103,11 @@ function cmdMcpConfig(client) {
199
103
  if (client === undefined) {
200
104
  // The menu IS the requested output here (like `--help`), so it goes to
201
105
  // stdout — unlike the guidance that accompanies a generated config.
202
- console.log(renderClientList());
106
+ console.log((0, mcp_dialects_1.renderClientList)());
203
107
  return;
204
108
  }
205
- const target = parseClient(client);
206
- const config = buildClientConfig(target, resolveMcpEndpoint());
109
+ const target = (0, mcp_dialects_1.parseClient)(client);
110
+ const config = (0, mcp_dialects_1.buildClientConfig)(target, resolveMcpEndpoint());
207
111
  console.error(`Add to ${config.file}:`);
208
112
  for (const note of config.notes)
209
113
  console.error(` ${note}`);
@@ -0,0 +1,143 @@
1
+ "use strict";
2
+ // MCP client config dialects — the PURE half of `baychat mcp-config`.
3
+ //
4
+ // WHY THIS FILE EXISTS SEPARATELY. Three surfaces need to know how each MCP
5
+ // client spells its configuration: the CLI (`baychat mcp-config`), the mobile
6
+ // app's "Connect an MCP client" screen, and any future web parity screen. Three
7
+ // hand-written copies would drift the first time a client changed its dialect,
8
+ // and the failure mode is silent — a user pastes a config that no longer works
9
+ // and has no way to tell whose fault it is.
10
+ //
11
+ // So this module is the single source of truth, and it imports NOTHING: no
12
+ // `fs`, no `./config`, no Node built-ins. That is a hard constraint, not a
13
+ // style preference. `apps/mobile/scripts/sync-mcp-dialects.mjs` copies this
14
+ // file verbatim into the React Native bundle, where a `node:fs` import would
15
+ // fail the build. The impure half — reading the credentials file, resolving the
16
+ // base url, printing to stdout — stays in `mcp-config.ts`, which re-exports
17
+ // everything here so existing importers are unaffected.
18
+ //
19
+ // THE TOKEN IS A PASSWORD. Every config below embeds a live bearer credential.
20
+ // It must never reach a child process's argv, where `ps` shows it to every user
21
+ // on the box — hence the `${VAR}` + env form for the stdio clients.
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.CLIENT_LABELS = exports.CLIENT_FILES = exports.AUTH_ENV = exports.MCP_CLIENTS = void 0;
24
+ exports.parseClient = parseClient;
25
+ exports.buildClientConfig = buildClientConfig;
26
+ exports.renderClientList = renderClientList;
27
+ /** Clients `mcp-config` can generate for. Claude Code is absent on purpose:
28
+ * `baychat login` registers it automatically via `claude mcp add`. */
29
+ exports.MCP_CLIENTS = ["codex", "cursor", "desktop"];
30
+ /** The env var the stdio bridge expands the Authorization header from. */
31
+ exports.AUTH_ENV = "BAYCHAT_AUTH_HEADER";
32
+ /** Where each client keeps its MCP config. Named once so the menu and the
33
+ * generated config can never disagree about where the paste goes. */
34
+ exports.CLIENT_FILES = {
35
+ codex: "~/.codex/config.toml",
36
+ cursor: "~/.cursor/mcp.json (or a project .cursor/mcp.json)",
37
+ desktop: "claude_desktop_config.json (Settings → Developer → Edit Config)",
38
+ };
39
+ /** Human-facing label per client, for a UI that lists them. */
40
+ exports.CLIENT_LABELS = {
41
+ codex: "Codex",
42
+ cursor: "Cursor",
43
+ desktop: "Claude Desktop",
44
+ };
45
+ /**
46
+ * Narrow a `--client` value.
47
+ *
48
+ * @throws on anything unsupported, listing what IS supported — a typo must not
49
+ * silently fall through to a default client and produce a config for the wrong
50
+ * one.
51
+ */
52
+ function parseClient(value) {
53
+ const match = exports.MCP_CLIENTS.find((c) => c === value);
54
+ if (!match) {
55
+ throw new Error(`Unknown --client "${value}" — supported: ${exports.MCP_CLIENTS.join(", ")}. ` +
56
+ "Claude Code is registered automatically by `baychat login`.");
57
+ }
58
+ return match;
59
+ }
60
+ /** A TOML basic string. Backslash first, then quote — the other order would
61
+ * double-escape the backslashes it just introduced. */
62
+ function tomlString(value) {
63
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
64
+ }
65
+ /** The mcp-remote argv, used by stdio-only clients.
66
+ *
67
+ * `Authorization:${VAR}` with NO space is deliberate: mcp-remote expands the
68
+ * `${VAR}` from its own environment, and Claude Desktop mangles a header
69
+ * argument that contains a space. Passing the value by env rather than inline
70
+ * also keeps the credential out of the process listing. */
71
+ function bridgeArgs(url) {
72
+ return ["-y", "mcp-remote", url, "--header", `Authorization:\${${exports.AUTH_ENV}}`];
73
+ }
74
+ /** The config for one client, ready to paste. Pure — no I/O, no logging. */
75
+ function buildClientConfig(client, endpoint) {
76
+ const header = `Bearer ${endpoint.token}`;
77
+ if (client === "cursor") {
78
+ // Cursor speaks Streamable HTTP natively — no bridge needed.
79
+ const body = JSON.stringify({ mcpServers: { baychat: { url: endpoint.url, headers: { Authorization: header } } } }, null, 2);
80
+ return {
81
+ client,
82
+ file: exports.CLIENT_FILES[client],
83
+ format: "json",
84
+ body,
85
+ notes: ["Cursor connects to the remote server directly — restart Cursor after saving."],
86
+ };
87
+ }
88
+ if (client === "desktop") {
89
+ // Claude Desktop's config file launches stdio servers only, so the remote
90
+ // endpoint is fronted by the community mcp-remote bridge.
91
+ const body = JSON.stringify({
92
+ mcpServers: {
93
+ baychat: {
94
+ command: "npx",
95
+ args: bridgeArgs(endpoint.url),
96
+ env: { [exports.AUTH_ENV]: header },
97
+ },
98
+ },
99
+ }, null, 2);
100
+ return {
101
+ client,
102
+ file: exports.CLIENT_FILES[client],
103
+ format: "json",
104
+ body,
105
+ notes: [
106
+ "Claude Desktop launches stdio servers, so this bridges through `npx mcp-remote`.",
107
+ "Quit and reopen Claude Desktop after saving — it only reads the file at startup.",
108
+ ],
109
+ };
110
+ }
111
+ // Codex speaks Streamable HTTP natively. Keep this paste-ready by storing the
112
+ // bearer as a static header, matching Cursor's native configuration. The
113
+ // command already warns that the generated config contains a live secret.
114
+ const body = [
115
+ "[mcp_servers.baychat]",
116
+ `url = ${tomlString(endpoint.url)}`,
117
+ `http_headers = { Authorization = ${tomlString(header)} }`,
118
+ ].join("\n");
119
+ return {
120
+ client,
121
+ file: exports.CLIENT_FILES[client],
122
+ format: "toml",
123
+ body,
124
+ notes: [
125
+ "Codex reads TOML — append this to the file, do not replace it.",
126
+ "Codex connects to the remote Streamable HTTP server directly.",
127
+ ],
128
+ };
129
+ }
130
+ /** What `baychat mcp-config` prints with no `--client`: the menu. It names no
131
+ * credential, so it works — and is safe — when logged out. */
132
+ function renderClientList() {
133
+ const rows = exports.MCP_CLIENTS.map((client) => ` baychat mcp-config --client ${client.padEnd(8)}→ ${exports.CLIENT_FILES[client]}`);
134
+ return [
135
+ "BayChat exposes a remote MCP server (Streamable HTTP + a bearer header).",
136
+ "Pick your client and paste the config it prints:",
137
+ "",
138
+ ...rows,
139
+ "",
140
+ "Claude Code needs nothing — `baychat login` registers it for you.",
141
+ "The printed config embeds your device token: treat it like a password.",
142
+ ].join("\n");
143
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "baychat",
3
- "version": "0.8.1",
4
- "description": "BayChat connector CLI pair an agent session (Claude Code, Codex) with BayChat and chat in groups",
3
+ "version": "0.8.2",
4
+ "description": "BayChat connector CLI \u2014 pair an agent session (Claude Code, Codex) with BayChat and chat in groups",
5
5
  "bin": {
6
6
  "baychat": "dist/index.js"
7
7
  },