@beryl-so/cli 0.14.1 → 0.21.4

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.
@@ -0,0 +1,331 @@
1
+ import { ApiError } from "../http.js";
2
+ import { dim, green, table, yellow } from "../output.js";
3
+ import { arg, flagBool, flagStr, projectPath, readJsonFlag } from "./util.js";
4
+ // The stored login plan heals, so a blind overwrite could silently clobber a repair the
5
+ // caller never read. A write cites the hash it read; this turns the server's refusal into
6
+ // the two-step recovery, and treats "you already wrote exactly this" as success.
7
+ function staleLoginResult(err) {
8
+ if (!(err instanceof ApiError) || err.status !== 409)
9
+ return undefined;
10
+ const detail = err.detail;
11
+ if (typeof detail !== "object" || detail === null || detail.code !== "stale_login_plan")
12
+ return undefined;
13
+ return {
14
+ data: {
15
+ stale: true,
16
+ current_hash: detail.current_hash,
17
+ message: detail.message,
18
+ },
19
+ human: (detail.message ?? "This account's login plan changed since you read it.") +
20
+ `\nCurrent hash: ${detail.current_hash ?? "unknown"}`,
21
+ exitCode: 1,
22
+ };
23
+ }
24
+ const accountsPath = (ws, p) => `${projectPath(ws, p)}/test-accounts`;
25
+ function accountTable(rows) {
26
+ return table(rows.map((a) => ({
27
+ label: a.identity_label + (a.is_default ? " *" : ""),
28
+ email: a.email,
29
+ type: a.type,
30
+ login: a.login_method,
31
+ status: a.status,
32
+ id: a.id,
33
+ })));
34
+ }
35
+ export const testAccountCommands = [
36
+ {
37
+ name: "accounts list",
38
+ summary: "List the test accounts an environment's tests sign in as",
39
+ groupDefault: true,
40
+ groupSummary: "Durable identities on the site under test — what an authenticated test signs in as.",
41
+ description: "Every test account on the project, with the identity label a plan's `auth_label` " +
42
+ "names. The row marked * is the default: what a plan gets when it sets " +
43
+ "requires_auth without an auth_label.",
44
+ scope: "project",
45
+ flags: [{ name: "env", type: "string", description: "Only accounts on this environment id" }],
46
+ async run(ctx, input) {
47
+ const { workspaceId, projectId } = await ctx.requireProject(input);
48
+ const rows = (await ctx.client.get(accountsPath(workspaceId, projectId), {
49
+ environment_id: flagStr(input, "env"),
50
+ }));
51
+ if (rows.length === 0)
52
+ return {
53
+ data: rows,
54
+ human: dim("No test accounts. Authenticated tests fall back to the LOGIN_EMAIL variable " +
55
+ "and LOGIN_PASSWORD secret, or mint a throwaway with {{inbox_address}}."),
56
+ };
57
+ return { data: rows, human: accountTable(rows) };
58
+ },
59
+ },
60
+ {
61
+ name: "accounts create",
62
+ summary: "Add a test account — the customer's own, or one Beryl signs up",
63
+ description: "Two kinds. `--type user_provided` records a dedicated account you already have on " +
64
+ "the site: pass --email and --password, and it is usable immediately. `--type beryl` " +
65
+ "reserves one Beryl will sign up itself, addressed at the project mailbox — it starts " +
66
+ "`pending` and becomes usable after `beryl accounts provision`. " +
67
+ "The first account an environment gets is its default whatever you pass.",
68
+ scope: "project",
69
+ flags: [
70
+ {
71
+ name: "type",
72
+ type: "string",
73
+ enum: ["user_provided", "beryl"],
74
+ description: "Whose account this is (default: user_provided when --password is given)",
75
+ },
76
+ {
77
+ name: "label",
78
+ type: "string",
79
+ description: "Identity label a plan's auth_label names (default 'default')",
80
+ },
81
+ { name: "email", type: "string", description: "user_provided only: the account's email" },
82
+ {
83
+ name: "password",
84
+ type: "string",
85
+ description: "user_provided only: stored encrypted as a project secret, never returned",
86
+ },
87
+ {
88
+ name: "login-method",
89
+ type: "string",
90
+ enum: ["password", "magic_link", "otp"],
91
+ description: "How tests sign in as it (default password)",
92
+ },
93
+ { name: "env", type: "string", description: "Environment id (default: the project's)" },
94
+ { name: "default", type: "boolean", description: "Make this the environment's default" },
95
+ ],
96
+ examples: [
97
+ "beryl accounts create --email qa@acme.test --password 'hunter2'",
98
+ "beryl accounts create --type beryl --login-method otp",
99
+ "beryl accounts create --email admin@acme.test --password 'hunter2' --label admin",
100
+ ],
101
+ async run(ctx, input) {
102
+ const { workspaceId, projectId } = await ctx.requireProject(input);
103
+ const password = flagStr(input, "password");
104
+ const account = (await ctx.client.post(accountsPath(workspaceId, projectId), {
105
+ type: flagStr(input, "type") ?? (password ? "user_provided" : "beryl"),
106
+ identity_label: flagStr(input, "label") ?? "default",
107
+ email: flagStr(input, "email") ?? null,
108
+ password: password ?? null,
109
+ login_method: flagStr(input, "login-method") ?? null,
110
+ environment_id: flagStr(input, "env") ?? null,
111
+ is_default: flagBool(input, "default"),
112
+ }));
113
+ const next = account.status === "pending"
114
+ ? yellow(`\nNot proven yet — run:\n beryl accounts provision ${account.id} --file <plan.json>\n` +
115
+ `with a plan that ends logged in (a signup, or a sign-in if it already exists).`)
116
+ : "";
117
+ return {
118
+ data: account,
119
+ human: `${green("Created")} test account ${account.id}\n\n${accountTable([account])}${next}`,
120
+ };
121
+ },
122
+ },
123
+ {
124
+ name: "accounts provision",
125
+ summary: "Prove a test account can get in, by replaying a plan that ends logged in",
126
+ description: "Runs the plan once in a real browser and, if it passes, marks the account ready. " +
127
+ "The plan is an ordinary ActionPlan that ends logged in — a SIGNUP when the account " +
128
+ "does not exist yet, or a SIGN-IN when it already does (you created it by hand, or " +
129
+ "the site already had it). Either proves the same thing, and a sign-in is what every " +
130
+ "later test will do anyway. Type {{mailbox_address}} into the email field and " +
131
+ "{{login_password}} into the password field — both resolve at replay time, and an " +
132
+ "`await_email` step reads the project mailbox, so a verification code works.",
133
+ scope: "project",
134
+ args: [{ name: "account-id", description: "Account id from `beryl accounts list`", required: true }],
135
+ flags: [
136
+ {
137
+ name: "file",
138
+ type: "string",
139
+ required: true,
140
+ description: "ActionPlan that ends logged in, as JSON (path, or - for stdin)",
141
+ },
142
+ ],
143
+ examples: [
144
+ "beryl accounts provision acc_123 --file signup-plan.json",
145
+ "beryl accounts provision acc_123 --file signin-plan.json",
146
+ ],
147
+ async run(ctx, input) {
148
+ const { workspaceId, projectId } = await ctx.requireProject(input);
149
+ const account = (await ctx.client.post(`${accountsPath(workspaceId, projectId)}/${arg(input, "account-id")}/provision`, { plan: readJsonFlag(input, "file") }));
150
+ if (account.status !== "ready")
151
+ return {
152
+ data: account,
153
+ human: `${yellow("The account could not get in")}: ${account.last_error ?? account.status}`,
154
+ exitCode: 1,
155
+ };
156
+ return {
157
+ data: account,
158
+ human: `${green("Ready")} — ${account.email}\n\n${accountTable([account])}`,
159
+ };
160
+ },
161
+ },
162
+ {
163
+ name: "accounts set-login",
164
+ summary: "Store the sign-in plan a run replays once, plus the probe that proves it",
165
+ description: "Session mode: instead of every authenticated test signing in for itself, the " +
166
+ "account signs in ONCE at the start of a run and every test rides that session. " +
167
+ "This stores the two plans that makes possible.\n\n" +
168
+ "--file is the SIGN-IN plan (not the signup): it must end logged in. Type " +
169
+ "{{login_email}} into the email field and {{login_password}} into the password " +
170
+ "field; an emailed code or magic link arrives at the account's own address, so an " +
171
+ "`await_email` step reads it with no human involved.\n\n" +
172
+ "--probe is the liveness check: a two-step plan (goto a gated page, then a POSITIVE " +
173
+ "assertion that only holds when signed in — the account menu, a 'Sign out' control). " +
174
+ "It is replayed in a fresh browser carrying only the captured session. It is " +
175
+ "REQUIRED, and not a formality: assertions like `hidden` and `count 0` all pass " +
176
+ "against a logged-out page, so without a positive signal a dead session would run " +
177
+ "every test logged-out and still report the run green.\n\n" +
178
+ "Storing only stores. Run `beryl accounts check` to prove it against the live app.",
179
+ scope: "project",
180
+ args: [
181
+ { name: "account-id", description: "Account id from `beryl accounts list`", required: true },
182
+ ],
183
+ flags: [
184
+ {
185
+ name: "file",
186
+ type: "string",
187
+ required: true,
188
+ description: "The sign-in ActionPlan, as JSON (path, or - for stdin)",
189
+ },
190
+ {
191
+ name: "probe",
192
+ type: "string",
193
+ required: true,
194
+ description: "The liveness probe plan, as JSON (path, or - for stdin): goto a gated page, " +
195
+ "then assert something only a signed-in user sees",
196
+ },
197
+ {
198
+ name: "base-hash",
199
+ type: "string",
200
+ description: "The login_plan_hash from `beryl accounts get-login`. The write is rejected if " +
201
+ "the stored plan changed since. Omit only when writing the first plan",
202
+ },
203
+ ],
204
+ examples: [
205
+ "beryl accounts set-login acc_123 --file signin.json --probe probe.json",
206
+ "beryl accounts set-login acc_123 --file signin.json --probe probe.json --base-hash 9f2c…",
207
+ ],
208
+ async run(ctx, input) {
209
+ const { workspaceId, projectId } = await ctx.requireProject(input);
210
+ try {
211
+ const account = (await ctx.client.put(`${accountsPath(workspaceId, projectId)}/${arg(input, "account-id")}/login`, {
212
+ plan: readJsonFlag(input, "file"),
213
+ probe: readJsonFlag(input, "probe"),
214
+ base_hash: flagStr(input, "base-hash") ?? null,
215
+ }));
216
+ return {
217
+ data: account,
218
+ human: `${green("Stored")} — ${account.email}\n` +
219
+ dim("Now run `beryl accounts check " + account.id + "` to prove it works."),
220
+ };
221
+ }
222
+ catch (err) {
223
+ const stale = staleLoginResult(err);
224
+ if (stale)
225
+ return stale;
226
+ throw err;
227
+ }
228
+ },
229
+ },
230
+ {
231
+ name: "accounts get-login",
232
+ summary: "Read the stored sign-in plan, its probe, and the hash a safe write must cite",
233
+ description: "The login plan is readable, not just writable: it heals, so overwriting without " +
234
+ "reading first can clobber a repair you never saw. Pass login_plan_hash back as " +
235
+ "--base-hash on the next `accounts set-login`.",
236
+ scope: "project",
237
+ args: [{ name: "account-id", description: "Account id", required: true }],
238
+ async run(ctx, input) {
239
+ const { workspaceId, projectId } = await ctx.requireProject(input);
240
+ const login = (await ctx.client.get(`${accountsPath(workspaceId, projectId)}/${arg(input, "account-id")}/login`));
241
+ if (!login.plan)
242
+ return {
243
+ data: login,
244
+ human: dim("No login plan stored. Author one by driving the sign-in in a browser, then " +
245
+ "`beryl accounts set-login " + login.account_id + " --file … --probe …`."),
246
+ };
247
+ return {
248
+ data: login,
249
+ human: `${login.identity_label} — session ${login.session_support}\n` +
250
+ `hash ${login.login_plan_hash}\n` +
251
+ (login.login_verified_at
252
+ ? dim(`last proved ${login.login_verified_at}`)
253
+ : dim("never proved — run `beryl accounts check`")),
254
+ };
255
+ },
256
+ },
257
+ {
258
+ name: "accounts check",
259
+ summary: "Sign in now and prove the session survives into a fresh browser",
260
+ description: "Replays the stored sign-in plan, captures the session it produces, then injects " +
261
+ "that session into a clean browser and runs the probe. Green here is the same green " +
262
+ "a run gets, because it is the same code path.\n\n" +
263
+ "A failure tells you which half broke: SESSION_LOGIN_FAILED means the sign-in " +
264
+ "itself did not complete (fix the plan); SESSION_PROOF_FAILED means the sign-in " +
265
+ "worked but the session did not survive the move to a fresh browser, so this app " +
266
+ "keeps its credential somewhere that cannot be carried (a service worker, a " +
267
+ "WebAuthn binding). In that case the account is marked unsupported and its tests " +
268
+ "keep signing in inline — degraded, not broken.\n\n" +
269
+ "Blocks for two browser replays.",
270
+ scope: "project",
271
+ args: [{ name: "account-id", description: "Account id", required: true }],
272
+ examples: ["beryl accounts check acc_123"],
273
+ async run(ctx, input) {
274
+ const { workspaceId, projectId } = await ctx.requireProject(input);
275
+ const res = (await ctx.client.post(`${accountsPath(workspaceId, projectId)}/${arg(input, "account-id")}/check`, {}));
276
+ if (!res.proved)
277
+ return {
278
+ data: res,
279
+ human: `${yellow("Not proved")}: ${res.error ?? res.session_support}`,
280
+ exitCode: 1,
281
+ };
282
+ return {
283
+ data: res,
284
+ human: `${green("Proved")} — ${res.identity_label} signs in and its session survives a fresh browser.`,
285
+ };
286
+ },
287
+ },
288
+ {
289
+ name: "accounts update",
290
+ summary: "Change a test account's password, login method, or default flag",
291
+ scope: "project",
292
+ args: [{ name: "account-id", description: "Account id", required: true }],
293
+ flags: [
294
+ { name: "email", type: "string", description: "New email" },
295
+ { name: "password", type: "string", description: "New password (re-marks the account ready)" },
296
+ {
297
+ name: "login-method",
298
+ type: "string",
299
+ enum: ["password", "magic_link", "otp"],
300
+ description: "How tests sign in as it",
301
+ },
302
+ { name: "default", type: "boolean", description: "Make this the environment's default" },
303
+ ],
304
+ async run(ctx, input) {
305
+ const { workspaceId, projectId } = await ctx.requireProject(input);
306
+ const account = (await ctx.client.patch(`${accountsPath(workspaceId, projectId)}/${arg(input, "account-id")}`, {
307
+ email: flagStr(input, "email") ?? null,
308
+ password: flagStr(input, "password") ?? null,
309
+ login_method: flagStr(input, "login-method") ?? null,
310
+ is_default: flagBool(input, "default") ? true : null,
311
+ }));
312
+ return { data: account, human: accountTable([account]) };
313
+ },
314
+ },
315
+ {
316
+ name: "accounts delete",
317
+ summary: "Delete a test account",
318
+ description: "Removes the identity. Tests naming it in auth_label start failing with a clear " +
319
+ "reason rather than silently signing in as somebody else.",
320
+ scope: "project",
321
+ args: [{ name: "account-id", description: "Account id", required: true }],
322
+ flags: [{ name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
323
+ async run(ctx, input) {
324
+ const { workspaceId, projectId } = await ctx.requireProject(input);
325
+ const id = arg(input, "account-id");
326
+ await ctx.confirm(`Delete test account ${id}?`, flagBool(input, "force"));
327
+ await ctx.client.del(`${accountsPath(workspaceId, projectId)}/${id}`);
328
+ return { human: "Deleted." };
329
+ },
330
+ },
331
+ ];
@@ -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
  },
@@ -38,7 +38,6 @@ export const environmentCommands = [
38
38
  enum: ["public", "gated"],
39
39
  description: "Whether this environment needs a login",
40
40
  },
41
- { name: "allow-mutations", type: "boolean", description: "Allow state-changing actions" },
42
41
  ],
