baychat 0.7.0 → 0.8.1

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
@@ -52,6 +52,7 @@ per session, never one that another integration already uses.
52
52
 
53
53
  | Command | Description |
54
54
  |---------|-------------|
55
+ | `baychat login [--token <PAT>] [--base <url>]` | Log this laptop in to BayChat — scan the QR with your phone, approve, and the BayChat MCP server is registered with Claude Code (`claude mcp add`). Then run `/baychat <name>` in any session |
55
56
  | `baychat onboard [<conv>]` | **Run first.** Print the agent protocol + your live identity, conversations, and (a) room's context |
56
57
  | `baychat pair <code> [--base <url>]` | Redeem a pairing code and store credentials |
57
58
  | `baychat link [--name <n>] [--base <url>]` | Link this session by scanning a QR with your phone — no code to copy. Approve on your phone and the token is stored automatically |
@@ -66,6 +67,7 @@ per session, never one that another integration already uses.
66
67
  | `baychat fetch <url> [--max-chars <n>]` | Fetch one public `http(s)` page through BayChat and print its readable text (see [Tools](#tools)) |
67
68
  | `baychat watch <conv> [--interval <sec>] [--timeout <sec>]` | Block until new messages arrive (exit 0) or timeout (exit 2) |
68
69
  | `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
+ | `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)) |
69
71
 
70
72
  `baychat onboard <conv> --catch-up` combines onboarding with a catch-up: after
71
73
  the protocol, your identity, conversations, and the room's instructions, it
@@ -283,11 +285,52 @@ on the MCP server entry so the launched process inherits it:
283
285
  }
284
286
  ```
285
287
 
288
+ ### Other MCP clients
289
+
290
+ `baychat login` also gives you a **remote** MCP server — `https://api.baychat.io/api/mcp`,
291
+ standard MCP Streamable HTTP. Any client that supports a remote MCP server and custom headers
292
+ connects with just two values:
293
+
294
+ - URL — `https://api.baychat.io/api/mcp`
295
+ - Header — `Authorization: Bearer <device token>`
296
+
297
+ The device token is written to `~/.baychat/credentials.json` (0600) under `device.token` by
298
+ `baychat login`; `BAYCHAT_DEVICE_TOKEN` is used in its place when set.
299
+
300
+ Don't hand-write any of that — let the CLI print it:
301
+
302
+ ```bash
303
+ baychat mcp-config # which clients are supported, and where each config lives
304
+ baychat mcp-config --client cursor # a config carrying your token, ready to paste
305
+ ```
306
+
307
+ | `--client` | File | Shape |
308
+ |-----------|------|-------|
309
+ | `cursor` | `~/.cursor/mcp.json` (or project `.cursor/mcp.json`) | Cursor speaks remote HTTP natively — `url` + `headers` |
310
+ | `desktop` | `claude_desktop_config.json` | stdio only, so it bridges through `npx -y mcp-remote` |
311
+ | `codex` | `~/.codex/config.toml` | Native Streamable HTTP in TOML — `url` + `http_headers.Authorization` |
312
+
313
+ The Claude Desktop bridge config passes the header through an env var
314
+ (`--header Authorization:${BAYCHAT_AUTH_HEADER}`) rather than inline, so your token never
315
+ appears in the child process's command line. Restart the client after saving.
316
+
317
+ Only the config body goes to **stdout** — the destination path and the warnings go to stderr —
318
+ so `baychat mcp-config --client cursor > ~/.cursor/mcp.json` writes a valid file. The output
319
+ contains a live credential: don't commit it or paste it into a shared channel. If you are not
320
+ logged in, or the credential on disk is unusable, the command refuses and tells you to run
321
+ `baychat login` rather than printing a config with an empty token.
322
+
323
+ `baychat login` registers **Claude Code** for you (`claude mcp add --transport http --scope
324
+ user baychat …`); if the `claude` binary is missing or the add fails, login still succeeds and
325
+ prints the command to run by hand. On Windows `claude` is a `.cmd` shim, which Node can only
326
+ launch through a shell, so the CLI shells out there and quotes each argument itself.
327
+
286
328
  ## Configuration
287
329
 
288
330
  | Env var | Effect |
289
331
  |---------|--------|
