@beryl-so/cli 0.67.0 → 0.77.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.
@@ -51,9 +51,9 @@ export const testAccountCommands = [
51
51
  if (rows.length === 0)
52
52
  return {
53
53
  data: rows,
54
- human: dim("No test accounts. Authenticated tests fall back to the LOGIN_EMAIL variable " +
55
- "and LOGIN_PASSWORD secret. A flow that must sign up fresh cites " +
56
- "{{inbox_address}} and deletes the account in `after`."),
54
+ human: dim("No test accounts, so {{login_email}} and {{login_password}} have nothing to " +
55
+ "resolve to. Add one with `beryl accounts create`. A flow that must sign up " +
56
+ "fresh cites {{inbox_address}} and deletes the account in `after`."),
57
57
  };
58
58
  return { data: rows, human: accountTable(rows) };
59
59
  },
@@ -81,7 +81,9 @@ export const testAccountCommands = [
81
81
  "`--type provisioned` reserves one Beryl will sign up itself, addressed at the project " +
82
82
  "mailbox; `--lifetime` says whether it is kept (permanent, the default) or a " +
83
83
  "throwaway. It starts `pending` and becomes usable after `beryl accounts signup`. " +
84
- "A captured account (a signed-in session from the Chrome plugin) is not created here. " +
84
+ "A captured account (a signed-in session from the Beryl Chrome extension) is not " +
85
+ "created here: capture it with the extension, then give it a probe with " +
86
+ "`beryl accounts set-login --probe`. " +
85
87
  "Works for Google-only apps too: bank a dedicated Google test account as provided " +
86
88
  "and author its sign-in plan through Google's form. " +
87
89
  "The first account an environment gets is its default whatever you pass.",
@@ -148,6 +150,54 @@ export const testAccountCommands = [
148
150
  };
149
151
  },
150
152
  },