43
42
  async run(ctx, input) {
44
43
  const { workspaceId, projectId } = await ctx.requireProject(input);
@@ -47,7 +46,6 @@ export const environmentCommands = [
47
46
  name: arg(input, "name"),
48
47
  root_url: arg(input, "url"),
49
48
  requires_auth_choice: flagStr(input, "auth") ?? null,
50
- mutation_choice: flagBool(input, "allow-mutations") ? "allow_mutations" : null,
51
49
  }),
52
50
  };
53
51
  },
@@ -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 { hasPlaywrightTest, installPlaywright, PLAYWRIGHT_INSTALL_COMMANDS, } from "../playwright-install.js";
10
+ import { anyGap, describeGaps, installCommandsFor, installPlaywright, playwrightGaps, } 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";
@@ -82,22 +82,24 @@ function claudeAddHint(name, entry) {
82
82
  function cursorUserConfigPath() {
83
83
  return path.join(os.homedir(), ".cursor", "mcp.json");
84
84
  }
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.
85
+ // Write the skill to one `.../beryl-test/SKILL.md` file. The skill is generated content
86
+ // that ships with the CLI and changes every release, so anything that differs is
87
+ // overwritten: a stale copy silently feeds agents months-old guidance, which is worse than
88
+ // losing a local edit the user can redo (and `beryl guide` still prints the same content).
88
89
  function writeSkillFile(file) {
90
+ let outcome = "installed";
89
91
  if (fs.existsSync(file)) {
90
- // Compare with line endings normalized so a CRLF checkout of our own content still
91
- // reads as unchanged (not falsely "customized") — we always write LF.
92
+ // Compare with line endings normalized so a CRLF copy of our own content counts as
93
+ // current and isn't rewritten on every run — we always write LF.
92
94
  const norm = (s) => s.replace(/\r\n/g, "\n");
93
95
  const current = fs.readFileSync(file, "utf8");
94
96
  if (norm(current) === norm(BERYL_TEST_SKILL))
95
97
  return { outcome: "unchanged", file };
96
- return { outcome: "customized", file };
98
+ outcome = "updated";
97
99
  }
98
100
  fs.mkdirSync(path.dirname(file), { recursive: true });
99
101
  fs.writeFileSync(file, BERYL_TEST_SKILL);
100
- return { outcome: "wrote", file };
102
+ return { outcome, file };
101
103
  }
102
104
  const skillLeaf = (root) => path.join(root, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME);
103
105
  // The authoring skill follows the `--scope` flag, same as the MCP servers: under `user`
@@ -107,8 +109,7 @@ const skillLeaf = (root) => path.join(root, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKI
107
109
  // `project` both copies stay repo-local (committed, so teammates get them with the repo).
108
110
  // `.agents/skills/` is the vendor-neutral location; Claude Code does NOT index it, so the
109
111
  // 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
+ // refreshed to this CLI's version on every run.
112
113
  function writeSkills(cwd, scope) {
113
114
  const root = scope === "user" ? os.homedir() : cwd;
114
115
  return [
@@ -119,21 +120,28 @@ function writeSkills(cwd, scope) {
119
120
  // Local authoring drives a real browser via `@playwright/test` + chromium, and the whole
120
121
  // authoring workflow (walk the flow first, then bank the plan) depends on it — so the
121
122
  // 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.
123
+ // Both halves count: the npm package without the browser binary is a real state, and it
124
+ // fails every local run with the same launch error. Never throws a failed install must not
125
+ // fail `init`, which has already done its wiring; it prints the exact commands instead.
124
126
  async function ensureLocalPlaywright(ctx, cwd) {
125
- if (hasPlaywrightTest(cwd)) {
126
- ctx.err(`${green("✓")} @playwright/test already installed ${dim("(local runs ready)")}`);
127
+ const gaps = playwrightGaps(cwd);
128
+ if (!anyGap(gaps)) {
129
+ ctx.err(`${green("✓")} @playwright/test + Chromium already installed ${dim("(local runs ready)")}`);
127
130
  return;
128
131
  }
129
132
  try {
130
133
  await installPlaywright(cwd, (line) => ctx.err(dim(line)));
131
- ctx.err(`${green("✓")} Local Playwright installed ${dim("(local runs ready)")}`);
134
+ // Believe the check, not the exit code — "local runs ready" is only honest if both
135
+ // halves are actually there afterwards.
136
+ const left = playwrightGaps(cwd);
137
+ if (anyGap(left))
138
+ throw new Error(`${describeGaps(left)} still missing afterwards`);
139
+ ctx.err(`${green("✓")} ${describeGaps(gaps)} installed ${dim("(local runs ready)")}`);
132
140
  }
133
141
  catch (err) {
134
142
  ctx.err(`${red("✗")} Playwright install failed: ${err.message}`);
135
143
  ctx.err(`${dim("•")} Finish the install by hand — local runs and browser authoring need it:\n` +
136
- ` ${cyan(PLAYWRIGHT_INSTALL_COMMANDS)}`);
144
+ ` ${cyan(installCommandsFor(gaps))}`);
137
145
  }
138
146
  }
139
147
  export const initCommands = [
@@ -142,15 +150,17 @@ export const initCommands = [
142
150
  summary: "Set up Beryl in this repo — sign in and wire up your coding agent",
143
151
  description: "One-command onboarding: signs you in (emailed one-time code) and wires up your coding " +
144
152
  "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 " +
153
+ "playwright MCP servers, writes the authoring skill (user scope: your home " +
146
154
  ".agents/skills/ + .claude/skills/, so it follows you into every session; --scope " +
147
155
  "project: the repo's own dirs, committed for teammates), and installs @playwright/test " +
148
156
  "+ chromium if missing — browser authoring and local runs depend on it. By default the " +
149
157
  "servers are wired per-user (matching where your login token lives) via `claude mcp add " +
150
158
  "-s user`; pass --scope project to write a committed .mcp.json for a shared repo " +
151
159
  "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.",
160
+ "your site and it resolves the workspace, project, and URL. Safe to re-run: every run " +
161
+ "refreshes the authoring skill to this CLI's version (overwriting an older or edited " +
162
+ "copy — `beryl guide` prints the same content), and skips whatever else is already set " +
163
+ "up.",
154
164
  interactive: true,
155
165
  flags: [
156
166
  {
@@ -221,10 +231,15 @@ export const initCommands = [
221
231
  for (const skill of skills) {
222
232
  const rel = path.relative(cwd, skill.file);
223
233
  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)}`);
234
+ const what = skill.outcome === "installed"
235
+ ? "installed"
236
+ : skill.outcome === "updated"
237
+ ? "updated"
238
+ : "already current";
239
+ const where = skill.outcome === "updated"
240
+ ? `${skillWhere} (refreshed to this CLI's version)`
241
+ : skillWhere;
242
+ ctx.err(`${green("✓")} Beryl authoring skill ${what} ${dim(where)}`);
228
243
  }
229
244
  await ensureLocalPlaywright(ctx, cwd);
230
245
  const nextSteps = `\n${bold("Beryl is set up — now open your editor and ask Claude to write tests.")}\n` +
@@ -251,10 +266,11 @@ export const initCommands = [
251
266
  name: "guide",
252
267
  summary: "Print the Beryl test-authoring guide",
253
268
  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).",
269
+ "assertions, natural-language intent, test accounts and the project mailbox " +
270
+ "({{login_email}}, {{mailbox_address}}, {{inbox_address}} + await_email), and the " +
271
+ "local run-fix loop. The same content `beryl init` installs as the beryl-test " +
272
+ "skill — call this before authoring your first plan when no skill is installed " +
273
+ "(works without logging in).",
258
274
  examples: ["beryl guide"],
259
275
  async run() {
260
276
  return { human: BERYL_TEST_SKILL };