@beryl-so/cli 0.7.1 → 0.8.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.
@@ -19,6 +19,26 @@ export const BERYL_TEST_SKILL_EXAMPLE_PLAN = {
19
19
  { action: "expect", expect_kind: "visible", selector: "text=Pro plan" },
20
20
  ],
21
21
  };
22
+ // The OTP/signup example the skill shows verbatim (§5). Exported so the test suite lints
23
+ // it with lintPlan, same as the minimal example — the documented await_email shape must
24
+ // pass `beryl tests lint` on the first try.
25
+ export const BERYL_TEST_SKILL_OTP_EXAMPLE_PLAN = {
26
+ steps: [
27
+ { action: "goto", url: "https://app.example.com/signup" },
28
+ { action: "fill", selector: "input[name=email]", value: "{{inbox_address}}" },
29
+ { action: "click", selector: "button[type=submit]" },
30
+ {
31
+ action: "await_email",
32
+ extract: "code",
33
+ subject_contains: "verification code",
34
+ capture_as: "otp",
35
+ wait_s: 45,
36
+ },
37
+ { action: "fill", selector: "input[name=code]", value: "{{otp}}" },
38
+ { action: "click", selector: "text=Verify" },
39
+ { action: "expect", expect_kind: "visible", selector: "text=Welcome" },
40
+ ],
41
+ };
22
42
  export const BERYL_TEST_SKILL = `---
23
43
  name: beryl-test
24
44
  description: Author durable, healable end-to-end tests for a web app with Beryl. Use when writing, running, or fixing a Beryl test locally with your own coding agent — drafting the plan over the Playwright MCP, writing the natural-language intent, and running it with \`beryl runs local\`.
@@ -176,4 +196,52 @@ beryl runs local <test-id> --url-override http://localhost:3000 --dir ./beryl-lo
176
196
 
177
197
  Once the test passes locally against a real outcome, it's ready to bank and let Beryl run
178
198
  and heal it.
199
+
200
+ ## 5. Testing an OTP / signup flow (\`await_email\`)
201
+
202
+ A flow that emails the user — a signup verification code, a magic sign-in link, a receipt
203
+ — is testable with the \`await_email\` action. Beryl mints a **run-scoped inbox**
204
+ automatically whenever a plan contains an \`await_email\` step (or cites
205
+ \`{{inbox_address}}\`): no setup, no environment configuration, no flag to turn on. The
206
+ minted address is in scope from step 1 as the reserved \`{{inbox_address}}\` handle.
207
+
208
+ The wiring is a three-part chain:
209
+
210
+ 1. **Type the minted address into the app** — a \`fill\` with \`value: "{{inbox_address}}"\`.
211
+ Every run gets a fresh address, so a signup flow is repeatable by construction (no
212
+ \`{{unique}}\` needed for the email itself; use \`{{unique}}\` for other must-not-collide
213
+ values like a username).
214
+ 2. **Await the mail and bank the extracted value** — an \`await_email\` step with:
215
+ - \`extract\` (required): \`code\` (an OTP), \`link\` (the sign-in/verify URL), or
216
+ \`pattern\` (your own regex in \`extract_pattern\`, exactly one capture group).
217
+ - \`capture_as\` (required): the handle name the extracted string is banked under.
218
+ - \`subject_contains\` / \`from_contains\` (optional): match the right mail when the app
219
+ sends more than one.
220
+ - \`wait_s\` (optional, 1–50, default 30): how many seconds the step blocks waiting
221
+ for the mail to land.
222
+ 3. **Use the banked value** — cite \`{{<capture_as>}}\` in a later step's \`value\` (fill the
223
+ code) or \`url\` (goto the magic link). A captured handle is legal **only** in
224
+ \`value\`/\`url\`; in a \`selector\`, \`option\`, or \`expect_text\` it would be used as
225
+ literal text, and the linter rejects it there.
226
+
227
+ A fully valid signup-with-OTP plan:
228
+
229
+ \`\`\`json
230
+ ${JSON.stringify(BERYL_TEST_SKILL_OTP_EXAMPLE_PLAN, null, 2)}
231
+ \`\`\`
232
+
233
+ For a magic-link flow, replace the code steps with
234
+ \`{ "action": "await_email", "extract": "link", "capture_as": "signin_link" }\` followed by
235
+ \`{ "action": "goto", "url": "{{signin_link}}" }\`.
236
+
237
+ Two caveats:
238
+
239
+ - \`beryl tests create\` verifies an \`await_email\` plan like any other — the replay mints
240
+ its own inbox, so the app's mail really is received and extracted before the test is
241
+ accepted. (Note the replay signs up / sends mail for real; pass \`--no-verify\` only if
242
+ that side effect is unwanted.) \`runs local\` cannot serve \`await_email\` — the inbox
243
+ lives in Beryl's cloud — so iterate on these flows with
244
+ \`beryl runs trigger --test <id> --watch\`.
245
+ - The outcome assertion discipline from §1 still applies: the green signal is the
246
+ post-verification state (the welcome screen, the dashboard), not "an email arrived".
179
247
  `;
