@beryl-so/cli 0.11.1 → 0.14.1

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.
package/README.md CHANGED
@@ -71,6 +71,14 @@ Set up Beryl in this repo — sign in and wire up your coding agent
71
71
  | --- | --- | --- |
72
72
  | `beryl init` | Set up Beryl in this repo — sign in and wire up your coding agent | — |
73
73
 
74
+ ### guide
75
+
76
+ Print the Beryl test-authoring guide
77
+
78
+ | Command | Summary | MCP tool |
79
+ | --- | --- | --- |
80
+ | `beryl guide` | Print the Beryl test-authoring guide | `guide` |
81
+
74
82
  ### login
75
83
 
76
84
  Authenticate the CLI with your Beryl account
@@ -302,9 +310,7 @@ Drive a browser session that captures a target-site login for Beryl to reuse.
302
310
  | Command | Summary | MCP tool |
303
311
  | --- | --- | --- |
304
312
  | `beryl auth-capture start` | Start a login-capture browser session for the project (non-interactive) | `auth_capture_start` |
305
- | `beryl auth-capture login <session-id>` | Log into the target site headlessly with credentials (no human at the browser) | `auth_capture_login` |
306
- | `beryl auth-capture capture <session-id>` | Capture the session after the user has logged in via the live-view URL | `auth_capture_capture` |
307
- | `beryl auth-capture refresh <session-id>` | Capture a refreshed session for a project whose login is expiring | `auth_capture_refresh` |
313
+ | `beryl auth-capture capture <session-id>` | Save the session after the user has logged in via the live-view URL (first login or re-login) | `auth_capture_capture` |
308
314
  | `beryl auth-capture release <session-id>` | Release a login-capture browser session without capturing | `auth_capture_release` |
309
315
 
310
316
  ### inbox
