@beryl-so/cli 0.46.4 → 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/README.md CHANGED
@@ -220,6 +220,7 @@ Author, inspect, version, and heal a project's tests: the checks Beryl runs on e
220
220
  | `beryl tests set-plan <test-id>` | Replace a test's step plan from a JSON file (creates a new version) | `tests_set_plan` |
221
221
  | `beryl tests rename <test-id> <title>` | Rename a test | `tests_rename` |
222
222
  | `beryl tests set-groups <test-id>` | Replace the groups a test belongs to | `tests_set_groups` |
223
+ | `beryl tests set-access <test-id> <access>` | Mark a test read or write | `tests_set_access` |
223
224
  | `beryl tests add-groups <test-ids...>` | Add groups to one or more tests, keeping the groups they already have | `tests_add_groups` |
224
225
  | `beryl tests quarantine <test-id> <state>` | Mute a flaky test: it keeps running, but its failures stop failing the run | `tests_quarantine` |
225
226
  | `beryl tests delete <test-ids...>` | Delete tests, their version history, and their results | `tests_delete` |
@@ -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 = {
@@ -97,10 +131,11 @@ Then, per test:
97
131
  1. **Drive the flow** in a real browser over the Playwright MCP — never author from
98
132
  imagination (§2).
99
133
  2. **Write the ActionPlan**, with one real outcome assertion (§2).
100
- 3. \`beryl tests lint --file plan.json\` — schema check, offline, no network.
101
- 4. \`beryl tests create --title … --file … --description "<the intent>"\` — replays the
102
- plan locally, banks it only on green, records no run; a red replay hands back the
103
- failure — fix and re-run (§3 for the intent, §4 for the loop).
134
+ 3. \`beryl tests lint --file plan.json --access read|write\` — schema and access check,
135
+ offline, no network.
136
+ 4. \`beryl tests create --title … --file … --access read|write --description "<the
137
+ intent>"\` — replays the plan locally, banks it only on green, records no run; a red
138
+ replay hands back the failure — fix and re-run (§3 for the intent, §4 for the loop).
104
139
  5. When the batch is banked, run the new ids together ONCE (\`beryl runs local <id…>\`,
105
140
  or \`beryl runs trigger\` for the cloud) so they land as one run with real first results.
106
141
 
@@ -151,6 +186,14 @@ The trade is drift — run 200 has 200 of everything run 1 created. So:
151
186
  your own victim first — in \`before\` or the opening steps, \`{{unique}}\`-named —
152
187
  then delete it and assert \`gone\`. Never aim a delete at standing data: run 2 would
153
188
  have nothing to delete, and it eats the account's accumulated coverage.
189
+ - **Every test is \`read\` or \`write\`.** \`read\` (the default) only looks. A test
190
+ that creates, saves, deletes, renames, invites, toggles or uploads anything is
191
+ \`write\`: bank it with \`--access write\`, and end its \`after\` with an \`expect\`
192
+ that proves the undo: \`gone\`, \`hidden\`, \`have_count\` or \`count_delta\` for a
193
+ removal, \`have_text\`, \`have_value\` or \`persisted\` for a restored value.
194
+ Create refuses a plan that writes without the label and a \`write\` plan
195
+ without that closing proof; a \`write\` test whose teardown found nothing to remove
196
+ fails the run. Flip a banked test with \`beryl tests set-access <id> write\`.
154
197
 
155
198
  ### If there is no account yet
156
199
 
@@ -261,7 +304,8 @@ leaves Beryl's cloud).
261
304
  gone) the teardown ends quietly right there instead of failing the test. Put it on
262
305
  the \`wait_for\` or \`expect\` that probes for the record, so the delete steps
263
306
  after it run only when there is something to delete; an optional \`click\` or
264
- \`fill\` must be the last step of \`after\`.
307
+ \`fill\` must be the last step of \`after\`, so a \`write\` test (§1), whose
308
+ \`after\` ends with the proof \`expect\`, never marks its delete optional.
265
309
 
266
310
  The full action vocabulary:
267
311
 
@@ -432,7 +476,8 @@ this test alone.
432
476
  | creates an account, or signs in as one it created (a returning-user test: \`before\` signs up the alias and logs out, the steps sign in as it) | an \`after\` section that deletes it through the app, its steps marked \`optional\`; a plan that reads its signup mail is refused at save time without one |
433
477
  | only makes mail arrive, read or not, no account created | nothing to clean up; mail is kept for 48 hours |
434
478
 
435
- Every alias minted is listed by \`beryl mailbox identities\` with how its teardown ended.
479
+ Every alias minted is listed by \`beryl mailbox identities\` with how its teardown
480
+ ended; the run detail shows the same outcome on each result.
436
481
 
437
482
  The wiring is a three-part chain:
438
483
 
@@ -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) {
@@ -66,11 +66,20 @@ function verifyFailureResult(err) {
66
66
  // The concise human table for `tests list` — the full TestResponse is a 24-column
67
67
  // firehose of internal ids that wraps unreadably in a normal terminal. `--wide`
68
68
  // (and `--json`) still expose every field.
69
- const LIST_COLUMNS = ["title", "status", "id", "last_result_status", "last_run_at"];
69
+ const LIST_COLUMNS = ["title", "status", "access", "id", "last_result_status", "last_run_at"];
70
+ const ACCESS_VALUES = ["read", "write"];
71
+ function accessFlag(input, fallback) {
72
+ const raw = flagStr(input, "access") ?? fallback;
73
+ if (raw !== undefined && !ACCESS_VALUES.includes(raw)) {
74
+ throw new UsageError(`--access must be read or write, got "${raw}"`);
75
+ }
76
+ return raw;
77
+ }
70
78
  function conciseTestRow(row) {
71
79
  return {
72
80
  title: row.nl_title,
73
81
  status: row.quarantined ? "quarantined" : row.is_active ? "active" : "inactive",
82
+ access: row.access,
74
83
  id: row.id,
75
84
  last_result_status: row.last_result_status,
76
85
  last_run_at: row.last_run_at,
@@ -98,14 +107,22 @@ export const testCommands = [
98
107
  description: "The file is an account's signup or sign-in plan (for `accounts signup` / " +
99
108
  "`accounts set-login`), so it may fill {{mailbox_address}}",
100
109
  },
110
+ {
111
+ name: "access",
112
+ type: "string",
113
+ description: "The label the plan will be banked with, read (default) or write; a plan that " +
114
+ "creates, edits, deletes or toggles something must be write",
115
+ },
101
116
  ],
102
117
  examples: [
103
118
  "beryl tests lint --file plan.json",
119
+ "beryl tests lint --file plan.json --access write",
104
120
  "beryl tests lint --file signup-plan.json --provisioning",
105
121
  ],
106
122
  async run(_ctx, input) {
107
123
  const issues = lintPlan(readJsonFlag(input, "file"), {
108
124
  provisioning: flagBool(input, "provisioning"),
125
+ access: accessFlag(input, "read"),
109
126
  });
110
127
  const lines = issues.map((i) => `${i.severity === "warning" ? "warning" : "error"} ${i.path}: ${i.message}`);
111
128
  if (issues.every((i) => i.severity === "warning")) {
@@ -134,6 +151,7 @@ export const testCommands = [
134
151
  description: "Show only tests in this group (by name; see `beryl groups list`)",
135
152
  },
136
153
  { name: "wide", type: "boolean", description: "Show all columns, not the concise default" },
154
+ { name: "access", type: "string", description: "Only read or only write tests" },
137
155
  {
138
156
  name: "page",
139
157
  type: "number",
@@ -164,14 +182,17 @@ export const testCommands = [
164
182
  const { workspaceId, projectId } = await ctx.requireProject(input);
165
183
  const page = flagNum(input, "page");
166
184
  const group = flagStr(input, "group");
185
+ const access = accessFlag(input);
167
186
  let data;
168
187
  if (page === undefined) {
169
188
  data = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests`, {
170
189
  environment_id: flagStr(input, "env"),
171
190
  }));
