@beryl-so/cli 0.67.0 → 0.73.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.
@@ -20,8 +20,8 @@ const MCP_SERVER_ENTRY = {
20
20
  command: "npx",
21
21
  args: ["-y", "@beryl-so/cli@latest", "mcp"],
22
22
  };
23
- // Headless: the coding agent drives this browser to author tests — nobody watches the
24
- // window, and a headed default breaks on CI / headless boxes. Matches how most devs
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
25
  // already wire their own user-scoped playwright.
26
26
  const PLAYWRIGHT_SERVER_ENTRY = {
27
27
  command: "npx",
@@ -194,7 +194,7 @@ const outcomeLabel = (o) => o === "installed"
194
194
  ? "updated (refreshed to this CLI's version)"
195
195
  : "already current";
196
196
  const skillLeaf = (root) => path.join(root, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME);
197
- // The authoring skill follows the `--scope` flag, same as the MCP servers: under `user`
197
+ // The beryl-test skill follows the `--scope` flag, same as the MCP servers: under `user`
198
198
  // (the default) it lands in the HOME `.agents/skills/` + `.claude/skills/` dirs, so the
199
199
  // knowledge travels with the user into every session the user-scoped MCP tools do —
200
200
  // otherwise an agent outside this repo has all the tools and none of the guide. Under
@@ -230,8 +230,7 @@ export function refreshInstalledSkills(cwd) {
230
230
  }
231
231
  return refreshed;
232
232
  }
233
- // Local authoring drives a real browser, and the whole authoring workflow (walk the flow
234
- // first, then bank the plan) depends on it — so the install is mandatory, not offered:
233
+ // Local runs drive a real browser, so the install is mandatory, not offered:
235
234
  // missing means install now, no prompt, no opt-out. The runner ships with the CLI; the
236
235
  // browser is the per-machine half, fetched for the runner's own revision. Never throws — a
237
236
  // failed install must not fail `init`, which has already done its wiring; it prints the
@@ -253,7 +252,7 @@ async function ensureLocalPlaywright(ctx, cwd) {
253
252
  }
254
253
  catch (err) {
255
254
  ctx.err(`${red("✗")} Playwright install failed: ${err.message}`);
256
- ctx.err(`${dim("•")} Finish the install by hand — local runs and browser authoring need it:\n` +
255
+ ctx.err(`${dim("•")} Finish the install by hand; local runs need it:\n` +
257
256
  ` ${cyan(installCommandsFor(gaps, cwd))}`);
258
257
  }
259
258
  }
