@beryl-so/cli 0.9.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
@@ -160,7 +168,7 @@ Create and manage projects — a site Beryl explores, authors tests for, and run
160
168
  | --- | --- | --- |
161
169
  | `beryl projects list` | List projects in the workspace | `projects_list` |
162
170
  | `beryl projects get` | Show one project, including its current exploration state | `projects_get` |
163
- | `beryl projects create <url>` | Create a project — the agent starts exploring and authoring tests immediately | `projects_create` |
171
+ | `beryl projects create [url]` | Create a project — with a URL the agent starts exploring; with just --name an empty one | `projects_create` |
164
172
  | `beryl projects rename <name>` | Rename a project | `projects_rename` |
165
173
  | `beryl projects delete` | Delete a project and all its tests and runs | `projects_delete` |
166
174
  | `beryl projects re-explore` | Send the agent back in — run/heal existing tests and discover new flows | — |
@@ -302,19 +310,18 @@ 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
311
317
 
312
- Disposable email inboxes for testing flows that send mail — signups, OTPs, receipts.
318
+ Email inboxes for testing flows that send mail — signups, OTPs, receipts.
313
319
 
314
320
  | Command | Summary | MCP tool |
315
321
  | --- | --- | --- |
316
- | `beryl inbox create` | Mint a disposable email inbox that Beryl receives mail for | `inbox_create` |
322
+ | `beryl inbox create` | Mint an email inbox that Beryl receives mail for | `inbox_create` |
317
323
  | `beryl inbox list` | List the workspace's inboxes, newest first | `inbox_list` |
324
+ | `beryl inbox delete <inbox-id>` | Delete an inbox and every email it has received | `inbox_delete` |
318
325
  | `beryl inbox read <inbox-id>` | Read the latest email from an inbox (waits for one to arrive) | `inbox_read` |
319
326
  | `beryl inbox emails <inbox-id>` | List the emails an inbox has received | `inbox_emails` |
320
327
 
@@ -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,5 +1,7 @@
1
1
  // The `beryl-test` authoring skill, installed by `beryl init` into
2
- // `.agents/skills/beryl-test/SKILL.md` (vendor-neutral, editor-agnostic). Kept as an
2
+ // `.agents/skills/beryl-test/SKILL.md` (vendor-neutral, editor-agnostic) and, when
3
+ // claude-code is a selected editor, ALSO into the `.claude/skills/beryl-test/SKILL.md`
4
+ // that Claude Code actually indexes (see `writeSkills` in `commands/init.ts`). Kept as an
3
5
  // embedded string so it ships in the published package (`files: ["dist"]`) with no
4
6
  // build-time asset copy, and so there is ONE source for the guidance — not a copy in
5
7
  // the CLI and another in the docs. Edit here; `init` writes it verbatim.
