@beryl-so/cli 0.11.1 → 0.14.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.
- package/README.md +9 -3
- package/dist/adapters/mcp.js +8 -1
- package/dist/commands/credentials.js +3 -46
- package/dist/commands/init.js +93 -122
- package/dist/commands/tests.js +57 -9
- package/package.json +2 -1
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
|
|
@@ -302,9 +310,7 @@ Drive a browser session that captures a target-site login for Beryl to reuse.
|
|
|
302
310
|
| Command | Summary | MCP tool |
|
|
303
311
|
| --- | --- | --- |
|
|
304
312
|
| `beryl auth-capture start` | Start a login-capture browser session for the project (non-interactive) | `auth_capture_start` |
|
|
305
|
-
| `beryl auth-capture
|
|
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` |
|
|
313
|
+
| `beryl auth-capture capture <session-id>` | Save the session after the user has logged in via the live-view URL (first login or re-login) | `auth_capture_capture` |
|
|
308
314
|
| `beryl auth-capture release <session-id>` | Release a login-capture browser session without capturing | `auth_capture_release` |
|
|
309
315
|
|
|
310
316
|
### inbox
|
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),
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { UsageError } from "../errors.js";
|
|
2
1
|
import { dim, green, yellow } from "../output.js";
|
|
3
|
-
import { arg, flagBool
|
|
2
|
+
import { arg, flagBool } from "./util.js";
|
|
4
3
|
const capturePath = (ws, p) => `/auth-capture/workspaces/${ws}/projects/${p}/sessions`;
|
|
5
4
|
export const credentialCommands = [
|
|
6
5
|
{
|
|
@@ -98,7 +97,7 @@ export const credentialCommands = [
|
|
|
98
97
|
.del(`${capturePath(workspaceId, projectId)}/${session.session_id}`)
|
|
99
98
|
.catch(() => { });
|
|
100
99
|
}
|
|
101
|
-
return { human: `${green("Login captured.")} ${dim("The agent can
|
|
100
|
+
return { human: `${green("Login captured.")} ${dim("The agent can test the gated app with it.")}` };
|
|
102
101
|
},
|
|
103
102
|
},
|
|
104
103
|
{
|
|
@@ -111,40 +110,9 @@ export const credentialCommands = [
|
|
|
111
110
|
return { data: await ctx.client.post(capturePath(workspaceId, projectId)) };
|
|
112
111
|
},
|
|
113
112
|
},
|
|
114
|
-
{
|
|
115
|
-
name: "auth-capture login",
|
|
116
|
-
summary: "Log into the target site headlessly with credentials (no human at the browser)",
|
|
117
|
-
description: "Drives the login inside the capture session started by `auth-capture start`, so " +
|
|
118
|
-
"an agent can complete start → login → capture with zero human intervention. The " +
|
|
119
|
-
"credentials are sent to the server, typed into the target site over the wire, and " +
|
|
120
|
-
"never stored, logged, or returned — the captured session stays encrypted " +
|
|
121
|
-
"server-side. Follow with `auth-capture capture` to snapshot the authenticated session.",
|
|
122
|
-
scope: "project",
|
|
123
|
-
args: [
|
|
124
|
-
{ name: "session-id", description: "Session id from auth-capture start", required: true },
|
|
125
|
-
],
|
|
126
|
-
flags: [
|
|
127
|
-
{ name: "username", type: "string", description: "Login username / email", required: true },
|
|
128
|
-
{ name: "password", type: "string", description: "Login password", required: true },
|
|
129
|
-
{
|
|
130
|
-
name: "login-url",
|
|
131
|
-
type: "string",
|
|
132
|
-
description: "Explicit login page URL (defaults to the session's current page)",
|
|
133
|
-
},
|
|
134
|
-
],
|
|
135
|
-
async run(ctx, input) {
|
|
136
|
-
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
137
|
-
const username = flagStr(input, "username");
|
|
138
|
-
const password = flagStr(input, "password");
|
|
139
|
-
if (!username || !password)
|
|
140
|
-
throw new UsageError("--username and --password are required");
|
|
141
|
-
await ctx.client.post(`${capturePath(workspaceId, projectId)}/${arg(input, "session-id")}/login`, { username, password, login_url: flagStr(input, "login-url") ?? null });
|
|
142
|
-
return { human: "Logged in." };
|
|
143
|
-
},
|
|
144
|
-
},
|
|
145
113
|
{
|
|
146
114
|
name: "auth-capture capture",
|
|
147
|
-
summary: "
|
|
115
|
+
summary: "Save the session after the user has logged in via the live-view URL (first login or re-login)",
|
|
148
116
|
scope: "project",
|
|
149
117
|
args: [{ name: "session-id", description: "Session id from auth-capture start", required: true }],
|
|
150
118
|
async run(ctx, input) {
|
|
@@ -153,17 +121,6 @@ export const credentialCommands = [
|
|
|
153
121
|
return { human: "Captured." };
|
|
154
122
|
},
|
|
155
123
|
},
|
|
156
|
-
{
|
|
157
|
-
name: "auth-capture refresh",
|
|
158
|
-
summary: "Capture a refreshed session for a project whose login is expiring",
|
|
159
|
-
scope: "project",
|
|
160
|
-
args: [{ name: "session-id", description: "Session id from auth-capture start", required: true }],
|
|
161
|
-
async run(ctx, input) {
|
|
162
|
-
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
163
|
-
await ctx.client.post(`${capturePath(workspaceId, projectId)}/${arg(input, "session-id")}/capture-refresh`);
|
|
164
|
-
return { human: "Captured." };
|
|
165
|
-
},
|
|
166
|
-
},
|
|
167
124
|
{
|
|
168
125
|
name: "auth-capture release",
|
|
169
126
|
summary: "Release a login-capture browser session without capturing",
|
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
|
];
|
package/dist/commands/tests.js
CHANGED
|
@@ -1,10 +1,44 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { UsageError } from "../errors.js";
|
|
3
|
+
import { ApiError } from "../http.js";
|
|
3
4
|
import { lintPlan } from "../lint.js";
|
|
4
5
|
import { table } from "../output.js";
|
|
5
6
|
import { ACTION_PLAN_SCHEMA } from "../schema.generated.js";
|
|
6
7
|
import { arg, argList, flagBool, flagNum, flagStr, projectPath, readJsonFlag } from "./util.js";
|
|
7
8
|
const testPath = (ws, p, id) => `${projectPath(ws, p)}/tests/${id}`;
|
|
9
|
+
// The server transcodes a failure screenshot to WebP to fit it under the payload cap,
|
|
10
|
+
// so the format is not knowable up front — sniff it off the decoded magic bytes rather
|
|
11
|
+
// than asserting PNG.
|
|
12
|
+
export function sniffImageMime(b64) {
|
|
13
|
+
const head = Buffer.from(b64.slice(0, 24), "base64");
|
|
14
|
+
if (head.subarray(0, 4).toString("latin1") === "RIFF" && head.subarray(8, 12).toString("latin1") === "WEBP") {
|
|
15
|
+
return "image/webp";
|
|
16
|
+
}
|
|
17
|
+
if (head.subarray(0, 3).toString("hex") === "ffd8ff")
|
|
18
|
+
return "image/jpeg";
|
|
19
|
+
return "image/png";
|
|
20
|
+
}
|
|
21
|
+
// A verify-failure 422 carries evidence (a11y page state + failure screenshot).
|
|
22
|
+
// Returned as a CommandResult rather than thrown: a thrown error is text-only in
|
|
23
|
+
// the MCP adapter, and the screenshot only reaches the agent as image content.
|
|
24
|
+
function verifyFailureResult(err) {
|
|
25
|
+
if (!(err instanceof ApiError) || err.status !== 422)
|
|
26
|
+
return undefined;
|
|
27
|
+
const detail = err.detail;
|
|
28
|
+
if (typeof detail !== "object" || detail === null || !detail.message)
|
|
29
|
+
return undefined;
|
|
30
|
+
const parts = [detail.message];
|
|
31
|
+
if (detail.error_context) {
|
|
32
|
+
parts.push("", "----- Page state at failure (accessibility snapshot) -----", detail.error_context);
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
human: parts.join("\n"),
|
|
36
|
+
images: detail.screenshot_b64
|
|
37
|
+
? [{ data: detail.screenshot_b64, mimeType: sniffImageMime(detail.screenshot_b64) }]
|
|
38
|
+
: undefined,
|
|
39
|
+
exitCode: 1,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
8
42
|
// The concise human table for `tests list` — the full TestResponse is a 24-column
|
|
9
43
|
// firehose of internal ids that wraps unreadably in a normal terminal. `--wide`
|
|
10
44
|
// (and `--json`) still expose every field.
|
|
@@ -124,14 +158,22 @@ export const testCommands = [
|
|
|
124
158
|
if (!title)
|
|
125
159
|
throw new UsageError("--title is required");
|
|
126
160
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
161
|
+
try {
|
|
162
|
+
return {
|
|
163
|
+
data: await ctx.client.post(`${projectPath(workspaceId, projectId)}/tests`, {
|
|
164
|
+
title,
|
|
165
|
+
plan: readJsonFlag(input, "file"),
|
|
166
|
+
verify: !flagBool(input, "no-verify"),
|
|
167
|
+
description: flagStr(input, "description"),
|
|
168
|
+
}),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
const failure = verifyFailureResult(err);
|
|
173
|
+
if (failure)
|
|
174
|
+
return failure;
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
135
177
|
},
|
|
136
178
|
},
|
|
137
179
|
{
|
|
@@ -227,8 +269,14 @@ export const testCommands = [
|
|
|
227
269
|
],
|
|
228
270
|
async run(ctx, input) {
|
|
229
271
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
272
|
+
const res = (await ctx.client.post(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/plan/recompile`, { json_plan: readJsonFlag(input, "file") }));
|
|
273
|
+
// Base64 in the JSON output would flood the caller's context — hand the
|
|
274
|
+
// failure screenshot over as image content instead.
|
|
275
|
+
const shot = typeof res.screenshot_b64 === "string" ? res.screenshot_b64 : undefined;
|
|
276
|
+
delete res.screenshot_b64;
|
|
230
277
|
return {
|
|
231
|
-
data:
|
|
278
|
+
data: res,
|
|
279
|
+
images: shot ? [{ data: shot, mimeType: sniffImageMime(shot) }] : undefined,
|
|
232
280
|
};
|
|
233
281
|
},
|
|
234
282
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@beryl-so/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "Beryl on the command line — projects, runs, the exploring agent, and an MCP server over the same commands.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
|
+
"@playwright/test": "^1.61.1",
|
|
37
38
|
"@types/node": "^26.1.1",
|
|
38
39
|
"tsx": "^4.23.1",
|
|
39
40
|
"typescript": "^7.0.2",
|