153
+ {
154
+ name: "accounts capture",
155
+ summary: "Store a signed-in session as a captured test account",
156
+ description: "Usually done by the Beryl Chrome extension from a signed-in tab; this takes the " +
157
+ "same session JSON from a file: {schema_version: 2, final_url, cookies (CDP " +
158
+ "shape), local_storage, session_storage, indexed_db}. Upserts by --label on the " +
159
+ "environment, so capturing an existing label replaces its session. Cookies for " +
160
+ "other domains are dropped and listed. A captured account cannot sign itself in: " +
161
+ "give it a probe with `beryl accounts set-login --probe`, then prove it with " +
162
+ "`beryl accounts check`. Its session is never served back to a client.",
163
+ scope: "project",
164
+ flags: [
165
+ {
166
+ name: "file",
167
+ type: "string",
168
+ required: true,
169
+ description: "The captured session, as JSON (path, or - for stdin)",
170
+ },
171
+ {
172
+ name: "label",
173
+ type: "string",
174
+ description: "Identity label a plan's auth_label names (default 'default')",
175
+ },
176
+ { name: "email", type: "string", description: "The signed-in user's email" },
177
+ { name: "env", type: "string", description: "Environment id (default: the project's)" },
178
+ { name: "default", type: "boolean", description: "Make this the environment's default" },
179
+ ],
180
+ examples: ["beryl accounts capture --file session.json --label admin"],
181
+ async run(ctx, input) {
182
+ const { workspaceId, projectId } = await ctx.requireProject(input);
183
+ const account = (await ctx.client.put(`${accountsPath(workspaceId, projectId)}/captured`, {
184
+ session: readJsonFlag(input, "file"),
185
+ identity_label: flagStr(input, "label") ?? "default",
186
+ email: flagStr(input, "email") ?? null,
187
+ environment_id: flagStr(input, "env") ?? null,
188
+ is_default: flagBool(input, "default"),
189
+ }));
190
+ const ignored = account.ignored_cookie_domains.length
191
+ ? yellow(`\nDropped cookies for other domains: ${account.ignored_cookie_domains.join(", ")}`)
192
+ : "";
193
+ return {
194
+ data: account,
195
+ human: `${green("Captured")} ${account.identity_label} (${account.id})${ignored}\n` +
196
+ dim(`Next: beryl accounts set-login ${account.id} --probe probe.json, then ` +
197
+ `beryl accounts check ${account.id}`),
198
+ };
199
+ },
200
+ },
151
201
  {
152
202
  name: "accounts signup",
153
203
  summary: "Store a test account's signup plan and sign it up on its environment",
@@ -201,6 +251,12 @@ export const testAccountCommands = [
201
251
  "match on a redirect all pass against a logged-out page, so without a positive " +
202
252
  "signal a dead session would run every test logged-out and still report the run " +
203
253
  "green.\n\n" +
254
+ "A captured account (from the Beryl Chrome extension) takes --probe alone: it rides " +
255
+ "the captured session and cannot sign itself in, so there is no --file.\n\n" +
256
+ "--surface android stores the sign-in Android tests use instead, beside the web one; " +
257
+ "Beryl proves it on an Android worker the first time a signed-in android test runs. " +
258
+ "A test-creation job saves one itself when it meets the app's login screen, so this " +
259
+ "is for fixing or replacing it.\n\n" +
204
260
  "Storing only stores. Run `beryl accounts check` to prove it against the live app.",
205
261
  scope: "project",
206
262
  args: [
@@ -210,8 +266,8 @@ export const testAccountCommands = [
210
266
  {
211
267
  name: "file",
212
268
  type: "string",
213
- required: true,
214
- description: "The sign-in ActionPlan, as JSON (path, or - for stdin)",
269
+ description: "The sign-in ActionPlan, as JSON (path, or - for stdin). Required except for a " +
270
+ "captured account",
215
271
  },
216
272
  {
217
273
  name: "probe",
@@ -226,23 +282,37 @@ export const testAccountCommands = [
226
282
  description: "The login_plan_hash from `beryl accounts get-login`. The write is rejected if " +
227
283
  "the stored plan changed since. Omit only when writing the first plan",
228
284
  },
285
+ {
286
+ name: "surface",
287
+ type: "string",
288
+ enum: ["android", "ios"],
289
+ description: "An app surface: store (or read) the sign-in through the app's own screens. Both " +
290
+ "plans are plans for that device (device android or ios, starting with a launch); " +
291
+ "a signed-in app test restores the session this sign-in saved on the device",
292
+ },
229
293
  ],
230
294
  examples: [
231
295
  "beryl accounts set-login acc_123 --file signin.json --probe probe.json",
232
296
  "beryl accounts set-login acc_123 --file signin.json --probe probe.json --base-hash 9f2c…",
297
+ "beryl accounts set-login acc_456 --probe probe.json # captured account",
298
+ "beryl accounts set-login acc_123 --surface android --file app-signin.json --probe app-probe.json",
233
299
  ],
234
300
  async run(ctx, input) {
235
301
  const { workspaceId, projectId } = await ctx.requireProject(input);
236
302
  try {
237
303
  const account = (await ctx.client.put(`${accountsPath(workspaceId, projectId)}/${arg(input, "account-id")}/login`, {
238
- plan: readJsonFlag(input, "file"),
304
+ plan: flagStr(input, "file") ? readJsonFlag(input, "file") : null,
239
305
  probe: readJsonFlag(input, "probe"),
240
306
  base_hash: flagStr(input, "base-hash") ?? null,
307
+ ...(flagStr(input, "surface") ? { surface: flagStr(input, "surface") } : {}),
241
308
  }));
309
+ const surface = flagStr(input, "surface");
242
310
  return {
243
311
  data: account,
244
312
  human: `${green("Stored")} — ${account.email}\n` +
245
- dim("Now run `beryl accounts check " + account.id + "` to prove it works."),
313
+ dim(surface
314
+ ? `Beryl proves it the first time a signed-in ${surface} test runs.`
315
+ : "Now run `beryl accounts check " + account.id + "` to prove it works."),
246
316
  };
247
317
  }
248
318
  catch (err) {
@@ -261,10 +331,22 @@ export const testAccountCommands = [
261
331
  "--base-hash on the next `accounts set-login`.",
262
332
  scope: "project",
263
333
  args: [{ name: "account-id", description: "Account id", required: true }],
334
+ flags: [
335
+ {
336
+ name: "surface",
337
+ type: "string",
338
+ enum: ["android", "ios"],
339
+ description: "An app surface: store (or read) the sign-in through the app's own screens. Both " +
340
+ "plans are plans for that device (device android or ios, starting with a launch); " +
341
+ "a signed-in app test restores the session this sign-in saved on the device",
342
+ },
343
+ ],
264
344
  async run(ctx, input) {
265
345
  const { workspaceId, projectId } = await ctx.requireProject(input);
266
- const login = (await ctx.client.get(`${accountsPath(workspaceId, projectId)}/${arg(input, "account-id")}/login`));
267
- if (!login.plan)
346
+ const surface = flagStr(input, "surface");
347
+ const login = (await ctx.client.get(`${accountsPath(workspaceId, projectId)}/${arg(input, "account-id")}/login` +
348
+ (surface ? `?surface=${encodeURIComponent(surface)}` : "")));
349
+ if (!login.plan && !login.probe)
268
350
  return {
269
351
  data: login,
270
352
  human: dim("No login plan stored. Author one by driving the sign-in in a browser, then " +
@@ -1,7 +1,12 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { UsageError } from "../errors.js";
3
+ import { CliError, UsageError } from "../errors.js";
4
+ import { ApiError } from "../http.js";
5
+ import { dim, green, table, timeAgo } from "../output.js";
4
6
  import { arg, flagBool, flagStr, projectPath } from "./util.js";
7
+ const APK_RE = /\.apk$/i;
8
+ const BUILD_KINDS = new Set(["android_build", "ios_build"]);
9
+ const buildLabel = (f) => `${f.app_id ?? "?"} ${f.app_version ?? "?"} (${f.build_number ?? "?"})`;
5
10
  const configPath = (ws, p) => `${projectPath(ws, p)}/config`;
6
11
  function findByKey(rows, value, kind) {
7
12
  const byId = rows.find((r) => r.id === value);
@@ -86,7 +91,7 @@ export const configCommands = [
86
91
  },
87
92
  {
88
93
  name: "config secrets list",
89
- summary: "List the project's secrets (values are never returned)",
94
+ summary: "List the project's secrets (values are never returned); a test account's password is managed on the account, not here",
90
95
  scope: "project",
91
96
  async run(ctx, input) {
92
97
  const { workspaceId, projectId } = await ctx.requireProject(input);
@@ -165,11 +170,20 @@ export const configCommands = [
165
170
  },
166
171
  {
167
172
  name: "config files list",
168
- summary: "List files uploaded for the agent to use (e.g. CSVs, upload fixtures)",
173
+ summary: "List uploaded files: fixtures tests attach and Android and iOS app builds",
169
174
  scope: "project",
170
175
  async run(ctx, input) {
171
176
  const { workspaceId, projectId } = await ctx.requireProject(input);
172
- return { data: await ctx.client.get(`${configPath(workspaceId, projectId)}/files`) };
177
+ const rows = (await ctx.client.get(`${configPath(workspaceId, projectId)}/files`));
178
+ const human = table(rows.map((f) => ({
179
+ name: f.name,
180
+ kind: f.kind ?? "fixture",
181
+ app: BUILD_KINDS.has(String(f.kind)) ? buildLabel(f) : "",
182
+ env: f.environment_id ?? "",
183
+ created: timeAgo(f.created_at),
184
+ id: f.id,
185
+ })));
186
+ return { data: rows, human };
173
187
  },
174
188
  },
175
189
  {
@@ -195,32 +209,89 @@ export const configCommands = [
195
209
  },
196
210
  {
197
211
  name: "config files upload",
198
- summary: "Upload a file",
212
+ summary: "Upload a file: a fixture an upload step attaches, or an Android or iOS app build",
213
+ description: "The bytes go straight to storage over a short-lived URL, never through the API, up to " +
214
+ "1 GB. An .apk uploads as an Android build (or pass --kind android_build): Beryl reads " +
215
+ "its package name, version and native ABIs, refuses one the emulators cannot run (an " +
216
+ ".aab, a split APK, a too-new minSdk, ARM-only native code on x86 emulators). An iOS " +
217
+ "simulator build (the .app folder zipped, or EAS's .tar.gz) uploads with --kind " +
218
+ "ios_build: Beryl reads its bundle id and version and refuses an .ipa, a device build " +
219
+ "or one with no arm64 slice. App tests of an environment whose app id for that surface " +
220
+ "matches install the newest build. Beryl keeps the newest 5 builds per app per " +
221
+ "environment. Upload after each build, e.g. in CI.",
199
222
  scope: "project",
200
223
  args: [{ name: "file", description: "Path to the local file", required: true }],
201
- flags: [{ name: "env", type: "string", description: "Scope to one environment id" }],
224
+ flags: [
225
+ { name: "env", type: "string", description: "Scope to one environment id" },
226
+ {
227
+ name: "kind",
228
+ type: "string",
229
+ enum: ["fixture", "android_build", "ios_build"],
230
+ description: "fixture: a file tests attach. android_build: an Android APK (the default for .apk). " +
231
+ "ios_build: a zipped or tarred iOS simulator .app",
232
+ },
233
+ ],
234
+ examples: [
235
+ "beryl config files upload ./fixtures/avatar.png",
236
+ "beryl config files upload ./android/app/build/outputs/apk/release/app-release.apk",
237
+ "beryl config files upload ./MyApp.zip --kind ios_build",
238
+ ],
202
239
  async run(ctx, input) {
203
240
  const { workspaceId, projectId } = await ctx.requireProject(input);
204
241
  const file = arg(input, "file");
205
- let bytes;
242
+ const explicitKind = flagStr(input, "kind");
243
+ const kind = explicitKind ?? (APK_RE.test(file) ? "android_build" : "fixture");
244
+ const envId = flagStr(input, "env");
245
+ let blob;
206
246
  try {
207
- bytes = new Uint8Array(fs.readFileSync(file));
247
+ if (!fs.statSync(file).isFile())
248
+ throw new UsageError(`not a file: ${file}`);
249
+ blob = await fs.openAsBlob(file);
208
250
  }
209
251
  catch (err) {
252
+ if (err instanceof UsageError)
253
+ throw err;
210
254
  const code = err.code;
211
255
  if (code === "ENOENT")
212
256
  throw new UsageError(`file not found: ${file}`);
213
257
  throw new UsageError(`cannot read ${file} (${code ?? err.message})`);
214
258
  }
215
- const form = new FormData();
216
- form.set("file", new Blob([bytes]), path.basename(file));
217
- const envId = flagStr(input, "env");
218
- if (envId)
219
- form.set("environment_id", envId);
259
+ if (blob.size === 0)
260
+ throw new UsageError(`${file} is empty`);
261
+ const base = `${configPath(workspaceId, projectId)}/files`;
262
+ const upload = (await ctx.client.post(`${base}/uploads`, {
263
+ name: path.basename(file),
264
+ size: blob.size,
265
+ kind,
266
+ ...(envId ? { environment_id: envId } : {}),
267
+ }));
268
+ let put;
269
+ try {
270
+ put = await fetch(upload.upload_url, {
271
+ method: upload.method,
272
+ headers: upload.headers,
273
+ body: blob,
274
+ });
275
+ }
276
+ catch (err) {
277
+ throw new CliError(`Upload of ${file} failed: ${err.message}`);
278
+ }
279
+ if (!put.ok)
280
+ throw new CliError(`Upload of ${file} failed: storage answered HTTP ${put.status}`);
281
+ let done;
282
+ try {
283
+ done = (await ctx.client.post(`${base}/uploads/${upload.file_id}/complete`));
284
+ }
285
+ catch (err) {
286
+ if (err instanceof ApiError && err.status === 422 && typeof err.detail === "string")
287
+ throw new CliError(`Build refused: ${err.detail}`);
288
+ throw err;
289
+ }
220
290
  return {
221
- data: await ctx.client.request("POST", `${configPath(workspaceId, projectId)}/files`, {
222
- form,
223
- }),
291
+ data: done,
292
+ human: BUILD_KINDS.has(String(done.kind))
293
+ ? `${green("Build uploaded")}: ${buildLabel(done)} ${dim(String(done.id))}`
294
+ : `${green("Uploaded")}: ${String(done.name)} ${dim(String(done.id))}`,
224
295
  };
225
296
  },
226
297
  },
@@ -72,12 +72,17 @@ export const environmentCommands = [
72
72
  },
73
73
  {
74
74
  name: "envs update",
75
- summary: "Update an environment's name, URL, auth settings, Site Health screen sizes, schedule, CI and pages, or issue tracker settings",
75
+ summary: "Update an environment's name, URL, app ids, auth settings, Site Health screen sizes, schedule, CI and pages, or issue tracker settings",
76
76
  scope: "project",
77
77
  args: [{ name: "env-id", description: "Environment id", required: true }],
78
78
  flags: [
79
79
  { name: "name", type: "string", description: "New name" },
80
80
  { name: "url", type: "string", description: "New root URL" },
81
+ {
82
+ name: "app-id",
83
+ type: "strings",
84
+ description: 'App id for an app surface, as surface=id (android=com.acme.app, ios=com.acme.App), repeatable: that surface\'s tests install the newest uploaded build of it; "ios=" clears it',
85
+ },
81
86
  { name: "auth", type: "string", enum: ["public", "gated"], description: "New auth choice" },
82
87
  {
83
88
  name: "viewport",
@@ -91,6 +96,7 @@ export const environmentCommands = [
91
96
  ],
92
97
  examples: [
93
98
  "beryl envs update <env-id> --tracker linear --tracker-team <team-id>",
99
+ "beryl envs update <env-id> --app-id android=com.acme.app --app-id ios=com.acme.App",
94
100
  "beryl envs update <env-id> --tracker-threshold medium --tracker-max-tickets 5",
95
101
  "beryl envs update <env-id> --tracker-priority high=urgent --tracker-pause on",
96
102
  "beryl envs update <env-id> --health-schedule daily --health-time 06:00 --health-timezone Europe/Berlin",
@@ -104,6 +110,17 @@ export const environmentCommands = [
104
110
  body.name = flagStr(input, "name");
105
111
  if (flagStr(input, "url"))
106
112
  body.root_url = flagStr(input, "url");
113
+ const appIds = flagStrings(input, "app-id");
114
+ if (appIds) {
115
+ const parsed = {};
116
+ for (const pair of appIds) {
117
+ const [surface, ...rest] = pair.split("=");
118
+ if (!surface || rest.length === 0)
119
+ throw new UsageError(`--app-id must be surface=id (android=com.acme.app), got ${pair}`);
120
+ parsed[surface] = rest.join("=");
121
+ }
122
+ body.app_ids = parsed;
123
+ }
107
124
  if (flagStr(input, "auth"))
108
125
  body.requires_auth_choice = flagStr(input, "auth");
109
126
  const viewports = flagStrings(input, "viewport");
@@ -6,31 +6,22 @@ import { BERYL_COPILOT_MARK_BEGIN, BERYL_COPILOT_MARK_END, BERYL_COPILOT_SECTION
6
6
  import { loadConfig } from "../config.js";
7
7
  import { AuthError, CliError, UsageError } from "../errors.js";
8
8
  import { ApiClient } from "../http.js";
9
- import { bold, cyan, dim, green, red, yellow } from "../output.js";
10
- import { anyGap, describeGaps, installCommandsFor, installPlaywright, playwrightGaps, } from "../playwright-install.js";
9
+ import { bold, cyan, dim, green, yellow } from "../output.js";
11
10
  import { aliasMachineToAccount, captureCliEvent, flushTelemetry, shutdownTelemetry, } from "../telemetry.js";
12
11
  import { cliVersion, warnIfStale } from "../version-check.js";
13
12
  import { authCommands } from "./auth.js";
14
13
  import { flagStr } from "./util.js";
15
- // Pinned to @latest (like the Playwright entry below) — an unpinned spec lets npx
16
- // serve whatever stale global/cache install already resolves, so the agent's MCP
17
- // server would silently miss newer tools. mergeMcpConfig compares entries verbatim,
18
- // so re-running init also upgrades an old unpinned entry in place.
14
+ // Pinned to @latest: an unpinned spec lets npx serve whatever stale global/cache
15
+ // install already resolves, so the agent's MCP server would silently miss newer tools.
16
+ // mergeMcpConfig compares entries verbatim, so re-running init also upgrades an old
17
+ // unpinned entry in place.
19
18
  const MCP_SERVER_ENTRY = {
20
19
  command: "npx",
21
20
  args: ["-y", "@beryl-so/cli@latest", "mcp"],
22
21
  };
23
- // Headless: the coding agent drives this browser to author tests — nobody watches the
24
- // window, and a headed default breaks on CI / headless boxes. Matches how most devs
25
- // already wire their own user-scoped playwright.
26
- const PLAYWRIGHT_SERVER_ENTRY = {
27
- command: "npx",
28
- args: ["@playwright/mcp@latest", "--headless"],
29
- };
30
22
  // VS Code's `.vscode/mcp.json` uses a top-level `servers` key and tags each stdio server
31
- // with `type`, unlike Cursor/Claude's `mcpServers`. Same pinned commands, different shape.
23
+ // with `type`, unlike Cursor/Claude's `mcpServers`. Same pinned command, different shape.
32
24
  const VSCODE_MCP_SERVER_ENTRY = { type: "stdio", ...MCP_SERVER_ENTRY };
33
- const VSCODE_PLAYWRIGHT_SERVER_ENTRY = { type: "stdio", ...PLAYWRIGHT_SERVER_ENTRY };
34
25
  // opencode flattens command + args into one array under `mcp`.
35
26
  function opencodeEntry(entry) {
36
27
  return { type: "local", command: [entry.command, ...entry.args], enabled: true };
@@ -45,14 +36,14 @@ function snippetEntry(name, entry) {
45
36
  const MANUAL_CONFIG_SNIPPET = [
46
37
  " {",
47
38
  ' "mcpServers": {',
48
- `${snippetEntry("beryl", MCP_SERVER_ENTRY)},`,
49
- snippetEntry("playwright", PLAYWRIGHT_SERVER_ENTRY),
39
+ snippetEntry("beryl", MCP_SERVER_ENTRY),
50
40
  " }",
51
41
  " }",
52
42
  ].join("\n");
53
43
  // `key` is the container the editor expects: `mcpServers` for Claude/Cursor, `servers` for
54
- // VS Code, `mcp` for opencode. `entries` are the editor-shaped server objects.
55
- function mergeMcpConfig(file, key, entries) {
44
+ // VS Code, `mcp` for opencode. Only the beryl entry is ours; every other server (including
45
+ // a user-wired playwright) is left untouched.
46
+ function mergeMcpConfig(file, key, entry) {
56
47
  let existing = {};
57
48
  if (fs.existsSync(file)) {
58
49
  try {
@@ -63,19 +54,14 @@ function mergeMcpConfig(file, key, entries) {
63
54
  }
64
55
  }
65
56
  const servers = (existing[key] ?? {});
66
- const beryl = JSON.stringify(servers.beryl) !== JSON.stringify(entries.beryl);
67
- if (beryl)
68
- servers.beryl = entries.beryl;
69
- // Never clobber a playwright server the user already wired up.
70
- const playwright = servers.playwright === undefined;
71
- if (playwright)
72
- servers.playwright = entries.playwright;
73
- if (beryl || playwright) {
57
+ const beryl = JSON.stringify(servers.beryl) !== JSON.stringify(entry);
58
+ if (beryl) {
59
+ servers.beryl = entry;
74
60
  existing[key] = servers;
75
61
  fs.mkdirSync(path.dirname(file), { recursive: true });
76
62
  fs.writeFileSync(file, JSON.stringify(existing, null, 2) + "\n");
77
63
  }
78
- return { beryl, playwright };
64
+ return { beryl };
79
65
  }
80
66
  function claudeUserAdd(name, entry) {
81
67
  try {
@@ -194,7 +180,7 @@ const outcomeLabel = (o) => o === "installed"
194
180
  ? "updated (refreshed to this CLI's version)"
195
181
  : "already current";
196
182
  const skillLeaf = (root) => path.join(root, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME);
197
- // The authoring skill follows the `--scope` flag, same as the MCP servers: under `user`
183
+ // The beryl-test skill follows the `--scope` flag, same as the MCP servers: under `user`
198
184
  // (the default) it lands in the HOME `.agents/skills/` + `.claude/skills/` dirs, so the
199
185
  // knowledge travels with the user into every session the user-scoped MCP tools do —
200
186
  // otherwise an agent outside this repo has all the tools and none of the guide. Under
@@ -230,33 +216,6 @@ export function refreshInstalledSkills(cwd) {
230
216
  }
231
217
  return refreshed;
232
218
  }
233
- // Local authoring drives a real browser, and the whole authoring workflow (walk the flow
234
- // first, then bank the plan) depends on it — so the install is mandatory, not offered:
235
- // missing means install now, no prompt, no opt-out. The runner ships with the CLI; the
236
- // browser is the per-machine half, fetched for the runner's own revision. Never throws — a
237
- // failed install must not fail `init`, which has already done its wiring; it prints the
238
- // exact commands instead.
239
- async function ensureLocalPlaywright(ctx, cwd) {
240
- const gaps = playwrightGaps(cwd);
241
- if (!anyGap(gaps)) {
242
- ctx.err(`${green("✓")} Playwright + Chromium already installed ${dim("(local runs ready)")}`);
243
- return;
244
- }
245
- try {
246
- await installPlaywright(cwd, (line) => ctx.err(dim(line)));
247
- // Believe the check, not the exit code — "local runs ready" is only honest if both
248
- // halves are actually there afterwards.
249
- const left = playwrightGaps(cwd);
250
- if (anyGap(left))
251
- throw new Error(`${describeGaps(left)} still missing afterwards`);
252
- ctx.err(`${green("✓")} ${describeGaps(gaps)} installed ${dim("(local runs ready)")}`);
253
- }
254
- catch (err) {
255
- ctx.err(`${red("✗")} Playwright install failed: ${err.message}`);
256
- ctx.err(`${dim("•")} Finish the install by hand — local runs and browser authoring need it:\n` +
257
- ` ${cyan(installCommandsFor(gaps, cwd))}`);
258
- }
259
- }
260
219
  const CLIENTS = [
261
220
  ["claude", "Claude Code + Claude desktop app"],
262
221
  ["cursor", "Cursor"],
@@ -291,8 +250,8 @@ export const initCommands = [
291
250
  {
292
251
  name: "init",
293
252
  summary: "Set up Beryl in this repo: sign in and wire up your coding agent",
294
- description: "Signs you in, wires the beryl and playwright MCP servers into the coding agent you " +
295
- "pick, writes the beryl-test authoring skill, and installs Chromium if it is missing. " +
253
+ description: "Signs you in, wires the beryl MCP server into the coding agent you pick, and writes " +
254
+ "the beryl-test skill. " +
296
255
  "Pick an agent with --claude, --cursor, --vscode and/or --opencode. With no flag it " +
297
256
  "prompts (Enter picks Claude); with no flag and no TTY it wires nothing and prints the " +
298
257
  "flags. --claude also wires the Claude DESKTOP app when installed, which needs a quit " +
@@ -395,51 +354,35 @@ export const initCommands = [
395
354
  if (clients.has("claude") && scope === "user") {
396
355
  // Claude Code owns ~/.claude.json — shell out to `claude mcp add` rather than write it.
397
356
  const beryl = claudeUserAdd("beryl", MCP_SERVER_ENTRY);
398
- const playwright = claudeUserAdd("playwright", PLAYWRIGHT_SERVER_ENTRY);
399
- if (beryl.ok && playwright.ok) {
400
- ctx.err(`${green("✓")} beryl + playwright MCP configured ${dim("(user scope)")}`);
357
+ if (beryl.ok) {
358
+ ctx.err(`${green("✓")} beryl MCP configured ${dim("(user scope)")}`);
401
359
  }
402
360
  else {
403
- const failure = [beryl, playwright].find((r) => !r.ok && r.reason === "failed");
404
- ctx.err(yellow(failure
405
- ? `• \`claude mcp add\` failed (${failure.detail}); run these to wire user-scope MCP servers:`
406
- : "• `claude` not on PATH; run these to wire user-scope MCP servers:"));
361
+ ctx.err(yellow(beryl.reason === "failed"
362
+ ? `• \`claude mcp add\` failed (${beryl.detail}); run this to wire the user-scope MCP server:`
363
+ : "• `claude` not on PATH; run this to wire the user-scope MCP server:"));
407
364
  ctx.err(` ${cyan(claudeAddHint("beryl", MCP_SERVER_ENTRY))}`);
408
- ctx.err(` ${cyan(claudeAddHint("playwright", PLAYWRIGHT_SERVER_ENTRY))}`);
409
365
  }
410
366
  }
411
367
  else if (clients.has("claude")) {
412
368
  const file = path.join(cwd, ".mcp.json");
413
- const wrote = mergeMcpConfig(file, "mcpServers", {
414
- beryl: MCP_SERVER_ENTRY,
415
- playwright: PLAYWRIGHT_SERVER_ENTRY,
416
- });
417
- const where = path.relative(cwd, file);
418
- report("beryl MCP", wrote.beryl, where);
419
- report("playwright MCP", wrote.playwright, where);
369
+ const wrote = mergeMcpConfig(file, "mcpServers", MCP_SERVER_ENTRY);
370
+ report("beryl MCP", wrote.beryl, path.relative(cwd, file));
420
371
  }
421
372
  // Separate surface from Claude Code, read only at launch.
422
373
  const desktopFile = clients.has("claude") && scope === "user" ? claudeDesktopConfigFile() : null;
423
374
  if (desktopFile) {
424
- const wrote = mergeMcpConfig(desktopFile, "mcpServers", {
425
- beryl: MCP_SERVER_ENTRY,
426
- playwright: PLAYWRIGHT_SERVER_ENTRY,
427
- });
375
+ const wrote = mergeMcpConfig(desktopFile, "mcpServers", MCP_SERVER_ENTRY);
428
376
  report("Claude desktop beryl MCP", wrote.beryl, "(Claude desktop app)");
429
- report("Claude desktop playwright MCP", wrote.playwright, "(Claude desktop app)");
430
- if (wrote.beryl || wrote.playwright) {
377
+ if (wrote.beryl) {
431
378
  ctx.err(yellow("• Quit and reopen Claude desktop: it reads that config at launch."));
432
379
  }
433
380
  }
434
381
  if (clients.has("cursor")) {
435
382
  const file = scope === "user" ? cursorUserConfigPath() : path.join(cwd, ".cursor", "mcp.json");
436
- const wrote = mergeMcpConfig(file, "mcpServers", {
437
- beryl: MCP_SERVER_ENTRY,
438
- playwright: PLAYWRIGHT_SERVER_ENTRY,
439
- });
383
+ const wrote = mergeMcpConfig(file, "mcpServers", MCP_SERVER_ENTRY);
440
384
  const where = scope === "user" ? file : path.relative(cwd, file);
441
385
  report("cursor beryl MCP", wrote.beryl, where);
442
- report("cursor playwright MCP", wrote.playwright, where);
443
386
  const rule = writeCursorRule(cwd);
444
387
  ctx.err(`${green("✓")} cursor authoring rule ${outcomeLabel(rule.outcome)} ${dim(path.relative(cwd, rule.file))}`);
445
388
  if (scope === "user") {
@@ -456,13 +399,9 @@ export const initCommands = [
456
399
  const file = userDir
457
400
  ? path.join(userDir, "mcp.json")
458
401
  : path.join(cwd, ".vscode", "mcp.json");
459
- const wrote = mergeMcpConfig(file, "servers", {
460
- beryl: VSCODE_MCP_SERVER_ENTRY,
461
- playwright: VSCODE_PLAYWRIGHT_SERVER_ENTRY,
462
- });
402
+ const wrote = mergeMcpConfig(file, "servers", VSCODE_MCP_SERVER_ENTRY);
463
403
  const where = userDir ? file : path.relative(cwd, file);
464
404
  report("vscode beryl MCP", wrote.beryl, where);
465
- report("vscode playwright MCP", wrote.playwright, where);
466
405
  if (scope === "user" && !userDir) {
467
406
  ctx.err(dim(" • VS Code's user config dir was not found, so this went to a repo-local " +
468
407
  ".vscode/mcp.json. Run --vscode from your project, or add the server via " +
@@ -480,19 +419,14 @@ export const initCommands = [
480
419
  const { file, jsonc } = opencodeConfigFile(scope, cwd);
481
420
  if (jsonc) {
482
421
  ctx.err(yellow(`• ${file} is JSONC and may hold comments, so it was left alone.`));
483
- ctx.err(cyan(` Add these under "mcp": ${JSON.stringify({
422
+ ctx.err(cyan(` Add this under "mcp": ${JSON.stringify({
484
423
  beryl: opencodeEntry(MCP_SERVER_ENTRY),
485
- playwright: opencodeEntry(PLAYWRIGHT_SERVER_ENTRY),
486
424
  })}`));
487
425
  }
488
426
  else {
489
- const wrote = mergeMcpConfig(file, "mcp", {
490
- beryl: opencodeEntry(MCP_SERVER_ENTRY),
491
- playwright: opencodeEntry(PLAYWRIGHT_SERVER_ENTRY),
492
- });
427
+ const wrote = mergeMcpConfig(file, "mcp", opencodeEntry(MCP_SERVER_ENTRY));
493
428
  const where = scope === "user" ? file : path.relative(cwd, file);
494
429
  report("opencode beryl MCP", wrote.beryl, where);
495
- report("opencode playwright MCP", wrote.playwright, where);
496
430
  }
497
431
  }
498
432
  const skills = writeSkills(cwd, scope);
@@ -507,9 +441,8 @@ export const initCommands = [
507
441
  const where = skill.outcome === "updated"
508
442
  ? `${skillWhere} (refreshed to this CLI's version)`
509
443
  : skillWhere;
510
- ctx.err(`${green("✓")} Beryl authoring skill ${what} ${dim(where)}`);
444
+ ctx.err(`${green("✓")} beryl-test skill ${what} ${dim(where)}`);
511
445
  }
512
- await ensureLocalPlaywright(ctx, cwd);
513
446
  // Name the agent actually wired, not the raw flag: the picker sets `clients` too.
514
447
  const ed = clients.has("claude")
515
448
  ? { open: "open your editor", agent: "Claude" }
@@ -519,7 +452,7 @@ export const initCommands = [
519
452
  ? { open: "open VS Code", agent: "Copilot" }
520
453
  : { open: "open opencode", agent: "opencode" };
521
454
  const nextSteps = clients.size === 0
522
- ? `\n${bold("Beryl is signed in and the authoring skill is installed.")}\n` +
455
+ ? `\n${bold("Beryl is signed in and the beryl-test skill is installed.")}\n` +
523
456
  ` ${dim("• No MCP server is wired yet, so no agent can reach Beryl.")}\n` +
524
457
  ` ${dim(`• Using ${CLIENT_LABEL_LIST}? Re-run and pick it:`)}\n` +
525
458
  ` ${cyan("npx @beryl-so/cli@latest init")}\n` +
@@ -527,10 +460,10 @@ export const initCommands = [
527
460
  `${cyan(MANUAL_CONFIG_SNIPPET)}\n` +
528
461
  ` ${dim("Some clients name that key `servers` or `mcp`.")}\n` +
529
462
  ` ${yellow(`Details: ${MANUAL_SETUP_DOCS}`)}\n`
530
- : `\n${bold(`Beryl is set up. Now ${ed.open} and ask ${ed.agent} to write tests.`)}\n` +
531
- ` ${dim(`• Say: "write tests for https://example.com" and ${ed.agent} picks your workspace/project`)}\n` +
532
- ` ${dim(" and sets the URL for you (no pin, no prompt).")}\n` +
533
- ` ${dim(`• Playwright MCP is wired, so ${ed.agent} can drive a real browser to author from your plan.`)}\n`;
463
+ : `\n${bold(`Beryl is set up. Now ${ed.open} and ask ${ed.agent} to create tests.`)}\n` +
464
+ ` ${dim(`• Say: "create tests for https://example.com from docs/checkout.md" and ${ed.agent} starts`)}\n` +
465
+ ` ${dim(" a cloud test-creation job (jobs create) that authors them from that document.")}\n` +
466
+ ` ${dim(`• Then ask ${ed.agent} to run them in Beryl's cloud (runs trigger).`)}\n`;
534
467
  // The .mcp.json entry is pinned to @latest, but a global install / old npx cache
535
468
  // still wins resolution — so tell the user when the CLI they just ran is stale.
536
469
  await warnIfStale(cliVersion(), (msg) => ctx.err(yellow(msg)));
@@ -549,11 +482,12 @@ export const initCommands = [
549
482
  },
550
483
  {
551
484
  name: "guide",
552
- summary: "Print the Beryl test-authoring guide",
553
- description: "The full guide to authoring durable, healable tests: the ActionPlan shape, outcome " +
554
- "assertions, natural-language intent, test accounts and the project mailbox " +
555
- "({{login_email}}, {{mailbox_address}}, {{inbox_address}} + await_email), and the " +
556
- "local run-fix loop. Same content as the beryl-test skill `beryl init` installs. " +
485
+ summary: "Print the Beryl test guide",
486
+ description: "The guide to durable, healable tests: where new tests come from (`jobs create`), the " +
487
+ "ActionPlan shape, outcome assertions, natural-language intent, test accounts and the " +
488
+ "project mailbox ({{login_email}}, {{mailbox_address}}, {{inbox_address}} + " +
489
+ "await_email), and the run-fix loop. Same content as the beryl-test skill " +
490
+ "`beryl init` installs. " +
557
491
  `Skip if a loaded beryl-test skill states v${cliVersion()}; else call this first ` +
558
492
  "(works without logging in).",
559
493
  examples: ["beryl guide"],