@beryl-so/cli 0.11.1 → 0.17.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.
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
@@ -214,7 +222,7 @@ Author, inspect, version, and heal a project's tests — the checks Beryl runs o
214
222
  | `beryl tests reset <test-id>` | Discard user edits and return the test to its latest system-authored version | `tests_reset` |
215
223
  | `beryl tests heal <test-id> <state>` | Turn self-healing on or off for a test | `tests_heal` |
216
224
  | `beryl tests history <test-id>` | Pass/fail history, streak, and stability for a test | `tests_history` |
217
- | `beryl tests script <test-id>` | Print the rendered Playwright spec for a test | `tests_script` |
225
+ | `beryl tests script [test-id]` | Print the rendered Playwright spec for a test (or an unbanked plan file) | `tests_script` |
218
226
  | `beryl tests export <test-ids...>` | Export tests as Playwright .spec.ts files in a ZIP | `tests_export` |
219
227
 
220
228
  ### runs
@@ -226,7 +234,7 @@ Trigger a run of a project's tests (e.g. in CI), then watch, inspect, and downlo
226
234
  | Command | Summary | MCP tool |
227
235
  | --- | --- | --- |
228
236
  | `beryl runs trigger` | Trigger a test run (whole suite, a subset, or one environment) | `runs_trigger` |
229
- | `beryl runs local <test-id>` | Run a banked test locally with your own Playwright (public flows) | `runs_local` |
237
+ | `beryl runs local [test-ids...]` | Run tests on your machine with your own Playwright; results sync to Beryl | `runs_local` |
230
238
  | `beryl runs list` | List recent runs | `runs_list` |
231
239
  | `beryl runs get <run-id>` | Show one run with its per-test results | `runs_get` |
232
240
  | `beryl runs watch <run-id>` | Attach to a run and stream progress until it finishes | `runs_watch` |
@@ -259,7 +267,7 @@ Manage the variables, secrets, and files the agent can use while exploring and r
259
267
  | `beryl config vars delete <key>` | Delete a config variable | `config_vars_delete` |
260
268
  | `beryl config secrets list` | List the project's secrets (values are never returned) | `config_secrets_list` |
261
269
  | `beryl config secrets set <key> <value>` | Create a secret (write-only; re-setting a key replaces it) | `config_secrets_set` |
262
- | `beryl config secrets get <key>` | Show one secret's metadata (the value is never returned) | `config_secrets_get` |
270
+ | `beryl config secrets get <key>` | Show one secret's metadata, or reveal its value with --reveal | `config_secrets_get` |
263
271
  | `beryl config secrets delete <key>` | Delete a secret | `config_secrets_delete` |
264
272
  | `beryl config files list` | List files uploaded for the agent to use (e.g. CSVs, upload fixtures) | `config_files_list` |
265
273
  | `beryl config files get <file>` | Show one uploaded file's metadata | `config_files_get` |
@@ -280,33 +288,6 @@ Send run outcomes to a Slack channel via a per-project incoming webhook.
280
288
  | `beryl slack clear` | Remove the project's Slack webhook (stops all alerts) | — |
281
289
  | `beryl slack test` | Post a sample alert to the configured webhook | — |
282
290
 
283
- ### credentials
284
-
285
- Manage saved logins Beryl reuses to test behind authentication, and attach them to projects.
286
-
287
- | Command | Summary | MCP tool |
288
- | --- | --- | --- |
289
- | `beryl credentials list` | List the workspace's saved logins | `credentials_list` |
290
- | `beryl credentials get <credential-id>` | Show one saved login (status and freshness — never the session itself) | `credentials_get` |
291
- | `beryl credentials projects <credential-id>` | List the projects using a saved login | `credentials_projects` |
292
- | `beryl credentials delete <credential-id>` | Delete a saved login | `credentials_delete` |
293
- | `beryl credentials attach <credential-id>` | Attach a saved login to a project | `credentials_attach` |
294
- | `beryl credentials detach` | Detach the project's saved login | `credentials_detach` |
295
- | `beryl credentials recapture <credential-id>` | Start a re-capture for an expiring saved login (returns a live browser URL) | `credentials_recapture` |
296
- | `beryl credentials capture` | Capture a login for the project interactively: log in once in a real browser | — |
297
-
298
- ### auth-capture
299
-
300
- Drive a browser session that captures a target-site login for Beryl to reuse.
301
-
302
- | Command | Summary | MCP tool |
303
- | --- | --- | --- |
304
- | `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` |
308
- | `beryl auth-capture release <session-id>` | Release a login-capture browser session without capturing | `auth_capture_release` |
309
-
310
291
  ### inbox
