@beryl-so/cli 0.52.0 → 0.54.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
@@ -13,9 +13,10 @@ npx @beryl-so/cli@latest init
13
13
  ```
14
14
 
15
15
  Signs you in (browser approval by default, emailed one-time code as fallback; new
16
- emails are signed up on the spot), wires the beryl + playwright MCP servers into Claude
17
- Code or Cursor, installs the beryl-test authoring skill, and installs Playwright for
18
- local runs. Safe to re-run.
16
+ emails are signed up on the spot), wires the beryl + playwright MCP servers into the
17
+ agent you pick with --claude, --cursor, --vscode or --opencode (it asks when you pass
18
+ none), installs the beryl-test authoring skill, and installs Playwright for local runs.
19
+ Safe to re-run.
19
20
 
20
21
  ## Install
21
22
 
@@ -9,7 +9,7 @@ import { AuthError, CliError } from "../errors.js";
9
9
  import { ApiClient, ApiError } from "../http.js";
10
10
  import { parseArgv } from "./cli.js";
11
11
  import { commands } from "../registry/index.js";
12
- import { analyticsOptions, createPostHogClient, setClient, setSurface, shutdownTelemetry, withTool, } from "../telemetry.js";
12
+ import { analyticsOptions, createPostHogClient, captureMcpSessionStarted, setClient, setSurface, shutdownTelemetry, withTool, } from "../telemetry.js";
13
13
  import { refreshInstalledSkills } from "../commands/init.js";
14
14
  import { cliVersion, warnIfStale } from "../version-check.js";
15
15
  export function toolName(spec) {
@@ -353,7 +353,11 @@ export async function serveMcp(baseCtx) {
353
353
  };
354
354
  }
355
355
  });
356
- server.oninitialized = () => setClient(server.getClientVersion());
356
+ // Emitted here, not at boot: the handshake is what carries the client's name.
357
+ server.oninitialized = () => {
358
+ setClient(server.getClientVersion());
359
+ void captureMcpSessionStarted(baseCtx.config.apiUrl, identifyUser(baseCtx));
360
+ };
357
361
  instrument(server, createPostHogClient(), analyticsOptions(baseCtx.config.apiUrl, identifyUser(baseCtx)));
358
362
  // A coding agent ends an MCP server either by closing the pipe or by signalling it, and
359
363
  // only the first path reaches onclose — without these the last batch of events dies with
@@ -73,7 +73,7 @@ async function otpLogin(ctx, input, codeFlag) {
73
73
  const why = `${email} signs in with Google, so there is no emailed code`;
74
74
  if (!ctx.interactive) {
75
75
  throw new UsageError(`${why} — run \`beryl login\` in a terminal to sign in via the browser, ` +
76
- "or pass --token with a token from beryl.so → Account → API tokens");
76
+ "or pass --token with a token from beryl.so → Account → Personal access tokens");
77
77
  }
78
78
  ctx.err(`${why} — confirm the sign-in in your browser instead.`);
79
79
  const fromBrowser = await deviceLogin(ctx, process.stdin, flagStr(input, "token-name"));
