@beryl-so/cli 0.1.0 → 0.5.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.
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { BERYL_TEST_SKILL, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME, } from "../beryl-test-skill.js";
3
4
  import { LOCAL_CONFIG_FILENAME, loadConfig } from "../config.js";
4
5
  import { CliError, UsageError } from "../errors.js";
5
6
  import { ApiClient } from "../http.js";
@@ -14,6 +15,22 @@ const PLAYWRIGHT_SERVER_ENTRY = {
14
15
  command: "npx",
15
16
  args: ["@playwright/mcp@latest"],
16
17
  };
18
+ const ACTION_PLAN_SCHEMA_URL = "https://api.beryl.so/api/v1/schemas/action-plan.schema.json";
19
+ // The authoring fork only appears when we create a fresh project with no tests yet; an
20
+ // explicit flag wins, otherwise a TTY prompt defaults to local on enter, and a
21
+ // non-interactive run defaults to local so CI never burns server exploration on a project
22
+ // the user means to hand-author.
23
+ async function resolveAuthoring(ctx, flag) {
24
+ if (flag === "local" || flag === "agent")
25
+ return flag;
26
+ if (!ctx.interactive)
27
+ return "local";
28
+ ctx.err(`\n${bold("How do you want to author tests for this project?")}`);
29
+ ctx.err(` ${green("1")}. Local / manual — with your own coding agent (Playwright MCP) or by hand ${dim("(default)")}`);
30
+ ctx.err(` 2. Beryl agent explores the site and authors tests for you`);
31
+ const answer = await ctx.prompt("Authoring [1-2]: ");
32
+ return answer.trim() === "2" ? "agent" : "local";
33
+ }
17
34
  async function pick(ctx, kind, items) {
18
35
  if (items.length === 1)
19
36
  return items[0];
@@ -62,19 +79,53 @@ function detectEditors(cwd) {
62
79
  editors.push("cursor");
63
80
  return editors;
64
81
  }
82
+ // The authoring skill lives in the vendor-neutral `.agents/skills/` dir (mirroring
83
+ // Momentic), NOT `.claude/` — any coding agent that reads `.agents/skills/` picks it up.
84
+ // Idempotent: an identical copy is left alone; a customer-EDITED copy is never clobbered —
85
+ // we notice and skip so their changes survive a re-run.
86
+ function writeSkill(cwd) {
87
+ const file = path.join(cwd, ".agents", "skills", BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME);
88
+ if (fs.existsSync(file)) {
89
+ // Compare with line endings normalized so a CRLF checkout of our own content still
90
+ // reads as unchanged (not falsely "customized") — we always write LF.
91
+ const norm = (s) => s.replace(/\r\n/g, "\n");
92
+ const current = fs.readFileSync(file, "utf8");
93
+ if (norm(current) === norm(BERYL_TEST_SKILL))
94
+ return { outcome: "unchanged", file };
95
+ return { outcome: "customized", file };
96
+ }
97
+ fs.mkdirSync(path.dirname(file), { recursive: true });
98
+ fs.writeFileSync(file, BERYL_TEST_SKILL);
99
+ return { outcome: "wrote", file };
100
+ }
65
101
  export const initCommands = [
66
102
  {
67
103
  name: "init",
68
104
  summary: "Set up Beryl in this repo — sign in, pin a project, wire up your coding agent",
69
105
  description: "One-command onboarding: signs you in (emailed one-time code), pins this repo to a " +
70
- "workspace and project via .beryl.json (offering to create the project the agent " +
71
- "starts exploring and authoring tests immediately), and writes the MCP server config " +
72
- "for your coding agent (.mcp.json for Claude Code, .cursor/mcp.json for Cursor). " +
73
- "Safe to re-run; every step skips what is already set up.",
106
+ "workspace and project via .beryl.json (offering to create the project), and writes the " +
107
+ "MCP server config for your coding agent (.mcp.json for Claude Code, .cursor/mcp.json for " +
108
+ "Cursor). When creating a fresh project it asks how you want to author tests — locally with " +
109
+ "your own coding agent or by hand (the default), or by letting Beryl's agent explore and " +
110
+ "author them for you. Safe to re-run; every step skips what is already set up.",
74
111
  interactive: true,
75
112
  flags: [
76
113
  { name: "workspace", type: "string", description: "Workspace id or name to pin" },
77
- { name: "project", type: "string", description: "Project id, name, or URL to pin" },
114
+ {
115
+ name: "project",
116
+ type: "string",
117
+ description: "Project id, name, or URL to pin. With no projects yet, a URL here is the root URL of " +
118
+ "the project to create — supply it (plus --authoring) to run init non-interactively in CI",
119
+ },
120
+ {
121
+ name: "authoring",
122
+ type: "string",
123
+ enum: ["local", "agent"],
124
+ description: "How to author tests for a newly created project: `local` (author yourself with your " +
125
+ "coding agent or by hand — the project is created without server exploration) or " +
126
+ "`agent` (Beryl's agent explores the site and authors tests). Default: local. " +
127
+ "Pass this to skip the interactive prompt in CI",
128
+ },
78
129
  {
79
130
  name: "editor-tools",
80
131
  type: "string",
@@ -86,13 +137,15 @@ export const initCommands = [
86
137
  name: "local",
87
138
  type: "boolean",
88
139
  description: "Also wire the Playwright MCP so your coding agent can drive a local browser " +
89
- "(for authoring tests yourself)",
140
+ "(for authoring tests yourself). Default: on whenever a coding agent is wired; " +
141
+ "pass --no-local to skip it",
90
142
  },
91
143
  ],
92
144
  examples: [
93
145
  "npx @beryl-so/cli@latest init",
94
146
  "beryl init --editor-tools claude-code",
95
- "beryl init --project https://app.example.com --editor-tools none",
147
+ "beryl init --project https://app.example.com --authoring agent",
148
+ "beryl init --project https://app.example.com --authoring local --editor-tools none",
96
149
  ],
97
150
  async run(ctx, input) {
98
151
  const cwd = process.cwd();
@@ -111,6 +164,7 @@ export const initCommands = [
111
164
  }
112
165
  let workspaceId;
113
166
  let projectId;
167
+ let authoredLocally = false;
114
168
  if (!flagBool(input, "no-pin")) {
115
169
  const workspaces = (await client.get("/workspaces/"));
116
170
  if (workspaces.length === 0)
@@ -124,7 +178,9 @@ export const initCommands = [
124
178
  workspaceId = workspace.id;
125
179
  const projects = (await client.get(`/workspaces/${workspaceId}/projects`));
126
180
  const wantedProject = flagStr(input, "project");
127
- if (wantedProject) {
181
+ // With projects already present, --project pins an existing one; with none, it's the
182
+ // root URL of the project to create — which is what makes a fresh init CI-drivable.
183
+ if (wantedProject && projects.length > 0) {
128
184
  projectId = (await ctx.requireProject({
129
185
  args: {},
130
186
  flags: { workspace: workspaceId, project: wantedProject },
@@ -134,13 +190,19 @@ export const initCommands = [
134
190
  projectId = (await pick(ctx, "project", projects)).id;
135
191
  }
136
192
  else {
137
- const url = await ctx.prompt("No projects yet. Root URL of the site to test: ");
193
+ const url = wantedProject ?? (await ctx.prompt("No projects yet. Root URL of the site to test: "));
194
+ const authoring = await resolveAuthoring(ctx, flagStr(input, "authoring"));
195
+ authoredLocally = authoring === "local";
138
196
  const created = (await client.post(`/workspaces/${workspaceId}/projects`, {
139
197
  root_url: url,
198
+ skip_exploration: authoredLocally,
140
199
  }));
141
200
  projectId = created.project_id;
142
- ctx.err(`${green("✓")} Project created — the agent is exploring ${url} and authoring tests ` +
143
- `(watch with \`beryl explorations watch\`)`);
201
+ if (authoredLocally)
202
+ ctx.err(`${green("✓")} Project created for ${url} ${dim("(no server exploration — you'll author the tests)")}`);
203
+ else
204
+ ctx.err(`${green("✓")} Project created — the agent is exploring ${url} and authoring tests ` +
205
+ `(watch with \`beryl explorations watch\`)`);
144
206
  }
145
207
  const localFile = path.join(cwd, LOCAL_CONFIG_FILENAME);
146
208
  let local = {};
@@ -161,7 +223,10 @@ export const initCommands = [
161
223
  : choice === "none"
162
224
  ? []
163
225
  : [choice];
164
- const local = flagBool(input, "local");
226
+ // Wiring an editor's MCP config at all implies the user wants to author tests there,
227
+ // and local authoring needs the Playwright MCP — so default it on. `--no-local` (parsed
228
+ // as an explicit false) opts out.
229
+ const local = input.flags.local ?? editors.length > 0;
165
230
  for (const editor of editors) {
166
231
  const file = editor === "claude-code"
167
232
  ? path.join(cwd, ".mcp.json")
@@ -173,16 +238,34 @@ export const initCommands = [
173
238
  }
174
239
  if (choice === "auto" && editors.length === 0)
175
240
  ctx.err(dim("No coding agent detected — pass --editor-tools claude-code|cursor to wire one."));
176
- return {
177
- data: { workspace: workspaceId ?? null, project: projectId ?? null, editors },
178
- human: `\n${bold("Beryl is set up.")} Try:\n` +
241
+ // Editor-agnostic: the authoring skill goes to `.agents/skills/` regardless of which
242
+ // (if any) editor MCP config we wrote, so any `.agents/skills/`-aware harness gets it.
243
+ const skill = writeSkill(cwd);
244
+ const skillRel = path.relative(cwd, skill.file);
245
+ if (skill.outcome === "customized")
246
+ ctx.err(`${dim("•")} Beryl authoring skill left as-is ${dim(`(${skillRel} — you edited it; delete it to reinstall)`)}`);
247
+ else
248
+ ctx.err(`${green("✓")} Beryl authoring skill ${skill.outcome === "wrote" ? "installed" : "already installed"} ${dim(skillRel)}`);
249
+ const nextSteps = authoredLocally
250
+ ? `\n${bold("Beryl is set up — author your tests locally.")} Next steps:\n` +
251
+ (local
252
+ ? ` ${dim("• Playwright MCP is wired — point your coding agent at it to drive a real browser and draft a plan.")}\n`
253
+ : ` ${dim("• Re-run with --editor-tools to wire the Playwright MCP for your coding agent, or author a plan by hand.")}\n`) +
254
+ ` ${cyan("beryl tests lint --file plan.json")} validate a plan offline against the schema\n` +
255
+ ` ${cyan("beryl tests create --file plan.json")} push a test from your plan\n` +
256
+ ` ${cyan("beryl runs trigger --watch")} run the suite\n\n` +
257
+ `Author against the ActionPlan JSON Schema: ${cyan(ACTION_PLAN_SCHEMA_URL)}`
258
+ : `\n${bold("Beryl is set up.")} Try:\n` +
179
259
  ` ${cyan("beryl runs trigger --watch")} run the suite\n` +
180
260
  ` ${cyan("beryl explorations watch")} watch the agent work\n` +
181
261
  ` ${cyan("beryl tests list")} see authored tests` +
182
262
  (local
183
263
  ? `\n\nPlaywright MCP is wired — your coding agent can explore the site locally ` +
184
264
  `and push tests with ${cyan("beryl tests create")}.`
185
- : ""),
265
+ : "");
266
+ return {
267
+ data: { workspace: workspaceId ?? null, project: projectId ?? null, editors },
268
+ human: nextSteps,
186
269
  };
187
270
  },
188
271
  },
@@ -1,13 +1,44 @@
1
1
  import { saveGlobalConfig } from "../config.js";
2
+ import { detectAuthGating } from "../detect.js";
2
3
  import { UsageError } from "../errors.js";
3
4
  import { dim, green } from "../output.js";
4
5
  import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
5
6
  import { pollCurrentExploration, watchExploration } from "./watch.js";
7
+ // The API treats the auth choice as the visitor's answer and never infers it — a null
8
+ // choice leaves the project waiting on a login capture that never comes — so resolve it
9
+ // here. A saved login for the host settles it; otherwise probe the site.
10
+ async function resolveAuthChoice(ctx, workspaceId, url) {
11
+ let reusable;
12
+ try {
13
+ reusable = (await ctx.client.get(`/workspaces/${workspaceId}/projects/reusable-auth`, {
14
+ url,
15
+ }));
16
+ }
17
+ catch {
18
+ // a probe is still worth trying if the lookup fails
19
+ }
20
+ if (reusable?.available) {
21
+ ctx.err(dim("Detected a gated site — a saved login for this host is already on file."));
22
+ return "gated";
23
+ }
24
+ const detected = await detectAuthGating(url);
25
+ if (detected.choice) {
26
+ ctx.err(dim(`Detected a ${detected.choice} site — ${detected.reason}.`));
27
+ return detected.choice;
28
+ }
29
+ const question = `Could not tell whether ${url} needs a login — ${detected.reason}.`;
30
+ if (!ctx.interactive)
31
+ throw new UsageError(`${question}\n\nPass --auth public or --auth gated to say which it is.`);
32
+ const answer = await ctx.prompt(`${question}\nDoes testing it require signing in? [y/N] `);
33
+ return /^y(es)?$/i.test(answer) ? "gated" : "public";
34
+ }
6
35
  export const projectCommands = [
7
36
  {
8
37
  name: "projects list",
9
38
  summary: "List projects in the workspace",
10
39
  scope: "workspace",
40
+ groupDefault: true,
41
+ groupSummary: "Create and manage projects — a site Beryl explores, authors tests for, and runs.",
11
42
  async run(ctx, input) {
12
43
  const ws = await ctx.requireWorkspace(input);
13
44
  return { data: await ctx.client.get(`/workspaces/${ws}/projects`) };
@@ -32,7 +63,8 @@ export const projectCommands = [
32
63
  name: "auth",
33
64
  type: "string",
34
65
  enum: ["public", "gated"],
35
- description: "Whether the site needs a login (gated) or not (public)",
66
+ description: "Whether the site needs a login (gated) or not (public). Default: detected from the " +
67
+ "site — only asked when detection is genuinely unsure",
36
68
  },
37
69
  {
38
70
  name: "allow-mutations",
@@ -50,7 +82,7 @@ export const projectCommands = [
50
82
  { name: "timeout", type: "number", description: "With --watch: max minutes to wait" },
51
83
  ],
52
84
  examples: [
53
- "beryl projects create https://app.example.com --auth public --watch",
85
+ "beryl projects create https://app.example.com --watch",
54
86
  "beryl projects create https://app.example.com --auth gated",
55
87
  "beryl projects create https://app.example.com --auth public --no-explore",
56
88
  ],
@@ -59,10 +91,11 @@ export const projectCommands = [
59
91
  if (noExplore && flagBool(input, "watch"))
60
92
  throw new UsageError("--no-explore cannot be combined with --watch");
61
93
  const ws = await ctx.requireWorkspace(input);
62
- const auth = flagStr(input, "auth");
94
+ const url = arg(input, "url");
95
+ const auth = flagStr(input, "auth") ?? (await resolveAuthChoice(ctx, ws, url));
63
96
  const created = (await ctx.client.post(`/workspaces/${ws}/projects`, {
64
- root_url: arg(input, "url"),
65
- requires_auth_choice: auth ?? null,
97
+ root_url: url,
98
+ requires_auth_choice: auth,
66
99
  mutation_choice: flagBool(input, "allow-mutations") ? "allow_mutations" : null,
67
100
  force_new_login: flagBool(input, "force-new-login"),
68
101
  skip_exploration: noExplore,
@@ -73,6 +106,15 @@ export const projectCommands = [
73
106
  human: `${green("Project created")}: ${created.project_id} ${dim("(no exploration started)")}\n` +
74
107
  `Ready for your own tests — author a plan and push it with \`beryl tests create\`.`,
75
108
  };
109
+ // The agent cannot start until a login is captured, so there is nothing to watch.
110
+ if (created.auth_status === "needs_capture")
111
+ return {
112
+ data: created,
113
+ human: `${green("Project created")}: ${created.project_id}\n` +
114
+ `The site needs a login before the agent can explore it. Capture one with ` +
115
+ `\`beryl credentials capture --project ${created.project_id}\` — the exploration ` +
116
+ `starts as soon as you do.`,
117
+ };
76
118
  if (!flagBool(input, "watch"))
77
119
  return { data: created };
78
120
  ctx.err(dim(`project ${created.project_id} created — waiting for the agent to start…`));
@@ -1,6 +1,24 @@
1
1
  import fs from "node:fs";
2
+ import { downloadRunArtifacts, failureImages, isFailing, resultsOf, } from "../artifacts.js";
3
+ import { CliError, UsageError } from "../errors.js";
4
+ import { runSpecLocally } from "../local-run.js";
5
+ import { dim, green, red, yellow } from "../output.js";
2
6
  import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
3
7
  import { watchRun } from "./watch.js";
8
+ const MAX_FAILURE_SCREENSHOTS = 5;
9
+ export function parseHeaders(raw) {
10
+ if (!raw || raw.length === 0)
11
+ return null;
12
+ const headers = {};
13
+ for (const entry of raw) {
14
+ const eq = entry.indexOf("=");
15
+ const key = eq > 0 ? entry.slice(0, eq).trim() : "";
16
+ if (!key)
17
+ throw new UsageError(`--header must be KEY=VALUE, got: ${entry}`);
18
+ headers[key] = entry.slice(eq + 1);
19
+ }
20
+ return headers;
21
+ }
4
22
  export const runCommands = [
5
23
  {
6
24
  name: "runs trigger",
@@ -12,31 +30,126 @@ export const runCommands = [
12
30
  { name: "test", type: "strings", description: "Run only these test ids (repeatable)" },
13
31
  { name: "env", type: "string", description: "Environment id to run against" },
14
32
  { name: "url-override", type: "string", description: "Replace the base URL (preview deploys)" },
33
+ {
34
+ name: "header",
35
+ type: "strings",
36
+ description: "Send a custom request header on every navigation, KEY=VALUE (repeatable). " +
37
+ "Reaches auth-walled preview deploys, e.g. --header x-vercel-protection-bypass=<token>",
38
+ },
15
39
  { name: "watch", type: "boolean", description: "Stream progress and exit non-zero on failure" },
16
40
  { name: "timeout", type: "number", description: "With --watch: max minutes to wait" },
41
+ {
42
+ name: "retries",
43
+ type: "number",
44
+ description: "Retry a failing test up to N times (0 disables); omit for the default",
45
+ },
17
46
  ],
18
47
  examples: [
19
48
  "beryl runs trigger --watch",
20
49
  "beryl runs trigger --url-override https://preview-123.example.com --watch --timeout 30",
50
+ "beryl runs trigger --url-override https://preview-123.example.com --header x-vercel-protection-bypass=<token> --watch",
21
51
  "beryl runs trigger --test 4f… --test 9a…",
52
+ "beryl runs trigger --retries 0 --watch",
22
53
  ],
23
54
  async run(ctx, input) {
24
55
  const { workspaceId, projectId } = await ctx.requireProject(input);
25
56
  const tests = input.flags.test;
57
+ const extraHeaders = parseHeaders(input.flags.header);
26
58
  const created = (await ctx.client.post(`${projectPath(workspaceId, projectId)}/runs`, {
27
59
  test_case_ids: tests && tests.length > 0 ? tests : null,
28
60
  environment_id: flagStr(input, "env") ?? null,
29
61
  target_url_override: flagStr(input, "url-override") ?? null,
62
+ max_retries: flagNum(input, "retries") ?? null,
63
+ extra_headers: extraHeaders,
30
64
  }));
31
65
  if (!flagBool(input, "watch"))
32
66
  return { data: created };
33
67
  return await watchRun(ctx, workspaceId, projectId, created.id, flagNum(input, "timeout"));
34
68
  },
35
69
  },
70
+ {
71
+ name: "runs local",
72
+ summary: "Run a banked test locally with your own Playwright (public flows)",
73
+ description: "Unlike `runs trigger`, this runs on YOUR machine, not Beryl's cloud — fetches the test's " +
74
+ "rendered spec, then runs it with your local @playwright/test (install it once with " +
75
+ "`npm i -D @playwright/test && npx playwright install`). Point --url-override at a local " +
76
+ "dev server or preview, and --dir to keep the spec, artifacts, and JSON report on disk so " +
77
+ "an agent can run-fix-run. v1 targets public/unauthenticated flows: an authenticated test " +
78
+ "refuses to run locally (those run in Beryl's cloud, which holds the session) — no session " +
79
+ "is ever decrypted to your disk. Exits 0 if every test passed, 1 on a test failure.",
80
+ scope: "project",
81
+ args: [{ name: "test-id", description: "Test id to run (from `beryl tests list`)", required: true }],
82
+ flags: [
83
+ {
84
+ name: "url-override",
85
+ type: "string",
86
+ description: "Run against this base URL instead of the environment's (e.g. http://localhost:3000)",
87
+ },
88
+ {
89
+ name: "dir",
90
+ type: "string",
91
+ description: "Write the spec, artifacts, and JSON report here (default: a temp dir)",
92
+ },
93
+ ],
94
+ examples: [
95
+ "beryl runs local 4f…",
96
+ "beryl runs local 4f… --url-override http://localhost:3000 --dir ./beryl-local",
97
+ ],
98
+ async run(ctx, input) {
99
+ const { workspaceId, projectId } = await ctx.requireProject(input);
100
+ const testId = arg(input, "test-id");
101
+ const script = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests/${testId}/script`, { base_url: flagStr(input, "url-override") }));
102
+ // v1 is public flows only. A gated test's session lives (encrypted) in the cloud and is
103
+ // never handed to a local runner, so refuse rather than run a spec doomed to fail at the
104
+ // login wall. Exit 1: it's a failure to run this test locally, not a usage error.
105
+ if (script.requires_auth) {
106
+ throw new CliError("This test signs in first, so it can only run in Beryl's cloud (which holds the " +
107
+ "session) — local runs are for public/unauthenticated flows. Run it with " +
108
+ "`beryl runs trigger`.");
109
+ }
110
+ let outcome;
111
+ try {
112
+ outcome = await runSpecLocally({
113
+ spec: script.content,
114
+ testName: testId,
115
+ dir: flagStr(input, "dir"),
116
+ onProgress: (line) => ctx.err(dim(line)),
117
+ });
118
+ }
119
+ catch (err) {
120
+ // Every failure to run the spec (missing Playwright, a compile error, a customer
121
+ // config filtering our spec away) already carries an actionable message — surface it
122
+ // as a CliError so the user sees that, not a raw Node stack trace.
123
+ throw new CliError(err instanceof Error ? err.message : String(err));
124
+ }
125
+ const summary = { passed: outcome.passed, failed: outcome.failed, results: outcome.results };
126
+ const marker = (status) => status === "passed" || status === "expected"
127
+ ? green("✓")
128
+ : status === "skipped"
129
+ ? dim("○")
130
+ : red("✗");
131
+ const lines = outcome.results.map((r) => ` ${marker(r.status)} ${r.name}${r.error ? `\n ${dim(r.error.split("\n")[0] ?? "")}` : ""}`);
132
+ const human = `${outcome.failed === 0 ? green("All tests passed") : red(`${outcome.failed} test(s) failed`)} ` +
133
+ `(${outcome.passed} passed, ${outcome.failed} failed)\n${lines.join("\n")}` +
134
+ (flagStr(input, "dir") ? `\n${dim(`Spec + artifacts + report in ${outcome.directory}`)}` : "");
135
+ const persisted = Boolean(flagStr(input, "dir"));
136
+ return {
137
+ // Only surface on-disk paths when --dir kept them; without it the run dir is deleted.
138
+ data: persisted
139
+ ? { ...summary, directory: outcome.directory, report: outcome.report }
140
+ : summary,
141
+ human,
142
+ // Exit codes are a CI contract: any failing test → exit 1.
143
+ ...(outcome.failed > 0 ? { exitCode: 1 } : {}),
144
+ };
145
+ },
146
+ },
36
147
  {
37
148
  name: "runs list",
38
149
  summary: "List recent runs",
39
150
  scope: "project",
151
+ groupDefault: true,
152
+ groupSummary: "Trigger a run of a project's tests (e.g. in CI), then watch, inspect, and download results.",
40
153
  flags: [{ name: "env", type: "string", description: "Filter by environment id" }],
41
154
  async run(ctx, input) {
42
155
  const { workspaceId, projectId } = await ctx.requireProject(input);
@@ -49,13 +162,31 @@ export const runCommands = [
49
162
  {
50
163
  name: "runs get",
51
164
  summary: "Show one run with its per-test results",
165
+ description: "Over MCP the failure screenshots come back as viewable image content, so an agent can " +
166
+ "look at the page that broke instead of guessing from the error string. Set screenshots " +
167
+ "to false to skip fetching them. Ignored outside MCP — the terminal cannot show an image.",
52
168
  scope: "project",
53
169
  args: [{ name: "run-id", description: "Run id", required: true }],
170
+ flags: [
171
+ {
172
+ name: "screenshots",
173
+ type: "boolean",
174
+ default: true,
175
+ description: "Attach failure screenshots as image content (MCP only; default true)",
176
+ },
177
+ ],
54
178
  async run(ctx, input) {
55
179
  const { workspaceId, projectId } = await ctx.requireProject(input);
56
- return {
57
- data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/runs/${arg(input, "run-id")}`),
58
- };
180
+ const data = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/runs/${arg(input, "run-id")}`));
181
+ // Only the MCP adapter renders images; fetching bytes for a plain CLI run
182
+ // would be wasted network.
183
+ if (!ctx.mcp || input.flags.screenshots === false)
184
+ return { data };
185
+ const { images, skipped } = await failureImages(data, MAX_FAILURE_SCREENSHOTS);
186
+ // A silently-missing picture reads as "there was none" — say what was lost.
187
+ for (const note of skipped)
188
+ ctx.err(yellow(`! ${note}`));
189
+ return { data, images };
59
190
  },
60
191
  },
61
192
  {
@@ -96,24 +227,74 @@ export const runCommands = [
96
227
  },
97
228
  {
98
229
  name: "runs download",
99
- summary: "Download a run's full results as JSON",
230
+ summary: "Download a run's results, with its artifacts, to disk",
231
+ description: "With --dir, fetches the artifact bytes — screenshots, DOM snapshots, the Playwright " +
232
+ "trace zip, and the filmstrip frames of the failing tests — into <dir>/<test-result-id>/ " +
233
+ "alongside a run.json manifest. Artifact URLs are short-lived, so download rather than " +
234
+ "stash them. With --out (or neither), writes only the JSON manifest.",
100
235
  scope: "project",
101
236
  args: [{ name: "run-id", description: "Run id", required: true }],
102
- flags: [{ name: "out", type: "string", description: "Write to a file instead of stdout" }],
237
+ flags: [
238
+ {
239
+ name: "dir",
240
+ type: "string",
241
+ description: "Write run.json plus the artifact files into this directory",
242
+ },
243
+ {
244
+ name: "all-frames",
245
+ type: "boolean",
246
+ description: "With --dir: also fetch the filmstrip frames of passing tests",
247
+ },
248
+ { name: "out", type: "string", description: "Write the JSON to a file instead of stdout" },
249
+ ],
250
+ examples: [
251
+ "beryl runs download 7c1… --dir ./beryl-run",
252
+ "beryl runs download 7c1… --out run.json",
253
+ ],
103
254
  async run(ctx, input) {
104
255
  const { workspaceId, projectId } = await ctx.requireProject(input);
105
- const data = await ctx.client.get(`${projectPath(workspaceId, projectId)}/runs/${arg(input, "run-id")}/download`);
256
+ const data = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/runs/${arg(input, "run-id")}/download`));
257
+ // --out and --dir are not alternatives: one says where the JSON goes, the other
258
+ // asks for the bytes as well. Honour both when both are given.
106
259
  const out = flagStr(input, "out");
107
- if (out) {
260
+ if (out)
108
261
  fs.writeFileSync(out, JSON.stringify(data, null, 2) + "\n");
109
- return { data: { written: out }, human: `Wrote ${out}` };
262
+ const dir = flagStr(input, "dir");
263
+ if (dir) {
264
+ const report = await downloadRunArtifacts(data, dir, {
265
+ allFrames: flagBool(input, "all-frames"),
266
+ onProgress: (line) => ctx.err(dim(line)),
267
+ });
268
+ for (const failure of report.failures) {
269
+ ctx.err(yellow(`! could not fetch ${failure.url}: ${failure.error}`));
270
+ }
271
+ const failed = resultsOf(data).filter((r) => isFailing(r.status)).length;
272
+ // Exit codes are a CI contract: a bundle with zero artifacts on disk when the
273
+ // run had some is a failed download, not a success with a caveat.
274
+ const lostEverything = report.artifacts.length === 0 && report.failures.length > 0;
275
+ return {
276
+ data: {
277
+ directory: report.directory,
278
+ manifest: report.manifest,
279
+ artifacts: report.artifacts,
280
+ failed_tests: failed,
281
+ skipped: report.failures.length,
282
+ ...(out ? { written: out } : {}),
283
+ },
284
+ human: `Wrote ${report.manifest} and ${report.artifacts.length} artifact file(s) to ${dir}` +
285
+ (report.failures.length ? ` (${report.failures.length} could not be fetched)` : "") +
286
+ (out ? `\nWrote ${out}` : ""),
287
+ ...(lostEverything ? { exitCode: 1 } : {}),
288
+ };
110
289
  }
290
+ if (out)
291
+ return { data: { written: out }, human: `Wrote ${out}` };
111
292
  return { data, human: JSON.stringify(data, null, 2) };
112
293
  },
113
294
  },
114
295
  {
115
296
  name: "runs explain",
116
- summary: "AI explanation of why a test result failed",
297
+ summary: "Explain, with AI, why a test result failed",
117
298
  scope: "project",
118
299
  args: [{ name: "result-id", description: "Test result id (from `beryl runs get`)", required: true }],
119
300
  async run(ctx, input) {