@beryl-so/cli 0.73.2 → 0.78.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.
@@ -6,31 +6,22 @@ import { BERYL_COPILOT_MARK_BEGIN, BERYL_COPILOT_MARK_END, BERYL_COPILOT_SECTION
6
6
  import { loadConfig } from "../config.js";
7
7
  import { AuthError, CliError, UsageError } from "../errors.js";
8
8
  import { ApiClient } from "../http.js";
9
- import { bold, cyan, dim, green, red, yellow } from "../output.js";
10
- import { anyGap, describeGaps, installCommandsFor, installPlaywright, playwrightGaps, } from "../playwright-install.js";
9
+ import { bold, cyan, dim, green, yellow } from "../output.js";
11
10
  import { aliasMachineToAccount, captureCliEvent, flushTelemetry, shutdownTelemetry, } from "../telemetry.js";
12
11
  import { cliVersion, warnIfStale } from "../version-check.js";
13
12
  import { authCommands } from "./auth.js";
14
13
  import { flagStr } from "./util.js";
15
- // Pinned to @latest (like the Playwright entry below) — an unpinned spec lets npx
16
- // serve whatever stale global/cache install already resolves, so the agent's MCP
17
- // server would silently miss newer tools. mergeMcpConfig compares entries verbatim,
18
- // so re-running init also upgrades an old unpinned entry in place.
14
+ // Pinned to @latest: an unpinned spec lets npx serve whatever stale global/cache
15
+ // install already resolves, so the agent's MCP server would silently miss newer tools.
16
+ // mergeMcpConfig compares entries verbatim, so re-running init also upgrades an old
17
+ // unpinned entry in place.
19
18
  const MCP_SERVER_ENTRY = {
20
19
  command: "npx",
21
20
  args: ["-y", "@beryl-so/cli@latest", "mcp"],
22
21
  };
23
- // Headless: the coding agent drives this browser to set up sign-ins and repair plans; nobody
24
- // watches the window, and a headed default breaks on CI / headless boxes. Matches how most devs
25
- // already wire their own user-scoped playwright.
26
- const PLAYWRIGHT_SERVER_ENTRY = {
27
- command: "npx",
28
- args: ["@playwright/mcp@latest", "--headless"],
29
- };
30
22
  // VS Code's `.vscode/mcp.json` uses a top-level `servers` key and tags each stdio server
31
- // with `type`, unlike Cursor/Claude's `mcpServers`. Same pinned commands, different shape.
23
+ // with `type`, unlike Cursor/Claude's `mcpServers`. Same pinned command, different shape.
32
24
  const VSCODE_MCP_SERVER_ENTRY = { type: "stdio", ...MCP_SERVER_ENTRY };
33
- const VSCODE_PLAYWRIGHT_SERVER_ENTRY = { type: "stdio", ...PLAYWRIGHT_SERVER_ENTRY };
34
25
  // opencode flattens command + args into one array under `mcp`.
35
26
  function opencodeEntry(entry) {
36
27
  return { type: "local", command: [entry.command, ...entry.args], enabled: true };
@@ -45,14 +36,14 @@ function snippetEntry(name, entry) {
45
36
  const MANUAL_CONFIG_SNIPPET = [
46
37
  " {",
47
38
  ' "mcpServers": {',
48
- `${snippetEntry("beryl", MCP_SERVER_ENTRY)},`,
49
- snippetEntry("playwright", PLAYWRIGHT_SERVER_ENTRY),
39
+ snippetEntry("beryl", MCP_SERVER_ENTRY),
50
40
  " }",
51
41
  " }",
52
42
  ].join("\n");
53
43
  // `key` is the container the editor expects: `mcpServers` for Claude/Cursor, `servers` for
54
- // VS Code, `mcp` for opencode. `entries` are the editor-shaped server objects.
55
- function mergeMcpConfig(file, key, entries) {
44
+ // VS Code, `mcp` for opencode. Only the beryl entry is ours; every other server (including
45
+ // a user-wired playwright) is left untouched.
46
+ function mergeMcpConfig(file, key, entry) {
56
47
  let existing = {};
57
48
  if (fs.existsSync(file)) {
58
49
  try {
@@ -63,19 +54,14 @@ function mergeMcpConfig(file, key, entries) {
63
54
  }
64
55
  }
65
56
  const servers = (existing[key] ?? {});
66
- const beryl = JSON.stringify(servers.beryl) !== JSON.stringify(entries.beryl);
67
- if (beryl)
68
- servers.beryl = entries.beryl;
69
- // Never clobber a playwright server the user already wired up.
70
- const playwright = servers.playwright === undefined;
71
- if (playwright)
72
- servers.playwright = entries.playwright;
73
- if (beryl || playwright) {
57
+ const beryl = JSON.stringify(servers.beryl) !== JSON.stringify(entry);
58
+ if (beryl) {
59
+ servers.beryl = entry;
74
60
  existing[key] = servers;
75
61
  fs.mkdirSync(path.dirname(file), { recursive: true });
76
62
  fs.writeFileSync(file, JSON.stringify(existing, null, 2) + "\n");
77
63
  }
78
- return { beryl, playwright };
64
+ return { beryl };
79
65
  }
80
66
  function claudeUserAdd(name, entry) {
81
67
  try {
@@ -230,32 +216,6 @@ export function refreshInstalledSkills(cwd) {
230
216
  }
231
217
  return refreshed;
232
218
  }
233
- // Local runs drive a real browser, so the install is mandatory, not offered:
234
- // missing means install now, no prompt, no opt-out. The runner ships with the CLI; the
235
- // browser is the per-machine half, fetched for the runner's own revision. Never throws — a
236
- // failed install must not fail `init`, which has already done its wiring; it prints the
237
- // exact commands instead.
238
- async function ensureLocalPlaywright(ctx, cwd) {
239
- const gaps = playwrightGaps(cwd);
240
- if (!anyGap(gaps)) {
241
- ctx.err(`${green("✓")} Playwright + Chromium already installed ${dim("(local runs ready)")}`);
242
- return;
243
- }
244
- try {
245
- await installPlaywright(cwd, (line) => ctx.err(dim(line)));
246
- // Believe the check, not the exit code — "local runs ready" is only honest if both
247
- // halves are actually there afterwards.
248
- const left = playwrightGaps(cwd);
249
- if (anyGap(left))
250
- throw new Error(`${describeGaps(left)} still missing afterwards`);
251
- ctx.err(`${green("✓")} ${describeGaps(gaps)} installed ${dim("(local runs ready)")}`);
252
- }
253
- catch (err) {
254
- ctx.err(`${red("✗")} Playwright install failed: ${err.message}`);
255
- ctx.err(`${dim("•")} Finish the install by hand; local runs need it:\n` +
256
- ` ${cyan(installCommandsFor(gaps, cwd))}`);
257
- }
258
- }
259
219
  const CLIENTS = [
260
220
  ["claude", "Claude Code + Claude desktop app"],
261
221
  ["cursor", "Cursor"],
@@ -290,8 +250,8 @@ export const initCommands = [
290
250
  {
291
251
  name: "init",
292
252
  summary: "Set up Beryl in this repo: sign in and wire up your coding agent",
293
- description: "Signs you in, wires the beryl and playwright MCP servers into the coding agent you " +
294
- "pick, writes the beryl-test skill, and installs Chromium if it is missing. " +
253
+ description: "Signs you in, wires the beryl MCP server into the coding agent you pick, and writes " +
254
+ "the beryl-test skill. " +
295
255
  "Pick an agent with --claude, --cursor, --vscode and/or --opencode. With no flag it " +
296
256
  "prompts (Enter picks Claude); with no flag and no TTY it wires nothing and prints the " +
297
257
  "flags. --claude also wires the Claude DESKTOP app when installed, which needs a quit " +
@@ -394,51 +354,35 @@ export const initCommands = [
394
354
  if (clients.has("claude") && scope === "user") {
395
355
  // Claude Code owns ~/.claude.json — shell out to `claude mcp add` rather than write it.
396
356
  const beryl = claudeUserAdd("beryl", MCP_SERVER_ENTRY);
397
- const playwright = claudeUserAdd("playwright", PLAYWRIGHT_SERVER_ENTRY);
398
- if (beryl.ok && playwright.ok) {
399
- ctx.err(`${green("✓")} beryl + playwright MCP configured ${dim("(user scope)")}`);
357
+ if (beryl.ok) {
358
+ ctx.err(`${green("✓")} beryl MCP configured ${dim("(user scope)")}`);
400
359
  }
401
360
  else {
402
- const failure = [beryl, playwright].find((r) => !r.ok && r.reason === "failed");
403
- ctx.err(yellow(failure
404
- ? `• \`claude mcp add\` failed (${failure.detail}); run these to wire user-scope MCP servers:`
405
- : "• `claude` not on PATH; run these to wire user-scope MCP servers:"));
361
+ ctx.err(yellow(beryl.reason === "failed"
362
+ ? `• \`claude mcp add\` failed (${beryl.detail}); run this to wire the user-scope MCP server:`
363
+ : "• `claude` not on PATH; run this to wire the user-scope MCP server:"));
406
364
  ctx.err(` ${cyan(claudeAddHint("beryl", MCP_SERVER_ENTRY))}`);
407
- ctx.err(` ${cyan(claudeAddHint("playwright", PLAYWRIGHT_SERVER_ENTRY))}`);
408
365
  }
409
366
  }
410
367
  else if (clients.has("claude")) {
411
368
  const file = path.join(cwd, ".mcp.json");
412
- const wrote = mergeMcpConfig(file, "mcpServers", {
413
- beryl: MCP_SERVER_ENTRY,
414
- playwright: PLAYWRIGHT_SERVER_ENTRY,
415
- });
416
- const where = path.relative(cwd, file);
417
- report("beryl MCP", wrote.beryl, where);
418
- report("playwright MCP", wrote.playwright, where);
369
+ const wrote = mergeMcpConfig(file, "mcpServers", MCP_SERVER_ENTRY);
370
+ report("beryl MCP", wrote.beryl, path.relative(cwd, file));
419
371
  }
420
372
  // Separate surface from Claude Code, read only at launch.
421
373
  const desktopFile = clients.has("claude") && scope === "user" ? claudeDesktopConfigFile() : null;
422
374
  if (desktopFile) {
423
- const wrote = mergeMcpConfig(desktopFile, "mcpServers", {
424
- beryl: MCP_SERVER_ENTRY,
425
- playwright: PLAYWRIGHT_SERVER_ENTRY,
426
- });
375
+ const wrote = mergeMcpConfig(desktopFile, "mcpServers", MCP_SERVER_ENTRY);
427
376
  report("Claude desktop beryl MCP", wrote.beryl, "(Claude desktop app)");
428
- report("Claude desktop playwright MCP", wrote.playwright, "(Claude desktop app)");
429
- if (wrote.beryl || wrote.playwright) {
377
+ if (wrote.beryl) {
430
378
  ctx.err(yellow("• Quit and reopen Claude desktop: it reads that config at launch."));
431
379
  }
432
380
  }
433
381
  if (clients.has("cursor")) {
434
382
  const file = scope === "user" ? cursorUserConfigPath() : path.join(cwd, ".cursor", "mcp.json");
435
- const wrote = mergeMcpConfig(file, "mcpServers", {
436
- beryl: MCP_SERVER_ENTRY,
437
- playwright: PLAYWRIGHT_SERVER_ENTRY,
438
- });
383
+ const wrote = mergeMcpConfig(file, "mcpServers", MCP_SERVER_ENTRY);
439
384
  const where = scope === "user" ? file : path.relative(cwd, file);
440
385
  report("cursor beryl MCP", wrote.beryl, where);
441
- report("cursor playwright MCP", wrote.playwright, where);
442
386
  const rule = writeCursorRule(cwd);
443
387
  ctx.err(`${green("✓")} cursor authoring rule ${outcomeLabel(rule.outcome)} ${dim(path.relative(cwd, rule.file))}`);
444
388
  if (scope === "user") {
@@ -455,13 +399,9 @@ export const initCommands = [
455
399
  const file = userDir
456
400
  ? path.join(userDir, "mcp.json")
457
401
  : path.join(cwd, ".vscode", "mcp.json");
458
- const wrote = mergeMcpConfig(file, "servers", {
459
- beryl: VSCODE_MCP_SERVER_ENTRY,
460
- playwright: VSCODE_PLAYWRIGHT_SERVER_ENTRY,
461
- });
402
+ const wrote = mergeMcpConfig(file, "servers", VSCODE_MCP_SERVER_ENTRY);
462
403
  const where = userDir ? file : path.relative(cwd, file);
463
404
  report("vscode beryl MCP", wrote.beryl, where);
464
- report("vscode playwright MCP", wrote.playwright, where);
465
405
  if (scope === "user" && !userDir) {
466
406
  ctx.err(dim(" • VS Code's user config dir was not found, so this went to a repo-local " +
467
407
  ".vscode/mcp.json. Run --vscode from your project, or add the server via " +
@@ -479,19 +419,14 @@ export const initCommands = [
479
419
  const { file, jsonc } = opencodeConfigFile(scope, cwd);
480
420
  if (jsonc) {
481
421
  ctx.err(yellow(`• ${file} is JSONC and may hold comments, so it was left alone.`));
482
- ctx.err(cyan(` Add these under "mcp": ${JSON.stringify({
422
+ ctx.err(cyan(` Add this under "mcp": ${JSON.stringify({
483
423
  beryl: opencodeEntry(MCP_SERVER_ENTRY),
484
- playwright: opencodeEntry(PLAYWRIGHT_SERVER_ENTRY),
485
424
  })}`));
486
425
  }
487
426
  else {
488
- const wrote = mergeMcpConfig(file, "mcp", {
489
- beryl: opencodeEntry(MCP_SERVER_ENTRY),
490
- playwright: opencodeEntry(PLAYWRIGHT_SERVER_ENTRY),
491
- });
427
+ const wrote = mergeMcpConfig(file, "mcp", opencodeEntry(MCP_SERVER_ENTRY));
492
428
  const where = scope === "user" ? file : path.relative(cwd, file);
493
429
  report("opencode beryl MCP", wrote.beryl, where);
494
- report("opencode playwright MCP", wrote.playwright, where);
495
430
  }
496
431
  }
497
432
  const skills = writeSkills(cwd, scope);
@@ -508,7 +443,6 @@ export const initCommands = [
508
443
  : skillWhere;
509
444
  ctx.err(`${green("✓")} beryl-test skill ${what} ${dim(where)}`);
510
445
  }
511
- await ensureLocalPlaywright(ctx, cwd);
512
446
  // Name the agent actually wired, not the raw flag: the picker sets `clients` too.
513
447
  const ed = clients.has("claude")
514
448
  ? { open: "open your editor", agent: "Claude" }
@@ -528,8 +462,8 @@ export const initCommands = [
528
462
  ` ${yellow(`Details: ${MANUAL_SETUP_DOCS}`)}\n`
529
463
  : `\n${bold(`Beryl is set up. Now ${ed.open} and ask ${ed.agent} to create tests.`)}\n` +
530
464
  ` ${dim(`• Say: "create tests for https://example.com from docs/checkout.md" and ${ed.agent} starts`)}\n` +
531
- ` ${dim(" a cloud test-creation job that authors them from that document.")}\n` +
532
- ` ${dim(`• Then ask ${ed.agent} to run them on this machine (local runs) or in the cloud.`)}\n`;
465
+ ` ${dim(" a cloud test-creation job (jobs create) that authors them from that document.")}\n` +
466
+ ` ${dim(`• Then ask ${ed.agent} to run them in Beryl's cloud (runs trigger).`)}\n`;
533
467
  // The .mcp.json entry is pinned to @latest, but a global install / old npx cache
534
468
  // still wins resolution — so tell the user when the CLI they just ran is stale.
535
469
  await warnIfStale(cliVersion(), (msg) => ctx.err(yellow(msg)));
@@ -552,7 +486,7 @@ export const initCommands = [
552
486
  description: "The guide to durable, healable tests: where new tests come from (`jobs create`), the " +
553
487
  "ActionPlan shape, outcome assertions, natural-language intent, test accounts and the " +
554
488
  "project mailbox ({{login_email}}, {{mailbox_address}}, {{inbox_address}} + " +
555
- "await_email), and the local run-fix loop. Same content as the beryl-test skill " +
489
+ "await_email), and the run-fix loop. Same content as the beryl-test skill " +
556
490
  "`beryl init` installs. " +
557
491
  `Skip if a loaded beryl-test skill states v${cliVersion()}; else call this first ` +
558
492
  "(works without logging in).",
@@ -29,6 +29,10 @@ export function jobHuman(job) {
29
29
  lines.push("Skipped scenarios:");
30
30
  for (const s of skipped)
31
31
  lines.push(` ${s.handle}: ${s.reason}`);
32
+ for (const step of job.surface_steps ?? []) {
33
+ for (const w of step.warnings ?? [])
34
+ lines.push(`Warning [${step.surface}]: ${w}`);
35
+ }
32
36
  return lines.join("\n");
33
37
  }
34
38
  export const jobCommands = [
@@ -94,16 +94,6 @@ export const projectCommands = [
94
94
  "site, and only asked when detection is genuinely unsure",
95
95
  },
96
96
  { name: "force-new-login", type: "boolean", description: "Ignore any reusable saved login" },
97
- {
98
- name: "explore",
99
- type: "boolean",
100
- description: "Also start a cloud exploration that authors tests for you. Off by default: create " +
101
- "tests from a document with `beryl jobs create`",
102
- mcpDescription: "Leave unset: create tests from a document with `jobs create`. Set only when the " +
103
- "user explicitly asks for a cloud exploration",
104
- },
105
- { name: "watch", type: "boolean", description: "Start the exploration (implies --explore) and stream it live" },
106
- { name: "timeout", type: "number", description: "With --watch: max minutes to wait" },
107
97
  ],
108
98
  examples: [
109
99
  "beryl projects create https://app.example.com",
@@ -111,21 +101,15 @@ export const projectCommands = [
111
101
  'beryl projects create --name "Acme production"',
112
102
  ],
113
103
  async run(ctx, input) {
114
- const watch = flagBool(input, "watch");
115
- if (input.flags.explore === false && watch)
116
- throw new UsageError("--no-explore cannot be combined with --watch");
117
- const explore = flagBool(input, "explore") || watch;
118
104
  const ws = await ctx.requireWorkspace(input);
119
105
  const rawUrl = input.args.url;
120
106
  const url = typeof rawUrl === "string" ? rawUrl : "";
121
107
  const name = flagStr(input, "name");
122
- // Name-only: create a real empty project (no URL, no exploration). Set a URL
123
- // later with `beryl envs update <env-id>`.
108
+ // Name-only: create a real empty project (no URL). Set a URL later with
109
+ // `beryl envs update <env-id>`.
124
110
  if (!url) {
125
111
  if (!name)
126
112
  throw new UsageError("Provide a URL, or --name to create an empty project");
127
- if (explore)
128
- throw new UsageError("--explore and --watch need a URL to explore");
129
113
  const created = (await ctx.client.post(`/workspaces/${ws}/projects`, {
130
114
  name,
131
115
  }));
@@ -141,29 +125,12 @@ export const projectCommands = [
141
125
  root_url: url,
142
126
  requires_auth_choice: auth,
143
127
  force_new_login: flagBool(input, "force-new-login"),
144
- skip_exploration: !explore,
145
128
  }));
146
- if (!explore)
147
- return {
148
- data: created,
149
- human: `${green("Project created")}: ${created.project_id}\n` +
150
- `Create tests from a document describing the flows: \`beryl jobs create <file>\`.`,
151
- };
152
- // The agent cannot start until a login is captured, so there is nothing to watch.
153
- if (created.auth_status === "needs_capture")
154
- return {
155
- data: created,
156
- human: `${green("Project created")}: ${created.project_id}\n` +
157
- `The site needs a login before the agent can explore it. Capture one in the ` +
158
- `Beryl webapp (open the project — it walks you through the login) — the ` +
159
- `exploration starts as soon as you do.`,
160
- };
161
- if (!flagBool(input, "watch"))
162
- return { data: created };
163
- ctx.err(dim(`project ${created.project_id} created — waiting for the agent to start…`));
164
- const explorationId = await pollCurrentExploration(ctx, ws, created.project_id);
165
- const watched = await watchExploration(ctx, ws, created.project_id, explorationId, flagNum(input, "timeout"));
166
- return { data: { ...created, ...watched.data }, exitCode: watched.exitCode };
129
+ return {
130
+ data: created,
131
+ human: `${green("Project created")}: ${created.project_id}\n` +
132
+ `Create tests from a document describing the flows: \`beryl jobs create <file>\`.`,
133
+ };
167
134
  },
168
135
  },
169
136
  {
@@ -231,19 +198,26 @@ export const projectCommands = [
231
198
  name: "projects surfaces",
232
199
  summary: "Set which surfaces are enabled for this project",
233
200
  description: "Replaces the project's enabled surfaces with the ones listed: web-desktop, " +
234
- "web-tablet, web-mobile. A new project has web-desktop only. At least one is " +
235
- "required; `projects get` shows the current set. Turning a surface off removes " +
236
- "no tests: delete a surface's tests to stop running them.",
201
+ "web-tablet, web-mobile, android, ios. A new project has web-desktop only. At least " +
202
+ "one is required; `projects get` shows the current set. Turning a surface off " +
203
+ "removes no tests: delete a surface's tests to stop running them. android and ios " +
204
+ "test the app named by each environment's app id for that surface (`envs update " +
205
+ "--app-id android=<package> --app-id ios=<bundle id>`) from its newest uploaded build " +
206
+ "(`config files upload app.apk`, `config files upload App.zip --kind ios_build`), on " +
207
+ "Beryl's Android and iOS workers.",
237
208
  scope: "project",
238
209
  args: [
239
210
  {
240
211
  name: "surfaces",
241
- description: "web-desktop | web-tablet | web-mobile, one or more",
212
+ description: "web-desktop | web-tablet | web-mobile | android | ios, one or more",
242
213
  required: true,
243
214
  variadic: true,
244
215
  },
245
216
  ],
246
- examples: ["beryl projects surfaces web-desktop web-mobile", "beryl projects surfaces web-desktop"],
217
+ examples: [
218
+ "beryl projects surfaces web-desktop web-mobile",
219
+ "beryl projects surfaces web-desktop android ios",
220
+ ],
247
221
  async run(ctx, input) {
248
222
  const { workspaceId, projectId } = await ctx.requireProject(input);
249
223
  const data = (await ctx.client.patch(projectPath(workspaceId, projectId), {