@@ -292,7 +291,7 @@ export const initCommands = [
292
291
  name: "init",
293
292
  summary: "Set up Beryl in this repo: sign in and wire up your coding agent",
294
293
  description: "Signs you in, wires the beryl and playwright MCP servers into the coding agent you " +
295
- "pick, writes the beryl-test authoring skill, and installs Chromium if it is missing. " +
294
+ "pick, writes the beryl-test skill, and installs Chromium if it is missing. " +
296
295
  "Pick an agent with --claude, --cursor, --vscode and/or --opencode. With no flag it " +
297
296
  "prompts (Enter picks Claude); with no flag and no TTY it wires nothing and prints the " +
298
297
  "flags. --claude also wires the Claude DESKTOP app when installed, which needs a quit " +
@@ -507,7 +506,7 @@ export const initCommands = [
507
506
  const where = skill.outcome === "updated"
508
507
  ? `${skillWhere} (refreshed to this CLI's version)`
509
508
  : skillWhere;
510
- ctx.err(`${green("✓")} Beryl authoring skill ${what} ${dim(where)}`);
509
+ ctx.err(`${green("✓")} beryl-test skill ${what} ${dim(where)}`);
511
510
  }
512
511
  await ensureLocalPlaywright(ctx, cwd);
513
512
  // Name the agent actually wired, not the raw flag: the picker sets `clients` too.
@@ -519,7 +518,7 @@ export const initCommands = [
519
518
  ? { open: "open VS Code", agent: "Copilot" }
520
519
  : { open: "open opencode", agent: "opencode" };
521
520
  const nextSteps = clients.size === 0
522
- ? `\n${bold("Beryl is signed in and the authoring skill is installed.")}\n` +
521
+ ? `\n${bold("Beryl is signed in and the beryl-test skill is installed.")}\n` +
523
522
  ` ${dim("• No MCP server is wired yet, so no agent can reach Beryl.")}\n` +
524
523
  ` ${dim(`• Using ${CLIENT_LABEL_LIST}? Re-run and pick it:`)}\n` +
525
524
  ` ${cyan("npx @beryl-so/cli@latest init")}\n` +
@@ -527,10 +526,10 @@ export const initCommands = [
527
526
  `${cyan(MANUAL_CONFIG_SNIPPET)}\n` +
528
527
  ` ${dim("Some clients name that key `servers` or `mcp`.")}\n` +
529
528
  ` ${yellow(`Details: ${MANUAL_SETUP_DOCS}`)}\n`
530
- : `\n${bold(`Beryl is set up. Now ${ed.open} and ask ${ed.agent} to write tests.`)}\n` +
531
- ` ${dim(`• Say: "write tests for https://example.com" and ${ed.agent} picks your workspace/project`)}\n` +
532
- ` ${dim(" and sets the URL for you (no pin, no prompt).")}\n` +
533
- ` ${dim(`• Playwright MCP is wired, so ${ed.agent} can drive a real browser to author from your plan.`)}\n`;
529
+ : `\n${bold(`Beryl is set up. Now ${ed.open} and ask ${ed.agent} to create tests.`)}\n` +
530
+ ` ${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`;
534
533
  // The .mcp.json entry is pinned to @latest, but a global install / old npx cache
535
534
  // still wins resolution — so tell the user when the CLI they just ran is stale.
536
535
  await warnIfStale(cliVersion(), (msg) => ctx.err(yellow(msg)));
@@ -549,11 +548,12 @@ export const initCommands = [
549
548
  },
550
549
  {
551
550
  name: "guide",
552
- summary: "Print the Beryl test-authoring guide",
553
- description: "The full guide to authoring durable, healable tests: the ActionPlan shape, outcome " +
554
- "assertions, natural-language intent, test accounts and the project mailbox " +
555
- "({{login_email}}, {{mailbox_address}}, {{inbox_address}} + await_email), and the " +
556
- "local run-fix loop. Same content as the beryl-test skill `beryl init` installs. " +
551
+ summary: "Print the Beryl test guide",
552
+ description: "The guide to durable, healable tests: where new tests come from (`jobs create`), the " +
553
+ "ActionPlan shape, outcome assertions, natural-language intent, test accounts and the " +
554
+ "project mailbox ({{login_email}}, {{mailbox_address}}, {{inbox_address}} + " +
555
+ "await_email), and the local run-fix loop. Same content as the beryl-test skill " +
556
+ "`beryl init` installs. " +
557
557
  `Skip if a loaded beryl-test skill states v${cliVersion()}; else call this first ` +
558
558
  "(works without logging in).",
559
559
  examples: ["beryl guide"],
@@ -0,0 +1,125 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { UsageError } from "../errors.js";
4
+ import { arg, flagBool, flagStr, projectPath } from "./util.js";
5
+ const jobsPath = (ws, p) => `${projectPath(ws, p)}/test-creation-jobs`;
6
+ const LLM_NOTICE = "The document is sent to the LLM that drives the agent, so leave out anything you " +
7
+ "would not share with it.";
8
+ function readDocument(file) {
9
+ try {
10
+ return new Uint8Array(fs.readFileSync(file));
11
+ }
12
+ catch (err) {
13
+ const code = err.code;
14
+ if (code === "ENOENT")
15
+ throw new UsageError(`file not found: ${file}`);
16
+ throw new UsageError(`cannot read ${file} (${code ?? err.message})`);
17
+ }
18
+ }
19
+ export function jobHuman(job) {
20
+ const lines = [`Job ${job.id}: ${job.status}`];
21
+ if (job.error)
22
+ lines.push(`Error: ${job.error}`);
23
+ for (const t of job.tests)
24
+ lines.push(` ${t.title ?? t.id} [${t.surface}] ${t.id}`);
25
+ if (!job.tests.length)
26
+ lines.push(" No tests yet.");
27
+ const skipped = job.skipped_flows ?? [];
28
+ if (skipped.length)
29
+ lines.push("Skipped scenarios:");
30
+ for (const s of skipped)
31
+ lines.push(` ${s.handle}: ${s.reason}`);
32
+ return lines.join("\n");
33
+ }
34
+ export const jobCommands = [
35
+ {
36
+ name: "jobs create",
37
+ groupSummary: "Test-creation jobs: the cloud agent reads a document and authors tests from it.",
38
+ summary: "Create tests from a document (PRD, release notes) on Beryl's cloud",
39
+ description: "Uploads a document (Markdown, plain text, PDF or DOCX, or --text) and queues a " +
40
+ "job: the agent explores the environment, drafts one flow per scenario the document " +
41
+ "describes, and authors one test per enabled surface of the project. New tests get " +
42
+ "no groups. A job runs once; to run it again, use `jobs clone`. " +
43
+ LLM_NOTICE,
44
+ scope: "project",
45
+ args: [{ name: "file", description: "Path to the document" }],
46
+ flags: [
47
+ { name: "text", type: "string", description: "The document as text, instead of a file" },
48
+ {
49
+ name: "env",
50
+ type: "string",
51
+ description: "Environment id to explore (defaults to the project's default)",
52
+ },
53
+ ],
54
+ examples: ["beryl jobs create ./release-notes.md --env <env-id>"],
55
+ async run(ctx, input) {
56
+ const { workspaceId, projectId } = await ctx.requireProject(input);
57
+ const file = input.args.file;
58
+ const text = flagStr(input, "text");
59
+ if (Boolean(file) === Boolean(text)) {
60
+ throw new UsageError("pass a document file or --text, not both");
61
+ }
62
+ const form = new FormData();
63
+ if (file)
64
+ form.set("file", new Blob([readDocument(file)]), path.basename(file));
65
+ else
66
+ form.set("text", text);
67
+ const envId = flagStr(input, "env");
68
+ if (envId)
69
+ form.set("environment_id", envId);
70
+ const job = (await ctx.client.request("POST", jobsPath(workspaceId, projectId), {
71
+ form,
72
+ }));
73
+ return {
74
+ data: job,
75
+ human: `Job ${job.id} queued. Check it with \`beryl jobs get ${job.id}\`.`,
76
+ };
77
+ },
78
+ },
79
+ {
80
+ name: "jobs list",
81
+ summary: "List the project's test-creation jobs",
82
+ scope: "project",
83
+ async run(ctx, input) {
84
+ const { workspaceId, projectId } = await ctx.requireProject(input);
85
+ return { data: await ctx.client.get(jobsPath(workspaceId, projectId)) };
86
+ },
87
+ },
88
+ {
89
+ name: "jobs get",
90
+ summary: "Show a job's status and the tests it created",
91
+ scope: "project",
92
+ args: [{ name: "job-id", description: "Job id", required: true }],
93
+ async run(ctx, input) {
94
+ const { workspaceId, projectId } = await ctx.requireProject(input);
95
+ const job = (await ctx.client.get(`${jobsPath(workspaceId, projectId)}/${arg(input, "job-id")}`));
96
+ return { data: job, human: jobHuman(job) };
97
+ },
98
+ },
99
+ {
100
+ name: "jobs clone",
101
+ summary: "Run a job again: queue a new job with the same document and environment",
102
+ description: LLM_NOTICE,
103
+ scope: "project",
104
+ args: [{ name: "job-id", description: "Job id to clone", required: true }],
105
+ async run(ctx, input) {
106
+ const { workspaceId, projectId } = await ctx.requireProject(input);
107
+ const job = (await ctx.client.post(`${jobsPath(workspaceId, projectId)}/${arg(input, "job-id")}/clone`));
108
+ return { data: job, human: `Job ${job.id} queued.` };
109
+ },
110
+ },
111
+ {
112
+ name: "jobs delete",
113
+ summary: "Delete a job; the flows and tests it created stay",
114
+ scope: "project",
115
+ args: [{ name: "job-id", description: "Job id", required: true }],
116
+ flags: [{ name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
117
+ async run(ctx, input) {
118
+ const { workspaceId, projectId } = await ctx.requireProject(input);
119
+ const jobId = arg(input, "job-id");
120
+ await ctx.confirm(`Delete job ${jobId}? Its tests stay.`, flagBool(input, "force"));
121
+ await ctx.client.del(`${jobsPath(workspaceId, projectId)}/${jobId}`);
122
+ return { human: "Deleted." };
123
+ },
124
+ },
125
+ ];
@@ -1,7 +1,7 @@
1
1
  import { detectAuthGating } from "../detect.js";
2
2
  import { UsageError } from "../errors.js";
3
3
  import { dim, green, table, timeAgo } from "../output.js";
4
- import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
4
+ import { arg, argList, flagBool, flagNum, flagStr, projectPath } from "./util.js";
5
5
  import { pollCurrentExploration, watchExploration } from "./watch.js";
6
6
  // The API treats the auth choice as the visitor's answer and never infers it — a null
7
7
  // choice leaves the project waiting on a login capture that never comes — so resolve it
@@ -84,7 +84,7 @@ export const projectCommands = [
84
84
  name: "name",
85
85
  type: "string",
86
86
  description: "Name for an empty project when no URL is given. Add a URL later with " +
87
- "`beryl envs update <env-id>`, or author tests over the CLI/MCP",
87
+ "`beryl envs update <env-id>`",
88
88
  },
89
89
  {
90
90
  name: "auth",
@@ -97,9 +97,9 @@ export const projectCommands = [
97
97
  {
98
98
  name: "explore",
99
99
  type: "boolean",
100
- description: "Also start a cloud exploration that authors tests for you. Off by default: author " +
101
- "tests with `beryl tests create` or your coding agent over MCP",
102
- mcpDescription: "Leave unset: you author the tests yourself with `tests create`. Set only when the " +
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
103
  "user explicitly asks for a cloud exploration",
104
104
  },
105
105
  { name: "watch", type: "boolean", description: "Start the exploration (implies --explore) and stream it live" },
@@ -120,7 +120,7 @@ export const projectCommands = [
120
120
  const url = typeof rawUrl === "string" ? rawUrl : "";
121
121
  const name = flagStr(input, "name");
122
122
  // Name-only: create a real empty project (no URL, no exploration). Set a URL
123
- // later with `beryl envs update <env-id>` or author over the CLI/MCP.
123
+ // later with `beryl envs update <env-id>`.
124
124
  if (!url) {
125
125
  if (!name)
126
126
  throw new UsageError("Provide a URL, or --name to create an empty project");
@@ -132,8 +132,8 @@ export const projectCommands = [
132
132
  return {
133
133
  data: created,
134
134
  human: `${green("Project created")}: ${created.project_id} ${dim("(empty)")}\n` +
135
- `Add a site with \`beryl envs update <env-id>\`, or author tests with ` +
136
- `\`beryl tests create\`.`,
135
+ `Add a site with \`beryl envs update <env-id>\`, then create tests with ` +
136
+ `\`beryl jobs create <document>\`.`,
137
137
  };
138
138
  }
139
139
  const auth = flagStr(input, "auth") ?? (await resolveAuthChoice(ctx, ws, url));
@@ -147,7 +147,7 @@ export const projectCommands = [
147
147
  return {
148
148
  data: created,
149
149
  human: `${green("Project created")}: ${created.project_id}\n` +
150
- `Ready for your own tests: author a plan and push it with \`beryl tests create\`.`,
150
+ `Create tests from a document describing the flows: \`beryl jobs create <file>\`.`,
151
151
  };
152
152
  // The agent cannot start until a login is captured, so there is nothing to watch.
153
153
  if (created.auth_status === "needs_capture")
@@ -227,6 +227,55 @@ export const projectCommands = [
227
227
  return { data: await ctx.client.patch(projectPath(workspaceId, projectId), body) };
228
228
  },
229
229
  },
230
+ {
231
+ name: "projects surfaces",
232
+ summary: "Set which surfaces are enabled for this project",
233
+ 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.",
237
+ scope: "project",
238
+ args: [
239
+ {
240
+ name: "surfaces",
241
+ description: "web-desktop | web-tablet | web-mobile, one or more",
242
+ required: true,
243
+ variadic: true,
244
+ },
245
+ ],
246
+ examples: ["beryl projects surfaces web-desktop web-mobile", "beryl projects surfaces web-desktop"],
247
+ async run(ctx, input) {
248
+ const { workspaceId, projectId } = await ctx.requireProject(input);
249
+ const data = (await ctx.client.patch(projectPath(workspaceId, projectId), {
250
+ enabled_surfaces: argList(input, "surfaces"),
251
+ }));
252
+ return { data, human: `Enabled surfaces: ${data.enabled_surfaces.join(", ")}` };
253
+ },
254
+ },
255
+ {
256
+ name: "projects findings-gate",
257
+ summary: "Turn the findings gate on each run on or off",
258
+ description: "With the gate on (new-since-green), every run also reports a findings gate: failed " +
259
+ "when the environment's Site Health has a finding first seen after the last green run " +
260
+ "or came back after it (every current finding if there is no green run yet), unknown " +
261
+ "when Site Health has not run there or is not recording findings (its issue tracker " +
262
+ "must be connected). The gate sits next to the test verdicts and never changes one, nor the " +
263
+ "exit code of `runs watch`; read it from `findings_gate` on `runs get`. Off by default.",
264
+ scope: "project",
265
+ args: [{ name: "mode", description: "off | new-since-green", required: true }],
266
+ examples: ["beryl projects findings-gate new-since-green", "beryl projects findings-gate off"],
267
+ async run(ctx, input) {
268
+ const { workspaceId, projectId } = await ctx.requireProject(input);
269
+ const mode = arg(input, "mode").toLowerCase().replace(/-/g, "_");
270
+ if (mode !== "off" && mode !== "new_since_green") {
271
+ throw new UsageError(`Expected "off" or "new-since-green", got "${arg(input, "mode")}"`);
272
+ }
273
+ const data = (await ctx.client.patch(projectPath(workspaceId, projectId), {
274
+ findings_gate: mode,
275
+ }));
276
+ return { data, human: `Findings gate: ${data.findings_gate.replace(/_/g, "-")}` };
277
+ },
278
+ },
230
279
  {
231
280
  name: "projects delete",
232
281
  summary: "Delete a project and all its tests and runs",
@@ -258,7 +307,7 @@ export const projectCommands = [
258
307
  },
259
308
  {
260
309
  name: "projects report",
261
- summary: "Aggregate quality report across recent runs (pass rates, flaky tests, trend)",
310
+ summary: "Aggregate quality report across recent runs (pass rates, per-test history, trend)",
262
311
  scope: "project",
263
312
  flags: [
264
313
  { name: "runs", type: "number", description: "How many recent runs to aggregate" },
@@ -581,7 +581,8 @@ export const runCommands = [
581
581
  "carries `heal` when Beryl healed it: its steps were rewritten with the intent " +
582
582
  "unchanged, proved in a browser and it re-ran green; `version_no` is the revision it " +
583
583
  "wrote (see `tests diff`) and `rationale` the agent's reason. A heal that did not turn " +
584
- "the test green is not reported: a failure is a failure. " +
584
+ "the test green is not reported: a failure is a failure. A red that Beryl did not try " +
585
+ "to heal carries `heal_skip_reason` saying why. " +
585
586
  "`runs report` returns the generated report document, `runs explain` an AI explanation " +
586
587
  "of one failed result.",
587
588
  scope: "project",
@@ -607,7 +608,7 @@ export const runCommands = [
607
608
  name: "status",
608
609
  type: "string",
609
610
  description: "With --page, show only this bucket: passed, failed, errored, cancelled, " +
610
- "quarantined, or unstable (currently unstable in the run's environment)",
611
+ "or quarantined",
611
612
  },
612
613
  {
613
614
  name: "group",