@beryl-so/cli 0.1.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.
@@ -0,0 +1,50 @@
1
+ import { arg, flagNum, projectPath } from "./util.js";
2
+ import { watchExploration } from "./watch.js";
3
+ export const explorationCommands = [
4
+ {
5
+ name: "explorations list",
6
+ summary: "List the agent's exploration passes for a project",
7
+ scope: "project",
8
+ async run(ctx, input) {
9
+ const { workspaceId, projectId } = await ctx.requireProject(input);
10
+ return { data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/explorations`) };
11
+ },
12
+ },
13
+ {
14
+ name: "explorations get",
15
+ summary: "Show one exploration: authored tests, abandoned flows, coverage, frontier",
16
+ scope: "project",
17
+ args: [{ name: "exploration-id", description: "Exploration id", required: true }],
18
+ async run(ctx, input) {
19
+ const { workspaceId, projectId } = await ctx.requireProject(input);
20
+ return {
21
+ data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/explorations/${arg(input, "exploration-id")}`),
22
+ };
23
+ },
24
+ },
25
+ {
26
+ name: "explorations steps",
27
+ summary: "List every step the agent took in an exploration",
28
+ scope: "project",
29
+ args: [{ name: "exploration-id", description: "Exploration id", required: true }],
30
+ async run(ctx, input) {
31
+ const { workspaceId, projectId } = await ctx.requireProject(input);
32
+ return {
33
+ data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/explorations/${arg(input, "exploration-id")}/steps`),
34
+ };
35
+ },
36
+ },
37
+ {
38
+ name: "explorations watch",
39
+ summary: "Stream an exploration live — watch the agent explore and author tests",
40
+ description: "Replays every recorded step on connect, then follows live until the exploration " +
41
+ "completes or fails.",
42
+ scope: "project",
43
+ args: [{ name: "exploration-id", description: "Exploration id", required: true }],
44
+ flags: [{ name: "timeout", type: "number", description: "Max minutes to wait" }],
45
+ async run(ctx, input) {
46
+ const { workspaceId, projectId } = await ctx.requireProject(input);
47
+ return await watchExploration(ctx, workspaceId, projectId, arg(input, "exploration-id"), flagNum(input, "timeout"));
48
+ },
49
+ },
50
+ ];
@@ -0,0 +1,189 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { LOCAL_CONFIG_FILENAME, loadConfig } from "../config.js";
4
+ import { CliError, UsageError } from "../errors.js";
5
+ import { ApiClient } from "../http.js";
6
+ import { bold, cyan, dim, green } from "../output.js";
7
+ import { authCommands } from "./auth.js";
8
+ import { flagBool, flagStr } from "./util.js";
9
+ const MCP_SERVER_ENTRY = {
10
+ command: "npx",
11
+ args: ["-y", "@beryl-so/cli", "mcp"],
12
+ };
13
+ const PLAYWRIGHT_SERVER_ENTRY = {
14
+ command: "npx",
15
+ args: ["@playwright/mcp@latest"],
16
+ };
17
+ async function pick(ctx, kind, items) {
18
+ if (items.length === 1)
19
+ return items[0];
20
+ ctx.err(`\n${bold(`Which ${kind}?`)}`);
21
+ items.forEach((item, i) => ctx.err(` ${i + 1}. ${item.name ?? item.root_url ?? item.id} ${dim(item.id)}`));
22
+ const answer = await ctx.prompt(`${kind} [1-${items.length}]: `);
23
+ const index = Number(answer) - 1;
24
+ const chosen = items[index];
25
+ if (!chosen)
26
+ throw new UsageError(`"${answer}" is not between 1 and ${items.length}`);
27
+ return chosen;
28
+ }
29
+ function mergeMcpConfig(file, withPlaywright) {
30
+ let existing = {};
31
+ if (fs.existsSync(file)) {
32
+ try {
33
+ existing = JSON.parse(fs.readFileSync(file, "utf8"));
34
+ }
35
+ catch (err) {
36
+ throw new CliError(`${file} exists but is not valid JSON (${err.message})`);
37
+ }
38
+ }
39
+ const servers = (existing.mcpServers ?? {});
40
+ const beryl = JSON.stringify(servers.beryl) !== JSON.stringify(MCP_SERVER_ENTRY);
41
+ if (beryl)
42
+ servers.beryl = MCP_SERVER_ENTRY;
43
+ let playwright;
44
+ if (withPlaywright) {
45
+ // Never clobber a playwright server the user already wired up.
46
+ playwright = servers.playwright === undefined;
47
+ if (playwright)
48
+ servers.playwright = PLAYWRIGHT_SERVER_ENTRY;
49
+ }
50
+ if (beryl || playwright) {
51
+ existing.mcpServers = servers;
52
+ fs.mkdirSync(path.dirname(file), { recursive: true });
53
+ fs.writeFileSync(file, JSON.stringify(existing, null, 2) + "\n");
54
+ }
55
+ return { beryl, playwright };
56
+ }
57
+ function detectEditors(cwd) {
58
+ const editors = [];
59
+ if (fs.existsSync(path.join(cwd, ".claude")) || fs.existsSync(path.join(cwd, "CLAUDE.md")))
60
+ editors.push("claude-code");
61
+ if (fs.existsSync(path.join(cwd, ".cursor")))
62
+ editors.push("cursor");
63
+ return editors;
64
+ }
65
+ export const initCommands = [
66
+ {
67
+ name: "init",
68
+ summary: "Set up Beryl in this repo — sign in, pin a project, wire up your coding agent",
69
+ 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.",
74
+ interactive: true,
75
+ flags: [
76
+ { name: "workspace", type: "string", description: "Workspace id or name to pin" },
77
+ { name: "project", type: "string", description: "Project id, name, or URL to pin" },
78
+ {
79
+ name: "editor-tools",
80
+ type: "string",
81
+ enum: ["claude-code", "cursor", "both", "none"],
82
+ description: "Which coding agent to write MCP config for (default: auto-detect)",
83
+ },
84
+ { name: "no-pin", type: "boolean", description: "Skip writing .beryl.json" },
85
+ {
86
+ name: "local",
87
+ type: "boolean",
88
+ description: "Also wire the Playwright MCP so your coding agent can drive a local browser " +
89
+ "(for authoring tests yourself)",
90
+ },
91
+ ],
92
+ examples: [
93
+ "npx @beryl-so/cli@latest init",
94
+ "beryl init --editor-tools claude-code",
95
+ "beryl init --project https://app.example.com --editor-tools none",
96
+ ],
97
+ async run(ctx, input) {
98
+ const cwd = process.cwd();
99
+ let { client, config } = ctx;
100
+ if (config.token) {
101
+ const me = (await client.get("/account/"));
102
+ ctx.err(`${green("✓")} Signed in as ${me.email}`);
103
+ }
104
+ else {
105
+ const login = authCommands.find((c) => c.name === "login");
106
+ await login.run(ctx, { args: {}, flags: {} });
107
+ config = loadConfig();
108
+ if (!config.token)
109
+ throw new CliError("Login did not persist a token");
110
+ client = new ApiClient(config.apiUrl, config.token);
111
+ }
112
+ let workspaceId;
113
+ let projectId;
114
+ if (!flagBool(input, "no-pin")) {
115
+ const workspaces = (await client.get("/workspaces/"));
116
+ if (workspaces.length === 0)
117
+ throw new CliError("You have no workspaces yet — create one with `beryl workspaces create`");
118
+ const wanted = flagStr(input, "workspace");
119
+ const workspace = wanted
120
+ ? workspaces.find((w) => w.id === wanted || w.name?.toLowerCase() === wanted.toLowerCase())
121
+ : await pick(ctx, "workspace", workspaces);
122
+ if (!workspace)
123
+ throw new CliError(`No workspace named "${wanted}" found`);
124
+ workspaceId = workspace.id;
125
+ const projects = (await client.get(`/workspaces/${workspaceId}/projects`));
126
+ const wantedProject = flagStr(input, "project");
127
+ if (wantedProject) {
128
+ projectId = (await ctx.requireProject({
129
+ args: {},
130
+ flags: { workspace: workspaceId, project: wantedProject },
131
+ })).projectId;
132
+ }
133
+ else if (projects.length > 0) {
134
+ projectId = (await pick(ctx, "project", projects)).id;
135
+ }
136
+ else {
137
+ const url = await ctx.prompt("No projects yet. Root URL of the site to test: ");
138
+ const created = (await client.post(`/workspaces/${workspaceId}/projects`, {
139
+ root_url: url,
140
+ }));
141
+ projectId = created.project_id;
142
+ ctx.err(`${green("✓")} Project created — the agent is exploring ${url} and authoring tests ` +
143
+ `(watch with \`beryl explorations watch\`)`);
144
+ }
145
+ const localFile = path.join(cwd, LOCAL_CONFIG_FILENAME);
146
+ let local = {};
147
+ try {
148
+ local = JSON.parse(fs.readFileSync(localFile, "utf8"));
149
+ }
150
+ catch {
151
+ // no local config yet
152
+ }
153
+ fs.writeFileSync(localFile, JSON.stringify({ ...local, workspace: workspaceId, project: projectId }, null, 2) + "\n");
154
+ ctx.err(`${green("✓")} Pinned to ${LOCAL_CONFIG_FILENAME} ${dim(`(${projectId})`)}`);
155
+ }
156
+ const choice = flagStr(input, "editor-tools") ?? "auto";
157
+ const editors = choice === "auto"
158
+ ? detectEditors(cwd)
159
+ : choice === "both"
160
+ ? ["claude-code", "cursor"]
161
+ : choice === "none"
162
+ ? []
163
+ : [choice];
164
+ const local = flagBool(input, "local");
165
+ for (const editor of editors) {
166
+ const file = editor === "claude-code"
167
+ ? path.join(cwd, ".mcp.json")
168
+ : path.join(cwd, ".cursor", "mcp.json");
169
+ const wrote = mergeMcpConfig(file, local);
170
+ ctx.err(`${green("✓")} ${editor} MCP ${wrote.beryl ? "configured" : "already configured"} ${dim(path.relative(cwd, file))}`);
171
+ if (wrote.playwright !== undefined)
172
+ ctx.err(`${green("✓")} ${editor} Playwright MCP ${wrote.playwright ? "configured" : "already configured"} ${dim(path.relative(cwd, file))}`);
173
+ }
174
+ if (choice === "auto" && editors.length === 0)
175
+ 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` +
179
+ ` ${cyan("beryl runs trigger --watch")} run the suite\n` +
180
+ ` ${cyan("beryl explorations watch")} watch the agent work\n` +
181
+ ` ${cyan("beryl tests list")} see authored tests` +
182
+ (local
183
+ ? `\n\nPlaywright MCP is wired — your coding agent can explore the site locally ` +
184
+ `and push tests with ${cyan("beryl tests create")}.`
185
+ : ""),
186
+ };
187
+ },
188
+ },
189
+ ];
@@ -0,0 +1,15 @@
1
+ export const mcpCommands = [
2
+ {
3
+ name: "mcp",
4
+ summary: "Run the Beryl MCP server (stdio) — every CLI command as an agent tool",
5
+ description: "Exposes the CLI's commands as MCP tools over stdio, so coding agents (Claude Code, " +
6
+ "Cursor, …) can create projects, trigger runs, watch the agent, and edit tests. " +
7
+ "Authenticate via BERYL_API_KEY or a prior `beryl login`.",
8
+ examples: ["claude mcp add beryl -- beryl mcp"],
9
+ async run(ctx) {
10
+ const { serveMcp } = await import("../adapters/mcp.js");
11
+ await serveMcp(ctx);
12
+ return {};
13
+ },
14
+ },
15
+ ];
@@ -0,0 +1,177 @@
1
+ import { saveGlobalConfig } from "../config.js";
2
+ import { UsageError } from "../errors.js";
3
+ import { dim, green } from "../output.js";
4
+ import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
5
+ import { pollCurrentExploration, watchExploration } from "./watch.js";
6
+ export const projectCommands = [
7
+ {
8
+ name: "projects list",
9
+ summary: "List projects in the workspace",
10
+ scope: "workspace",
11
+ async run(ctx, input) {
12
+ const ws = await ctx.requireWorkspace(input);
13
+ return { data: await ctx.client.get(`/workspaces/${ws}/projects`) };
14
+ },
15
+ },
16
+ {
17
+ name: "projects get",
18
+ summary: "Show one project, including its current exploration state",
19
+ scope: "project",
20
+ async run(ctx, input) {
21
+ const { workspaceId, projectId } = await ctx.requireProject(input);
22
+ return { data: await ctx.client.get(projectPath(workspaceId, projectId)) };
23
+ },
24
+ },
25
+ {
26
+ name: "projects create",
27
+ summary: "Create a project — the agent starts exploring and authoring tests immediately",
28
+ scope: "workspace",
29
+ args: [{ name: "url", description: "Root URL of the site to test", required: true }],
30
+ flags: [
31
+ {
32
+ name: "auth",
33
+ type: "string",
34
+ enum: ["public", "gated"],
35
+ description: "Whether the site needs a login (gated) or not (public)",
36
+ },
37
+ {
38
+ name: "allow-mutations",
39
+ type: "boolean",
40
+ description: "Let the agent perform state-changing actions while exploring",
41
+ },
42
+ { name: "force-new-login", type: "boolean", description: "Ignore any reusable saved login" },
43
+ {
44
+ name: "no-explore",
45
+ type: "boolean",
46
+ description: "Create the project without starting the cloud exploration — author tests yourself " +
47
+ "via `beryl tests create` or your coding agent over MCP",
48
+ },
49
+ { name: "watch", type: "boolean", description: "Stream the agent's exploration live" },
50
+ { name: "timeout", type: "number", description: "With --watch: max minutes to wait" },
51
+ ],
52
+ examples: [
53
+ "beryl projects create https://app.example.com --auth public --watch",
54
+ "beryl projects create https://app.example.com --auth gated",
55
+ "beryl projects create https://app.example.com --auth public --no-explore",
56
+ ],
57
+ async run(ctx, input) {
58
+ const noExplore = flagBool(input, "no-explore");
59
+ if (noExplore && flagBool(input, "watch"))
60
+ throw new UsageError("--no-explore cannot be combined with --watch");
61
+ const ws = await ctx.requireWorkspace(input);
62
+ const auth = flagStr(input, "auth");
63
+ const created = (await ctx.client.post(`/workspaces/${ws}/projects`, {
64
+ root_url: arg(input, "url"),
65
+ requires_auth_choice: auth ?? null,
66
+ mutation_choice: flagBool(input, "allow-mutations") ? "allow_mutations" : null,
67
+ force_new_login: flagBool(input, "force-new-login"),
68
+ skip_exploration: noExplore,
69
+ }));
70
+ if (noExplore)
71
+ return {
72
+ data: created,
73
+ human: `${green("Project created")}: ${created.project_id} ${dim("(no exploration started)")}\n` +
74
+ `Ready for your own tests — author a plan and push it with \`beryl tests create\`.`,
75
+ };
76
+ if (!flagBool(input, "watch"))
77
+ return { data: created };
78
+ ctx.err(dim(`project ${created.project_id} created — waiting for the agent to start…`));
79
+ const explorationId = await pollCurrentExploration(ctx, ws, created.project_id);
80
+ const watched = await watchExploration(ctx, ws, created.project_id, explorationId, flagNum(input, "timeout"));
81
+ return { data: { ...created, ...watched.data }, exitCode: watched.exitCode };
82
+ },
83
+ },
84
+ {
85
+ name: "projects rename",
86
+ summary: "Rename a project",
87
+ scope: "project",
88
+ args: [{ name: "name", description: "New name", required: true }],
89
+ async run(ctx, input) {
90
+ const { workspaceId, projectId } = await ctx.requireProject(input);
91
+ return {
92
+ data: await ctx.client.patch(projectPath(workspaceId, projectId), {
93
+ name: arg(input, "name"),
94
+ }),
95
+ };
96
+ },
97
+ },
98
+ {
99
+ name: "projects delete",
100
+ summary: "Delete a project and all its tests and runs",
101
+ scope: "project",
102
+ flags: [{ name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
103
+ async run(ctx, input) {
104
+ const { workspaceId, projectId } = await ctx.requireProject(input);
105
+ await ctx.confirm(`Delete project ${projectId} and all its data?`, flagBool(input, "force"));
106
+ await ctx.client.del(projectPath(workspaceId, projectId));
107
+ return { human: "Deleted." };
108
+ },
109
+ },
110
+ {
111
+ name: "projects re-explore",
112
+ summary: "Send the agent back in — run/heal existing tests and discover new flows",
113
+ scope: "project",
114
+ flags: [
115
+ { name: "watch", type: "boolean", description: "Stream the agent's exploration live" },
116
+ { name: "timeout", type: "number", description: "With --watch: max minutes to wait" },
117
+ ],
118
+ async run(ctx, input) {
119
+ const { workspaceId, projectId } = await ctx.requireProject(input);
120
+ await ctx.client.post(`${projectPath(workspaceId, projectId)}/re-explore`);
121
+ if (!flagBool(input, "watch"))
122
+ return { human: "Exploration queued." };
123
+ const explorationId = await pollCurrentExploration(ctx, workspaceId, projectId);
124
+ return await watchExploration(ctx, workspaceId, projectId, explorationId, flagNum(input, "timeout"));
125
+ },
126
+ },
127
+ {
128
+ name: "projects report",
129
+ summary: "Aggregate quality report across recent runs (pass rates, flaky tests, trend)",
130
+ scope: "project",
131
+ flags: [
132
+ { name: "runs", type: "number", description: "How many recent runs to aggregate" },
133
+ { name: "env", type: "string", description: "Limit to one environment id" },
134
+ ],
135
+ async run(ctx, input) {
136
+ const { workspaceId, projectId } = await ctx.requireProject(input);
137
+ return {
138
+ data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/report`, {
139
+ run_limit: flagNum(input, "runs"),
140
+ environment_id: flagStr(input, "env"),
141
+ }),
142
+ };
143
+ },
144
+ },
145
+ {
146
+ name: "projects reusable-auth",
147
+ summary: "Check whether a saved login can be reused for a URL before creating a project",
148
+ scope: "workspace",
149
+ args: [{ name: "url", description: "The URL you plan to test", required: true }],
150
+ async run(ctx, input) {
151
+ const ws = await ctx.requireWorkspace(input);
152
+ return {
153
+ data: await ctx.client.get(`/workspaces/${ws}/projects/reusable-auth`, {
154
+ url: arg(input, "url"),
155
+ }),
156
+ };
157
+ },
158
+ },
159
+ {
160
+ name: "projects use",
161
+ summary: "Set the default project for future commands",
162
+ scope: "workspace",
163
+ args: [{ name: "project", description: "Project id, name, or URL", required: true }],
164
+ async run(ctx, input) {
165
+ const value = arg(input, "project");
166
+ const { workspaceId, projectId } = await ctx.requireProject({
167
+ args: {},
168
+ flags: { ...input.flags, project: value },
169
+ });
170
+ saveGlobalConfig({ workspace: workspaceId, project: projectId });
171
+ return {
172
+ data: { workspace: workspaceId, project: projectId },
173
+ human: `${green("Default project set")}: ${projectId} ${dim(`(workspace ${workspaceId})`)}`,
174
+ };
175
+ },
176
+ },
177
+ ];
@@ -0,0 +1,126 @@
1
+ import fs from "node:fs";
2
+ import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
3
+ import { watchRun } from "./watch.js";
4
+ export const runCommands = [
5
+ {
6
+ name: "runs trigger",
7
+ summary: "Trigger a test run (whole suite, a subset, or one environment)",
8
+ description: "Runs execute in Beryl's cloud. With --watch the CLI streams live progress and " +
9
+ "exits 0 only if every test passed — wire it straight into CI.",
10
+ scope: "project",
11
+ flags: [
12
+ { name: "test", type: "strings", description: "Run only these test ids (repeatable)" },
13
+ { name: "env", type: "string", description: "Environment id to run against" },
14
+ { name: "url-override", type: "string", description: "Replace the base URL (preview deploys)" },
15
+ { name: "watch", type: "boolean", description: "Stream progress and exit non-zero on failure" },
16
+ { name: "timeout", type: "number", description: "With --watch: max minutes to wait" },
17
+ ],
18
+ examples: [
19
+ "beryl runs trigger --watch",
20
+ "beryl runs trigger --url-override https://preview-123.example.com --watch --timeout 30",
21
+ "beryl runs trigger --test 4f… --test 9a…",
22
+ ],
23
+ async run(ctx, input) {
24
+ const { workspaceId, projectId } = await ctx.requireProject(input);
25
+ const tests = input.flags.test;
26
+ const created = (await ctx.client.post(`${projectPath(workspaceId, projectId)}/runs`, {
27
+ test_case_ids: tests && tests.length > 0 ? tests : null,
28
+ environment_id: flagStr(input, "env") ?? null,
29
+ target_url_override: flagStr(input, "url-override") ?? null,
30
+ }));
31
+ if (!flagBool(input, "watch"))
32
+ return { data: created };
33
+ return await watchRun(ctx, workspaceId, projectId, created.id, flagNum(input, "timeout"));
34
+ },
35
+ },
36
+ {
37
+ name: "runs list",
38
+ summary: "List recent runs",
39
+ scope: "project",
40
+ flags: [{ name: "env", type: "string", description: "Filter by environment id" }],
41
+ async run(ctx, input) {
42
+ const { workspaceId, projectId } = await ctx.requireProject(input);
43
+ const rows = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/runs`, {
44
+ environment_id: flagStr(input, "env"),
45
+ }));
46
+ return { data: rows.map(({ test_results: _omit, ...row }) => row) };
47
+ },
48
+ },
49
+ {
50
+ name: "runs get",
51
+ summary: "Show one run with its per-test results",
52
+ scope: "project",
53
+ args: [{ name: "run-id", description: "Run id", required: true }],
54
+ async run(ctx, input) {
55
+ const { workspaceId, projectId } = await ctx.requireProject(input);
56
+ return {
57
+ data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/runs/${arg(input, "run-id")}`),
58
+ };
59
+ },
60
+ },
61
+ {
62
+ name: "runs watch",
63
+ summary: "Attach to a run and stream progress until it finishes",
64
+ description: "Replays what already happened, then follows live. Exits 0 only if every test passed.",
65
+ scope: "project",
66
+ args: [{ name: "run-id", description: "Run id", required: true }],
67
+ flags: [{ name: "timeout", type: "number", description: "Max minutes to wait" }],
68
+ async run(ctx, input) {
69
+ const { workspaceId, projectId } = await ctx.requireProject(input);
70
+ return await watchRun(ctx, workspaceId, projectId, arg(input, "run-id"), flagNum(input, "timeout"));
71
+ },
72
+ },
73
+ {
74
+ name: "runs cancel",
75
+ summary: "Cancel an in-flight run",
76
+ scope: "project",
77
+ args: [{ name: "run-id", description: "Run id", required: true }],
78
+ async run(ctx, input) {
79
+ const { workspaceId, projectId } = await ctx.requireProject(input);
80
+ return {
81
+ data: await ctx.client.post(`${projectPath(workspaceId, projectId)}/runs/${arg(input, "run-id")}/cancel`),
82
+ };
83
+ },
84
+ },
85
+ {
86
+ name: "runs report",
87
+ summary: "Show the generated report for a run",
88
+ scope: "project",
89
+ args: [{ name: "run-id", description: "Run id", required: true }],
90
+ async run(ctx, input) {
91
+ const { workspaceId, projectId } = await ctx.requireProject(input);
92
+ return {
93
+ data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/runs/${arg(input, "run-id")}/report`),
94
+ };
95
+ },
96
+ },
97
+ {
98
+ name: "runs download",
99
+ summary: "Download a run's full results as JSON",
100
+ scope: "project",
101
+ args: [{ name: "run-id", description: "Run id", required: true }],
102
+ flags: [{ name: "out", type: "string", description: "Write to a file instead of stdout" }],
103
+ async run(ctx, input) {
104
+ const { workspaceId, projectId } = await ctx.requireProject(input);
105
+ const data = await ctx.client.get(`${projectPath(workspaceId, projectId)}/runs/${arg(input, "run-id")}/download`);
106
+ const out = flagStr(input, "out");
107
+ if (out) {
108
+ fs.writeFileSync(out, JSON.stringify(data, null, 2) + "\n");
109
+ return { data: { written: out }, human: `Wrote ${out}` };
110
+ }
111
+ return { data, human: JSON.stringify(data, null, 2) };
112
+ },
113
+ },
114
+ {
115
+ name: "runs explain",
116
+ summary: "AI explanation of why a test result failed",
117
+ scope: "project",
118
+ args: [{ name: "result-id", description: "Test result id (from `beryl runs get`)", required: true }],
119
+ async run(ctx, input) {
120
+ const { workspaceId, projectId } = await ctx.requireProject(input);
121
+ return {
122
+ data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/results/${arg(input, "result-id")}/explain`),
123
+ };
124
+ },
125
+ },
126
+ ];