290
- | `BAYCHAT_TOKEN` | Use this API token instead of the credentials file (headless/CI) |
332
+ | `BAYCHAT_TOKEN` | Use this agent API token instead of the credentials file (headless/CI) |
333
+ | `BAYCHAT_DEVICE_TOKEN` | Use this `bay_u_*` device token (from `baychat login`) instead of the credentials file (headless/CI) |
291
334
  | `BAYCHAT_API_URL` | API origin (default `https://api.baychat.io`) |
292
335
  | `BAYCHAT_CONFIG_DIR` | Credentials/cursor directory (default `~/.baychat`) |
293
336
 
package/dist/api.js CHANGED
@@ -6,6 +6,9 @@ exports.fetchContext = fetchContext;
6
6
  exports.pairRequest = pairRequest;
7
7
  exports.createLinkRequest = createLinkRequest;
8
8
  exports.pollLinkRequest = pollLinkRequest;
9
+ exports.createDeviceLink = createDeviceLink;
10
+ exports.pollDeviceLink = pollDeviceLink;
11
+ exports.deviceMe = deviceMe;
9
12
  class ApiError extends Error {
10
13
  status;
11
14
  code;
@@ -114,3 +117,36 @@ async function pollLinkRequest(baseUrl, id, pollSecret) {
114
117
  throw await parseError(res);
115
118
  return (await res.json());
116
119
  }
120
+ /** Create a device link request. `deviceName` labels the laptop in the approve UI. */
121
+ async function createDeviceLink(baseUrl, deviceName) {
122
+ const res = await fetch(`${baseUrl}/api/device-links`, {
123
+ method: "POST",
124
+ headers: { "Content-Type": "application/json" },
125
+ body: JSON.stringify(deviceName ? { deviceName } : {}),
126
+ });
127
+ if (!res.ok)
128
+ throw await parseError(res);
129
+ return (await res.json());
130
+ }
131
+ /**
132
+ * Poll a device link request with its secret (query string, per the server
133
+ * contract). Unlike the agent flow this does NOT translate 404 into a status:
134
+ * pickup is single-use, so 404 covers expired, unknown, wrong-secret and
135
+ * already-consumed alike. It surfaces as an ApiError and the caller decides —
136
+ * `cmdLogin` treats it as "expired" and stops polling.
137
+ */
138
+ async function pollDeviceLink(baseUrl, id, pollSecret) {
139
+ const res = await fetch(`${baseUrl}/api/device-links/${id}?secret=${encodeURIComponent(pollSecret)}`);
140
+ if (!res.ok)
141
+ throw await parseError(res);
142
+ return (await res.json());
143
+ }
144
+ /** Verify a device token and learn whose Bay it opens (`baychat login --token`). */
145
+ async function deviceMe(baseUrl, token) {
146
+ const res = await fetch(`${baseUrl}/api/device-credentials/me`, {
147
+ headers: { Authorization: `Bearer ${token}` },
148
+ });
149
+ if (!res.ok)
150
+ throw await parseError(res);
151
+ return (await res.json());
152
+ }
package/dist/commands.js CHANGED
@@ -16,9 +16,15 @@ exports.resetSessionState = resetSessionState;
16
16
  exports.cmdCheck = cmdCheck;
17
17
  exports.cmdWatch = cmdWatch;
18
18
  exports.cmdLink = cmdLink;
19
+ exports.claudeMcpAddSpawn = claudeMcpAddSpawn;
20
+ exports.cmdLogin = cmdLogin;
21
+ exports.deviceExpiryWarning = deviceExpiryWarning;
22
+ exports.printDeviceExpiryWarning = printDeviceExpiryWarning;
19
23
  exports.cmdSearch = cmdSearch;
20
24
  exports.cmdFetch = cmdFetch;
21
25
  exports.cmdQr = cmdQr;
26
+ const node_child_process_1 = require("node:child_process");
27
+ const node_os_1 = __importDefault(require("node:os"));
22
28
  const qrcode_1 = __importDefault(require("qrcode"));
23
29
  const api_1 = require("./api");
24
30
  const protocol_1 = require("./protocol");
@@ -29,9 +35,16 @@ const tools_1 = require("./tools");
29
35
  const DEFAULT_BASE_URL = "https://api.baychat.io";
30
36
  function requireCredentials() {
31
37
  const creds = (0, config_1.loadCredentials)();
32
- if (!creds)
33
- throw new Error("Not connected. Run: baychat pair <code>");
34
- return creds;
38
+ if (creds)
39
+ return creds;
40
+ // A device login (`baychat login`) is not an agent pairing — these commands
41
+ // speak the agent API and still need one. Telling a logged-in user they are
42
+ // "not connected" is false and sends them round the wrong loop, so name what
43
+ // they have and what is missing.
44
+ const device = (0, config_1.loadDeviceCredentials)();
45
+ throw new Error(device
46
+ ? `Logged in as ${device.user.name} (device). No agent paired for this session — run: baychat pair <code>`
47
+ : "Not connected. Run: baychat pair <code>");
35
48
  }
36
49
  async function cmdPair(code, baseUrl) {
37
50
  const base = (baseUrl || process.env.BAYCHAT_API_URL || DEFAULT_BASE_URL).replace(/\/$/, "");
@@ -42,6 +55,11 @@ async function cmdPair(code, baseUrl) {
42
55
  console.log("Credentials saved. Try: baychat whoami");
43
56
  }
44
57
  async function cmdWhoami() {
58
+ // Before requireCredentials, which throws for a device-only setup: a lapsing
59
+ // device login is exactly what a `baychat login`-only user needs to hear, and
60
+ // it would otherwise be unreachable on the one command people run to check
61
+ // their connection.
62
+ printDeviceExpiryWarning();
45
63
  const creds = requireCredentials();
46
64
  const me = await (0, api_1.apiRequest)(creds, "GET", "/api/agent-api/me");
47
65
  console.log(`${me.name} (${me.id}) — status ${me.status} — ${creds.baseUrl}`);
@@ -383,10 +401,15 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
383
401
  * connection can't be made) are transient. Real 4xx errors are not: they signal
384
402
  * a genuine problem the caller must see, so they rethrow. Note ApiError 404 is
385
403
  * handled as a terminal "expired" state by callers before reaching here.
404
+ *
405
+ * 429 is the one 4xx that belongs on the transient side: it is the server saying
406
+ * "later", not "no". Aborting on it would kill a link the user is seconds from
407
+ * approving because the poll loop — or an unrelated tab on the same IP — brushed
408
+ * a rate limit; the next tick is already an interval away.
386
409
  */
387
410
  function isTransientPollError(err) {
388
411
  if (err instanceof api_1.ApiError)
389
- return err.status >= 500;
412
+ return err.status >= 500 || err.status === 429;
390
413
  return err instanceof TypeError; // network-level fetch failure
391
414
  }
392
415
  async function cmdWatch(conversationId, opts = {}) {
@@ -472,6 +495,256 @@ async function cmdLink(opts = {}) {
472
495
  console.log("Link request expired — run baychat link again.");
473
496
  return false;
474
497
  }
498
+ // ─── Device login (`baychat login`) ────────────────────────────────────────
499
+ // The user-credential twin of `cmdLink`: same reverse-QR mechanics, but the
500
+ // approved token acts as the HUMAN and unlocks the remote MCP server, so one
501
+ // command both logs the laptop in and registers BayChat with Claude Code.
502
+ /** The copy-pasteable `claude mcp add`, with a PLACEHOLDER where the token goes:
503
+ * printing the real one would leave the secret in scrollback and in any pasted
504
+ * transcript, which argv exposure (below) does not. */
505
+ function printManualMcpAdd(baseUrl) {
506
+ console.log(` claude mcp add --transport http --scope user baychat ${baseUrl}/api/mcp \\`);
507
+ console.log(` --header "Authorization: Bearer <your token — in ~/.baychat/credentials.json under device.token>"`);
508
+ }
509
+ /**
510
+ * cmd.exe quoting: wrap the whole argument so spaces, `&`, `|` and `>` inside it
511
+ * stay literal. Only usable on values `WINDOWS_UNSAFE` has already cleared.
512
+ */
513
+ function quoteForCmd(arg) {
514
+ return `"${arg}"`;
515
+ }
516
+ /**
517
+ * Characters that survive — or break out of — cmd.exe double quotes.
518
+ *
519
+ * A `"` ends the wrapper, so everything after it is parsed as shell syntax. A
520
+ * `%NAME%` is expanded *inside* quotes. A newline ends the command line. Nothing
521
+ * else in cmd's metacharacter set (`&`, `|`, `<`, `>`, `^`) is interpreted while
522
+ * quoted, and `!` only expands under delayed expansion, which `cmd /d /s /c` —
523
+ * what Node's `shell: true` invokes — does not enable.
524
+ */
525
+ const WINDOWS_UNSAFE = /["%\u0000-\u001f\u007f]/;
526
+ /**
527
+ * The spawn recipe for `claude mcp add`, or null when it cannot be run safely.
528
+ *
529
+ * On Windows `claude` is a `.cmd` shim, and Node has refused to spawn `.bat` /
530
+ * `.cmd` without `shell: true` since the CVE-2024-27980 fix (18.20.2 /
531
+ * 20.12.2+) — it throws EINVAL. Every runtime this package supports (node >=20)
532
+ * is past that fix, so naming `claude.cmd` explicitly cannot work either: a
533
+ * shell is the only route.
534
+ *
535
+ * The cost of a shell is that arguments become shell syntax. Node does NOT
536
+ * escape them — with `shell: true` on Windows it joins argv with spaces and
537
+ * hands the string to `cmd.exe /d /s /c` verbatim — so `Authorization: Bearer
538
+ * <token>` would arrive as three separate arguments, and a `&` in an
539
+ * interpolated value would arrive as a command separator. Hence: quote every
540
+ * argument here, and refuse outright when an interpolated value contains
541
+ * something quoting cannot contain. Refusing costs the user one manual paste;
542
+ * guessing would run their token through a command interpreter.
543
+ *
544
+ * POSIX keeps `execve` semantics — argv is passed verbatim, there is no shell to
545
+ * interpret it, and so nothing to quote or refuse.
546
+ */
547
+ function claudeMcpAddSpawn(platform, baseUrl, token) {
548
+ const args = [
549
+ "mcp",
550
+ "add",
551
+ "--transport",
552
+ "http",
553
+ "--scope",
554
+ "user",
555
+ "baychat",
556
+ `${baseUrl}/api/mcp`,
557
+ "--header",
558
+ `Authorization: Bearer ${token}`,
559
+ ];
560
+ if (platform !== "win32")
561
+ return { command: "claude", args, shell: false };
562
+ if (args.some((a) => WINDOWS_UNSAFE.test(a)))
563
+ return null;
564
+ return { command: "claude", args: args.map(quoteForCmd), shell: true };
565
+ }
566
+ /**
567
+ * Register the remote BayChat MCP server with Claude Code, carrying the device
568
+ * token as a static Authorization header.
569
+ *
570
+ * The token travels in argv, which is briefly visible in a process listing on a
571
+ * shared machine. That is the accepted trade for a one-command login.
572
+ *
573
+ * Neither a missing `claude` binary nor a rejected add is a login failure — the
574
+ * credential is already saved — so both only print and return. They print
575
+ * DIFFERENTLY, though: the common non-zero exit is a renewal where an MCP server
576
+ * named `baychat` already exists, and reporting that as "CLI not found" would
577
+ * send the user hunting for the wrong problem while Claude Code quietly keeps
578
+ * the old, expiring token. We surface the real reason and suggest the removal —
579
+ * we never run it for them, since that server entry may not be ours.
580
+ */
581
+ function registerWithClaude(baseUrl, token) {
582
+ const recipe = claudeMcpAddSpawn(process.platform, baseUrl, token);
583
+ if (!recipe) {
584
+ // Windows only, and only for a value a quoted cmd.exe argument cannot hold.
585
+ console.log("\nCould not add BayChat to Claude Code safely on Windows — add it manually:");
586
+ printManualMcpAdd(baseUrl);
587
+ return;
588
+ }
589
+ // stderr is captured (not ignored) so a failure can quote claude's own words;
590
+ // the timeout keeps a hung binary from hanging a login whose credential is
591
+ // already on disk — a timeout lands in the failure branch below as ETIMEDOUT.
592
+ const res = (0, node_child_process_1.spawnSync)(recipe.command, recipe.args, {
593
+ encoding: "utf8",
594
+ stdio: ["ignore", "ignore", "pipe"],
595
+ timeout: 15_000,
596
+ killSignal: "SIGKILL",
597
+ shell: recipe.shell,
598
+ });
599
+ // 9009 is cmd.exe's "'claude' is not recognized": with a shell there is no
600
+ // ENOENT to catch, and reporting a missing binary as a generic failure would
601
+ // point the user at the renewal advice below instead of at installing it.
602
+ if (recipe.shell && res.status === 9009) {
603
+ console.log("\nClaude Code CLI not found — add BayChat manually:");
604
+ printManualMcpAdd(baseUrl);
605
+ return;
606
+ }
607
+ if (res.error && res.error.code === "ENOENT") {
608
+ console.log("\nClaude Code CLI not found — add BayChat manually:");
609
+ printManualMcpAdd(baseUrl);
610
+ return;
611
+ }
612
+ if (res.error || res.status !== 0) {
613
+ const reason = res.error?.code ??
614
+ res.error?.message ??
615
+ `exit ${res.status}`;
616
+ console.log(`\nCould not add BayChat to Claude Code (${reason}).`);
617
+ // Defensive redaction: the token is in argv, not in output, but a CLI that
618
+ // echoes the failing command back would otherwise print it to scrollback.
619
+ const stderr = String(res.stderr ?? "").split(token).join("<token>").trim();
620
+ if (stderr)
621
+ for (const line of stderr.split("\n").slice(0, 3))
622
+ console.log(` ${line}`);
623
+ console.log('\n If a server named "baychat" is already registered (a renewal), remove it:');
624
+ console.log(" claude mcp remove baychat");
625
+ console.log(" then add it back:");
626
+ printManualMcpAdd(baseUrl);
627
+ return;
628
+ }
629
+ console.log("✓ BayChat added to Claude Code");
630
+ }
631
+ /**
632
+ * `baychat login` — log this laptop in to BayChat as the human.
633
+ *
634
+ * Default path: create a device link request, render its QR, and poll until the
635
+ * user approves it in the app. `--token <PAT>` skips the QR and verifies a
636
+ * pasted device credential against `/api/device-credentials/me` before saving
637
+ * it — a bad token errors out rather than being written to disk.
638
+ *
639
+ * The QR, the printed URL and every log line carry only public data; the token
640
+ * lives in the credentials file (and in the `claude mcp add` handoff) alone.
641
+ * Returns true when logged in, false on expiry/timeout (exit 2 in index.ts).
642
+ */
643
+ async function cmdLogin(opts = {}) {
644
+ const base = (opts.base || process.env.BAYCHAT_API_URL || DEFAULT_BASE_URL).replace(/\/$/, "");
645
+ if (opts.token) {
646
+ const me = await (0, api_1.deviceMe)(base, opts.token);
647
+ (0, config_1.saveDeviceCredentials)({
648
+ baseUrl: base,
649
+ token: opts.token,
650
+ user: me.user,
651
+ expiresAt: me.expiresAt,
652
+ });
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.");
656
+ return true;
657
+ }
658
+ // The hostname labels this laptop in the approve UI; the server caps the field
659
+ // at 60 chars, so a long corporate hostname must not 400 the whole login.
660
+ const request = await (0, api_1.createDeviceLink)(base, node_os_1.default.hostname().slice(0, 60));
661
+ console.log(await qrcode_1.default.toString(request.url, { type: "terminal", small: true }));
662
+ console.log(request.url);
663
+ console.log("Scan with your phone — BayChat will open to approve this laptop.");
664
+ // Stop polling shortly after the server-declared expiry (+5s for clock skew).
665
+ const deadline = new Date(request.expiresAt).getTime() + 5_000;
666
+ const intervalMs = opts.intervalMs ?? 3_000;
667
+ let warnedUnavailable = false;
668
+ while (Date.now() < deadline) {
669
+ await sleep(intervalMs);
670
+ let status;
671
+ try {
672
+ status = await (0, api_1.pollDeviceLink)(base, request.id, request.pollSecret);
673
+ }
674
+ catch (err) {
675
+ // 404 is the terminal state: expired, unknown, or already picked up (the
676
+ // pickup is single-use). Stop and print the retry hint below.
677
+ if (err instanceof api_1.ApiError && err.status === 404)
678
+ break;
679
+ // A 5xx/network hiccup during an api restart must not orphan a login the
680
+ // user is about to approve — ride it out and keep polling.
681
+ if (!isTransientPollError(err))
682
+ throw err;
683
+ if (!warnedUnavailable) {
684
+ console.log("Server unavailable, retrying…");
685
+ warnedUnavailable = true;
686
+ }
687
+ continue;
688
+ }
689
+ if (status.status === "approved") {
690
+ // The base we RESOLVED is the one we save and hand to Claude Code — never
691
+ // the server's self-reported `status.baseUrl`. That field is the API's
692
+ // configured public URL (API_BASE_URL), which a local or self-hosted server
693
+ // usually leaves at the production default: obeying it would take a login
694
+ // the user aimed at `--base http://localhost:4000` and quietly point both
695
+ // the credentials file and the registered MCP server at api.baychat.io,
696
+ // where this token does not exist. `base` is always a resolved non-empty
697
+ // string (flag → env → default), so there is nothing to fall back to.
698
+ //
699
+ // Never print the token — it lives in the credentials file only.
700
+ (0, config_1.saveDeviceCredentials)({
701
+ baseUrl: base,
702
+ token: status.token,
703
+ user: status.user,
704
+ expiresAt: status.expiresAt,
705
+ });
706
+ console.log(`✓ Logged in as ${status.user.name}`);
707
+ registerWithClaude(base, status.token);
708
+ console.log("\n Run /baychat <name> in any session.");
709
+ return true;
710
+ }
711
+ }
712
+ console.log("Login request expired — run baychat login again.");
713
+ return false;
714
+ }
715
+ /**
716
+ * The renewal nudge for a device credential, or null when none is due. Device
717
+ * credentials expire (30 days), and the failure mode without a warning is an
718
+ * MCP server that silently stops answering mid-session.
719
+ *
720
+ * An unparseable `expiresAt` (the `BAYCHAT_DEVICE_TOKEN` env path, where the
721
+ * server is the authority on expiry) yields null rather than a bogus warning.
722
+ */
723
+ function deviceExpiryWarning(dc, now = new Date()) {
724
+ const msLeft = new Date(dc.expiresAt).getTime() - now.getTime();
725
+ if (Number.isNaN(msLeft))
726
+ return null;
727
+ if (msLeft <= 0)
728
+ return "Your BayChat login has expired — run `npx baychat login`.";
729
+ if (msLeft < 3 * 86_400_000) {
730
+ const hours = Math.max(1, Math.round(msLeft / 3_600_000));
731
+ return `Your BayChat login expires in about ${hours}h — run \`npx baychat login\` to renew.`;
732
+ }
733
+ return null;
734
+ }
735
+ /**
736
+ * Print the device-expiry nudge, if one is due, through `print`. Shared by the
737
+ * CLI (stdout) and the stdio MCP server (stderr — stdout is the JSON-RPC
738
+ * channel there). No device credentials → nothing to say.
739
+ */
740
+ function printDeviceExpiryWarning(print = console.log) {
741
+ const device = (0, config_1.loadDeviceCredentials)();
742
+ if (!device)
743
+ return;
744
+ const warning = deviceExpiryWarning(device);
745
+ if (warning)
746
+ print(warning);
747
+ }
475
748
  // ─── Agent tools (`search` / `fetch`) ──────────────────────────────────────
476
749
  // The shell twins of the `web_search` / `web_fetch` MCP tools — same client
477
750
  // functions, same rendering, same untrusted-content notice, so an agent without
package/dist/config.js CHANGED
@@ -33,38 +33,133 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.DEFAULT_API_URL = void 0;
36
37
  exports.configDir = configDir;
37
38
  exports.saveCredentials = saveCredentials;
38
39
  exports.loadCredentials = loadCredentials;
40
+ exports.saveDeviceCredentials = saveDeviceCredentials;
41
+ exports.loadDeviceCredentials = loadDeviceCredentials;
39
42
  exports.loadCursor = loadCursor;
40
43
  exports.saveCursor = saveCursor;
41
44
  const fs = __importStar(require("fs"));
42
45
  const os = __importStar(require("os"));
43
46
  const path = __importStar(require("path"));
47
+ exports.DEFAULT_API_URL = "https://api.baychat.io";
44
48
  function configDir() {
45
49
  return process.env.BAYCHAT_CONFIG_DIR || path.join(os.homedir(), ".baychat");
46
50
  }
47
51
  const credentialsPath = () => path.join(configDir(), "credentials.json");
48
52
  const cursorsPath = () => path.join(configDir(), "cursors.json");
49
- function saveCredentials(creds) {
53
+ /**
54
+ * The whole credentials file as a plain object, or `{}` when it is missing or
55
+ * unreadable. credentials.json holds TWO independent credentials — the agent
56
+ * pair (`baseUrl`/`token`/`agent`) and the device login (`device`) — so every
57
+ * write must merge rather than replace: `baychat login` must not log the agent
58
+ * out, and `baychat pair` must not log the laptop out.
59
+ */
60
+ function loadFile() {
61
+ try {
62
+ const parsed = JSON.parse(fs.readFileSync(credentialsPath(), "utf8"));
63
+ return parsed && typeof parsed === "object" ? parsed : {};
64
+ }
65
+ catch {
66
+ // Missing or corrupt file — treat as empty. Callers turn that into "not
67
+ // connected"; nothing here is worth surfacing to the user.
68
+ return {};
69
+ }
70
+ }
71
+ /**
72
+ * The same file, read for a WRITE rather than a read.
73
+ *
74
+ * `loadFile` treats an unreadable file as `{}` — correct for a read, where the
75
+ * answer is "not connected". It is NOT correct here: merging onto `{}` means the
76
+ * next `baychat login` silently overwrites a credentials file it could not parse,
77
+ * destroying the agent pairing (or device login) that may still be sitting in it,
78
+ * intact, behind one stray character. A missing file is genuinely empty; anything
79
+ * else is refused, by name, so the user can look at it before we replace it.
80
+ *
81
+ * @throws when the file exists but is not a readable JSON object.
82
+ */
83
+ function loadFileForMerge() {
84
+ const file = credentialsPath();
85
+ let raw;
86
+ try {
87
+ raw = fs.readFileSync(file, "utf8");
88
+ }
89
+ catch (err) {
90
+ if (err.code === "ENOENT")
91
+ return {};
92
+ throw new Error(`Cannot read ${file}: ${err.message}`);
93
+ }
94
+ let parsed;
95
+ try {
96
+ parsed = JSON.parse(raw);
97
+ }
98
+ catch {
99
+ parsed = undefined;
100
+ }
101
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
102
+ throw new Error(`${file} is not valid JSON — refusing to overwrite it and lose the credential it may still hold. Inspect or delete the file, then run this command again.`);
103
+ }
104
+ return parsed;
105
+ }
106
+ /**
107
+ * Merge `changes` into credentials.json, atomically.
108
+ *
109
+ * Written to a temp file and renamed into place: `rename` is atomic on POSIX, so
110
+ * an interrupted write (^C, a full disk, a crash) leaves the previous file
111
+ * untouched rather than a truncated one — and a truncated credentials file is
112
+ * both credentials gone at once, since the agent pairing and the device login
113
+ * share it. The mode is pinned AFTER the rename because `writeFileSync`'s `mode`
114
+ * only applies when it creates the file: a leftover temp file from a previous
115
+ * run keeps its old permissions and carries them across the rename.
116
+ */
117
+ function writeMerged(changes) {
50
118
  fs.mkdirSync(configDir(), { recursive: true, mode: 0o700 });
51
- fs.writeFileSync(credentialsPath(), JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 });
119
+ const file = credentialsPath();
120
+ const merged = { ...loadFileForMerge(), ...changes };
121
+ const tmp = `${file}.tmp`;
122
+ fs.writeFileSync(tmp, JSON.stringify(merged, null, 2) + "\n", { mode: 0o600 });
123
+ fs.renameSync(tmp, file);
124
+ fs.chmodSync(file, 0o600);
125
+ }
126
+ function saveCredentials(creds) {
127
+ writeMerged({ baseUrl: creds.baseUrl, token: creds.token, agent: creds.agent });
52
128
  }
53
129
  function loadCredentials() {
54
130
  // Env override first — headless setups pass the token without a pair step.
55
131
  if (process.env.BAYCHAT_TOKEN) {
56
132
  return {
57
- baseUrl: process.env.BAYCHAT_API_URL || "https://api.baychat.io",
133
+ baseUrl: process.env.BAYCHAT_API_URL || exports.DEFAULT_API_URL,
58
134
  token: process.env.BAYCHAT_TOKEN,
59
135
  agent: { id: "env", name: "env" },
60
136
  };
61
137
  }
62
- try {
63
- return JSON.parse(fs.readFileSync(credentialsPath(), "utf8"));
64
- }
65
- catch {
138
+ const file = loadFile();
139
+ const agent = file.agent;
140
+ if (typeof file.token !== "string" || !agent)
66
141
  return null;
142
+ return { baseUrl: String(file.baseUrl ?? exports.DEFAULT_API_URL), token: file.token, agent };
143
+ }
144
+ function saveDeviceCredentials(device) {
145
+ writeMerged({ device });
146
+ }
147
+ function loadDeviceCredentials() {
148
+ // Same env-override shape as BAYCHAT_TOKEN: a headless/CI box can supply a
149
+ // device token directly and skip the QR. `expiresAt` is unknown on this path
150
+ // (the server is the authority) — the empty string means "don't warn".
151
+ if (process.env.BAYCHAT_DEVICE_TOKEN) {
152
+ return {
153
+ baseUrl: process.env.BAYCHAT_API_URL || exports.DEFAULT_API_URL,
154
+ token: process.env.BAYCHAT_DEVICE_TOKEN,
155
+ user: { id: "env", name: "env" },
156
+ expiresAt: "",
157
+ };
67
158
  }
159
+ const device = loadFile().device;
160
+ if (!device || typeof device.token !== "string")
161
+ return null;
162
+ return device;
68
163
  }
69
164
  function loadCursors() {
70
165
  try {
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  const commands_1 = require("./commands");
5
5
  const mcp_1 = require("./mcp");
6
+ const mcp_config_1 = require("./mcp-config");
6
7
  const HELP = `baychat — BayChat connector CLI for agent sessions (Claude Code, Codex)
7
8
 
8
9
  Usage:
@@ -11,6 +12,9 @@ Usage:
11
12
  live identity, conversations, and room context.
12
13
  --catch-up also appends the rolling summary +
13
14
  the messages after its boundary
15
+ baychat login [--token <PAT>] [--base <url>]
16
+ Log this laptop in to BayChat (QR) and add the
17
+ BayChat MCP server to Claude Code
14
18
  baychat pair <code> [--base <url>] Redeem a pairing code from the BayChat app
15
19
  baychat link [--name <n>] [--base <url>]
16
20
  Link this session via a QR you scan with your phone
@@ -31,6 +35,11 @@ Usage:
31
35
  (Claude Desktop, Claude Code, Cursor) get BayChat
32
36
  as native tools. Speaks JSON-RPC on stdout — do not
33
37
  run it interactively
38
+ baychat mcp-config [--client codex|cursor|desktop]
39
+ Print a paste-ready MCP config for another
40
+ client, pointed at the remote BayChat server.
41
+ No --client lists what's supported. The config
42
+ goes to stdout, the guidance to stderr
34
43
  baychat watch <conversationId> [--interval <sec>] [--timeout <sec>]
35
44
  Block until new messages arrive (exit 0)
36
45
  or timeout (exit 2)
@@ -66,6 +75,10 @@ async function main() {
66
75
  case "onboard":
67
76
  await (0, commands_1.cmdOnboard)(positional(args), { catchUp: args.includes("--catch-up") });
68
77
  return 0;
78
+ case "login": {
79
+ const loggedIn = await (0, commands_1.cmdLogin)({ base: flag(args, "--base"), token: flag(args, "--token") });
80
+ return loggedIn ? 0 : 2; // 2 = the link request expired without approval
81
+ }
69
82
  case "pair": {
70
83
  if (!args[0])
71
84
  throw new Error("Usage: baychat pair <code>");
@@ -137,6 +150,12 @@ async function main() {
137
150
  });
138
151
  return got ? 0 : 2;
139
152
  }
153
+ case "mcp-config": {
154
+ // `--client` with no value is a typo, not a request for the menu: pass the
155
+ // empty string so it is rejected by name rather than silently listing.
156
+ (0, mcp_config_1.cmdMcpConfig)(args.includes("--client") ? (flag(args, "--client") ?? "") : undefined);
157
+ return 0;
158
+ }
140
159
  case "mcp": {
141
160
  // Boot the stdio MCP server, then block forever: the transport keeps the
142
161
  // process alive on stdin, and falling through to process.exit() would kill