@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.
@@ -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 = {
@@ -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
- function mergeMcpConfig(file) {
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.mcpServers ?? {});
42
- const beryl = JSON.stringify(servers.beryl) !== JSON.stringify(MCP_SERVER_ENTRY);
47
+ const servers = (existing[key] ?? {});
48
+ const beryl = JSON.stringify(servers.beryl) !== JSON.stringify(entries.beryl);
43
49
  if (beryl)
44
- servers.beryl = MCP_SERVER_ENTRY;
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 = PLAYWRIGHT_SERVER_ENTRY;
54
+ servers.playwright = entries.playwright;
49
55
  if (beryl || playwright) {
50
- existing.mcpServers = servers;
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 the skill to one `.../beryl-test/SKILL.md` file. The skill is generated content
87
- // that ships with the CLI and changes every release, so anything that differs is
88
- // overwritten: a stale copy silently feeds agents months-old guidance, which is worse than
89
- // losing a local edit the user can redo (and `beryl guide` still prints the same content).
90
- function writeSkillFile(file) {
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
- const current = fs.readFileSync(file, "utf8");
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, BERYL_TEST_SKILL);
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
- return [...files].filter((file) => fs.existsSync(file)).map(writeSkillFile);
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. No workspace/project pin and no URL prompt: ask Claude to write tests for " +
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 write the same two MCP servers to Cursor's config",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
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",