@@ -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",
@@ -25,25 +25,35 @@ function extractCode(email) {
25
25
  export const inboxCommands = [
26
26
  {
27
27
  name: "inbox create",
28
- summary: "Mint a disposable email inbox that Beryl receives mail for",
29
- groupSummary: "Disposable email inboxes for testing flows that send mail — signups, OTPs, receipts.",
28
+ summary: "Mint an email inbox that Beryl receives mail for",
29
+ groupSummary: "Email inboxes for testing flows that send mail — signups, OTPs, receipts.",
30
30
  description: "Creates a receiving address under Beryl's inbound email domain and returns it. " +
31
31
  "Use it wherever a test needs a real, readable mailbox — e.g. as the --email for " +
32
- "`beryl signup`, then read the code back with `beryl inbox read --extract-code`.",
32
+ "`beryl signup`, then read the code back with `beryl inbox read --extract-code`. " +
33
+ "Pass --permanent to mint the workspace's single permanent mailbox (no TTL).",
33
34
  scope: "workspace",
34
35
  flags: [
36
+ {
37
+ name: "permanent",
38
+ type: "boolean",
39
+ description: "Mint the workspace's permanent mailbox (no TTL); one per workspace",
40
+ },
35
41
  {
36
42
  name: "ttl-hours",
37
43
  type: "number",
38
- description: "Hours before the inbox expires and stops receiving (1-168, default 24)",
44
+ description: "Hours before the inbox expires and stops receiving (1-168, default 24; ignored with --permanent)",
39
45
  },
40
46
  { name: "project", type: "string", description: "Attach the inbox to a project id" },
41
47
  ],
42
- examples: ["beryl inbox create --json", "beryl inbox create --ttl-hours 2"],
48
+ examples: [
49
+ "beryl inbox create --json",
50
+ "beryl inbox create --ttl-hours 2",
51
+ "beryl inbox create --permanent",
52
+ ],
43
53
  async run(ctx, input) {
44
54
  const ws = await ctx.requireWorkspace(input);
45
55
  const inbox = (await ctx.client.post(`/workspaces/${ws}/inboxes`, {
46
- ttl_hours: flagNum(input, "ttl-hours") ?? 24,
56
+ ttl_hours: flagBool(input, "permanent") ? null : (flagNum(input, "ttl-hours") ?? 24),
47
57
  project_id: flagStr(input, "project") ?? null,
48
58
  }));
49
59
  return {
@@ -58,11 +68,33 @@ export const inboxCommands = [
58
68
  summary: "List the workspace's inboxes, newest first",
59
69
  description: "Every inbox the workspace has minted with `beryl inbox create`. Expired inboxes " +
60
70
  "stop receiving and are hard-deleted by a background sweep, so they drop off " +
61
- "this list shortly after their TTL.",
71
+ "this list shortly after their TTL. Pass --permanent for just the permanent mailbox.",
72
+ scope: "workspace",
73
+ flags: [
74
+ {
75
+ name: "permanent",
76
+ type: "boolean",
77
+ description: "Only the workspace's permanent mailbox (no TTL, not run/project scoped)",
78
+ },
79
+ ],
80
+ async run(ctx, input) {
81
+ const ws = await ctx.requireWorkspace(input);
82
+ const query = flagBool(input, "permanent") ? { permanent: true } : undefined;
83
+ return { data: await ctx.client.get(`/workspaces/${ws}/inboxes`, query) };
84
+ },
85
+ },
86
+ {
87
+ name: "inbox delete",
88
+ summary: "Delete an inbox and every email it has received",
62
89
  scope: "workspace",
90
+ args: [{ name: "inbox-id", description: "Inbox id from `beryl inbox create`", required: true }],
91
+ flags: [{ name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
63
92
  async run(ctx, input) {
64
93
  const ws = await ctx.requireWorkspace(input);
65
- return { data: await ctx.client.get(`/workspaces/${ws}/inboxes`) };
94
+ const id = arg(input, "inbox-id");
95
+ await ctx.confirm(`Delete inbox ${id} and its emails?`, flagBool(input, "force"));
96
+ await ctx.client.del(`/workspaces/${ws}/inboxes/${id}`);
97
+ return { human: "Deleted." };
66
98
  },
67
99
  },
68
100
  {
@@ -118,11 +150,16 @@ export const inboxCommands = [
118
150
  args: [{ name: "inbox-id", description: "Inbox id from `beryl inbox create`", required: true }],
119
151
  flags: [
120
152
  { name: "since", type: "string", description: "Only emails received after this ISO timestamp" },
153
+ {
154
+ name: "limit",
155
+ type: "number",
156
+ description: "Return only the most recent N emails (newest first)",
157
+ },
121
158
  ],
122
159
  async run(ctx, input) {
123
160
  const ws = await ctx.requireWorkspace(input);
124
161
  return {
125
- data: await ctx.client.get(`/workspaces/${ws}/inboxes/${arg(input, "inbox-id")}/emails`, { since: flagStr(input, "since") }),
162
+ data: await ctx.client.get(`/workspaces/${ws}/inboxes/${arg(input, "inbox-id")}/emails`, { since: flagStr(input, "since"), limit: flagNum(input, "limit") }),
126
163
  };
127
164
  },
128
165
  },
@@ -6,7 +6,8 @@ import { BERYL_TEST_SKILL, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME, } fr
6
6
  import { loadConfig } from "../config.js";
7
7
  import { CliError } from "../errors.js";
8
8
  import { ApiClient } from "../http.js";
9
- import { bold, cyan, dim, green, yellow } from "../output.js";
9
+ import { bold, cyan, dim, green, red, yellow } from "../output.js";
10
+ import { hasPlaywrightTest, installPlaywright, PLAYWRIGHT_INSTALL_COMMANDS, } from "../playwright-install.js";
10
11
  import { cliVersion, warnIfStale } from "../version-check.js";
11
12
  import { authCommands } from "./auth.js";
12
13
  import { flagStr } from "./util.js";
@@ -26,7 +27,7 @@ const PLAYWRIGHT_SERVER_ENTRY = {
26
27
  args: ["@playwright/mcp@latest", "--headless"],
27
28
  };
28
29
  const ACTION_PLAN_SCHEMA_URL = "https://api.beryl.so/api/v1/schemas/action-plan.schema.json";
29
- function mergeMcpConfig(file, withPlaywright) {
30
+ function mergeMcpConfig(file) {
30
31
  let existing = {};
31
32
  if (fs.existsSync(file)) {
32
33
  try {
@@ -40,13 +41,10 @@ function mergeMcpConfig(file, withPlaywright) {
40
41
  const beryl = JSON.stringify(servers.beryl) !== JSON.stringify(MCP_SERVER_ENTRY);
41
42
  if (beryl)
42
43
  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
- }
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;
50
48
  if (beryl || playwright) {
51
49
  existing.mcpServers = servers;
52
50
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -84,20 +82,10 @@ function claudeAddHint(name, entry) {
84
82
  function cursorUserConfigPath() {
85
83
  return path.join(os.homedir(), ".cursor", "mcp.json");
86
84
  }
87
- function detectEditors(cwd) {
88
- const editors = [];
89
- if (fs.existsSync(path.join(cwd, ".claude")) || fs.existsSync(path.join(cwd, "CLAUDE.md")))
90
- editors.push("claude-code");
91
- if (fs.existsSync(path.join(cwd, ".cursor")))
92
- editors.push("cursor");
93
- return editors;
94
- }
95
- // The authoring skill lives in the vendor-neutral `.agents/skills/` dir (mirroring
96
- // Momentic), NOT `.claude/` — any coding agent that reads `.agents/skills/` picks it up.
97
- // Idempotent: an identical copy is left alone; a customer-EDITED copy is never clobbered —
98
- // we notice and skip so their changes survive a re-run.
99
- function writeSkill(cwd) {
100
- const file = path.join(cwd, ".agents", "skills", BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME);
85
+ // Write the skill to one `.../beryl-test/SKILL.md` file. Idempotent: an identical copy is
86
+ // left alone; a customer-EDITED copy is never clobbered — we notice and skip so their
87
+ // changes survive a re-run.
88
+ function writeSkillFile(file) {
101
89
  if (fs.existsSync(file)) {
102
90
  // Compare with line endings normalized so a CRLF checkout of our own content still
103
91
  // reads as unchanged (not falsely "customized") — we always write LF.
@@ -111,47 +99,79 @@ function writeSkill(cwd) {
111
99
  fs.writeFileSync(file, BERYL_TEST_SKILL);
112
100
  return { outcome: "wrote", file };
113
101
  }
102
+ const skillLeaf = (root) => path.join(root, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME);
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
+ ];
118
+ }
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.
124
+ async function ensureLocalPlaywright(ctx, cwd) {
125
+ if (hasPlaywrightTest(cwd)) {
126
+ ctx.err(`${green("✓")} @playwright/test already installed ${dim("(local runs ready)")}`);
127
+ return;
128
+ }
129
+ try {
130
+ await installPlaywright(cwd, (line) => ctx.err(dim(line)));
131
+ ctx.err(`${green("✓")} Local Playwright installed ${dim("(local runs ready)")}`);
132
+ }
133
+ catch (err) {
134
+ ctx.err(`${red("✗")} Playwright install failed: ${err.message}`);
135
+ ctx.err(`${dim("•")} Finish the install by hand — local runs and browser authoring need it:\n` +
136
+ ` ${cyan(PLAYWRIGHT_INSTALL_COMMANDS)}`);
137
+ }
138
+ }
114
139
  export const initCommands = [
115
140
  {
116
141
  name: "init",
117
142
  summary: "Set up Beryl in this repo — sign in and wire up your coding agent",
118
- description: "One-command onboarding: signs you in (emailed one-time code) and wires the MCP servers " +
119
- "for your coding agent, then hands off to Claude. By default they're wired per-user " +
120
- "(matching where your login token lives) via `claude mcp add -s user` for Claude Code, " +
121
- "~/.cursor/mcp.json for Cursor; pass --scope project to write a committed .mcp.json for a " +
122
- "shared repo instead. No workspace/project pin and no URL prompt open your editor and ask " +
123
- "Claude to write tests for your site; it resolves the workspace/project and sets the URL for " +
124
- "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.",
125
154
  interactive: true,
126
155
  flags: [
127
- {
128
- name: "editor-tools",
129
- type: "string",
130
- enum: ["claude-code", "cursor", "both", "none"],
131
- description: "Which coding agent to write MCP config for (default: auto-detect)",
132
- },
133
156
  {
134
157
  name: "scope",
135
158
  type: "string",
136
159
  enum: ["user", "project"],
137
160
  description: "Where to wire the MCP servers. `user` (default) configures them per-user (matching " +
138
- "where your Beryl login token lives) via `claude mcp add -s user` / ~/.cursor/mcp.json. " +
139
- "`project` writes a committed .mcp.json for a shared repo — every teammate still runs " +
140
- "`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",
141
164
  },
142
165
  {
143
- name: "local",
166
+ name: "cursor",
144
167
  type: "boolean",
145
- description: "Also wire the Playwright MCP so your coding agent can drive a local browser " +
146
- "(for authoring tests yourself). Default: on whenever a coding agent is wired; " +
147
- "pass --no-local to skip it",
168
+ description: "Also write the same two MCP servers to Cursor's config",
148
169
  },
149
170
  ],
150
171
  examples: [
151
172
  "npx @beryl-so/cli@latest init",
152
- "beryl init --editor-tools claude-code",
153
173
  "beryl init --scope project",
154
- "beryl init --editor-tools none",
174
+ "beryl init --cursor",
155
175
  ],
156
176
  async run(ctx, input) {
157
177
  const cwd = process.cwd();
@@ -168,72 +188,76 @@ export const initCommands = [
168
188
  throw new CliError("Login did not persist a token");
169
189
  client = new ApiClient(config.apiUrl, config.token);
170
190
  }
171
- const choice = flagStr(input, "editor-tools") ?? "auto";
172
- const editors = choice === "auto"
173
- ? detectEditors(cwd)
174
- : choice === "both"
175
- ? ["claude-code", "cursor"]
176
- : choice === "none"
177
- ? []
178
- : [choice];
179
- // Wiring an editor's MCP config at all implies the user wants to author tests there,
180
- // and local authoring needs the Playwright MCP — so default it on. `--no-local` (parsed
181
- // as an explicit false) opts out.
182
- const local = input.flags.local ?? editors.length > 0;
183
191
  const scope = (flagStr(input, "scope") ?? "user");
184
- for (const editor of editors) {
185
- if (scope === "user" && editor === "claude-code") {
186
- // Claude Code owns ~/.claude.json — shell out to `claude mcp add` rather than write it.
187
- const berylOk = claudeUserAdd("beryl", MCP_SERVER_ENTRY);
188
- const playwrightOk = local ? claudeUserAdd("playwright", PLAYWRIGHT_SERVER_ENTRY) : undefined;
189
- if (berylOk) {
190
- ctx.err(`${green("✓")} claude-code MCP configured ${dim("(user scope)")}`);
191
- if (playwrightOk)
192
- ctx.err(`${green("✓")} claude-code Playwright MCP configured ${dim("(user scope)")}`);
193
- }
194
- else {
195
- ctx.err(yellow(" `claude` not on PATH — run these to wire user-scope MCP servers:"));
196
- ctx.err(` ${cyan(claudeAddHint("beryl", MCP_SERVER_ENTRY))}`);
197
- if (local)
198
- ctx.err(` ${cyan(claudeAddHint("playwright", PLAYWRIGHT_SERVER_ENTRY))}`);
199
- }
200
- 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)")}`);
199
+ }
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))}`);
201
204
  }
202
- const file = editor === "claude-code"
203
- ? path.join(cwd, ".mcp.json")
204
- : scope === "user"
205
- ? cursorUserConfigPath()
206
- : path.join(cwd, ".cursor", "mcp.json");
207
- const wrote = mergeMcpConfig(file, local);
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);
208
216
  const where = scope === "user" ? file : path.relative(cwd, file);
209
- ctx.err(`${green("✓")} ${editor} MCP ${wrote.beryl ? "configured" : "already configured"} ${dim(where)}`);
210
- if (wrote.playwright !== undefined)
211
- 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);
219
+ }
220
+ const skills = writeSkills(cwd, scope);
221
+ for (const skill of skills) {
222
+ const rel = path.relative(cwd, skill.file);
223
+ const skillWhere = rel.startsWith("..") ? skill.file : rel;
224
+ if (skill.outcome === "customized")
225
+ ctx.err(`${dim("•")} Beryl authoring skill left as-is ${dim(`(${skillWhere} — you edited it; delete it to reinstall)`)}`);
226
+ else
227
+ ctx.err(`${green("✓")} Beryl authoring skill ${skill.outcome === "wrote" ? "installed" : "already installed"} ${dim(skillWhere)}`);
212
228
  }
213
- if (choice === "auto" && editors.length === 0)
214
- ctx.err(dim("No coding agent detected — pass --editor-tools claude-code|cursor to wire one."));
215
- // Editor-agnostic: the authoring skill goes to `.agents/skills/` regardless of which
216
- // (if any) editor MCP config we wrote, so any `.agents/skills/`-aware harness gets it.
217
- const skill = writeSkill(cwd);
218
- const skillRel = path.relative(cwd, skill.file);
219
- if (skill.outcome === "customized")
220
- ctx.err(`${dim("•")} Beryl authoring skill left as-is ${dim(`(${skillRel} — you edited it; delete it to reinstall)`)}`);
221
- else
222
- ctx.err(`${green("✓")} Beryl authoring skill ${skill.outcome === "wrote" ? "installed" : "already installed"} ${dim(skillRel)}`);
229
+ await ensureLocalPlaywright(ctx, cwd);
223
230
  const nextSteps = `\n${bold("Beryl is set up — now open your editor and ask Claude to write tests.")}\n` +
224
231
  ` ${dim('• Say: "write tests for https://your-app.com" — Claude picks your workspace/project')}\n` +
225
232
  ` ${dim(" and sets the URL for you (no pin, no prompt).")}\n` +
226
- (local
227
- ? ` ${dim("• Playwright MCP is wired — Claude can drive a real browser to author from your plan.")}\n`
228
- : ` ${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` +
229
234
  `\nAuthor against the ActionPlan JSON Schema: ${cyan(ACTION_PLAN_SCHEMA_URL)}`;
230
235
  // The .mcp.json entry is pinned to @latest, but a global install / old npx cache
231
236
  // still wins resolution — so tell the user when the CLI they just ran is stale.
232
237
  await warnIfStale(cliVersion(), (msg) => ctx.err(yellow(msg)));
233
238
  return {
234
- 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
+ },
235
246
  human: nextSteps,
236
247
  };
237
248
  },
238
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
+ },
239
263
  ];
@@ -54,10 +54,22 @@ export const projectCommands = [
54
54
  },
55
55
  {
56
56
  name: "projects create",
57
- summary: "Create a project — the agent starts exploring and authoring tests immediately",
57
+ summary: "Create a project — with a URL the agent starts exploring; with just --name an empty one",
58
58
  scope: "workspace",
59
- args: [{ name: "url", description: "Root URL of the site to test", required: true }],
59
+ args: [
60
+ {
61
+ name: "url",
62
+ description: "Root URL of the site to test. Omit to create an empty project (see --name)",
63
+ required: false,
64
+ },
65
+ ],
60
66
  flags: [
67
+ {
68
+ name: "name",
69
+ type: "string",
70
+ description: "Name for an empty project when no URL is given. Add a URL later with " +
71
+ "`beryl envs update <env-id>`, or author tests over the CLI/MCP",
72
+ },
61
73
  {
62
74
  name: "auth",
63
75
  type: "string",
@@ -84,13 +96,31 @@ export const projectCommands = [
84
96
  "beryl projects create https://app.example.com --watch",
85
97
  "beryl projects create https://app.example.com --auth gated",
86
98
  "beryl projects create https://app.example.com --auth public --no-explore",
99
+ 'beryl projects create --name "Acme production"',
87
100
  ],
88
101
  async run(ctx, input) {
89
102
  const noExplore = flagBool(input, "no-explore");
90
103
  if (noExplore && flagBool(input, "watch"))
91
104
  throw new UsageError("--no-explore cannot be combined with --watch");
92
105
  const ws = await ctx.requireWorkspace(input);
93
- const url = arg(input, "url");
106
+ const rawUrl = input.args.url;
107
+ const url = typeof rawUrl === "string" ? rawUrl : "";
108
+ const name = flagStr(input, "name");
109
+ // Name-only: create a real empty project (no URL, no exploration). Set a URL
110
+ // later with `beryl envs update <env-id>` or author over the CLI/MCP.
111
+ if (!url) {
112
+ if (!name)
113
+ throw new UsageError("Provide a URL to explore, or --name to create an empty project");
114
+ const created = (await ctx.client.post(`/workspaces/${ws}/projects`, {
115
+ name,
116
+ }));
117
+ return {
118
+ data: created,
119
+ human: `${green("Project created")}: ${created.project_id} ${dim("(empty)")}\n` +
120
+ `Add a site with \`beryl envs update <env-id>\`, or author tests with ` +
121
+ `\`beryl tests create\`.`,
122
+ };
123
+ }
94
124
  const auth = flagStr(input, "auth") ?? (await resolveAuthChoice(ctx, ws, url));
95
125
  const created = (await ctx.client.post(`/workspaces/${ws}/projects`, {
96
126
  root_url: url,
@@ -1,8 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import { downloadRunArtifacts, failureImages, isFailing, resultsOf, } from "../artifacts.js";
3
3
  import { CliError, UsageError } from "../errors.js";
4
- import { runSpecLocally } from "../local-run.js";
4
+ import { PlaywrightMissingError, runSpecLocally } from "../local-run.js";
5
5
  import { dim, green, red, yellow } from "../output.js";
6
+ import { confirmInstall, installPlaywright } from "../playwright-install.js";
6
7
  import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
7
8
  import { watchRun } from "./watch.js";
8
9
  const MAX_FAILURE_SCREENSHOTS = 5;
@@ -71,8 +72,9 @@ export const runCommands = [
71
72
  name: "runs local",
72
73
  summary: "Run a banked test locally with your own Playwright (public flows)",
73
74
  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 " +
75
+ "rendered spec, then runs it with your local @playwright/test. On a terminal it offers to " +
76
+ "install @playwright/test + chromium for you the first time they're missing (over MCP it " +
77
+ "prints the install commands instead). Point --url-override at a local " +
76
78
  "dev server or preview, and --dir to keep the spec, artifacts, and JSON report on disk so " +
77
79
  "an agent can run-fix-run. v1 targets public/unauthenticated flows: an authenticated test " +
78
80
  "refuses to run locally (those run in Beryl's cloud, which holds the session) — no session " +
@@ -107,14 +109,34 @@ export const runCommands = [
107
109
  "session) — local runs are for public/unauthenticated flows. Run it with " +
108
110
  "`beryl runs trigger`.");
109
111
  }
112
+ const runOnce = () => runSpecLocally({
113
+ spec: script.content,
114
+ testName: testId,
115
+ dir: flagStr(input, "dir"),
116
+ onProgress: (line) => ctx.err(dim(line)),
117
+ });
110
118
  let outcome;
111
119
  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
- });
120
+ try {
121
+ outcome = await runOnce();
122
+ }
123
+ catch (err) {
124
+ // Local Playwright missing: on a TTY offer to install it and retry, instead of only
125
+ // printing a hint the user then has to act on by hand. Non-interactively we can't
126
+ // prompt, so we re-throw and the hint surfaces as before (no unprompted install).
127
+ if (err instanceof PlaywrightMissingError && ctx.interactive) {
128
+ // confirmInstall swallows a prompt failure into `false`, so a broken prompt falls
129
+ // back to re-throwing the actionable missing-Playwright hint, not the prompt's error.
130
+ if (!(await confirmInstall(ctx.prompt)))
131
+ throw err;
132
+ await installPlaywright(process.cwd(), (line) => ctx.err(dim(line)));
133
+ ctx.err(green("✓ Local Playwright installed — running the test…"));
134
+ outcome = await runOnce();
135
+ }
136
+ else {
137
+ throw err;
138
+ }
139
+ }
118
140
  }
119
141
  catch (err) {
120
142
  // Every failure to run the spec (missing Playwright, a compile error, a customer
@@ -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/dist/local-run.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- // The customer already has @playwright/test (the MCP `init` wires it), so we invoke it
5
- // as an external rather than bundling it — the CLI's zero-runtime-dep rule. In a node_modules
6
- // install `playwright` is on PATH; otherwise fall back to `npx playwright`, exactly as the
7
- // cloud runner does.
4
+ import { PLAYWRIGHT_INSTALL_COMMANDS } from "./playwright-install.js";
5
+ // We invoke @playwright/test as an external rather than bundling it — the CLI's zero-runtime-dep
6
+ // rule. In a node_modules install `playwright` is on PATH; otherwise fall back to `npx playwright`,
7
+ // exactly as the cloud runner does. The install commands come from playwright-install.ts so the
8
+ // hint text and the actual installer never drift apart.
8
9
  export const PLAYWRIGHT_INSTALL_HINT = "Local Playwright not found. Install it in this project, then re-run:\n" +
9
- " npm i -D @playwright/test && npx playwright install chromium";
10
+ ` ${PLAYWRIGHT_INSTALL_COMMANDS}`;
10
11
  // Isolate the run from any playwright.config.ts in the customer's repo: a stray `testMatch`
11
12
  // would exclude our spec (a zero-test run that reads as a false pass), and a `use.baseURL` /
12
13
  // `use.storageState` / `globalSetup` there would silently retarget or reauth the run we mean
@@ -0,0 +1,58 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+ // The two commands that turn "nothing Playwright-related installed" into "local runs work":
5
+ // the test runner as a dev dep, then its browser binary. Kept as data so the CLI can both
6
+ // run them and print them verbatim for the non-interactive / copy-paste path.
7
+ export const INSTALL_TEST_RUNNER = ["npm", "i", "-D", "@playwright/test"];
8
+ export const INSTALL_CHROMIUM = ["npx", "playwright", "install", "chromium"];
9
+ export const PLAYWRIGHT_INSTALL_COMMANDS = `${INSTALL_TEST_RUNNER.join(" ")} && ${INSTALL_CHROMIUM.join(" ")}`;
10
+ // Resolve `@playwright/test` the way Playwright itself will at run time — from the project
11
+ // tree, not from wherever the globally-installed CLI happens to live. `createRequire` rooted
12
+ // at cwd walks up the same node_modules chain, so this is true iff a local run would find it.
13
+ export function hasPlaywrightTest(cwd) {
14
+ try {
15
+ createRequire(path.join(cwd, "package.json")).resolve("@playwright/test");
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ export const INSTALL_PROMPT = `Install local Playwright now (${PLAYWRIGHT_INSTALL_COMMANDS})? [Y/n] `;
23
+ // Ask (default-yes) whether to install. Returns false — not throwing — when there is no answer
24
+ // or the prompt fails, so callers uniformly fall back to printing the install hint.
25
+ export async function confirmInstall(prompt) {
26
+ try {
27
+ return !/^n(o)?$/i.test(await prompt(INSTALL_PROMPT));
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ const withCmdExt = (cmd) => process.platform === "win32" && (cmd === "npm" || cmd === "npx") ? `${cmd}.cmd` : cmd;
34
+ function runInherit(command, args, cwd) {
35
+ return new Promise((resolve, reject) => {
36
+ // stdio inherited: an install is slow and the user wants to watch npm/browser-download
37
+ // progress live, exactly as if they'd typed it themselves.
38
+ const child = spawn(withCmdExt(command), [...args], { cwd, stdio: "inherit" });
39
+ child.on("error", reject);
40
+ child.on("close", (code) => code === 0
41
+ ? resolve()
42
+ : reject(new Error(`\`${command} ${args.join(" ")}\` exited with code ${code}`)));
43
+ });
44
+ }
45
+ /**
46
+ * Install the local Playwright test runner + chromium into `cwd`, streaming each step's output.
47
+ * Throws if either step exits non-zero (so the caller surfaces the failure, not a silent partial
48
+ * install). Skips the `@playwright/test` step when it is already resolvable, but always ensures
49
+ * the browser binary — an installed runner with no browser still fails a real run.
50
+ */
51
+ export async function installPlaywright(cwd, onStep) {
52
+ if (!hasPlaywrightTest(cwd)) {
53
+ onStep?.(`Installing @playwright/test — ${INSTALL_TEST_RUNNER.join(" ")}`);
54
+ await runInherit(INSTALL_TEST_RUNNER[0], INSTALL_TEST_RUNNER.slice(1), cwd);
55
+ }
56
+ onStep?.(`Installing the Chromium browser — ${INSTALL_CHROMIUM.join(" ")}`);
57
+ await runInherit(INSTALL_CHROMIUM[0], INSTALL_CHROMIUM.slice(1), cwd);
58
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.9.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,9 +34,13 @@
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",
40
41
  "vitest": "^4.1.10"
42
+ },
43
+ "overrides": {
44
+ "@hono/node-server": "2.0.11"
41
45
  }
42
46
  }