@beryl-so/cli 0.34.2 → 0.34.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.
- package/dist/adapters/mcp.js +48 -7
- package/dist/commands/auth.js +1 -1
- package/dist/context.js +19 -4
- package/dist/local-run.js +15 -17
- package/dist/playwright-install.js +20 -12
- package/dist/registry/index.js +4 -0
- package/package.json +1 -1
package/dist/adapters/mcp.js
CHANGED
|
@@ -18,29 +18,68 @@ export function mcpTools() {
|
|
|
18
18
|
return commands.filter((c) => !c.interactive && !c.hidden && !c.mcpHidden && c.name !== "mcp");
|
|
19
19
|
}
|
|
20
20
|
const mcpToolNames = new Set(mcpTools().map(toolName));
|
|
21
|
+
const toolByCommand = new Map(mcpTools().map((spec) => [spec.name, toolName(spec)]));
|
|
22
|
+
const flagNames = new Set(commands.flatMap((spec) => (spec.flags ?? []).map((f) => f.name)));
|
|
21
23
|
/** The tool name a command is exposed as under `beryl mcp`, or undefined if it isn't exposed. */
|
|
22
24
|
export function mcpToolFor(spec) {
|
|
23
25
|
const name = toolName(spec);
|
|
24
26
|
return mcpToolNames.has(name) ? name : undefined;
|
|
25
27
|
}
|
|
28
|
+
/** `[beryl] tests list ...` as its tool name plus the rest, or undefined when the words are
|
|
29
|
+
* no MCP tool (login, init, mcp): those are real terminal instructions and must stay. */
|
|
30
|
+
function commandToTool(text) {
|
|
31
|
+
const words = text.trim().split(/\s+/);
|
|
32
|
+
if (words[0] === "beryl")
|
|
33
|
+
words.shift();
|
|
34
|
+
for (const n of [3, 2, 1]) {
|
|
35
|
+
const tool = toolByCommand.get(words.slice(0, n).join(" "));
|
|
36
|
+
if (tool)
|
|
37
|
+
return [tool, ...words.slice(n)].join(" ");
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
function flagsToParams(text, wrap) {
|
|
42
|
+
return text
|
|
43
|
+
.replace(/--json\b/g, "JSON output (always on over MCP)")
|
|
44
|
+
.replace(/--no-([a-z][a-z-]*)/g, (_, name) => flagNames.has(`no-${name}`) ? wrap(`no-${name}`) : wrap(`${name}: false`))
|
|
45
|
+
.replace(/--([a-z][a-z-]*)/g, (_, name) => wrap(name));
|
|
46
|
+
}
|
|
47
|
+
/** Registry text is written for `beryl --help`; the agent sees JSON parameters and tool
|
|
48
|
+
* names, so `--wide` becomes `wide`, `beryl groups list` becomes `groups_list`, and a
|
|
49
|
+
* command that only exists in a terminal is left verbatim. */
|
|
50
|
+
export function toMcpDialect(text) {
|
|
51
|
+
return text.replace(/`[^`]*`|[^`]+/g, (segment) => {
|
|
52
|
+
if (segment.startsWith("`")) {
|
|
53
|
+
const inner = segment.slice(1, -1);
|
|
54
|
+
const asTool = commandToTool(inner);
|
|
55
|
+
if (asTool === undefined && inner.startsWith("beryl "))
|
|
56
|
+
return segment;
|
|
57
|
+
return `\`${flagsToParams(asTool ?? inner, (n) => n)}\``;
|
|
58
|
+
}
|
|
59
|
+
const prose = segment.replace(/\bberyl( [a-z][a-z-]*){1,3}(?=$|[^a-z-])/g, (command) => commandToTool(command) ?? command);
|
|
60
|
+
return flagsToParams(prose, (n) => `\`${n}\``);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
26
63
|
export function toolInputSchema(spec) {
|
|
27
64
|
const properties = {};
|
|
28
65
|
const required = [];
|
|
29
66
|
for (const a of spec.args ?? []) {
|
|
67
|
+
const description = toMcpDialect(a.description);
|
|
30
68
|
properties[a.name] = a.variadic
|
|
31
|
-
? { type: "array", items: { type: "string" }, description
|
|
32
|
-
: { type: "string", description
|
|
69
|
+
? { type: "array", items: { type: "string" }, description }
|
|
70
|
+
: { type: "string", description };
|
|
33
71
|
if (a.required)
|
|
34
72
|
required.push(a.name);
|
|
35
73
|
}
|
|
36
74
|
for (const f of spec.flags ?? []) {
|
|
37
75
|
const withDefault = f.default !== undefined ? { default: f.default } : {};
|
|
76
|
+
const description = toMcpDialect(f.mcpDescription ?? f.description);
|
|
38
77
|
properties[f.name] =
|
|
39
78
|
f.type === "strings"
|
|
40
|
-
? { type: "array", items: { type: "string" }, description
|
|
79
|
+
? { type: "array", items: { type: "string" }, description, ...withDefault }
|
|
41
80
|
: {
|
|
42
81
|
type: f.type,
|
|
43
|
-
description
|
|
82
|
+
description,
|
|
44
83
|
...(f.enum ? { enum: f.enum } : {}),
|
|
45
84
|
...withDefault,
|
|
46
85
|
};
|
|
@@ -124,7 +163,7 @@ export function exampleArgs(spec, example) {
|
|
|
124
163
|
return Object.keys(out).length ? out : null;
|
|
125
164
|
}
|
|
126
165
|
export function toolDescription(spec) {
|
|
127
|
-
const base = spec.description ? `${spec.summary}. ${spec.description}` : spec.summary;
|
|
166
|
+
const base = toMcpDialect(spec.description ? `${spec.summary}. ${spec.description}` : spec.summary);
|
|
128
167
|
const lines = (spec.examples ?? [])
|
|
129
168
|
.map((e) => exampleArgs(spec, e))
|
|
130
169
|
.filter((a) => a !== null)
|
|
@@ -251,7 +290,9 @@ export function mcpInstructions() {
|
|
|
251
290
|
"run-fix loop) ships as both the beryl-test skill and the `guide` tool — same " +
|
|
252
291
|
`content. If a beryl-test skill stating v${version} is already loaded, do not ` +
|
|
253
292
|
"call `guide`; if no beryl-test skill is available or it states another version, " +
|
|
254
|
-
"call `guide` before authoring your first test plan."
|
|
293
|
+
"call `guide` before authoring your first test plan. " +
|
|
294
|
+
"An account can hold several workspaces and projects: call `projects_list` first " +
|
|
295
|
+
"and pass `project` (an id is enough) on every project-scoped tool, or the call fails.");
|
|
255
296
|
}
|
|
256
297
|
export async function serveMcp(baseCtx) {
|
|
257
298
|
setSurface("mcp");
|
|
@@ -300,7 +341,7 @@ export async function serveMcp(baseCtx) {
|
|
|
300
341
|
return toolResult(result, lines);
|
|
301
342
|
}
|
|
302
343
|
catch (err) {
|
|
303
|
-
const message = err instanceof CliError ? err.message : String(err);
|
|
344
|
+
const message = toMcpDialect(err instanceof CliError ? err.message : String(err));
|
|
304
345
|
return {
|
|
305
346
|
content: [{ type: "text", text: [...lines, message].join("\n") }],
|
|
306
347
|
isError: true,
|
package/dist/commands/auth.js
CHANGED
|
@@ -186,7 +186,7 @@ export const authCommands = [
|
|
|
186
186
|
description: "Creates a passwordless account for the email and sends it a 6-digit code. " +
|
|
187
187
|
"Finish with `beryl login --email <addr> --code <the 6 digits>`, which verifies " +
|
|
188
188
|
"the account, creates its workspace, and signs the CLI in. With an inbox from " +
|
|
189
|
-
"`beryl
|
|
189
|
+
"`beryl mailbox create` as the address, an agent can provision a fresh account " +
|
|
190
190
|
"end-to-end with no human at a prompt.",
|
|
191
191
|
flags: [
|
|
192
192
|
{
|
package/dist/context.js
CHANGED
|
@@ -18,6 +18,23 @@ async function pickOne(ctx, candidates, kind, usageMessage) {
|
|
|
18
18
|
}
|
|
19
19
|
return candidates[n - 1].id;
|
|
20
20
|
}
|
|
21
|
+
// Over MCP there is no --flag and no env var to set: the only fix is a JSON parameter on
|
|
22
|
+
// every call, and a project id alone already resolves its workspace.
|
|
23
|
+
function ambiguousMessage(ctx, kind, candidates) {
|
|
24
|
+
const rows = candidates.map((c) => ` ${c.id} ${c.name ?? c.root_url ?? ""}`).join("\n");
|
|
25
|
+
let how;
|
|
26
|
+
if (ctx.mcp) {
|
|
27
|
+
how =
|
|
28
|
+
kind === "workspace"
|
|
29
|
+
? 'pass "workspace": "<id>" on each call, or "project": "<project id>", which implies its workspace:'
|
|
30
|
+
: 'pass "project": "<id>" on every project-scoped call (a project id alone is enough, no workspace needed):';
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
const forms = kind === "workspace" ? "<id|name>" : "<id|name|url>";
|
|
34
|
+
how = `pass --${kind} ${forms} or set BERYL_${kind.toUpperCase()}:`;
|
|
35
|
+
}
|
|
36
|
+
return `Multiple ${kind}s: ${how}\n${rows}`;
|
|
37
|
+
}
|
|
21
38
|
function matchByName(candidates, value, kind) {
|
|
22
39
|
const lower = value.toLowerCase();
|
|
23
40
|
const matches = candidates.filter((c) => c.name?.toLowerCase() === lower ||
|
|
@@ -82,8 +99,7 @@ export function createContext(options) {
|
|
|
82
99
|
throw new CliError("You have no workspaces yet — create one with `beryl workspaces create`");
|
|
83
100
|
}
|
|
84
101
|
else {
|
|
85
|
-
workspaceCache = await pickOne(ctx, workspaces, "workspace", "
|
|
86
|
-
workspaces.map((w) => ` ${w.id} ${w.name ?? ""}`).join("\n"));
|
|
102
|
+
workspaceCache = await pickOne(ctx, workspaces, "workspace", ambiguousMessage(ctx, "workspace", workspaces));
|
|
87
103
|
}
|
|
88
104
|
return workspaceCache;
|
|
89
105
|
},
|
|
@@ -121,8 +137,7 @@ export function createContext(options) {
|
|
|
121
137
|
"`beryl projects create <url>` (add --no-explore to author tests yourself).");
|
|
122
138
|
}
|
|
123
139
|
else {
|
|
124
|
-
projectId = await pickOne(ctx, projects, "project", "
|
|
125
|
-
projects.map((p) => ` ${p.id} ${p.name ?? p.root_url ?? ""}`).join("\n"));
|
|
140
|
+
projectId = await pickOne(ctx, projects, "project", ambiguousMessage(ctx, "project", projects));
|
|
126
141
|
}
|
|
127
142
|
projectCache = { workspaceId, projectId };
|
|
128
143
|
return projectCache;
|
package/dist/local-run.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// exactly as the cloud runner does. The install commands come from playwright-install.ts so the
|
|
8
|
-
// hint text and the actual installer never drift apart.
|
|
4
|
+
import { installHint, PLAYWRIGHT_INSTALL_COMMANDS, resolvePlaywrightRunner, } from "./playwright-install.js";
|
|
5
|
+
// @playwright/test is invoked as an external, never bundled — the CLI's zero-runtime-dep rule.
|
|
6
|
+
// The install commands come from playwright-install.ts so hint and installer never drift apart.
|
|
9
7
|
export const PLAYWRIGHT_INSTALL_HINT = "Local Playwright not found. Install it in this project, then re-run:\n" +
|
|
10
8
|
` ${PLAYWRIGHT_INSTALL_COMMANDS}`;
|
|
11
9
|
// Isolate the run from any playwright.config.ts in the customer's repo: a stray `testMatch`
|
|
@@ -51,20 +49,20 @@ function runProcess(command, args, cwd, env) {
|
|
|
51
49
|
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
|
52
50
|
});
|
|
53
51
|
}
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
60
|
-
return { command:
|
|
61
|
-
|
|
62
|
-
if (
|
|
52
|
+
// Resolved from the run dir — the exact place the spec imports from — so the process we
|
|
53
|
+
// spawn and the module the spec loads are the same copy. `npx playwright` is the last
|
|
54
|
+
// resort for a CLI install that somehow lost its dependencies.
|
|
55
|
+
export function playwrightBase(runDir) {
|
|
56
|
+
const runner = resolvePlaywrightRunner(runDir);
|
|
57
|
+
if (runner?.source === "project") {
|
|
58
|
+
return { command: process.execPath, args: [runner.cli, "test"], label: "playwright test" };
|
|
59
|
+
}
|
|
60
|
+
if (runner) {
|
|
63
61
|
return {
|
|
64
62
|
command: process.execPath,
|
|
65
|
-
args: [
|
|
63
|
+
args: [runner.cli, "test"],
|
|
66
64
|
label: "playwright test (bundled with the CLI)",
|
|
67
|
-
nodePath: path.resolve(path.dirname(
|
|
65
|
+
nodePath: path.resolve(path.dirname(runner.cli), "..", ".."),
|
|
68
66
|
};
|
|
69
67
|
}
|
|
70
68
|
const npx = process.platform === "win32" ? "npx.cmd" : "npx";
|
|
@@ -260,7 +258,7 @@ export async function runSpecLocally(opts) {
|
|
|
260
258
|
fs.writeFileSync(configPath, ISOLATING_CONFIG(runDir, artifactsDir));
|
|
261
259
|
const reportPath = path.join(runDir, "report.json");
|
|
262
260
|
const cleanup = () => fs.rmSync(runDir, { recursive: true, force: true });
|
|
263
|
-
const { command, args: base, label, nodePath } = playwrightBase(
|
|
261
|
+
const { command, args: base, label, nodePath } = playwrightBase(runDir);
|
|
264
262
|
const args = [...base, `--config=${configPath}`, "--reporter=json"];
|
|
265
263
|
opts.onProgress?.(`Running ${label} on ${opts.testName}…`);
|
|
266
264
|
await opts.setup?.(runDir);
|
|
@@ -17,16 +17,32 @@ const projectRequire = (cwd) => createRequire(path.join(cwd, "package.json"));
|
|
|
17
17
|
// A repo's own @playwright/test still wins when present (its version, its browser revision).
|
|
18
18
|
const cliRequire = createRequire(import.meta.url);
|
|
19
19
|
// cli.js is the package's bin, not an export, so it is located via package.json.
|
|
20
|
+
function runnerAt(pkgPath, source) {
|
|
21
|
+
const { version } = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
22
|
+
return { cli: path.join(path.dirname(pkgPath), "cli.js"), version, source };
|
|
23
|
+
}
|
|
24
|
+
function projectRunner(dir) {
|
|
25
|
+
try {
|
|
26
|
+
return runnerAt(projectRequire(dir).resolve("@playwright/test/package.json"), "project");
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
20
32
|
function bundledRunner() {
|
|
21
33
|
try {
|
|
22
|
-
|
|
23
|
-
const { version } = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
24
|
-
return { cli: path.join(path.dirname(pkgPath), "cli.js"), version };
|
|
34
|
+
return runnerAt(cliRequire.resolve("@playwright/test/package.json"), "bundled");
|
|
25
35
|
}
|
|
26
36
|
catch {
|
|
27
37
|
return undefined;
|
|
28
38
|
}
|
|
29
39
|
}
|
|
40
|
+
// The single answer to "which @playwright/test": what `import "@playwright/test"` from `dir`
|
|
41
|
+
// resolves to (any ancestor's node_modules), else the bundled copy. Runner and spec must load
|
|
42
|
+
// the same one or Playwright refuses to run ("did not expect test.use() to be called here").
|
|
43
|
+
export function resolvePlaywrightRunner(dir) {
|
|
44
|
+
return projectRunner(dir) ?? bundledRunner();
|
|
45
|
+
}
|
|
30
46
|
export const bundledRunnerCli = () => bundledRunner()?.cli;
|
|
31
47
|
// By hand, the browser must be fetched for the runner that will launch it: the repo's own
|
|
32
48
|
// (`npx playwright` resolves it) or, in a bare repo, the CLI's pinned version. An unpinned
|
|
@@ -46,15 +62,7 @@ function bundledCoreDir() {
|
|
|
46
62
|
return undefined;
|
|
47
63
|
}
|
|
48
64
|
}
|
|
49
|
-
export
|
|
50
|
-
try {
|
|
51
|
-
projectRequire(cwd).resolve("@playwright/test");
|
|
52
|
-
return true;
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
return false;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
65
|
+
export const hasPlaywrightTest = (cwd) => projectRunner(cwd) !== undefined;
|
|
58
66
|
// A local run launches chromium headless, which Playwright serves from a separate
|
|
59
67
|
// `chromium_headless_shell` build (`playwright install chromium` fetches both). That is the
|
|
60
68
|
// only engine a run ever launches, so it is the only one we install or check for.
|
package/dist/registry/index.js
CHANGED
|
@@ -17,11 +17,15 @@ export const WORKSPACE_FLAG = {
|
|
|
17
17
|
name: "workspace",
|
|
18
18
|
type: "string",
|
|
19
19
|
description: "Workspace id or name (defaults to BERYL_WORKSPACE, or your only workspace)",
|
|
20
|
+
mcpDescription: "Workspace id or name. Needed only when the account has more than one workspace and " +
|
|
21
|
+
"no project id is passed (a project id implies its workspace)",
|
|
20
22
|
};
|
|
21
23
|
export const PROJECT_FLAG = {
|
|
22
24
|
name: "project",
|
|
23
25
|
type: "string",
|
|
24
26
|
description: "Project id, name, or URL (defaults to BERYL_PROJECT, or the workspace's only project)",
|
|
27
|
+
mcpDescription: "Project id, name, or URL. Pass it on every call unless the account has exactly one " +
|
|
28
|
+
"project; a project id alone is enough, its workspace is looked up",
|
|
25
29
|
};
|
|
26
30
|
function withScopeFlags(spec) {
|
|
27
31
|
if (!spec.scope || spec.scope === "none")
|
package/package.json
CHANGED