@@ -99,8 +99,8 @@ export const authCommands = [
99
99
  description: "Signs in via your browser (a code you confirm at beryl.so) and stores a personal " +
100
100
  "access token in the CLI config. Pass --otp (or --email) to skip the browser and " +
101
101
  "sign in with an emailed one-time code instead. A new email gets an account " +
102
- "created automatically. Pass --token to use an existing token from Account → API " +
103
- "tokens. Pass --email plus --code (the 6 digits from the email, e.g. read from a " +
102
+ "created automatically. Pass --token to use an existing token from Account → " +
103
+ "Personal access tokens. Pass --email plus --code (the 6 digits from the email, e.g. read from a " +
104
104
  "`beryl inbox`) to complete the OTP flow without a prompt. In CI, prefer the " +
105
105
  "BERYL_API_KEY environment variable.",
106
106
  interactive: true,
@@ -4,7 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { BERYL_COPILOT_MARK_BEGIN, BERYL_COPILOT_MARK_END, BERYL_COPILOT_SECTION, BERYL_CURSOR_RULE, BERYL_CURSOR_RULE_FILENAME, BERYL_TEST_SKILL, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME, } from "../beryl-test-skill.js";
6
6
  import { loadConfig } from "../config.js";
7
- import { AuthError, CliError } from "../errors.js";
7
+ import { AuthError, CliError, UsageError } from "../errors.js";
8
8
  import { ApiClient } from "../http.js";
9
9
  import { bold, cyan, dim, green, red, yellow } from "../output.js";
10
10
  import { anyGap, describeGaps, installCommandsFor, installPlaywright, playwrightGaps, } from "../playwright-install.js";
@@ -31,9 +31,27 @@ const PLAYWRIGHT_SERVER_ENTRY = {
31
31
  // with `type`, unlike Cursor/Claude's `mcpServers`. Same pinned commands, different shape.
32
32
  const VSCODE_MCP_SERVER_ENTRY = { type: "stdio", ...MCP_SERVER_ENTRY };
33
33
  const VSCODE_PLAYWRIGHT_SERVER_ENTRY = { type: "stdio", ...PLAYWRIGHT_SERVER_ENTRY };
34
- const ACTION_PLAN_SCHEMA_URL = "https://api.beryl.so/api/v1/schemas/action-plan.schema.json";
34
+ // opencode flattens command + args into one array under `mcp`.
35
+ function opencodeEntry(entry) {
36
+ return { type: "local", command: [entry.command, ...entry.args], enabled: true };
37
+ }
38
+ const MANUAL_SETUP_DOCS = "https://beryl.so/docs/mcp#manual";
39
+ // Printed for the agents init cannot wire, from the same entries the flags write.
40
+ // Hand-formatted rather than JSON.stringify so args stay on one line.
41
+ function snippetEntry(name, entry) {
42
+ const args = entry.args.map((a) => JSON.stringify(a)).join(", ");
43
+ return ` "${name}": { "command": ${JSON.stringify(entry.command)}, "args": [${args}] }`;
44
+ }
45
+ const MANUAL_CONFIG_SNIPPET = [
46
+ " {",
47
+ ' "mcpServers": {',
48
+ `${snippetEntry("beryl", MCP_SERVER_ENTRY)},`,
49
+ snippetEntry("playwright", PLAYWRIGHT_SERVER_ENTRY),
50
+ " }",
51
+ " }",
52
+ ].join("\n");
35
53
  // `key` is the container the editor expects: `mcpServers` for Claude/Cursor, `servers` for
36
- // VS Code. `entries` are the editor-shaped server objects (VS Code tags them with `type`).
54
+ // VS Code, `mcp` for opencode. `entries` are the editor-shaped server objects.
37
55
  function mergeMcpConfig(file, key, entries) {
38
56
  let existing = {};
39
57
  if (fs.existsSync(file)) {
@@ -105,6 +123,33 @@ function vscodeUserConfigDir() {
105
123
  const dir = vscodeUserConfigDirFor(process.platform, os.homedir(), process.env);
106
124
  return fs.existsSync(dir) ? dir : null;
107
125
  }
126
+ // The Claude DESKTOP app, which is a different surface from Claude Code and a different
127
+ // file from ~/.claude.json: macOS ~/Library/Application Support/Claude, Windows
128
+ // %APPDATA%/Claude, Linux ~/.config/Claude. Pure resolver so all three are testable.
129
+ export function claudeDesktopConfigDirFor(platform, home, env) {
130
+ return platform === "darwin"
131
+ ? path.join(home, "Library", "Application Support", "Claude")
132
+ : platform === "win32"
133
+ ? path.join(env.APPDATA ?? path.join(home, "AppData", "Roaming"), "Claude")
134
+ : path.join(env.XDG_CONFIG_HOME ?? path.join(home, ".config"), "Claude");
135
+ }
136
+ // JSONC may carry comments JSON.parse rejects, so never rewrite it.
137
+ export function opencodeConfigFileFor(dir) {
138
+ const jsonc = path.join(dir, "opencode.jsonc");
139
+ if (fs.existsSync(jsonc) && !fs.existsSync(path.join(dir, "opencode.json"))) {
140
+ return { file: jsonc, jsonc: true };
141
+ }
142
+ return { file: path.join(dir, "opencode.json"), jsonc: false };
143
+ }
144
+ function opencodeConfigFile(scope, cwd) {
145
+ return opencodeConfigFileFor(scope === "user" ? path.join(os.homedir(), ".config", "opencode") : cwd);
146
+ }
147
+ // Null when the app isn't installed. Checked BEFORE writing because mergeMcpConfig
148
+ // mkdirs its parent, which would leave a stray config dir on machines without the app.
149
+ export function claudeDesktopConfigFile() {
150
+ const dir = claudeDesktopConfigDirFor(process.platform, os.homedir(), process.env);
151
+ return fs.existsSync(dir) ? path.join(dir, "claude_desktop_config.json") : null;
152
+ }
108
153
  // Write generated content that ships with the CLI and changes every release, so anything
109
154
  // that differs is overwritten: a stale copy silently feeds agents months-old guidance,
110
155
  // which is worse than losing a local edit the user can redo (and `beryl guide` still prints
@@ -217,59 +262,88 @@ async function ensureLocalPlaywright(ctx, cwd) {
217
262
  ` ${cyan(installCommandsFor(gaps, cwd))}`);
218
263
  }
219
264
  }
265
+ const CLIENTS = [
266
+ ["claude", "Claude Code + Claude desktop app"],
267
+ ["cursor", "Cursor"],
268
+ ["vscode", "VS Code / Copilot"],
269
+ ["opencode", "opencode"],
270
+ ];
271
+ // Whole commands, not bare flags: this is printed for someone who has to go run it.
272
+ const CLIENT_COMMAND_HINT = CLIENTS.map(([k, label]) => ` npx @beryl-so/cli@latest init --${k.padEnd(9)} ${label}`).join("\n");
273
+ // Picker labels are precise ("Claude Code + Claude desktop app"); prose wants short.
274
+ const CLIENT_SHORT_NAMES = CLIENTS.map(([, label]) => label.split(" + ")[0].split(" / ")[0]);
275
+ const CLIENT_LABEL_LIST = `${CLIENT_SHORT_NAMES.slice(0, -1).join(", ")} or ${CLIENT_SHORT_NAMES.at(-1)}`;
276
+ // Flags win; no flag and no TTY selects nothing.
277
+ async function selectClients(ctx, flags) {
278
+ const flagged = CLIENTS.map(([k]) => k).filter((k) => flags[k]);
279
+ if (flagged.length > 0)
280
+ return new Set(flagged);
281
+ if (!ctx.interactive)
282
+ return new Set();
283
+ ctx.err("Which agent should Beryl wire?");
284
+ CLIENTS.forEach(([, label], i) => ctx.err(` ${i + 1}. ${label}`));
285
+ ctx.err(` ${CLIENTS.length + 1}. Other`);
286
+ const answer = await ctx.prompt("Selection (comma-separated) [1]: ");
287
+ const picks = (answer.trim() || "1")
288
+ .split(",")
289
+ .map((part) => Number.parseInt(part.trim(), 10));
290
+ if (picks.some((n) => !Number.isInteger(n) || n < 1 || n > CLIENTS.length + 1)) {
291
+ throw new UsageError(`Pick 1-${CLIENTS.length + 1}, or pass a client flag:\n${CLIENT_COMMAND_HINT}`);
292
+ }
293
+ return new Set(picks.filter((n) => n <= CLIENTS.length).map((n) => CLIENTS[n - 1][0]));
294
+ }
220
295
  export const initCommands = [
221
296
  {
222
297
  name: "init",
223
298
  summary: "Set up Beryl in this repo: sign in and wire up your coding agent",
224
- description: "One-command onboarding: signs you in (browser confirm, or an emailed one-time code) " +
225
- "and wires up your coding " +
226
- "agent. Nothing is detected and nothing is conditional: every run wires the beryl AND " +
227
- "playwright MCP servers, writes the authoring skill (user scope: your home " +
228
- ".agents/skills/ + .claude/skills/, so it follows you into every session; --scope " +
229
- "project: the repo's own dirs, committed for teammates), and installs the Chromium " +
230
- "browser if missing (the Playwright runner ships with the CLI, so any repo can run tests " +
231
- "locally), since browser authoring and local runs depend on it. By default the " +
232
- "servers are wired per-user (matching where your login token lives) via `claude mcp add " +
233
- "-s user`; pass --scope project to write a committed .mcp.json for a shared repo " +
234
- "instead. With --cursor the same two servers are also wired into Cursor and a repo-local " +
235
- ".cursor/rules/beryl.mdc authoring rule is written (Cursor's equivalent of the skill, " +
236
- "since it reads neither skills dir); --vscode does the same for VS Code / GitHub Copilot " +
237
- "(VS Code's user mcp.json, or .vscode/mcp.json with --scope project, plus a marked " +
238
- "section in .github/copilot-instructions.md). No " +
239
- "workspace/project pin and no URL prompt: ask Claude to write tests for " +
240
- "your site and it resolves the workspace, project, and URL. Safe to re-run: every run " +
241
- "refreshes the authoring skill to this CLI's version (overwriting an older or edited " +
242
- "copy; `beryl guide` prints the same content), and skips whatever else is already set " +
243
- "up.",
299
+ description: "Signs you in, wires the beryl and playwright MCP servers into the coding agent you " +
300
+ "pick, writes the beryl-test authoring skill, and installs Chromium if it is missing. " +
301
+ "Pick an agent with --claude, --cursor, --vscode and/or --opencode. With no flag it " +
302
+ "prompts (Enter picks Claude); with no flag and no TTY it wires nothing and prints the " +
303
+ "flags. --claude also wires the Claude DESKTOP app when installed, which needs a quit " +
304
+ "and reopen to pick the config up. --scope user (default) configures everything " +
305
+ "per-user, matching where your login token lives; --scope project writes config " +
306
+ "committed to the repo. Safe to re-run: it refreshes the skill to this CLI's version " +
307
+ "and skips whatever is already set up.",
244
308
  interactive: true,
245
309
  flags: [
246
310
  {
247
311
  name: "scope",
248
312
  type: "string",
249
313
  enum: ["user", "project"],
250
- description: "Where to wire the MCP servers. `user` (default) configures them per-user (matching " +
251
- "where your Beryl login token lives) via `claude mcp add -s user`. `project` writes a " +
252
- "committed .mcp.json for a shared repo. Every teammate still runs `beryl login` to " +
253
- "authenticate",
314
+ description: "Where to wire: `user` (default) is per-user, `project` is committed to the repo. " +
315
+ "Every teammate still runs `beryl login` to authenticate",
316
+ },
317
+ {
318
+ name: "claude",
319
+ type: "boolean",
320
+ description: "Wire Claude Code (`claude mcp add -s user`, or a committed .mcp.json under " +
321
+ "--scope project), plus the Claude desktop app when it is installed",
254
322
  },
255
323
  {
256
324
  name: "cursor",
257
325
  type: "boolean",
258
- description: "Also wire the same two MCP servers into Cursor's config and write a repo-local " +
259
- ".cursor/rules/beryl.mdc authoring rule (Cursor's equivalent of the skill)",
326
+ description: "Wire Cursor, plus a repo-local .cursor/rules/beryl.mdc authoring rule, since " +
327
+ "Cursor reads no skills dir",
260
328
  },
261
329
  {
262
330
  name: "vscode",
263
331
  type: "boolean",
264
- description: "Also wire the two MCP servers into VS Code's user config (or .vscode/mcp.json with " +
265
- "--scope project) and add a Beryl section to .github/copilot-instructions.md for Copilot",
332
+ description: "Wire VS Code's user mcp.json (or .vscode/mcp.json under --scope project), plus a " +
333
+ "Beryl section in .github/copilot-instructions.md for Copilot",
334
+ },
335
+ {
336
+ name: "opencode",
337
+ type: "boolean",
338
+ description: "Wire ~/.config/opencode/opencode.json (or the repo's opencode.json under " +
339
+ "--scope project), in opencode's own config shape",
266
340
  },
267
341
  ],
268
342
  examples: [
269
343
  "npx @beryl-so/cli@latest init",
270
- "beryl init --scope project",
271
- "beryl init --cursor",
272
- "beryl init --vscode",
344
+ "beryl init --claude",
345
+ "beryl init --cursor --opencode",
346
+ "beryl init --vscode --scope project",
273
347
  ],
274
348
  async run(ctx, input) {
275
349
  const cwd = process.cwd();
@@ -311,12 +385,19 @@ export const initCommands = [
311
385
  else {
312
386
  await signIn();
313
387
  }
388
+ // A signed-in init with no account id is a bug, not a reason to skip: the alias is
389
+ // the only thing joining pre-login CLI events to the person who later signs up.
314
390
  if (accountId) {
315
391
  aliasMachineToAccount(accountId);
316
- flushTelemetry();
317
392
  }
393
+ else if (config.token) {
394
+ captureCliEvent("cli_alias_skipped", config.apiUrl, { reason: "no_account_id" });
395
+ }
396
+ flushTelemetry();
318
397
  const report = (label, fresh, where) => ctx.err(`${green("✓")} ${label} ${fresh ? "configured" : "already configured"} ${dim(where)}`);
319
- if (scope === "user") {
398
+ // No "nothing wired" notice here: the closing message below says it once, in full.
399
+ const clients = await selectClients(ctx, input.flags);
400
+ if (clients.has("claude") && scope === "user") {
320
401
  // Claude Code owns ~/.claude.json — shell out to `claude mcp add` rather than write it.
321
402
  const berylOk = claudeUserAdd("beryl", MCP_SERVER_ENTRY);
322
403
  const playwrightOk = claudeUserAdd("playwright", PLAYWRIGHT_SERVER_ENTRY);
@@ -329,7 +410,7 @@ export const initCommands = [
329
410
  ctx.err(` ${cyan(claudeAddHint("playwright", PLAYWRIGHT_SERVER_ENTRY))}`);
330
411
  }
331
412
  }
332
- else {
413
+ else if (clients.has("claude")) {
333
414
  const file = path.join(cwd, ".mcp.json");
334
415
  const wrote = mergeMcpConfig(file, "mcpServers", {
335
416
  beryl: MCP_SERVER_ENTRY,
@@ -339,7 +420,20 @@ export const initCommands = [
339
420
  report("beryl MCP", wrote.beryl, where);
340
421
  report("playwright MCP", wrote.playwright, where);
341
422
  }
342
- if (input.flags.cursor) {
423
+ // Separate surface from Claude Code, read only at launch.
424
+ const desktopFile = clients.has("claude") && scope === "user" ? claudeDesktopConfigFile() : null;
425
+ if (desktopFile) {
426
+ const wrote = mergeMcpConfig(desktopFile, "mcpServers", {
427
+ beryl: MCP_SERVER_ENTRY,
428
+ playwright: PLAYWRIGHT_SERVER_ENTRY,
429
+ });
430
+ report("Claude desktop beryl MCP", wrote.beryl, "(Claude desktop app)");
431
+ report("Claude desktop playwright MCP", wrote.playwright, "(Claude desktop app)");
432
+ if (wrote.beryl || wrote.playwright) {
433
+ ctx.err(yellow("• Quit and reopen Claude desktop: it reads that config at launch."));
434
+ }
435
+ }
436
+ if (clients.has("cursor")) {
343
437
  const file = scope === "user" ? cursorUserConfigPath() : path.join(cwd, ".cursor", "mcp.json");
344
438
  const wrote = mergeMcpConfig(file, "mcpServers", {
345
439
  beryl: MCP_SERVER_ENTRY,
@@ -357,7 +451,7 @@ export const initCommands = [
357
451
  // VS Code (Copilot) is always repo-local: `.vscode/mcp.json` for the servers and a
358
452
  // marked section in `.github/copilot-instructions.md` for the guide pointer. VS Code's
359
453
  // per-user MCP config is managed through its UI, not a portable file, so scope is moot.
360
- if (input.flags.vscode) {
454
+ if (clients.has("vscode")) {
361
455
  // User scope (default) targets VS Code's real profile mcp.json, so it works in
362
456
  // every project. Project scope, or a missing profile dir, falls back to the repo.
363
457
  const userDir = scope === "user" ? vscodeUserConfigDir() : null;
@@ -383,6 +477,26 @@ export const initCommands = [
383
477
  "Copilot may not read it. Run --vscode from your project root."));
384
478
  }
385
479
  }
480
+ // opencode reads ~/.claude/skills/, so writeSkills covers it.
481
+ if (clients.has("opencode")) {
482
+ const { file, jsonc } = opencodeConfigFile(scope, cwd);
483
+ if (jsonc) {
484
+ ctx.err(yellow(`• ${file} is JSONC and may hold comments, so it was left alone.`));
485
+ ctx.err(cyan(` Add these under "mcp": ${JSON.stringify({
486
+ beryl: opencodeEntry(MCP_SERVER_ENTRY),
487
+ playwright: opencodeEntry(PLAYWRIGHT_SERVER_ENTRY),
488
+ })}`));
489
+ }
490
+ else {
491
+ const wrote = mergeMcpConfig(file, "mcp", {
492
+ beryl: opencodeEntry(MCP_SERVER_ENTRY),
493
+ playwright: opencodeEntry(PLAYWRIGHT_SERVER_ENTRY),
494
+ });
495
+ const where = scope === "user" ? file : path.relative(cwd, file);
496
+ report("opencode beryl MCP", wrote.beryl, where);
497
+ report("opencode playwright MCP", wrote.playwright, where);
498
+ }
499
+ }
386
500
  const skills = writeSkills(cwd, scope);
387
501
  for (const skill of skills) {
388
502
  const rel = path.relative(cwd, skill.file);
@@ -398,17 +512,27 @@ export const initCommands = [
398
512
  ctx.err(`${green("✓")} Beryl authoring skill ${what} ${dim(where)}`);
399
513
  }
400
514
  await ensureLocalPlaywright(ctx, cwd);
401
- // Name the editor's own agent, so the message speaks to what the user just set up.
402
- const ed = input.flags.vscode
403
- ? { open: "open VS Code", agent: "Copilot" }
404
- : input.flags.cursor
515
+ // Name the agent actually wired, not the raw flag: the picker sets `clients` too.
516
+ const ed = clients.has("claude")
517
+ ? { open: "open your editor", agent: "Claude" }
518
+ : clients.has("cursor")
405
519
  ? { open: "open Cursor", agent: "Cursor" }
406
- : { open: "open your editor", agent: "Claude" };
407
- const nextSteps = `\n${bold(`Beryl is set up. Now ${ed.open} and ask ${ed.agent} to write tests.`)}\n` +
408
- ` ${dim(`• Say: "write tests for https://your-app.com" and ${ed.agent} picks your workspace/project`)}\n` +
409
- ` ${dim(" and sets the URL for you (no pin, no prompt).")}\n` +
410
- ` ${dim(`• Playwright MCP is wired, so ${ed.agent} can drive a real browser to author from your plan.`)}\n` +
411
- `\nAuthor against the ActionPlan JSON Schema: ${cyan(ACTION_PLAN_SCHEMA_URL)}`;
520
+ : clients.has("vscode")
521
+ ? { open: "open VS Code", agent: "Copilot" }
522
+ : { open: "open opencode", agent: "opencode" };
523
+ const nextSteps = clients.size === 0
524
+ ? `\n${bold("Beryl is signed in and the authoring skill is installed.")}\n` +
525
+ ` ${dim("• No MCP server is wired yet, so no agent can reach Beryl.")}\n` +
526
+ ` ${dim(`• Using ${CLIENT_LABEL_LIST}? Re-run and pick it:`)}\n` +
527
+ ` ${cyan("npx @beryl-so/cli@latest init")}\n` +
528
+ ` ${dim("• Any other agent: add this to its MCP config, then restart it.")}\n` +
529
+ `${cyan(MANUAL_CONFIG_SNIPPET)}\n` +
530
+ ` ${dim("Some clients name that key `servers` or `mcp`.")}\n` +
531
+ ` ${yellow(`Details: ${MANUAL_SETUP_DOCS}`)}\n`
532
+ : `\n${bold(`Beryl is set up. Now ${ed.open} and ask ${ed.agent} to write tests.`)}\n` +
533
+ ` ${dim(`• Say: "write tests for https://example.com" and ${ed.agent} picks your workspace/project`)}\n` +
534
+ ` ${dim(" and sets the URL for you (no pin, no prompt).")}\n` +
535
+ ` ${dim(`• Playwright MCP is wired, so ${ed.agent} can drive a real browser to author from your plan.`)}\n`;
412
536
  // The .mcp.json entry is pinned to @latest, but a global install / old npx cache
413
537
  // still wins resolution — so tell the user when the CLI they just ran is stale.
414
538
  await warnIfStale(cliVersion(), (msg) => ctx.err(yellow(msg)));
package/dist/telemetry.js CHANGED
@@ -227,6 +227,9 @@ export function baseEventProperties(apiUrl) {
227
227
  // Same uuid as the X-Beryl-Session header, so a PostHog session and the Sentry tags
228
228
  // on the API requests it caused describe the same thing.
229
229
  beryl_session_id: sessionId,
230
+ // Also a property, not only the distinct id: an event whose alias never landed
231
+ // is still joinable to the install afterwards.
232
+ machine_id: machineId(),
230
233
  };
231
234
  }
232
235
  let posthog;
@@ -263,6 +266,40 @@ export function aliasMachineToAccount(userId) {
263
266
  // telemetry must never break a command
264
267
  }
265
268
  }
269
+ /**
270
+ * One event per MCP server boot, so "connected but never called a tool" is measurable.
271
+ *
272
+ * Until this existed the surface was invisible until its first `test_created` /
273
+ * `run_created`, which hid the drop-off between wiring the MCP up and using it. Attributed
274
+ * to the account when a token resolves, else to the install, which a later alias merges.
275
+ * Best-effort: a failed identify still emits the event rather than losing the boot.
276
+ */
277
+ export async function captureMcpSessionStarted(apiUrl, resolveIdentity) {
278
+ let identity = null;
279
+ try {
280
+ identity = await resolveIdentity();
281
+ }
282
+ catch {
283
+ // unauthenticated, or the lookup failed: fall back to the install
284
+ }
285
+ const client = currentClient();
286
+ try {
287
+ createPostHogClient().capture({
288
+ distinctId: identity?.distinctId ?? machineId(),
289
+ event: "mcp_session_started",
290
+ properties: {
291
+ ...baseEventProperties(apiUrl),
292
+ surface: "mcp",
293
+ authenticated: identity !== null,
294
+ client: client?.name,
295
+ client_version: client?.version,
296
+ },
297
+ });
298
+ }
299
+ catch {
300
+ // telemetry must never break a command
301
+ }
302
+ }
266
303
  export function flushTelemetry() {
267
304
  // Fire-and-forget: init keeps running (login, installs) while the batch goes out, and a
268
305
  // failed send must never surface to the user.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.52.0",
4
- "description": "Beryl on the command line — projects, runs, the exploring agent, and an MCP server over the same commands.",
3
+ "version": "0.54.0",
4
+ "description": "Beryl on the command line: projects, runs, the exploring agent, and an MCP server over the same commands.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "homepage": "https://beryl.so/docs/cli",