172
- // GET /tests takes no group param; the paged route below filters server-side.
191
+ // GET /tests takes no group or access param; the paged route filters server-side.
173
192
  if (group)
174
193
  data = data.filter((t) => inGroup(t, group));
194
+ if (access)
195
+ data = data.filter((t) => t.access === access);
175
196
  }
176
197
  else {
177
198
  data = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests/page`, {
@@ -180,6 +201,7 @@ export const testCommands = [
180
201
  status: flagStr(input, "status"),
181
202
  group: flagBool(input, "ungrouped") ? "__ungrouped__" : group,
182
203
  q: flagStr(input, "q"),
204
+ access,
183
205
  environment_id: flagStr(input, "env"),
184
206
  })).items;
185
207
  }
@@ -274,6 +296,12 @@ export const testCommands = [
274
296
  "name is an error (create groups with `beryl groups create`). Omit for no group.",
275
297
  },
276
298
  { name: "env", type: "string", description: "Environment id to compile and prove against" },
299
+ {
300
+ name: "access",
301
+ type: "string",
302
+ description: "read (default): the test only reads the app. write: any step creates, edits, " +
303
+ "renames, deletes or toggles something; a writer must clean up in `after`",
304
+ },
277
305
  {
278
306
  name: "dir",
279
307
  type: "string",
@@ -295,11 +323,13 @@ export const testCommands = [
295
323
  const urlOverride = flagStr(input, "url-override");
296
324
  const env = flagStr(input, "env");
297
325
  const groups = flagStrings(input, "group");
326
+ const access = accessFlag(input, "read");
298
327
  const bank = (extra) => ctx.client.post(`${projectPath(workspaceId, projectId)}/tests`, {
299
328
  title,
300
329
  plan,
301
330
  description,
302
331
  groups,
332
+ access,
303
333
  ...extra,
304
334
  });
305
335
  if (flagBool(input, "no-verify")) {
@@ -547,6 +577,31 @@ export const testCommands = [
547
577
  };
548
578
  },
549
579
  },
580
+ {
581
+ name: "tests set-access",
582
+ summary: "Mark a test read or write",
583
+ description: "read: the test only reads the app. write: any step creates, edits, renames, deletes " +
584
+ "or toggles something. The server refuses a plan that writes on a read test, so flip " +
585
+ "the label before re-authoring such a plan. A write test must clean up in `after`.",
586
+ scope: "project",
587
+ args: [
588
+ { name: "test-id", description: "Test id", required: true },
589
+ { name: "access", description: "read | write", required: true },
590
+ ],
591
+ examples: ["beryl tests set-access 4f… write", "beryl tests set-access 4f… read"],
592
+ async run(ctx, input) {
593
+ const { workspaceId, projectId } = await ctx.requireProject(input);
594
+ const access = arg(input, "access").toLowerCase();
595
+ if (!ACCESS_VALUES.includes(access)) {
596
+ throw new UsageError(`Expected "read" or "write", got "${arg(input, "access")}"`);
597
+ }
598
+ return {
599
+ data: await ctx.client.patch(testPath(workspaceId, projectId, arg(input, "test-id")), {
600
+ access,
601
+ }),
602
+ };
603
+ },
604
+ },
550
605
  {
551
606
  name: "tests add-groups",
552
607
  summary: "Add groups to one or more tests, keeping the groups they already have",
package/dist/lint.js CHANGED
@@ -9,6 +9,13 @@ export const MAILBOX_ADDRESS_IN_TEST = "This plan types the standing account's a
9
9
  "and rides the account's session; one that needs an address the site can mail cites " +
10
10
  "{{inbox_address}}, an alias only this test reads. Only the account's own signup and " +
11
11
  "sign-in plans (accounts signup, accounts set-login) may fill {{mailbox_address}}.";
12
+ export const WRITE_NEEDS_LABEL = (label, detail) => `${label} (${detail}) changes the app, so this test must be marked access 'write' ` +
13
+ "(tests create --access write, or tests set-access <id> write) and clean up in " +
14
+ "'after'. A 'read' test only reads.";
15
+ export const WRITE_NEEDS_CLOSING_TEARDOWN = (detail) => "A 'write' test must end with an 'after' section that undoes what it changed and " +
16
+ "proves it: the last step of 'after' is an expect with expect_kind 'gone', 'hidden', " +
17
+ "'have_count', 'count_delta' (something removed) or 'have_text', 'have_value', " +
18
+ `'persisted' (something restored). ${detail}`;
12
19
  export const OPTIONAL_TEARDOWN_ACTION_MUST_BE_LAST = (label, action) => `${label} (${action}) is optional but not the last step of 'after'. An optional step ` +
13
20
  "that misses ends the teardown, so the steps after it would silently not run. Guard " +
14
21
  "them with an optional 'wait_for' or 'expect' instead, or move this step to the end.";
@@ -170,10 +177,69 @@ function identityIssues(plan, provisioning) {
170
177
  ? [{ path: "(root)", message: MINTED_IDENTITY_NEEDS_TEARDOWN }]
171
178
  : [];
172
179
  }
180
+ // Same verb list as the server's write_signal (api/app/ta_shared/schemas.py).
181
+ const WRITE_VERBS_RE = /\b(create|save|delete|remove|rename|revoke|enable|disable|invite|add|publish|archive|restore|quarantine|un-quarantine|turn on|turn off|join|decline)\b/i;
182
+ function writeSignal(plan) {
183
+ if (typeof plan !== "object" || plan === null)
184
+ return null;
185
+ const { before, steps: main, after } = plan;
186
+ if (steps(after).length > 0)
187
+ return ["after", "a teardown means something was created"];
188
+ const sectioned = steps(before).length > 0 || steps(after).length > 0;
189
+ const label = (section, i) => (sectioned ? `${section}[${i}]` : `step[${i}]`);
190
+ const all = [
191
+ ...steps(before).map((s, i) => [label("before", i), s]),
192
+ ...steps(main).map((s, i) => [label("steps", i), s]),
193
+ ];
194
+ for (const [where, s] of all) {
195
+ if (s.action === "upload")
196
+ return [where, "upload"];
197
+ if (s.action === "click" && typeof s.selector === "string") {
198
+ const hit = WRITE_VERBS_RE.exec(s.selector);
199
+ if (hit)
200
+ return [where, `click on '${hit[0]}'`];
201
+ }
202
+ }
203
+ return null;
204
+ }
205
+ const TEARDOWN_PROOF_KINDS = new Set([
206
+ "gone",
207
+ "hidden",
208
+ "have_count",
209
+ "count_delta",
210
+ "have_text",
211
+ "have_value",
212
+ "persisted",
213
+ ]);
214
+ function accessIssues(plan, access) {
215
+ if (access !== "write") {
216
+ const signal = writeSignal(plan);
217
+ return signal ? [{ path: "(root)", message: WRITE_NEEDS_LABEL(signal[0], signal[1]) }] : [];
218
+ }
219
+ if (typeof plan !== "object" || plan === null)
220
+ return [];
221
+ const after = steps(plan.after);
222
+ if (after.length === 0) {
223
+ return [{ path: "(root)", message: WRITE_NEEDS_CLOSING_TEARDOWN("This plan has no 'after'.") }];
224
+ }
225
+ const last = after[after.length - 1] ?? {};
226
+ const kind = typeof last.expect_kind === "string" ? last.expect_kind : undefined;
227
+ if (last.action !== "expect" || !kind || !TEARDOWN_PROOF_KINDS.has(kind)) {
228
+ const detail = `Its last step is ${String(last.action)}${kind ? ` ${kind}` : ""}.`;
229
+ return [{ path: "(root)", message: WRITE_NEEDS_CLOSING_TEARDOWN(detail) }];
230
+ }
231
+ return [];
232
+ }
173
233
  // `provisioning`: the plan is an account's own signup or sign-in plan, the one place
174
234
  // {{mailbox_address}} is legal (mirrors the server's validate_editable_plan flag).
235
+ // `access`: the label the plan will be banked with; given, a writing plan needs 'write'.
175
236
  export function lintPlan(plan, opts = {}) {
176
237
  const issues = validate(plan, ACTION_PLAN_SCHEMA, ACTION_PLAN_SCHEMA, "");
177
238
  // The identity rules read a well-formed plan; a shape error is reported alone.
178
- return issues.length > 0 ? issues : identityIssues(plan, opts.provisioning === true);
239
+ if (issues.length > 0)
240
+ return issues;
241
+ return [
242
+ ...identityIssues(plan, opts.provisioning === true),
243
+ ...(opts.access === undefined ? [] : accessIssues(plan, opts.access)),
244
+ ];
179
245
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.46.4",
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",