@beryl-so/cli 0.2.0 → 0.6.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",
@@ -93,7 +144,8 @@ export const initCommands = [
93
144
  examples: [
94
145
  "npx @beryl-so/cli@latest init",
95
146
  "beryl init --editor-tools claude-code",
96
- "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",
97
149
  ],
98
150
  async run(ctx, input) {
99
151
  const cwd = process.cwd();
@@ -112,6 +164,7 @@ export const initCommands = [
112
164
  }
113
165
  let workspaceId;
114
166
  let projectId;
167
+ let authoredLocally = false;
115
168
  if (!flagBool(input, "no-pin")) {
116
169
  const workspaces = (await client.get("/workspaces/"));
117
170
  if (workspaces.length === 0)
@@ -125,7 +178,9 @@ export const initCommands = [
125
178
  workspaceId = workspace.id;
126
179
  const projects = (await client.get(`/workspaces/${workspaceId}/projects`));
127
180
  const wantedProject = flagStr(input, "project");
128
- 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) {
129
184
  projectId = (await ctx.requireProject({
130
185
  args: {},
131
186
  flags: { workspace: workspaceId, project: wantedProject },
@@ -135,13 +190,19 @@ export const initCommands = [
135
190
  projectId = (await pick(ctx, "project", projects)).id;
136
191
  }
137
192
  else {
138
- 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";
139
196
  const created = (await client.post(`/workspaces/${workspaceId}/projects`, {
140
197
  root_url: url,
198
+ skip_exploration: authoredLocally,
141
199
  }));
142
200
  projectId = created.project_id;
143
- ctx.err(`${green("✓")} Project created — the agent is exploring ${url} and authoring tests ` +
144
- `(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\`)`);
145
206
  }
146
207
  const localFile = path.join(cwd, LOCAL_CONFIG_FILENAME);
147
208
  let local = {};
@@ -177,16 +238,34 @@ export const initCommands = [
177
238
  }
178
239
  if (choice === "auto" && editors.length === 0)
179
240
  ctx.err(dim("No coding agent detected — pass --editor-tools claude-code|cursor to wire one."));
180
- return {
181
- data: { workspace: workspaceId ?? null, project: projectId ?? null, editors },
182
- 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` +
183
259
  ` ${cyan("beryl runs trigger --watch")} run the suite\n` +
184
260
  ` ${cyan("beryl explorations watch")} watch the agent work\n` +
185
261
  ` ${cyan("beryl tests list")} see authored tests` +
186
262
  (local
187
263
  ? `\n\nPlaywright MCP is wired — your coding agent can explore the site locally ` +
188
264
  `and push tests with ${cyan("beryl tests create")}.`
189
- : ""),
265
+ : "");
266
+ return {
267
+ data: { workspace: workspaceId ?? null, project: projectId ?? null, editors },
268
+ human: nextSteps,
190
269
  };
191
270
  },
192
271
  },
@@ -38,6 +38,7 @@ export const projectCommands = [
38
38
  summary: "List projects in the workspace",
39
39
  scope: "workspace",
40
40
  groupDefault: true,
41
+ groupSummary: "Create and manage projects — a site Beryl explores, authors tests for, and runs.",
41
42
  async run(ctx, input) {
42
43
  const ws = await ctx.requireWorkspace(input);
43
44
  return { data: await ctx.client.get(`/workspaces/${ws}/projects`) };
@@ -1,9 +1,24 @@
1
1
  import fs from "node:fs";
2
2
  import { downloadRunArtifacts, failureImages, isFailing, resultsOf, } from "../artifacts.js";
3
- import { dim, yellow } from "../output.js";
3
+ import { CliError, UsageError } from "../errors.js";
4
+ import { runSpecLocally } from "../local-run.js";
5
+ import { dim, green, red, yellow } from "../output.js";
4
6
  import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
5
7
  import { watchRun } from "./watch.js";
6
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
+ }
7
22
  export const runCommands = [
8
23
  {
9
24
  name: "runs trigger",
@@ -15,6 +30,12 @@ export const runCommands = [
15
30
  { name: "test", type: "strings", description: "Run only these test ids (repeatable)" },
16
31
  { name: "env", type: "string", description: "Environment id to run against" },
17
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
+ },
18
39
  { name: "watch", type: "boolean", description: "Stream progress and exit non-zero on failure" },
19
40
  { name: "timeout", type: "number", description: "With --watch: max minutes to wait" },
20
41
  {
@@ -26,28 +47,109 @@ export const runCommands = [
26
47
  examples: [
27
48
  "beryl runs trigger --watch",
28
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",
29
51
  "beryl runs trigger --test 4f… --test 9a…",
30
52
  "beryl runs trigger --retries 0 --watch",
31
53
  ],
32
54
  async run(ctx, input) {
33
55
  const { workspaceId, projectId } = await ctx.requireProject(input);
34
56
  const tests = input.flags.test;
57
+ const extraHeaders = parseHeaders(input.flags.header);
35
58
  const created = (await ctx.client.post(`${projectPath(workspaceId, projectId)}/runs`, {
36
59
  test_case_ids: tests && tests.length > 0 ? tests : null,
37
60
  environment_id: flagStr(input, "env") ?? null,
38
61
  target_url_override: flagStr(input, "url-override") ?? null,
39
62
  max_retries: flagNum(input, "retries") ?? null,
63
+ extra_headers: extraHeaders,
40
64
  }));
41
65
  if (!flagBool(input, "watch"))
42
66
  return { data: created };
43
67
  return await watchRun(ctx, workspaceId, projectId, created.id, flagNum(input, "timeout"));
44
68
  },
45
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
+ },
46
147
  {
47
148
  name: "runs list",
48
149
  summary: "List recent runs",
49
150
  scope: "project",
50
151
  groupDefault: true,
152
+ groupSummary: "Trigger a run of a project's tests (e.g. in CI), then watch, inspect, and download results.",
51
153
  flags: [{ name: "env", type: "string", description: "Filter by environment id" }],
52
154
  async run(ctx, input) {
53
155
  const { workspaceId, projectId } = await ctx.requireProject(input);
@@ -192,7 +294,7 @@ export const runCommands = [
192
294
  },
193
295
  {
194
296
  name: "runs explain",
195
- summary: "AI explanation of why a test result failed",
297
+ summary: "Explain, with AI, why a test result failed",
196
298
  scope: "project",
197
299
  args: [{ name: "result-id", description: "Test result id (from `beryl runs get`)", required: true }],
198
300
  async run(ctx, input) {
@@ -0,0 +1,82 @@
1
+ import { arg, flagBool, projectPath } from "./util.js";
2
+ const slackPath = (ws, p) => `${projectPath(ws, p)}/slack`;
3
+ export const slackCommands = [
4
+ {
5
+ name: "slack show",
6
+ summary: "Show the project's Slack alert config (webhook is masked)",
7
+ groupSummary: "Send run outcomes to a Slack channel via a per-project incoming webhook.",
8
+ scope: "project",
9
+ groupDefault: true,
10
+ async run(ctx, input) {
11
+ const { workspaceId, projectId } = await ctx.requireProject(input);
12
+ return { data: await ctx.client.get(slackPath(workspaceId, projectId)) };
13
+ },
14
+ },
15
+ {
16
+ name: "slack set-webhook",
17
+ summary: "Set the Slack incoming-webhook URL run alerts post to",
18
+ scope: "project",
19
+ args: [
20
+ {
21
+ name: "url",
22
+ description: "Slack incoming-webhook URL (https://hooks.slack.com/…)",
23
+ required: true,
24
+ },
25
+ ],
26
+ flags: [
27
+ {
28
+ name: "all-runs",
29
+ type: "boolean",
30
+ description: "Post on every run, not only on a new regression",
31
+ },
32
+ {
33
+ name: "failures-only",
34
+ type: "boolean",
35
+ description: "Post only on a new regression (the default)",
36
+ },
37
+ ],
38
+ async run(ctx, input) {
39
+ const { workspaceId, projectId } = await ctx.requireProject(input);
40
+ const path = slackPath(workspaceId, projectId);
41
+ // Preserve the stored notify preference unless the caller states one, so
42
+ // rotating the webhook doesn't silently reset an existing all-runs opt-in.
43
+ let notifyAllRuns;
44
+ if (flagBool(input, "all-runs")) {
45
+ notifyAllRuns = true;
46
+ }
47
+ else if (flagBool(input, "failures-only")) {
48
+ notifyAllRuns = false;
49
+ }
50
+ else {
51
+ const current = (await ctx.client.get(path));
52
+ notifyAllRuns = current?.notify_all_runs ?? false;
53
+ }
54
+ return {
55
+ data: await ctx.client.put(path, {
56
+ webhook_url: arg(input, "url"),
57
+ notify_all_runs: notifyAllRuns,
58
+ }),
59
+ };
60
+ },
61
+ },
62
+ {
63
+ name: "slack clear",
64
+ summary: "Remove the project's Slack webhook (stops all alerts)",
65
+ scope: "project",
66
+ async run(ctx, input) {
67
+ const { workspaceId, projectId } = await ctx.requireProject(input);
68
+ await ctx.client.del(slackPath(workspaceId, projectId));
69
+ return { human: "Cleared." };
70
+ },
71
+ },
72
+ {
73
+ name: "slack test",
74
+ summary: "Post a sample alert to the configured webhook",
75
+ scope: "project",
76
+ async run(ctx, input) {
77
+ const { workspaceId, projectId } = await ctx.requireProject(input);
78
+ await ctx.client.post(`${slackPath(workspaceId, projectId)}/test`);
79
+ return { human: "Sent a test message to Slack." };
80
+ },
81
+ },
82
+ ];
@@ -35,6 +35,7 @@ export const testCommands = [
35
35
  summary: "List the project's tests with their latest result",
36
36
  scope: "project",
37
37
  groupDefault: true,
38
+ groupSummary: "Author, inspect, version, and heal a project's tests — the checks Beryl runs on each run.",
38
39
  flags: [{ name: "env", type: "string", description: "Filter by environment id" }],
39
40
  async run(ctx, input) {
40
41
  const { workspaceId, projectId } = await ctx.requireProject(input);
@@ -80,13 +81,23 @@ export const testCommands = [
80
81
  flags: [
81
82
  { name: "title", type: "string", required: true, description: "Title for the new test" },
82
83
  { name: "file", type: "string", required: true, description: "Plan JSON file, or - for stdin" },
84
+ {
85
+ name: "description",
86
+ type: "string",
87
+ description: "1–3 sentences stating what this test proves — the immutable outcome Beryl's healing " +
88
+ "checks against. State the purpose, not the steps; the one observable signal that's true " +
89
+ "only if the flow worked.",
90
+ },
83
91
  {
84
92
  name: "no-verify",
85
93
  type: "boolean",
86
94
  description: "Skip the compile-time browser/AI verification — trust the authored plan as-is",
87
95
  },
88
96
  ],
89
- examples: ['beryl tests create --title "Checkout happy path" --file plan.json'],
97
+ examples: [
98
+ 'beryl tests create --title "Checkout happy path" --file plan.json',
99
+ 'beryl tests create --title "Checkout happy path" --file plan.json --description "Proves a shopper can buy a product: after paying, an order-confirmation page with an order number appears."',
100
+ ],
90
101
  async run(ctx, input) {
91
102
  const title = flagStr(input, "title");
92
103
  if (!title)
@@ -97,6 +108,7 @@ export const testCommands = [
97
108
  title,
98
109
  plan: readJsonFlag(input, "file"),
99
110
  verify: !flagBool(input, "no-verify"),
111
+ description: flagStr(input, "description"),
100
112
  }),
101
113
  };
102
114
  },
@@ -106,17 +118,25 @@ export const testCommands = [
106
118
  summary: "Replace a test's step plan from a JSON file (creates a new version)",
107
119
  description: "Accepts the same plan shape as `tests create`, including the optional `before` and " +
108
120
  "`after` sections — `after` runs on pass and on fail, so cleanup happens even when the " +
109
- "test goes red.",
121
+ "test goes red. Pass `--description` when the re-authored plan changes what the test " +
122
+ "proves; omit it to keep the test's existing intent.",
110
123
  scope: "project",
111
124
  args: [{ name: "test-id", description: "Test id", required: true }],
112
125
  flags: [
113
126
  { name: "file", type: "string", required: true, description: "Plan JSON file, or - for stdin" },
127
+ {
128
+ name: "description",
129
+ type: "string",
130
+ description: "1–3 sentences stating what this test proves — the immutable outcome Beryl's healing " +
131
+ "checks against. State the purpose, not the steps; the one observable signal that's true " +
132
+ "only if the flow worked. Omit to keep the test's existing intent.",
133
+ },
114
134
  ],
115
135
  examples: ["beryl tests plan 4f… > plan.json # edit, then:", "beryl tests set-plan 4f… --file plan.json"],
116
136
  async run(ctx, input) {
117
137
  const { workspaceId, projectId } = await ctx.requireProject(input);
118
138
  return {
119
- data: await ctx.client.patch(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/plan`, { json_plan: readJsonFlag(input, "file") }),
139
+ data: await ctx.client.patch(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/plan`, { json_plan: readJsonFlag(input, "file"), description: flagStr(input, "description") }),
120
140
  };
121
141
  },
122
142
  },
@@ -6,6 +6,7 @@ export const workspaceCommands = [
6
6
  {
7
7
  name: "workspaces list",
8
8
  summary: "List workspaces you belong to",
9
+ groupSummary: "Create and manage workspaces, and pick the one your commands act on by default.",
9
10
  async run(ctx) {
10
11
  const rows = (await ctx.client.get("/workspaces/"));
11
12
  return { data: rows };
@@ -115,6 +116,7 @@ export const workspaceCommands = [
115
116
  {
116
117
  name: "members list",
117
118
  summary: "List workspace members",
119
+ groupSummary: "Manage who belongs to a workspace and their roles.",
118
120
  scope: "workspace",
119
121
  async run(ctx, input) {
120
122
  const ws = await ctx.requireWorkspace(input);
@@ -154,6 +156,7 @@ export const workspaceCommands = [
154
156
  {
155
157
  name: "invites send",
156
158
  summary: "Invite someone to the workspace by email",
159
+ groupSummary: "Send, list, and revoke workspace invitations, and accept ones sent to you.",
157
160
  scope: "workspace",
158
161
  args: [{ name: "email", description: "Invitee email", required: true }],
159
162
  flags: [