@@ -1,4 +1,6 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import fs from "node:fs";
3
+ import os from "node:os";
2
4
  import path from "node:path";
3
5
  import { BERYL_TEST_SKILL, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME, } from "../beryl-test-skill.js";
4
6
  import { LOCAL_CONFIG_FILENAME, loadConfig } from "../config.js";
@@ -16,9 +18,12 @@ const MCP_SERVER_ENTRY = {
16
18
  command: "npx",
17
19
  args: ["-y", "@beryl-so/cli@latest", "mcp"],
18
20
  };
21
+ // Headless: the coding agent drives this browser to author tests — nobody watches the
22
+ // window, and a headed default breaks on CI / headless boxes. Matches how most devs
23
+ // already wire their own user-scoped playwright.
19
24
  const PLAYWRIGHT_SERVER_ENTRY = {
20
25
  command: "npx",
21
- args: ["@playwright/mcp@latest"],
26
+ args: ["@playwright/mcp@latest", "--headless"],
22
27
  };
23
28
  const ACTION_PLAN_SCHEMA_URL = "https://api.beryl.so/api/v1/schemas/action-plan.schema.json";
24
29
  // The authoring fork only appears when we create a fresh project with no tests yet; an
@@ -76,6 +81,36 @@ function mergeMcpConfig(file, withPlaywright) {
76
81
  }
77
82
  return { beryl, playwright };
78
83
  }