311
292
 
312
293
  Email inboxes for testing flows that send mail — signups, OTPs, receipts.
@@ -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),
@@ -97,8 +97,14 @@ ${JSON.stringify(BERYL_TEST_SKILL_EXAMPLE_PLAN, null, 2)
97
97
  beryl tests create --title "Log in" --file plan.json \\
98
98
  --description "<the intent — see §3>"
99
99
  \`\`\`
100
- By default \`create\` verifies the plan in a real browser before accepting it. The full
101
- ActionPlan JSON Schema is at
100
+ By default \`create\` proves the plan by replaying it in a browser ON YOUR MACHINE
101
+ (your local \`@playwright/test\` — §4's prerequisites apply) before anything is banked.
102
+ The same is true over the MCP: the replay runs wherever the MCP server runs — your
103
+ computer, never Beryl's — so the §4 install must exist there too.
104
+ A red replay banks nothing: fix the plan file and re-run \`create\`. The green proving
105
+ replay is imported as the test's first run (\`--no-sync\` to skip); \`--no-verify\` banks
106
+ unproven. Inspect the exact spec that would run with \`beryl tests script --file
107
+ plan.json\`. The full ActionPlan JSON Schema is at
102
108
  https://api.beryl.so/api/v1/schemas/action-plan.schema.json.
103
109
 
104
110
  ### The outcome assertion is the whole game
@@ -183,18 +189,31 @@ for a scheduled run.
183
189
 
184
190
  \`\`\`
185
191
  npm i -D @playwright/test && npx playwright install # once
186
- beryl runs local <test-id> --url-override http://localhost:3000 --dir ./beryl-local
192
+ beryl runs local <test-id> --no-sync --url-override http://localhost:3000 --dir ./beryl-local
193
+ beryl runs local # the whole suite, results recorded in Beryl
187
194
  \`\`\`
188
195
 
196
+ - Takes one or more test ids; with none it runs every active test in the project.
197
+ - **Results sync to Beryl by default** — the finished run is imported as a first-class
198
+ run (history, replay, report; trigger source \`local\`). While ITERATING on a draft,
199
+ pass \`--no-sync\` so every fix-loop attempt doesn't land in the project's run history.
189
200
  - \`--url-override\` points the run at a local dev server or a preview deploy.
190
201
  - \`--dir\` keeps the **spec, artifacts, and a JSON \`report.json\`** on disk so you (or your
191
202
  coding agent) can read exactly what happened and iterate: read the report, see which step
192
203
  or assertion failed and why, fix the plan, \`beryl tests set-plan\`, run again.
193
204
  - It exits **0** if every test passed, **1** on a failure — so it drops straight into a
194
205
  run-fix-run loop.
195
- - **v1 is public / unauthenticated flows only.** A test that signs in first runs only in
196
- Beryl's cloud (which holds the encrypted session — it's never handed to your disk);
197
- \`runs local\` refuses it. Run those with \`beryl runs trigger\`.
206
+ - \`await_email\` steps work locally: the CLI mints the run inbox and answers them over
207
+ the API, exactly as the cloud runner would.
208
+ - **Saved-login tests work locally.** A plan that fills \`{{login_email}}\` /
209
+ \`{{login_password}}\` runs fine: the email is baked into the fetched spec and the
210
+ password is revealed once over the logged secret-reveal route, then scrubbed from
211
+ any uploaded error text or DOM snapshot. If the LOGIN_EMAIL variable or
212
+ LOGIN_PASSWORD secret isn't set, the test is skipped with the exact fix-it command.
213
+ - **Captured-session tests stay cloud-only.** A test that signs in with a captured
214
+ browser session runs only in Beryl's cloud (which holds the encrypted session — it's
215
+ never handed to your disk); \`runs local\` skips it with a note. Run those with
216
+ \`beryl runs trigger\`.
198
217
 
199
218
  Once the test passes locally against a real outcome, it's ready to bank and let Beryl run
200
219
  and heal it.
@@ -238,12 +257,49 @@ For a magic-link flow, replace the code steps with
238
257
 
239
258
  Two caveats:
240
259
 
241
- - \`beryl tests create\` verifies an \`await_email\` plan like any other — the replay mints
242
- its own inbox, so the app's mail really is received and extracted before the test is
243
- accepted. (Note the replay signs up / sends mail for real; pass \`--no-verify\` only if
244
- that side effect is unwanted.) \`runs local\` cannot serve \`await_email\` the inbox
245
- lives in Beryl's cloud so iterate on these flows with
246
- \`beryl runs trigger --test <id> --watch\`.
260
+ - \`beryl tests create\` verifies an \`await_email\` plan like any other — its local
261
+ replay mints a fresh run inbox and answers each step over the API, so the app's mail
262
+ really is received and extracted before the test is accepted. (Note the replay signs
263
+ up / sends mail for real; pass \`--no-verify\` only if that side effect is unwanted.)
264
+ \`beryl runs local\` serves \`await_email\` the same way, so the whole local loop covers
265
+ OTP/signup flows end to end.
247
266
  - The outcome assertion discipline from §1 still applies: the green signal is the
248
267
  post-verification state (the welcome screen, the dashboard), not "an email arrived".
268
+
269
+ ## 6. Accounts: minted or saved-login — never captured
270
+
271
+ How a test gets an account is a fixed decision, made at the start of every authoring
272
+ session from \`projects get\`, which reports \`login_email_set\` / \`login_password_set\`.
273
+ Both paths are plain steps in the plan — no captured sessions, no saved browser
274
+ state, no human-in-the-loop login.
275
+
276
+ - **Saved login present (both flags true) → use it.** The project's dedicated test
277
+ account lives in config: the \`LOGIN_EMAIL\` variable + the \`LOGIN_PASSWORD\`
278
+ secret. Author the login as ordinary opening steps citing the reserved handles —
279
+ fill \`{{login_email}}\`, fill \`{{login_password}}\`, submit, assert the logged-in
280
+ shell. NEVER paste the real values into a plan: the handles resolve at run time
281
+ (the password never lands in the rendered spec and is scrubbed from artifacts).
282
+ To drive the real login live while authoring, read the values with
283
+ \`config vars get LOGIN_EMAIL\` and \`config secrets get LOGIN_PASSWORD --reveal\`.
284
+ \`tests create\` still replays the whole flow (locally, on your machine) before
285
+ banking, so a login that doesn't work is rejected with evidence — nothing is
286
+ banked on faith.
287
+ - **Not set → mint.** \`{{inbox_address}}\` is a fresh real mailbox, minted per test,
288
+ per run. Type it into the site's own signup form, \`await_email\` the code or link
289
+ (§5). Fresh every run means nothing expires, nothing rots, no state leaks between
290
+ tests. Do not ask the user for credentials — mint is the default path.
291
+ - **Handed credentials in chat? Bank them first** (\`config vars set LOGIN_EMAIL\`,
292
+ \`config secrets set LOGIN_PASSWORD\`), then author with the handles as above.
293
+ A DEDICATED test account only — never a real user's.
294
+ - **Never hard-code an email address or password in a plan.** Minted inboxes expire
295
+ and pasted values rot on rotation — the handles are the only durable references.
296
+ - **One identity per banked test.** A flow involving a second account — invite a
297
+ teammate and accept as them, share and open as the viewer — is not bankable as one
298
+ test yet: a run has one inbox, and \`await_email\` reads only that inbox. Bank the
299
+ half the app shows to account A ("the invitation is listed as pending", "the share
300
+ link is issued") — a real, strong outcome. While AUTHORING you can still verify the
301
+ full handshake live: mint a second inbox (\`inbox create\`), read its mail
302
+ (\`inbox read\`), drive both sides in the browser — then bank the single-identity
303
+ halves.
304
+ - SSO-only sites (no email+password form at all) remain webapp territory.
249
305
  `;
@@ -112,12 +112,33 @@ export const configCommands = [
112
112
  },
113
113
  {
114
114
  name: "config secrets get",
115
- summary: "Show one secret's metadata (the value is never returned)",
115
+ summary: "Show one secret's metadata, or reveal its value with --reveal",
116
116
  scope: "project",
117
117
  args: [{ name: "key", description: "Secret key or id", required: true }],
118
+ flags: [
119
+ {
120
+ name: "reveal",
121
+ type: "boolean",
122
+ description: "Return the decrypted value — the explicit read that lets the agent drive a " +
123
+ "real login while authoring. Member-gated; every reveal is logged",
124
+ },
125
+ {
126
+ name: "env",
127
+ type: "string",
128
+ description: "With --reveal: prefer the row scoped to this environment id when the key " +
129
+ "exists at both scopes (matches what a run against that environment resolves)",
130
+ },
131
+ ],
118
132
  async run(ctx, input) {
119
133
  const { workspaceId, projectId } = await ctx.requireProject(input);
120
- const rows = (await ctx.client.get(`${configPath(workspaceId, projectId)}/secrets`));
134
+ const base = `${configPath(workspaceId, projectId)}/secrets`;
135
+ if (flagBool(input, "reveal")) {
136
+ const env = flagStr(input, "env");
137
+ return {
138
+ data: await ctx.client.get(`${base}/${encodeURIComponent(arg(input, "key"))}/value`, env ? { environment_id: env } : undefined),
139
+ };
140
+ }
141
+ const rows = (await ctx.client.get(base));
121
142
  return { data: findByKey(rows, arg(input, "key"), "secret") };
122
143
  },
123
144
  },
@@ -1,27 +1,6 @@
1
+ import { extractCode } from "../email-extract.js";
1
2
  import { dim, green } from "../output.js";
2
3
  import { arg, flagBool, flagNum, flagStr } from "./util.js";
3
- // Mirrors the API's own extractor (email_inbox/step_resolver.py): a labelled digit run
4
- // ("your code is 654321") beats the bare fenced pattern, because a real sign-in mail is
5
- // full of innocent 4-8 digit runs — "© 2026", a support number — and the bare fence
6
- // would happily return the first of them.
7
- const CODE_PATTERN = /(?<!\d)(\d{4,8})(?!\d)/;
8
- const LABELLED_CODE_PATTERN = /(?:verification|security|one[\s-]?time|login|sign[\s-]?in|access|confirmation)?\s*(?:code|otp|passcode|pin)\b[^0-9]{0,20}(?<!\d)(\d{4,8})(?!\d)/i;
9
- function visibleText(html) {
10
- return html
11
- .replace(/<(style|script|head)\b[\s\S]*?<\/\1>/gi, " ")
12
- .replace(/<[^>]+>/g, " ");
13
- }
14
- function extractCode(email) {
15
- const body = email.body_text || (email.body_html ? visibleText(email.body_html) : "");
16
- for (const pattern of [LABELLED_CODE_PATTERN, CODE_PATTERN]) {
17
- for (const text of [body, email.subject ?? ""]) {
18
- const match = text.match(pattern);
19
- if (match)
20
- return match[1];
21
- }
22
- }
23
- return null;
24
- }
25
4
  export const inboxCommands = [
26
5
  {
27
6
  name: "inbox create",
@@ -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
  ];