@beryl-so/cli 0.47.0 → 0.48.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/dist/beryl-test-skill.js +34 -0
- package/dist/commands/init.js +106 -22
- package/package.json +1 -1
package/dist/beryl-test-skill.js
CHANGED
|
@@ -13,6 +13,40 @@ const indent = (text) => text
|
|
|
13
13
|
.join("\n");
|
|
14
14
|
export const BERYL_TEST_SKILL_FILENAME = "SKILL.md";
|
|
15
15
|
export const BERYL_TEST_SKILL_DIR = "beryl-test";
|
|
16
|
+
// The prose both editor pointers share. Cursor and Copilot read neither `.agents/skills/`
|
|
17
|
+
// nor `.claude/skills/`, so `init` writes them their own pointer instead. Thin on purpose:
|
|
18
|
+
// it sends the agent to the `guide` MCP tool rather than inlining the 500-line skill, and
|
|
19
|
+
// `guide` ships with the CLI so the real guidance never drifts from the installed version.
|
|
20
|
+
const BERYL_GUIDE_POINTER = `When writing, editing, or debugging Beryl tests in this repo, call the \`guide\`
|
|
21
|
+
MCP tool first. It returns the full authoring guide: the ActionPlan shape, outcome
|
|
22
|
+
assertions, test accounts and the project mailbox, and the local run-fix loop.
|
|
23
|
+
|
|
24
|
+
Then use the Beryl MCP tools: \`projects_list\` to find the project, and \`tests_create\`
|
|
25
|
+
to author a plan and replay it locally before it is banked.
|
|
26
|
+
|
|
27
|
+
The \`guide\` tool ships with the CLI and always matches the installed version, so treat
|
|
28
|
+
it as the source of truth over anything cached here.`;
|
|
29
|
+
// Cursor reads its own `.cursor/rules/*.mdc`, auto-loaded when alwaysApply is set.
|
|
30
|
+
export const BERYL_CURSOR_RULE_FILENAME = "beryl.mdc";
|
|
31
|
+
export const BERYL_CURSOR_RULE = `---
|
|
32
|
+
description: Authoring and editing Beryl end-to-end tests
|
|
33
|
+
alwaysApply: true
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
# Beryl test authoring
|
|
37
|
+
|
|
38
|
+
${BERYL_GUIDE_POINTER}
|
|
39
|
+
`;
|
|
40
|
+
// GitHub Copilot reads `.github/copilot-instructions.md`, a file the repo may already own,
|
|
41
|
+
// so our content lives in a marked section that `init` appends once and refreshes in place.
|
|
42
|
+
// The markers must stay byte-stable across releases or the refresh can't find the block.
|
|
43
|
+
export const BERYL_COPILOT_MARK_BEGIN = "<!-- BEGIN beryl (managed by beryl init) -->";
|
|
44
|
+
export const BERYL_COPILOT_MARK_END = "<!-- END beryl -->";
|
|
45
|
+
export const BERYL_COPILOT_SECTION = `${BERYL_COPILOT_MARK_BEGIN}
|
|
46
|
+
## Beryl test authoring
|
|
47
|
+
|
|
48
|
+
${BERYL_GUIDE_POINTER}
|
|
49
|
+
${BERYL_COPILOT_MARK_END}`;
|
|
16
50
|
// The example plan the skill shows verbatim. Exported so the test suite lints it with
|
|
17
51
|
// lintPlan — the skill's documented shape must pass `beryl tests lint` on the first try.
|
|
18
52
|
export const BERYL_TEST_SKILL_EXAMPLE_PLAN = {
|
package/dist/commands/init.js
CHANGED
|
@@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { BERYL_TEST_SKILL, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME, } from "../beryl-test-skill.js";
|
|
5
|
+
import { BERYL_COPILOT_MARK_BEGIN, BERYL_COPILOT_MARK_END, BERYL_COPILOT_SECTION, BERYL_CURSOR_RULE, BERYL_CURSOR_RULE_FILENAME, BERYL_TEST_SKILL, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME, } from "../beryl-test-skill.js";
|
|
6
6
|
import { loadConfig } from "../config.js";
|
|
7
7
|
import { AuthError, CliError } from "../errors.js";
|
|
8
8
|
import { ApiClient } from "../http.js";
|
|
@@ -27,8 +27,14 @@ const PLAYWRIGHT_SERVER_ENTRY = {
|
|
|
27
27
|
command: "npx",
|
|
28
28
|
args: ["@playwright/mcp@latest", "--headless"],
|
|
29
29
|
};
|
|
30
|
+
// VS Code's `.vscode/mcp.json` uses a top-level `servers` key and tags each stdio server
|
|
31
|
+
// with `type`, unlike Cursor/Claude's `mcpServers`. Same pinned commands, different shape.
|
|
32
|
+
const VSCODE_MCP_SERVER_ENTRY = { type: "stdio", ...MCP_SERVER_ENTRY };
|
|
33
|
+
const VSCODE_PLAYWRIGHT_SERVER_ENTRY = { type: "stdio", ...PLAYWRIGHT_SERVER_ENTRY };
|
|
30
34
|
const ACTION_PLAN_SCHEMA_URL = "https://api.beryl.so/api/v1/schemas/action-plan.schema.json";
|
|
31
|
-
|
|
35
|
+
// `key` is the container the editor expects: `mcpServers` for Claude/Cursor, `servers` for
|
|
36
|
+
// VS Code. `entries` are the editor-shaped server objects (VS Code tags them with `type`).
|
|
37
|
+
function mergeMcpConfig(file, key, entries) {
|
|
32
38
|
let existing = {};
|
|
33
39
|
if (fs.existsSync(file)) {
|
|
34
40
|
try {
|
|
@@ -38,16 +44,16 @@ function mergeMcpConfig(file) {
|
|
|
38
44
|
throw new CliError(`${file} exists but is not valid JSON (${err.message})`);
|
|
39
45
|
}
|
|
40
46
|
}
|
|
41
|
-
const servers = (existing
|
|
42
|
-
const beryl = JSON.stringify(servers.beryl) !== JSON.stringify(
|
|
47
|
+
const servers = (existing[key] ?? {});
|
|
48
|
+
const beryl = JSON.stringify(servers.beryl) !== JSON.stringify(entries.beryl);
|
|
43
49
|
if (beryl)
|
|
44
|
-
servers.beryl =
|
|
50
|
+
servers.beryl = entries.beryl;
|
|
45
51
|
// Never clobber a playwright server the user already wired up.
|
|
46
52
|
const playwright = servers.playwright === undefined;
|
|
47
53
|
if (playwright)
|
|
48
|
-
servers.playwright =
|
|
54
|
+
servers.playwright = entries.playwright;
|
|
49
55
|
if (beryl || playwright) {
|
|
50
|
-
existing
|
|
56
|
+
existing[key] = servers;
|
|
51
57
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
52
58
|
fs.writeFileSync(file, JSON.stringify(existing, null, 2) + "\n");
|
|
53
59
|
}
|
|
@@ -83,25 +89,54 @@ function claudeAddHint(name, entry) {
|
|
|
83
89
|
function cursorUserConfigPath() {
|
|
84
90
|
return path.join(os.homedir(), ".cursor", "mcp.json");
|
|
85
91
|
}
|
|
86
|
-
// Write
|
|
87
|
-
// that
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
92
|
+
// Write generated content that ships with the CLI and changes every release, so anything
|
|
93
|
+
// that differs is overwritten: a stale copy silently feeds agents months-old guidance,
|
|
94
|
+
// which is worse than losing a local edit the user can redo (and `beryl guide` still prints
|
|
95
|
+
// the same content). CRLF is normalized so a CRLF copy of our own content counts as current
|
|
96
|
+
// and isn't rewritten on every run; we always write LF.
|
|
97
|
+
function writeGeneratedFile(file, content) {
|
|
91
98
|
let outcome = "installed";
|
|
92
99
|
if (fs.existsSync(file)) {
|
|
93
|
-
// Compare with line endings normalized so a CRLF copy of our own content counts as
|
|
94
|
-
// current and isn't rewritten on every run — we always write LF.
|
|
95
100
|
const norm = (s) => s.replace(/\r\n/g, "\n");
|
|
96
|
-
|
|
97
|
-
if (norm(current) === norm(BERYL_TEST_SKILL))
|
|
101
|
+
if (norm(fs.readFileSync(file, "utf8")) === norm(content))
|
|
98
102
|
return { outcome: "unchanged", file };
|
|
99
103
|
outcome = "updated";
|
|
100
104
|
}
|
|
101
105
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
102
|
-
fs.writeFileSync(file,
|
|
106
|
+
fs.writeFileSync(file, content);
|
|
103
107
|
return { outcome, file };
|
|
104
108
|
}
|
|
109
|
+
const writeSkillFile = (file) => writeGeneratedFile(file, BERYL_TEST_SKILL);
|
|
110
|
+
// Cursor's equivalent of the skill: a repo-local `.cursor/rules/beryl.mdc`. Always repo-local
|
|
111
|
+
// even at `--scope user`, because Cursor has no global rules FILE (User Rules live in its
|
|
112
|
+
// settings UI), so there is nowhere else to put it.
|
|
113
|
+
const writeCursorRule = (cwd) => writeGeneratedFile(path.join(cwd, ".cursor", "rules", BERYL_CURSOR_RULE_FILENAME), BERYL_CURSOR_RULE);
|
|
114
|
+
const copilotInstructionsPath = (cwd) => path.join(cwd, ".github", "copilot-instructions.md");
|
|
115
|
+
// Copilot's `.github/copilot-instructions.md` may already hold the repo's own instructions,
|
|
116
|
+
// so we own only a marked section: refresh it in place when present, append it once when the
|
|
117
|
+
// file exists without it, create the file when it doesn't. Never rewrites the user's prose.
|
|
118
|
+
function writeCopilotInstructions(cwd) {
|
|
119
|
+
const file = copilotInstructionsPath(cwd);
|
|
120
|
+
const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
121
|
+
const begin = current.indexOf(BERYL_COPILOT_MARK_BEGIN);
|
|
122
|
+
if (begin !== -1) {
|
|
123
|
+
const endAt = current.indexOf(BERYL_COPILOT_MARK_END, begin);
|
|
124
|
+
const endFull = endAt === -1 ? current.length : endAt + BERYL_COPILOT_MARK_END.length;
|
|
125
|
+
if (current.slice(begin, endFull) === BERYL_COPILOT_SECTION)
|
|
126
|
+
return { outcome: "unchanged", file };
|
|
127
|
+
fs.writeFileSync(file, current.slice(0, begin) + BERYL_COPILOT_SECTION + current.slice(endFull));
|
|
128
|
+
return { outcome: "updated", file };
|
|
129
|
+
}
|
|
130
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
131
|
+
const body = current ? current.replace(/\n*$/, "\n\n") + BERYL_COPILOT_SECTION + "\n" : BERYL_COPILOT_SECTION + "\n";
|
|
132
|
+
fs.writeFileSync(file, body);
|
|
133
|
+
return { outcome: "installed", file };
|
|
134
|
+
}
|
|
135
|
+
const outcomeLabel = (o) => o === "installed"
|
|
136
|
+
? "installed"
|
|
137
|
+
: o === "updated"
|
|
138
|
+
? "updated (refreshed to this CLI's version)"
|
|
139
|
+
: "already current";
|
|
105
140
|
const skillLeaf = (root) => path.join(root, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME);
|
|
106
141
|
// The authoring skill follows the `--scope` flag, same as the MCP servers: under `user`
|
|
107
142
|
// (the default) it lands in the HOME `.agents/skills/` + `.claude/skills/` dirs, so the
|
|
@@ -126,7 +161,18 @@ export function refreshInstalledSkills(cwd) {
|
|
|
126
161
|
skillLeaf(path.join(root, ".agents", "skills")),
|
|
127
162
|
skillLeaf(path.join(root, ".claude", "skills")),
|
|
128
163
|
]));
|
|
129
|
-
|
|
164
|
+
const refreshed = [...files].filter((file) => fs.existsSync(file)).map(writeSkillFile);
|
|
165
|
+
// The Cursor rule and Copilot section are repo-local pointers; refresh them the same way so
|
|
166
|
+
// a reworded pointer reaches those editors on the next MCP spawn, matching the skill. Both
|
|
167
|
+
// are refreshed only when already installed, and the Copilot writer touches only its block.
|
|
168
|
+
const cursorRule = path.join(cwd, ".cursor", "rules", BERYL_CURSOR_RULE_FILENAME);
|
|
169
|
+
if (fs.existsSync(cursorRule))
|
|
170
|
+
refreshed.push(writeGeneratedFile(cursorRule, BERYL_CURSOR_RULE));
|
|
171
|
+
const copilot = copilotInstructionsPath(cwd);
|
|
172
|
+
if (fs.existsSync(copilot) && fs.readFileSync(copilot, "utf8").includes(BERYL_COPILOT_MARK_BEGIN)) {
|
|
173
|
+
refreshed.push(writeCopilotInstructions(cwd));
|
|
174
|
+
}
|
|
175
|
+
return refreshed;
|
|
130
176
|
}
|
|
131
177
|
// Local authoring drives a real browser, and the whole authoring workflow (walk the flow
|
|
132
178
|
// first, then bank the plan) depends on it — so the install is mandatory, not offered:
|
|
@@ -169,7 +215,11 @@ export const initCommands = [
|
|
|
169
215
|
"locally), since browser authoring and local runs depend on it. By default the " +
|
|
170
216
|
"servers are wired per-user (matching where your login token lives) via `claude mcp add " +
|
|
171
217
|
"-s user`; pass --scope project to write a committed .mcp.json for a shared repo " +
|
|
172
|
-
"instead.
|
|
218
|
+
"instead. With --cursor the same two servers are also wired into Cursor and a repo-local " +
|
|
219
|
+
".cursor/rules/beryl.mdc authoring rule is written (Cursor's equivalent of the skill, " +
|
|
220
|
+
"since it reads neither skills dir); --vscode does the same for VS Code / GitHub Copilot " +
|
|
221
|
+
"(.vscode/mcp.json plus a marked section in .github/copilot-instructions.md). No " +
|
|
222
|
+
"workspace/project pin and no URL prompt: ask Claude to write tests for " +
|
|
173
223
|
"your site and it resolves the workspace, project, and URL. Safe to re-run: every run " +
|
|
174
224
|
"refreshes the authoring skill to this CLI's version (overwriting an older or edited " +
|
|
175
225
|
"copy; `beryl guide` prints the same content), and skips whatever else is already set " +
|
|
@@ -188,13 +238,21 @@ export const initCommands = [
|
|
|
188
238
|
{
|
|
189
239
|
name: "cursor",
|
|
190
240
|
type: "boolean",
|
|
191
|
-
description: "Also
|
|
241
|
+
description: "Also wire the same two MCP servers into Cursor's config and write a repo-local " +
|
|
242
|
+
".cursor/rules/beryl.mdc authoring rule (Cursor's equivalent of the skill)",
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
name: "vscode",
|
|
246
|
+
type: "boolean",
|
|
247
|
+
description: "Also wire the two MCP servers into .vscode/mcp.json and add a Beryl section to " +
|
|
248
|
+
".github/copilot-instructions.md for GitHub Copilot (both repo-local)",
|
|
192
249
|
},
|
|
193
250
|
],
|
|
194
251
|
examples: [
|
|
195
252
|
"npx @beryl-so/cli@latest init",
|
|
196
253
|
"beryl init --scope project",
|
|
197
254
|
"beryl init --cursor",
|
|
255
|
+
"beryl init --vscode",
|
|
198
256
|
],
|
|
199
257
|
async run(ctx, input) {
|
|
200
258
|
const cwd = process.cwd();
|
|
@@ -256,17 +314,43 @@ export const initCommands = [
|
|
|
256
314
|
}
|
|
257
315
|
else {
|
|
258
316
|
const file = path.join(cwd, ".mcp.json");
|
|
259
|
-
const wrote = mergeMcpConfig(file
|
|
317
|
+
const wrote = mergeMcpConfig(file, "mcpServers", {
|
|
318
|
+
beryl: MCP_SERVER_ENTRY,
|
|
319
|
+
playwright: PLAYWRIGHT_SERVER_ENTRY,
|
|
320
|
+
});
|
|
260
321
|
const where = path.relative(cwd, file);
|
|
261
322
|
report("beryl MCP", wrote.beryl, where);
|
|
262
323
|
report("playwright MCP", wrote.playwright, where);
|
|
263
324
|
}
|
|
264
325
|
if (input.flags.cursor) {
|
|
265
326
|
const file = scope === "user" ? cursorUserConfigPath() : path.join(cwd, ".cursor", "mcp.json");
|
|
266
|
-
const wrote = mergeMcpConfig(file
|
|
327
|
+
const wrote = mergeMcpConfig(file, "mcpServers", {
|
|
328
|
+
beryl: MCP_SERVER_ENTRY,
|
|
329
|
+
playwright: PLAYWRIGHT_SERVER_ENTRY,
|
|
330
|
+
});
|
|
267
331
|
const where = scope === "user" ? file : path.relative(cwd, file);
|
|
268
332
|
report("cursor beryl MCP", wrote.beryl, where);
|
|
269
333
|
report("cursor playwright MCP", wrote.playwright, where);
|
|
334
|
+
const rule = writeCursorRule(cwd);
|
|
335
|
+
ctx.err(`${green("✓")} cursor authoring rule ${outcomeLabel(rule.outcome)} ${dim(path.relative(cwd, rule.file))}`);
|
|
336
|
+
if (scope === "user") {
|
|
337
|
+
ctx.err(dim(" • The rule is repo-local (Cursor has no global rules file); commit it to share it."));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// VS Code (Copilot) is always repo-local: `.vscode/mcp.json` for the servers and a
|
|
341
|
+
// marked section in `.github/copilot-instructions.md` for the guide pointer. VS Code's
|
|
342
|
+
// per-user MCP config is managed through its UI, not a portable file, so scope is moot.
|
|
343
|
+
if (input.flags.vscode) {
|
|
344
|
+
const file = path.join(cwd, ".vscode", "mcp.json");
|
|
345
|
+
const wrote = mergeMcpConfig(file, "servers", {
|
|
346
|
+
beryl: VSCODE_MCP_SERVER_ENTRY,
|
|
347
|
+
playwright: VSCODE_PLAYWRIGHT_SERVER_ENTRY,
|
|
348
|
+
});
|
|
349
|
+
const where = path.relative(cwd, file);
|
|
350
|
+
report("vscode beryl MCP", wrote.beryl, where);
|
|
351
|
+
report("vscode playwright MCP", wrote.playwright, where);
|
|
352
|
+
const inst = writeCopilotInstructions(cwd);
|
|
353
|
+
ctx.err(`${green("✓")} copilot instructions ${outcomeLabel(inst.outcome)} ${dim(path.relative(cwd, inst.file))}`);
|
|
270
354
|
}
|
|
271
355
|
const skills = writeSkills(cwd, scope);
|
|
272
356
|
for (const skill of skills) {
|
package/package.json
CHANGED