@@ -75,7 +75,14 @@ export async function serveMcp(baseCtx) {
75
75
  // Fire-and-forget staleness warning: a stale MCP server silently exposes fewer
76
76
  // tools, and stderr is the one channel a stdio MCP server can safely log to.
77
77
  void warnIfStale(cliVersion(), (msg) => console.error(msg));
78
- const server = new Server({ name: "beryl", version: cliVersion() }, { capabilities: { tools: {} } });
78
+ const server = new Server({ name: "beryl", version: cliVersion() }, {
79
+ capabilities: { tools: {} },
80
+ instructions: "Beryl authors, runs, and heals end-to-end tests for any web app: tests are JSON " +
81
+ "action plans replayed in real cloud browsers, with per-run email inboxes that make " +
82
+ "signup/OTP/magic-link flows fully self-contained (no human login needed). Before " +
83
+ "authoring your first test plan, call the `guide` tool — it returns the full " +
84
+ "authoring guide (plan shape, outcome assertions, email/OTP wiring, run-fix loop).",
85
+ });
79
86
  server.setRequestHandler(ListToolsRequestSchema, () => ({
80
87
  tools: mcpTools().map((spec) => ({
81
88
  name: toolName(spec),
@@ -1,6 +1,5 @@
1
- import { UsageError } from "../errors.js";
2
1
  import { dim, green, yellow } from "../output.js";
3
- import { arg, flagBool, flagStr } from "./util.js";
2
+ import { arg, flagBool } from "./util.js";
4
3
  const capturePath = (ws, p) => `/auth-capture/workspaces/${ws}/projects/${p}/sessions`;
5
4
  export const credentialCommands = [
6
5
  {
@@ -98,7 +97,7 @@ export const credentialCommands = [
98
97
  .del(`${capturePath(workspaceId, projectId)}/${session.session_id}`)
99
98
  .catch(() => { });
100
99
  }
101
- return { human: `${green("Login captured.")} ${dim("The agent can now test the gated app.")}` };
100
+ return { human: `${green("Login captured.")} ${dim("The agent can test the gated app with it.")}` };
102
101
  },
103
102
  },
104
103
  {
@@ -111,40 +110,9 @@ export const credentialCommands = [
111
110
  return { data: await ctx.client.post(capturePath(workspaceId, projectId)) };
112
111
  },
113
112
  },
114
- {
115
- name: "auth-capture login",
116
- summary: "Log into the target site headlessly with credentials (no human at the browser)",
117
- description: "Drives the login inside the capture session started by `auth-capture start`, so " +
118
- "an agent can complete start → login → capture with zero human intervention. The " +
119
- "credentials are sent to the server, typed into the target site over the wire, and " +
120
- "never stored, logged, or returned — the captured session stays encrypted " +
121
- "server-side. Follow with `auth-capture capture` to snapshot the authenticated session.",
122
- scope: "project",
123
- args: [
124
- { name: "session-id", description: "Session id from auth-capture start", required: true },
125
- ],
126
- flags: [
127
- { name: "username", type: "string", description: "Login username / email", required: true },
128
- { name: "password", type: "string", description: "Login password", required: true },
129
- {
130
- name: "login-url",
131
- type: "string",
132
- description: "Explicit login page URL (defaults to the session's current page)",
133
- },
134
- ],
135
- async run(ctx, input) {
136
- const { workspaceId, projectId } = await ctx.requireProject(input);
137
- const username = flagStr(input, "username");
138
- const password = flagStr(input, "password");
139
- if (!username || !password)
140
- throw new UsageError("--username and --password are required");
141
- await ctx.client.post(`${capturePath(workspaceId, projectId)}/${arg(input, "session-id")}/login`, { username, password, login_url: flagStr(input, "login-url") ?? null });
142
- return { human: "Logged in." };
143
- },
144
- },
145
113
  {
146
114
  name: "auth-capture capture",
147
- summary: "Capture the session after the user has logged in via the live-view URL",
115
+ summary: "Save the session after the user has logged in via the live-view URL (first login or re-login)",
148
116
  scope: "project",
149
117
  args: [{ name: "session-id", description: "Session id from auth-capture start", required: true }],
150
118
  async run(ctx, input) {
@@ -153,17 +121,6 @@ export const credentialCommands = [
153
121
  return { human: "Captured." };
154
122
  },
155
123
  },
156
- {
157
- name: "auth-capture refresh",
158
- summary: "Capture a refreshed session for a project whose login is expiring",
159
- scope: "project",
160
- args: [{ name: "session-id", description: "Session id from auth-capture start", required: true }],
161
- async run(ctx, input) {
162
- const { workspaceId, projectId } = await ctx.requireProject(input);
163
- await ctx.client.post(`${capturePath(workspaceId, projectId)}/${arg(input, "session-id")}/capture-refresh`);
164
- return { human: "Captured." };
165
- },
166
- },
167
124
  {
168
125
  name: "auth-capture release",
169
126
  summary: "Release a login-capture browser session without capturing",
@@ -7,7 +7,7 @@ import { loadConfig } from "../config.js";
7
7
  import { CliError } from "../errors.js";
8
8
  import { ApiClient } from "../http.js";
9
9
  import { bold, cyan, dim, green, red, yellow } from "../output.js";
10
- import { confirmInstall, hasPlaywrightTest, installPlaywright, PLAYWRIGHT_INSTALL_COMMANDS, } from "../playwright-install.js";
10
+ import { hasPlaywrightTest, installPlaywright, PLAYWRIGHT_INSTALL_COMMANDS, } from "../playwright-install.js";
11
11
  import { cliVersion, warnIfStale } from "../version-check.js";
12
12
  import { authCommands } from "./auth.js";
13
13
  import { flagStr } from "./util.js";
@@ -27,7 +27,7 @@ const PLAYWRIGHT_SERVER_ENTRY = {
27
27
  args: ["@playwright/mcp@latest", "--headless"],
28
28
  };
29
29
  const ACTION_PLAN_SCHEMA_URL = "https://api.beryl.so/api/v1/schemas/action-plan.schema.json";
30
- function mergeMcpConfig(file, withPlaywright) {
30
+ function mergeMcpConfig(file) {
31
31
  let existing = {};
32
32
  if (fs.existsSync(file)) {
33
33
  try {
@@ -41,13 +41,10 @@ function mergeMcpConfig(file, withPlaywright) {
41
41
  const beryl = JSON.stringify(servers.beryl) !== JSON.stringify(MCP_SERVER_ENTRY);
42
42
  if (beryl)
43
43
  servers.beryl = MCP_SERVER_ENTRY;
44
- let playwright;
45
- if (withPlaywright) {
46
- // Never clobber a playwright server the user already wired up.
47
- playwright = servers.playwright === undefined;
48
- if (playwright)
49
- servers.playwright = PLAYWRIGHT_SERVER_ENTRY;
50
- }
44
+ // Never clobber a playwright server the user already wired up.
45
+ const playwright = servers.playwright === undefined;
46
+ if (playwright)
47
+ servers.playwright = PLAYWRIGHT_SERVER_ENTRY;
51
48
  if (beryl || playwright) {
52
49
  existing.mcpServers = servers;
53
50
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -85,14 +82,6 @@ function claudeAddHint(name, entry) {
85
82
  function cursorUserConfigPath() {
86
83
  return path.join(os.homedir(), ".cursor", "mcp.json");
87
84
  }
88
- function detectEditors(cwd) {
89
- const editors = [];
90
- if (fs.existsSync(path.join(cwd, ".claude")) || fs.existsSync(path.join(cwd, "CLAUDE.md")))
91
- editors.push("claude-code");
92
- if (fs.existsSync(path.join(cwd, ".cursor")))
93
- editors.push("cursor");
94
- return editors;
95
- }
96
85
  // Write the skill to one `.../beryl-test/SKILL.md` file. Idempotent: an identical copy is
97
86
  // left alone; a customer-EDITED copy is never clobbered — we notice and skip so their
98
87
  // changes survive a re-run.
@@ -111,89 +100,78 @@ function writeSkillFile(file) {
111
100
  return { outcome: "wrote", file };
112
101
  }
113
102
  const skillLeaf = (root) => path.join(root, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME);
114
- // The authoring skill always lands in the vendor-neutral `.agents/skills/` dir (mirroring
115
- // Momentic) any coding agent that reads `.agents/skills/` picks it up. Claude Code does
116
- // NOT index `.agents/skills/`; it auto-discovers skills from `~/.claude/skills/` (user
117
- // scope) and the repo's `.claude/skills/` (project scope). So when `claude-code` is a
118
- // selected editor we ALSO write the same skill string to the `.claude/skills/` location
119
- // that matches the MCP `--scope`, or the user never sees it. Each destination is written
120
- // with the same idempotent / never-clobber-a-customer-edit behavior.
121
- function writeSkills(cwd, editors, scope) {
122
- const results = [writeSkillFile(skillLeaf(path.join(cwd, ".agents", "skills")))];
123
- if (editors.includes("claude-code")) {
124
- const claudeRoot = scope === "user"
125
- ? path.join(os.homedir(), ".claude", "skills")
126
- : path.join(cwd, ".claude", "skills");
127
- results.push(writeSkillFile(skillLeaf(claudeRoot)));
128
- }
129
- return results;
103
+ // The authoring skill follows the `--scope` flag, same as the MCP servers: under `user`
104
+ // (the default) it lands in the HOME `.agents/skills/` + `.claude/skills/` dirs, so the
105
+ // knowledge travels with the user into every session the user-scoped MCP tools do
106
+ // otherwise an agent outside this repo has all the tools and none of the guide. Under
107
+ // `project` both copies stay repo-local (committed, so teammates get them with the repo).
108
+ // `.agents/skills/` is the vendor-neutral location; Claude Code does NOT index it, so the
109
+ // same string also goes to `.claude/skills/`, which Claude Code auto-discovers. Both are
110
+ // written with the same idempotent / never-clobber-a-customer-edit behavior. Existing
111
+ // repo-local copies from earlier inits are left untouched.
112
+ function writeSkills(cwd, scope) {
113
+ const root = scope === "user" ? os.homedir() : cwd;
114
+ return [
115
+ writeSkillFile(skillLeaf(path.join(root, ".agents", "skills"))),
116
+ writeSkillFile(skillLeaf(path.join(root, ".claude", "skills"))),
117
+ ];
130
118
  }
131
- // Local authoring drives a real browser via `@playwright/test` + chromium. init wires the
132
- // Playwright MCP but historically installed neither, so the first `beryl runs local` hit a wall.
133
- // On a TTY we offer to install now; non-interactively we print the exact commands rather than
134
- // running installs unprompted (which would be a surprise in CI). Never throws a declined or
135
- // failed install must not fail `init`, which has already done its wiring.
119
+ // Local authoring drives a real browser via `@playwright/test` + chromium, and the whole
120
+ // authoring workflow (walk the flow first, then bank the plan) depends on it so the
121
+ // install is mandatory, not offered: missing means install now, no prompt, no opt-out.
122
+ // Never throws a failed install must not fail `init`, which has already done its wiring;
123
+ // it prints the exact commands to finish by hand instead.
136
124
  async function ensureLocalPlaywright(ctx, cwd) {
137
125
  if (hasPlaywrightTest(cwd)) {
138
126
  ctx.err(`${green("✓")} @playwright/test already installed ${dim("(local runs ready)")}`);
139
127
  return;
140
128
  }
141
- const hint = () => ctx.err(`${dim("•")} To run tests locally, install Playwright in this project:\n` +
142
- ` ${cyan(PLAYWRIGHT_INSTALL_COMMANDS)}`);
143
- if (!ctx.interactive || !(await confirmInstall(ctx.prompt))) {
144
- hint();
145
- return;
146
- }
147
129
  try {
148
130
  await installPlaywright(cwd, (line) => ctx.err(dim(line)));
149
131
  ctx.err(`${green("✓")} Local Playwright installed ${dim("(local runs ready)")}`);
150
132
  }
151
133
  catch (err) {
152
134
  ctx.err(`${red("✗")} Playwright install failed: ${err.message}`);
153
- hint();
135
+ ctx.err(`${dim("•")} Finish the install by hand — local runs and browser authoring need it:\n` +
136
+ ` ${cyan(PLAYWRIGHT_INSTALL_COMMANDS)}`);
154
137
  }
155
138
  }
156
139
  export const initCommands = [
157
140
  {
158
141
  name: "init",
159
142
  summary: "Set up Beryl in this repo — sign in and wire up your coding agent",
160
- description: "One-command onboarding: signs you in (emailed one-time code) and wires the MCP servers " +
161
- "for your coding agent, then hands off to Claude. By default they're wired per-user " +
162
- "(matching where your login token lives) via `claude mcp add -s user` for Claude Code, " +
163
- "~/.cursor/mcp.json for Cursor; pass --scope project to write a committed .mcp.json for a " +
164
- "shared repo instead. No workspace/project pin and no URL prompt open your editor and ask " +
165
- "Claude to write tests for your site; it resolves the workspace/project and sets the URL for " +
166
- "you. Safe to re-run; every step skips what is already set up.",
143
+ description: "One-command onboarding: signs you in (emailed one-time code) and wires up your coding " +
144
+ "agent. Nothing is detected and nothing is conditional every run wires the beryl AND " +
145
+ "playwright MCP servers, installs the authoring skill (user scope: your home " +
146
+ ".agents/skills/ + .claude/skills/, so it follows you into every session; --scope " +
147
+ "project: the repo's own dirs, committed for teammates), and installs @playwright/test " +
148
+ "+ chromium if missing browser authoring and local runs depend on it. By default the " +
149
+ "servers are wired per-user (matching where your login token lives) via `claude mcp add " +
150
+ "-s user`; pass --scope project to write a committed .mcp.json for a shared repo " +
151
+ "instead. No workspace/project pin and no URL prompt — ask Claude to write tests for " +
152
+ "your site and it resolves the workspace, project, and URL. Safe to re-run; every step " +
153
+ "is idempotent and skips what is already set up.",
167
154
  interactive: true,
168
155
  flags: [
169
- {
170
- name: "editor-tools",
171
- type: "string",
172
- enum: ["claude-code", "cursor", "both", "none"],
173
- description: "Which coding agent to write MCP config for (default: auto-detect)",
174
- },
175
156
  {
176
157
  name: "scope",
177
158
  type: "string",
178
159
  enum: ["user", "project"],
179
160
  description: "Where to wire the MCP servers. `user` (default) configures them per-user (matching " +
180
- "where your Beryl login token lives) via `claude mcp add -s user` / ~/.cursor/mcp.json. " +
181
- "`project` writes a committed .mcp.json for a shared repo — every teammate still runs " +
182
- "`beryl login` to authenticate",
161
+ "where your Beryl login token lives) via `claude mcp add -s user`. `project` writes a " +
162
+ "committed .mcp.json for a shared repo — every teammate still runs `beryl login` to " +
163
+ "authenticate",
183
164
  },
184
165
  {
185
- name: "local",
166
+ name: "cursor",
186
167
  type: "boolean",
187
- description: "Also wire the Playwright MCP so your coding agent can drive a local browser, and " +
188
- "offer to install @playwright/test + chromium so local runs work (for authoring tests " +
189
- "yourself). Default: on whenever a coding agent is wired; pass --no-local to skip it",
168
+ description: "Also write the same two MCP servers to Cursor's config",
190
169
  },
191
170
  ],
192
171
  examples: [
193
172
  "npx @beryl-so/cli@latest init",
194
- "beryl init --editor-tools claude-code",
195
173
  "beryl init --scope project",
196
- "beryl init --editor-tools none",
174
+ "beryl init --cursor",
197
175
  ],
198
176
  async run(ctx, input) {
199
177
  const cwd = process.cwd();
@@ -210,57 +188,37 @@ export const initCommands = [
210
188
  throw new CliError("Login did not persist a token");
211
189
  client = new ApiClient(config.apiUrl, config.token);
212
190
  }
213
- const choice = flagStr(input, "editor-tools") ?? "auto";
214
- const editors = choice === "auto"
215
- ? detectEditors(cwd)
216
- : choice === "both"
217
- ? ["claude-code", "cursor"]
218
- : choice === "none"
219
- ? []
220
- : [choice];
221
- // Wiring an editor's MCP config at all implies the user wants to author tests there,
222
- // and local authoring needs the Playwright MCP — so default it on. `--no-local` (parsed
223
- // as an explicit false) opts out.
224
- const local = input.flags.local ?? editors.length > 0;
225
191
  const scope = (flagStr(input, "scope") ?? "user");
226
- for (const editor of editors) {
227
- if (scope === "user" && editor === "claude-code") {
228
- // Claude Code owns ~/.claude.json — shell out to `claude mcp add` rather than write it.
229
- const berylOk = claudeUserAdd("beryl", MCP_SERVER_ENTRY);
230
- const playwrightOk = local ? claudeUserAdd("playwright", PLAYWRIGHT_SERVER_ENTRY) : undefined;
231
- if (berylOk) {
232
- ctx.err(`${green("✓")} claude-code MCP configured ${dim("(user scope)")}`);
233
- if (playwrightOk)
234
- ctx.err(`${green("✓")} claude-code Playwright MCP configured ${dim("(user scope)")}`);
235
- }
236
- else {
237
- ctx.err(yellow("• `claude` not on PATH — run these to wire user-scope MCP servers:"));
238
- ctx.err(` ${cyan(claudeAddHint("beryl", MCP_SERVER_ENTRY))}`);
239
- if (local)
240
- ctx.err(` ${cyan(claudeAddHint("playwright", PLAYWRIGHT_SERVER_ENTRY))}`);
241
- }
242
- continue;
192
+ const report = (label, fresh, where) => ctx.err(`${green("✓")} ${label} ${fresh ? "configured" : "already configured"} ${dim(where)}`);
193
+ if (scope === "user") {
194
+ // Claude Code owns ~/.claude.json — shell out to `claude mcp add` rather than write it.
195
+ const berylOk = claudeUserAdd("beryl", MCP_SERVER_ENTRY);
196
+ const playwrightOk = claudeUserAdd("playwright", PLAYWRIGHT_SERVER_ENTRY);
197
+ if (berylOk && playwrightOk) {
198
+ ctx.err(`${green("✓")} beryl + playwright MCP configured ${dim("(user scope)")}`);
243
199
  }
244
- const file = editor === "claude-code"
245
- ? path.join(cwd, ".mcp.json")
246
- : scope === "user"
247
- ? cursorUserConfigPath()
248
- : path.join(cwd, ".cursor", "mcp.json");
249
- const wrote = mergeMcpConfig(file, local);
200
+ else {
201
+ ctx.err(yellow("• `claude` not on PATH — run these to wire user-scope MCP servers:"));
202
+ ctx.err(` ${cyan(claudeAddHint("beryl", MCP_SERVER_ENTRY))}`);
203
+ ctx.err(` ${cyan(claudeAddHint("playwright", PLAYWRIGHT_SERVER_ENTRY))}`);
204
+ }
205
+ }
206
+ else {
207
+ const file = path.join(cwd, ".mcp.json");
208
+ const wrote = mergeMcpConfig(file);
209
+ const where = path.relative(cwd, file);
210
+ report("beryl MCP", wrote.beryl, where);
211
+ report("playwright MCP", wrote.playwright, where);
212
+ }
213
+ if (input.flags.cursor) {
214
+ const file = scope === "user" ? cursorUserConfigPath() : path.join(cwd, ".cursor", "mcp.json");
215
+ const wrote = mergeMcpConfig(file);
250
216
  const where = scope === "user" ? file : path.relative(cwd, file);
251
- ctx.err(`${green("✓")} ${editor} MCP ${wrote.beryl ? "configured" : "already configured"} ${dim(where)}`);
252
- if (wrote.playwright !== undefined)
253
- ctx.err(`${green("✓")} ${editor} Playwright MCP ${wrote.playwright ? "configured" : "already configured"} ${dim(where)}`);
217
+ report("cursor beryl MCP", wrote.beryl, where);
218
+ report("cursor playwright MCP", wrote.playwright, where);
254
219
  }
255
- if (choice === "auto" && editors.length === 0)
256
- ctx.err(dim("No coding agent detected — pass --editor-tools claude-code|cursor to wire one."));
257
- // The authoring skill always goes to `.agents/skills/` (any `.agents/skills/`-aware
258
- // harness gets it); when claude-code is selected it ALSO goes to the `.claude/skills/`
259
- // location Claude Code actually indexes (per --scope), or the user never sees it.
260
- const skills = writeSkills(cwd, editors, scope);
220
+ const skills = writeSkills(cwd, scope);
261
221
  for (const skill of skills) {
262
- // Repo-relative for paths under cwd (`.agents/…`, project-scope `.claude/…`);
263
- // absolute for a user-scope `~/.claude/…` path that lives outside the repo.
264
222
  const rel = path.relative(cwd, skill.file);
265
223
  const skillWhere = rel.startsWith("..") ? skill.file : rel;
266
224
  if (skill.outcome === "customized")
@@ -268,25 +226,38 @@ export const initCommands = [
268
226
  else
269
227
  ctx.err(`${green("✓")} Beryl authoring skill ${skill.outcome === "wrote" ? "installed" : "already installed"} ${dim(skillWhere)}`);
270
228
  }
271
- // Local authoring needs @playwright/test + chromium on the customer's machine; wiring the
272
- // Playwright MCP alone isn't enough. Offer/print the install so the first `beryl runs local`
273
- // just works instead of hitting a "Local Playwright not found" wall.
274
- if (local)
275
- await ensureLocalPlaywright(ctx, cwd);
229
+ await ensureLocalPlaywright(ctx, cwd);
276
230
  const nextSteps = `\n${bold("Beryl is set up — now open your editor and ask Claude to write tests.")}\n` +
277
231
  ` ${dim('• Say: "write tests for https://your-app.com" — Claude picks your workspace/project')}\n` +
278
232
  ` ${dim(" and sets the URL for you (no pin, no prompt).")}\n` +
279
- (local
280
- ? ` ${dim("• Playwright MCP is wired — Claude can drive a real browser to author from your plan.")}\n`
281
- : ` ${dim("• Re-run with --editor-tools to wire the Playwright MCP for local authoring.")}\n`) +
233
+ ` ${dim("• Playwright MCP is wired — Claude can drive a real browser to author from your plan.")}\n` +
282
234
  `\nAuthor against the ActionPlan JSON Schema: ${cyan(ACTION_PLAN_SCHEMA_URL)}`;
283
235
  // The .mcp.json entry is pinned to @latest, but a global install / old npx cache
284
236
  // still wins resolution — so tell the user when the CLI they just ran is stale.
285
237
  await warnIfStale(cliVersion(), (msg) => ctx.err(yellow(msg)));
286
238
  return {
287
- data: { editors },
239
+ data: {
240
+ scope,
241
+ skills: skills.map((s) => {
242
+ const rel = path.relative(cwd, s.file);
243
+ return rel.startsWith("..") ? s.file : rel;
244
+ }),
245
+ },
288
246
  human: nextSteps,
289
247
  };
290
248
  },
291
249
  },
250
+ {
251
+ name: "guide",
252
+ summary: "Print the Beryl test-authoring guide",
253
+ description: "The full guide to authoring durable, healable tests: the ActionPlan shape, outcome " +
254
+ "assertions, natural-language intent, per-run email inboxes for OTP/signup flows " +
255
+ "({{inbox_address}} + await_email), and the local run-fix loop. The same content " +
256
+ "`beryl init` installs as the beryl-test skill — call this before authoring your " +
257
+ "first plan when no skill is installed (works without logging in).",
258
+ examples: ["beryl guide"],
259
+ async run() {
260
+ return { human: BERYL_TEST_SKILL };
261
+ },
262
+ },
292
263
  ];
@@ -1,10 +1,44 @@
1
1
  import fs from "node:fs";
2
2
  import { UsageError } from "../errors.js";
3
+ import { ApiError } from "../http.js";
3
4
  import { lintPlan } from "../lint.js";
4
5
  import { table } from "../output.js";
5
6
  import { ACTION_PLAN_SCHEMA } from "../schema.generated.js";
6
7
  import { arg, argList, flagBool, flagNum, flagStr, projectPath, readJsonFlag } from "./util.js";
7
8
  const testPath = (ws, p, id) => `${projectPath(ws, p)}/tests/${id}`;
9
+ // The server transcodes a failure screenshot to WebP to fit it under the payload cap,
10
+ // so the format is not knowable up front — sniff it off the decoded magic bytes rather
11
+ // than asserting PNG.
12
+ export function sniffImageMime(b64) {
13
+ const head = Buffer.from(b64.slice(0, 24), "base64");
14
+ if (head.subarray(0, 4).toString("latin1") === "RIFF" && head.subarray(8, 12).toString("latin1") === "WEBP") {
15
+ return "image/webp";
16
+ }
17
+ if (head.subarray(0, 3).toString("hex") === "ffd8ff")
18
+ return "image/jpeg";
19
+ return "image/png";
20
+ }
21
+ // A verify-failure 422 carries evidence (a11y page state + failure screenshot).
22
+ // Returned as a CommandResult rather than thrown: a thrown error is text-only in
23
+ // the MCP adapter, and the screenshot only reaches the agent as image content.
24
+ function verifyFailureResult(err) {
25
+ if (!(err instanceof ApiError) || err.status !== 422)
26
+ return undefined;
27
+ const detail = err.detail;
28
+ if (typeof detail !== "object" || detail === null || !detail.message)
29
+ return undefined;
30
+ const parts = [detail.message];
31
+ if (detail.error_context) {
32
+ parts.push("", "----- Page state at failure (accessibility snapshot) -----", detail.error_context);
33
+ }
34
+ return {
35
+ human: parts.join("\n"),
36
+ images: detail.screenshot_b64
37
+ ? [{ data: detail.screenshot_b64, mimeType: sniffImageMime(detail.screenshot_b64) }]
38
+ : undefined,
39
+ exitCode: 1,
40
+ };
41
+ }
8
42
  // The concise human table for `tests list` — the full TestResponse is a 24-column
9
43
  // firehose of internal ids that wraps unreadably in a normal terminal. `--wide`
10
44
  // (and `--json`) still expose every field.
@@ -124,14 +158,22 @@ export const testCommands = [
124
158
  if (!title)
125
159
  throw new UsageError("--title is required");
126
160
  const { workspaceId, projectId } = await ctx.requireProject(input);
127
- return {
128
- data: await ctx.client.post(`${projectPath(workspaceId, projectId)}/tests`, {
129
- title,
130
- plan: readJsonFlag(input, "file"),
131
- verify: !flagBool(input, "no-verify"),
132
- description: flagStr(input, "description"),
133
- }),
134
- };
161
+ try {
162
+ return {
163
+ data: await ctx.client.post(`${projectPath(workspaceId, projectId)}/tests`, {
164
+ title,
165
+ plan: readJsonFlag(input, "file"),
166
+ verify: !flagBool(input, "no-verify"),
167
+ description: flagStr(input, "description"),
168
+ }),
169
+ };
170
+ }
171
+ catch (err) {
172
+ const failure = verifyFailureResult(err);
173
+ if (failure)
174
+ return failure;
175
+ throw err;
176
+ }
135
177
  },
136
178
  },
137
179
  {
@@ -227,8 +269,14 @@ export const testCommands = [
227
269
  ],
228
270
  async run(ctx, input) {
229
271
  const { workspaceId, projectId } = await ctx.requireProject(input);
272
+ const res = (await ctx.client.post(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/plan/recompile`, { json_plan: readJsonFlag(input, "file") }));
273
+ // Base64 in the JSON output would flood the caller's context — hand the
274
+ // failure screenshot over as image content instead.
275
+ const shot = typeof res.screenshot_b64 === "string" ? res.screenshot_b64 : undefined;
276
+ delete res.screenshot_b64;
230
277
  return {
231
- data: await ctx.client.post(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/plan/recompile`, { json_plan: readJsonFlag(input, "file") }),
278
+ data: res,
279
+ images: shot ? [{ data: shot, mimeType: sniffImageMime(shot) }] : undefined,
232
280
  };
233
281
  },
234
282
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.11.1",
3
+ "version": "0.14.1",
4
4
  "description": "Beryl on the command line — projects, runs, the exploring agent, and an MCP server over the same commands.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,6 +34,7 @@
34
34
  "@modelcontextprotocol/sdk": "^1.29.0"
35
35
  },
36
36
  "devDependencies": {
37
+ "@playwright/test": "^1.61.1",
37
38
  "@types/node": "^26.1.1",
38
39
  "tsx": "^4.23.1",
39
40
  "typescript": "^7.0.2",