@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 +11 -30
- package/dist/adapters/mcp.js +8 -1
- package/dist/beryl-test-skill.js +68 -12
- package/dist/commands/config-vars.js +23 -2
- package/dist/commands/inboxes.js +1 -22
- package/dist/commands/init.js +93 -122
- package/dist/commands/projects.js +21 -5
- package/dist/commands/runs.js +231 -66
- package/dist/commands/tests.js +305 -16
- package/dist/context.js +33 -2
- package/dist/email-extract.js +99 -0
- package/dist/email-pump.js +102 -0
- package/dist/local-exec.js +176 -0
- package/dist/local-run.js +145 -4
- package/dist/output.js +19 -0
- package/dist/progress.js +44 -0
- package/dist/registry/index.js +0 -2
- package/dist/schema.generated.js +2 -0
- package/package.json +3 -2
- package/dist/commands/credentials.js +0 -178
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
|
|
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
|
|
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
|
|
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.
|
package/dist/adapters/mcp.js
CHANGED
|
@@ -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() }, {
|
|
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),
|
package/dist/beryl-test-skill.js
CHANGED
|
@@ -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\`
|
|
101
|
-
|
|
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
|
-
-
|
|
196
|
-
|
|
197
|
-
|
|
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 —
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
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
|
|
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
|
},
|
package/dist/commands/inboxes.js
CHANGED
|
@@ -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",
|
package/dist/commands/init.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
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
|
-
|
|
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
|
|
161
|
-
"
|
|
162
|
-
"
|
|
163
|
-
"
|
|
164
|
-
"
|
|
165
|
-
"
|
|
166
|
-
"
|
|
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`
|
|
181
|
-
"
|
|
182
|
-
"
|
|
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: "
|
|
166
|
+
name: "cursor",
|
|
186
167
|
type: "boolean",
|
|
187
|
-
description: "Also
|
|
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 --
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
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: {
|
|
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
|
];
|