84
+ // A tool's config lives per-user (same as the PAT it needs, in ~/.config/beryl) — so wire the
85
+ // MCP servers per-user too, not in a committed .mcp.json that 401s for every teammate until
86
+ // they run `beryl login` anyway. We DON'T hand-edit ~/.claude.json (Claude Code owns it; a
87
+ // corrupt write breaks the user's whole CLI) — we shell out to `claude mcp add` and let Claude
88
+ // own the edit. Returns false when the `claude` binary isn't on PATH so the caller can fall
89
+ // back to a copy-paste command.
90
+ function claudeUserAdd(name, entry) {
91
+ try {
92
+ execFileSync("claude", ["mcp", "add", name, "-s", "user", "--", entry.command, ...entry.args], {
93
+ stdio: "ignore",
94
+ });
95
+ return true;
96
+ }
97
+ catch (err) {
98
+ // A non-zero exit ALSO covers "already exists" — but so does a missing binary. Distinguish:
99
+ // ENOENT means no `claude` on PATH (fall back), anything else means it ran and declined
100
+ // (already configured — treat as success, nothing to do).
101
+ if (err.code === "ENOENT")
102
+ return false;
103
+ return true;
104
+ }
105
+ }
106
+ function claudeAddHint(name, entry) {
107
+ return `claude mcp add ${name} -s user -- ${entry.command} ${entry.args.join(" ")}`;
108
+ }
109
+ // ~/.cursor/mcp.json is Cursor's user-scope config — small and ours to write safely, unlike
110
+ // ~/.claude.json — so we merge it directly rather than shelling out.
111
+ function cursorUserConfigPath() {
112
+ return path.join(os.homedir(), ".cursor", "mcp.json");
113
+ }
79
114
  function detectEditors(cwd) {
80
115
  const editors = [];
81
116
  if (fs.existsSync(path.join(cwd, ".claude")) || fs.existsSync(path.join(cwd, "CLAUDE.md")))
@@ -108,9 +143,11 @@ export const initCommands = [
108
143
  name: "init",
109
144
  summary: "Set up Beryl in this repo — sign in, pin a project, wire up your coding agent",
110
145
  description: "One-command onboarding: signs you in (emailed one-time code), pins this repo to a " +
111
- "workspace and project via .beryl.json (offering to create the project), and writes the " +
112
- "MCP server config for your coding agent (.mcp.json for Claude Code, .cursor/mcp.json for " +
113
- "Cursor). When creating a fresh project it asks how you want to author tests locally with " +
146
+ "workspace and project via .beryl.json (offering to create the project), and wires the " +
147
+ "MCP servers for your coding agent. By default they're wired per-user (matching where your " +
148
+ "login token lives) via `claude mcp add -s user` for Claude Code, ~/.cursor/mcp.json for " +
149
+ "Cursor; pass --scope project to write a committed .mcp.json for a shared repo instead. " +
150
+ "When creating a fresh project it asks how you want to author tests — locally with " +
114
151
  "your own coding agent or by hand (the default), or by letting Beryl's agent explore and " +
115
152
  "author them for you. Safe to re-run; every step skips what is already set up.",
116
153
  interactive: true,
@@ -138,6 +175,15 @@ export const initCommands = [
138
175
  description: "Which coding agent to write MCP config for (default: auto-detect)",
139
176
  },
140
177
  { name: "no-pin", type: "boolean", description: "Skip writing .beryl.json" },
178
+ {
179
+ name: "scope",
180
+ type: "string",
181
+ enum: ["user", "project"],
182
+ description: "Where to wire the MCP servers. `user` (default) configures them per-user (matching " +
183
+ "where your Beryl login token lives) via `claude mcp add -s user` / ~/.cursor/mcp.json. " +
184
+ "`project` writes a committed .mcp.json for a shared repo — every teammate still runs " +
185
+ "`beryl login` to authenticate",
186
+ },
141
187
  {
142
188
  name: "local",
143
189
  type: "boolean",
@@ -149,6 +195,7 @@ export const initCommands = [
149
195
  examples: [
150
196
  "npx @beryl-so/cli@latest init",
151
197
  "beryl init --editor-tools claude-code",
198
+ "beryl init --scope project",
152
199
  "beryl init --project https://app.example.com --authoring agent",
153
200
  "beryl init --project https://app.example.com --authoring local --editor-tools none",
154
201
  ],
@@ -232,14 +279,35 @@ export const initCommands = [
232
279
  // and local authoring needs the Playwright MCP — so default it on. `--no-local` (parsed
233
280
  // as an explicit false) opts out.
234
281
  const local = input.flags.local ?? editors.length > 0;
282
+ const scope = (flagStr(input, "scope") ?? "user");
235
283
  for (const editor of editors) {
284
+ if (scope === "user" && editor === "claude-code") {
285
+ // Claude Code owns ~/.claude.json — shell out to `claude mcp add` rather than write it.
286
+ const berylOk = claudeUserAdd("beryl", MCP_SERVER_ENTRY);
287
+ const playwrightOk = local ? claudeUserAdd("playwright", PLAYWRIGHT_SERVER_ENTRY) : undefined;
288
+ if (berylOk) {
289
+ ctx.err(`${green("✓")} claude-code MCP configured ${dim("(user scope)")}`);
290
+ if (playwrightOk)
291
+ ctx.err(`${green("✓")} claude-code Playwright MCP configured ${dim("(user scope)")}`);
292
+ }
293
+ else {
294
+ ctx.err(yellow("• `claude` not on PATH — run these to wire user-scope MCP servers:"));
295
+ ctx.err(` ${cyan(claudeAddHint("beryl", MCP_SERVER_ENTRY))}`);
296
+ if (local)
297
+ ctx.err(` ${cyan(claudeAddHint("playwright", PLAYWRIGHT_SERVER_ENTRY))}`);
298
+ }
299
+ continue;
300
+ }
236
301
  const file = editor === "claude-code"
237
302
  ? path.join(cwd, ".mcp.json")
238
- : path.join(cwd, ".cursor", "mcp.json");
303
+ : scope === "user"
304
+ ? cursorUserConfigPath()
305
+ : path.join(cwd, ".cursor", "mcp.json");
239
306
  const wrote = mergeMcpConfig(file, local);
240
- ctx.err(`${green("✓")} ${editor} MCP ${wrote.beryl ? "configured" : "already configured"} ${dim(path.relative(cwd, file))}`);
307
+ const where = scope === "user" ? file : path.relative(cwd, file);
308
+ ctx.err(`${green("✓")} ${editor} MCP ${wrote.beryl ? "configured" : "already configured"} ${dim(where)}`);
241
309
  if (wrote.playwright !== undefined)
242
- ctx.err(`${green("✓")} ${editor} Playwright MCP ${wrote.playwright ? "configured" : "already configured"} ${dim(path.relative(cwd, file))}`);
310
+ ctx.err(`${green("✓")} ${editor} Playwright MCP ${wrote.playwright ? "configured" : "already configured"} ${dim(where)}`);
243
311
  }
244
312
  if (choice === "auto" && editors.length === 0)
245
313
  ctx.err(dim("No coding agent detected — pass --editor-tools claude-code|cursor to wire one."));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
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",