@beryl-so/cli 0.8.0 → 0.11.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 CHANGED
@@ -37,9 +37,10 @@ beryl login # emailed one-time code; mints + stores a personal
37
37
  export BERYL_API_KEY=beryl_pat_… # CI: use a token from Account → API tokens
38
38
  ```
39
39
 
40
- Defaults can be pinned per repo in a `.beryl.json` (`{"workspace": "…", "project": "…"}`)
41
- or globally via `beryl workspaces use` / `beryl projects use`.
42
- Precedence: flags > `BERYL_*` env > `.beryl.json` > `~/.config/beryl/config.json`.
40
+ Config stores your token + API URL only — nothing else persists. Point a command at a
41
+ workspace/project with the `--workspace` / `--project` flags or the `BERYL_WORKSPACE` /
42
+ `BERYL_PROJECT` env vars; with exactly one, the CLI auto-picks it.
43
+ Precedence: flags > `BERYL_*` env > auto-pick-if-one.
43
44
 
44
45
  ## CI in one line
45
46
 
@@ -56,18 +57,19 @@ claude mcp add beryl -- npx -y @beryl-so/cli mcp # no install needed
56
57
  claude mcp add beryl -- beryl mcp # or, with the CLI installed
57
58
  ```
58
59
 
59
- Every non-interactive command below is exposed as an MCP tool with the same name
60
- (spaces and dashes become underscores).
60
+ Most non-interactive commands below are exposed as an MCP tool with the same name
61
+ (spaces and dashes become underscores) — see the MCP tool column. `—` marks commands
62
+ kept CLI-only: account/billing/team admin, Slack alerts, feedback, and cloud exploration.
61
63
 
62
64
  ## Commands
63
65
 
64
66
  ### init
65
67
 
66
- Set up Beryl in this repo — sign in, pin a project, wire up your coding agent
68
+ Set up Beryl in this repo — sign in and wire up your coding agent
67
69
 
68
70
  | Command | Summary | MCP tool |
69
71
  | --- | --- | --- |
70
- | `beryl init` | Set up Beryl in this repo — sign in, pin a project, wire up your coding agent | — |
72
+ | `beryl init` | Set up Beryl in this repo — sign in and wire up your coding agent | — |
71
73
 
72
74
  ### login
73
75
 
@@ -95,11 +97,11 @@ Remove the stored token from the CLI config
95
97
 
96
98
  ### whoami
97
99
 
98
- Show the signed-in account and the CLI's resolved defaults
100
+ Show the signed-in account
99
101
 
100
102
  | Command | Summary | MCP tool |
101
103
  | --- | --- | --- |
102
- | `beryl whoami` | Show the signed-in account and the CLI's resolved defaults | `whoami` |
104
+ | `beryl whoami` | Show the signed-in account | `whoami` |
103
105
 
104
106
  ### tokens
105
107
 
@@ -122,7 +124,6 @@ Create and manage workspaces, and pick the one your commands act on by default.
122
124
  | `beryl workspaces create <name>` | Create a workspace | `workspaces_create` |
123
125
  | `beryl workspaces update` | Rename a workspace or change its visibility | `workspaces_update` |
124
126
  | `beryl workspaces delete` | Delete a workspace and everything in it | `workspaces_delete` |
125
- | `beryl workspaces use <workspace>` | Set the default workspace for future commands | `workspaces_use` |
126
127
  | `beryl workspaces history` | Show the workspace's action history (who did what, when) | `workspaces_history` |
127
128
  | `beryl workspaces leave` | Leave a workspace you are a member of | `workspaces_leave` |
128
129
 
@@ -132,9 +133,9 @@ Manage who belongs to a workspace and their roles.
132
133
 
133
134
  | Command | Summary | MCP tool |
134
135
  | --- | --- | --- |
135
- | `beryl members list` | List workspace members | `members_list` |
136
- | `beryl members set-role <user-id> <role>` | Change a member's role | `members_set_role` |
137
- | `beryl members remove <user-id>` | Remove a member from the workspace | `members_remove` |
136
+ | `beryl members list` | List workspace members | |
137
+ | `beryl members set-role <user-id> <role>` | Change a member's role | |
138
+ | `beryl members remove <user-id>` | Remove a member from the workspace | |
138
139
 
139
140
  ### invites
140
141
 
@@ -142,12 +143,12 @@ Send, list, and revoke workspace invitations, and accept ones sent to you.
142
143
 
143
144
  | Command | Summary | MCP tool |
144
145
  | --- | --- | --- |
145
- | `beryl invites send <email>` | Invite someone to the workspace by email | `invites_send` |
146
- | `beryl invites list` | List the workspace's outstanding invitations | `invites_list` |
147
- | `beryl invites revoke <invitation-id>` | Revoke a pending invitation | `invites_revoke` |
148
- | `beryl invites mine` | List invitations sent to you | `invites_mine` |
149
- | `beryl invites accept <invitation>` | Accept an invitation (by id, or by the token from the invite email) | `invites_accept` |
150
- | `beryl invites decline <invitation-id>` | Decline an invitation | `invites_decline` |
146
+ | `beryl invites send <email>` | Invite someone to the workspace by email | |
147
+ | `beryl invites list` | List the workspace's outstanding invitations | |
148
+ | `beryl invites revoke <invitation-id>` | Revoke a pending invitation | |
149
+ | `beryl invites mine` | List invitations sent to you | |
150
+ | `beryl invites accept <invitation>` | Accept an invitation (by id, or by the token from the invite email) | |
151
+ | `beryl invites decline <invitation-id>` | Decline an invitation | |
151
152
 
152
153
  ### projects
153
154
 
@@ -159,13 +160,12 @@ Create and manage projects — a site Beryl explores, authors tests for, and run
159
160
  | --- | --- | --- |
160
161
  | `beryl projects list` | List projects in the workspace | `projects_list` |
161
162
  | `beryl projects get` | Show one project, including its current exploration state | `projects_get` |
162
- | `beryl projects create <url>` | Create a project — the agent starts exploring and authoring tests immediately | `projects_create` |
163
+ | `beryl projects create [url]` | Create a project — with a URL the agent starts exploring; with just --name an empty one | `projects_create` |
163
164
  | `beryl projects rename <name>` | Rename a project | `projects_rename` |
164
165
  | `beryl projects delete` | Delete a project and all its tests and runs | `projects_delete` |
165
- | `beryl projects re-explore` | Send the agent back in — run/heal existing tests and discover new flows | `projects_re_explore` |
166
+ | `beryl projects re-explore` | Send the agent back in — run/heal existing tests and discover new flows | |
166
167
  | `beryl projects report` | Aggregate quality report across recent runs (pass rates, flaky tests, trend) | `projects_report` |
167
168
  | `beryl projects reusable-auth <url>` | Check whether a saved login can be reused for a URL before creating a project | `projects_reusable_auth` |
168
- | `beryl projects use <project>` | Set the default project for future commands | `projects_use` |
169
169
 
170
170
  ### envs
171
171
 
@@ -241,11 +241,11 @@ Inspect the agent's exploration runs — how it crawled a site and authored its
241
241
 
242
242
  | Command | Summary | MCP tool |
243
243
  | --- | --- | --- |
244
- | `beryl explorations list` | List the agent's exploration passes for a project | `explorations_list` |
245
- | `beryl explorations get <exploration-id>` | Show one exploration: authored tests, abandoned flows, coverage, frontier | `explorations_get` |
246
- | `beryl explorations steps <exploration-id>` | List every step the agent took in an exploration | `explorations_steps` |
247
- | `beryl explorations cancel <exploration-id>` | Cancel an in-flight exploration | `explorations_cancel` |
248
- | `beryl explorations watch <exploration-id>` | Stream an exploration live — watch the agent explore and author tests | `explorations_watch` |
244
+ | `beryl explorations list` | List the agent's exploration passes for a project | |
245
+ | `beryl explorations get <exploration-id>` | Show one exploration: authored tests, abandoned flows, coverage, frontier | |
246
+ | `beryl explorations steps <exploration-id>` | List every step the agent took in an exploration | |
247
+ | `beryl explorations cancel <exploration-id>` | Cancel an in-flight exploration | |
248
+ | `beryl explorations watch <exploration-id>` | Stream an exploration live — watch the agent explore and author tests | |
249
249
 
250
250
  ### config
251
251
 
@@ -275,10 +275,10 @@ Send run outcomes to a Slack channel via a per-project incoming webhook.
275
275
 
276
276
  | Command | Summary | MCP tool |
277
277
  | --- | --- | --- |
278
- | `beryl slack show` | Show the project's Slack alert config (webhook is masked) | `slack_show` |
279
- | `beryl slack set-webhook <url>` | Set the Slack incoming-webhook URL run alerts post to | `slack_set_webhook` |
280
- | `beryl slack clear` | Remove the project's Slack webhook (stops all alerts) | `slack_clear` |
281
- | `beryl slack test` | Post a sample alert to the configured webhook | `slack_test` |
278
+ | `beryl slack show` | Show the project's Slack alert config (webhook is masked) | |
279
+ | `beryl slack set-webhook <url>` | Set the Slack incoming-webhook URL run alerts post to | |
280
+ | `beryl slack clear` | Remove the project's Slack webhook (stops all alerts) | |
281
+ | `beryl slack test` | Post a sample alert to the configured webhook | |
282
282
 
283
283
  ### credentials
284
284
 
@@ -309,12 +309,13 @@ Drive a browser session that captures a target-site login for Beryl to reuse.
309
309
 
310
310
  ### inbox
311
311
 
312
- Disposable email inboxes for testing flows that send mail — signups, OTPs, receipts.
312
+ Email inboxes for testing flows that send mail — signups, OTPs, receipts.
313
313
 
314
314
  | Command | Summary | MCP tool |
315
315
  | --- | --- | --- |
316
- | `beryl inbox create` | Mint a disposable email inbox that Beryl receives mail for | `inbox_create` |
316
+ | `beryl inbox create` | Mint an email inbox that Beryl receives mail for | `inbox_create` |
317
317
  | `beryl inbox list` | List the workspace's inboxes, newest first | `inbox_list` |
318
+ | `beryl inbox delete <inbox-id>` | Delete an inbox and every email it has received | `inbox_delete` |
318
319
  | `beryl inbox read <inbox-id>` | Read the latest email from an inbox (waits for one to arrive) | `inbox_read` |
319
320
  | `beryl inbox emails <inbox-id>` | List the emails an inbox has received | `inbox_emails` |
320
321
 
@@ -324,9 +325,9 @@ View and update your personal account profile.
324
325
 
325
326
  | Command | Summary | MCP tool |
326
327
  | --- | --- | --- |
327
- | `beryl account get` | Show your account profile | `account_get` |
328
- | `beryl account update` | Update your profile | `account_update` |
329
- | `beryl account deletion-preview` | Preview what deleting your account would remove or leave | `account_deletion_preview` |
328
+ | `beryl account get` | Show your account profile | |
329
+ | `beryl account update` | Update your profile | |
330
+ | `beryl account deletion-preview` | Preview what deleting your account would remove or leave | |
330
331
 
331
332
  ### feedback
332
333
 
@@ -334,7 +335,7 @@ Send product feedback to the Beryl team.
334
335
 
335
336
  | Command | Summary | MCP tool |
336
337
  | --- | --- | --- |
337
- | `beryl feedback send <message>` | Send product feedback to the Beryl team | `feedback_send` |
338
+ | `beryl feedback send <message>` | Send product feedback to the Beryl team | |
338
339
 
339
340
  ### billing
340
341
 
@@ -342,10 +343,10 @@ Review a workspace's plan usage, subscription, and invoices.
342
343
 
343
344
  | Command | Summary | MCP tool |
344
345
  | --- | --- | --- |
345
- | `beryl billing usage` | Show plan usage: services and weekly AI units | `billing_usage` |
346
- | `beryl billing subscription` | Show the workspace's subscription | `billing_subscription` |
347
- | `beryl billing invoices` | List recent invoices | `billing_invoices` |
348
- | `beryl billing portal` | Get a Stripe billing-portal link for the workspace | `billing_portal` |
346
+ | `beryl billing usage` | Show plan usage: services and weekly AI units | |
347
+ | `beryl billing subscription` | Show the workspace's subscription | |
348
+ | `beryl billing invoices` | List recent invoices | |
349
+ | `beryl billing portal` | Get a Stripe billing-portal link for the workspace | |
349
350
 
350
351
  ### mcp
351
352
 
@@ -9,7 +9,7 @@ export function toolName(spec) {
9
9
  return spec.name.replace(/ /g, "_").replace(/-/g, "_");
10
10
  }
11
11
  export function mcpTools() {
12
- return commands.filter((c) => !c.interactive && !c.hidden && c.name !== "mcp");
12
+ return commands.filter((c) => !c.interactive && !c.hidden && !c.mcpHidden && c.name !== "mcp");
13
13
  }
14
14
  const mcpToolNames = new Set(mcpTools().map(toolName));
15
15
  /** The tool name a command is exposed as under `beryl mcp`, or undefined if it isn't exposed. */
@@ -1,5 +1,7 @@
1
1
  // The `beryl-test` authoring skill, installed by `beryl init` into
2
- // `.agents/skills/beryl-test/SKILL.md` (vendor-neutral, editor-agnostic). Kept as an
2
+ // `.agents/skills/beryl-test/SKILL.md` (vendor-neutral, editor-agnostic) and, when
3
+ // claude-code is a selected editor, ALSO into the `.claude/skills/beryl-test/SKILL.md`
4
+ // that Claude Code actually indexes (see `writeSkills` in `commands/init.ts`). Kept as an
3
5
  // embedded string so it ships in the published package (`files: ["dist"]`) with no
4
6
  // build-time asset copy, and so there is ONE source for the guidance — not a copy in
5
7
  // the CLI and another in the docs. Edit here; `init` writes it verbatim.
@@ -119,32 +119,16 @@ export const authCommands = [
119
119
  }
120
120
  const authed = new ApiClient(apiUrl, token);
121
121
  const me = (await authed.get("/account/"));
122
- let sole;
123
- let workspaceCount = 0;
124
- try {
125
- const workspaces = (await authed.get("/workspaces/"));
126
- workspaceCount = workspaces.length;
127
- if (workspaces.length === 1)
128
- sole = workspaces[0];
129
- }
130
- catch {
131
- // pinning a default is a nicety — never fail the login over it
132
- }
122
+ // Persist identity only — PAT + api_url. Workspace/project are never pinned;
123
+ // they resolve per-command from env/flags or auto-pick when there's exactly one.
133
124
  const saved = saveGlobalConfig({
134
125
  token,
135
126
  api_url: apiUrl === "https://api.beryl.so" ? undefined : apiUrl,
136
- ...(sole && sole.id !== ctx.config.workspace
137
- ? { workspace: sole.id, project: undefined }
138
- : {}),
139
127
  });
140
- let human = `${green("Logged in")} as ${me.name} <${me.email}>`;
141
- if (sole)
142
- human += `\n${dim(`Default workspace: ${sole.name} (${sole.id})`)}`;
143
- else if (workspaceCount > 1 && !ctx.config.workspace)
144
- human += `\n${dim("Pick a default workspace with `beryl workspaces use <id>`")}`;
145
- human += `\n${dim(`Token saved to ${saved}`)}`;
128
+ const human = `${green("Logged in")} as ${me.name} <${me.email}>` +
129
+ `\n${dim(`Token saved to ${saved}`)}`;
146
130
  return {
147
- data: { email: me.email, name: me.name, workspace: sole?.id ?? null, config: saved },
131
+ data: { email: me.email, name: me.name, config: saved },
148
132
  human,
149
133
  };
150
134
  },
@@ -207,15 +191,13 @@ export const authCommands = [
207
191
  },
208
192
  {
209
193
  name: "whoami",
210
- summary: "Show the signed-in account and the CLI's resolved defaults",
194
+ summary: "Show the signed-in account",
211
195
  async run(ctx) {
212
196
  const me = (await ctx.client.get("/account/"));
213
197
  return {
214
198
  data: {
215
199
  ...me,
216
200
  api_url: ctx.client.baseUrl,
217
- default_workspace: ctx.config.workspace ?? null,
218
- default_project: ctx.config.project ?? null,
219
201
  },
220
202
  };
221
203
  },
@@ -25,25 +25,35 @@ function extractCode(email) {
25
25
  export const inboxCommands = [
26
26
  {
27
27
  name: "inbox create",
28
- summary: "Mint a disposable email inbox that Beryl receives mail for",
29
- groupSummary: "Disposable email inboxes for testing flows that send mail — signups, OTPs, receipts.",
28
+ summary: "Mint an email inbox that Beryl receives mail for",
29
+ groupSummary: "Email inboxes for testing flows that send mail — signups, OTPs, receipts.",
30
30
  description: "Creates a receiving address under Beryl's inbound email domain and returns it. " +
31
31
  "Use it wherever a test needs a real, readable mailbox — e.g. as the --email for " +
32
- "`beryl signup`, then read the code back with `beryl inbox read --extract-code`.",
32
+ "`beryl signup`, then read the code back with `beryl inbox read --extract-code`. " +
33
+ "Pass --permanent to mint the workspace's single permanent mailbox (no TTL).",
33
34
  scope: "workspace",
34
35
  flags: [
36
+ {
37
+ name: "permanent",
38
+ type: "boolean",
39
+ description: "Mint the workspace's permanent mailbox (no TTL); one per workspace",
40
+ },
35
41
  {
36
42
  name: "ttl-hours",
37
43
  type: "number",
38
- description: "Hours before the inbox expires and stops receiving (1-168, default 24)",
44
+ description: "Hours before the inbox expires and stops receiving (1-168, default 24; ignored with --permanent)",
39
45
  },
40
46
  { name: "project", type: "string", description: "Attach the inbox to a project id" },
41
47
  ],
42
- examples: ["beryl inbox create --json", "beryl inbox create --ttl-hours 2"],
48
+ examples: [
49
+ "beryl inbox create --json",
50
+ "beryl inbox create --ttl-hours 2",
51
+ "beryl inbox create --permanent",
52
+ ],
43
53
  async run(ctx, input) {
44
54
  const ws = await ctx.requireWorkspace(input);
45
55
  const inbox = (await ctx.client.post(`/workspaces/${ws}/inboxes`, {
46
- ttl_hours: flagNum(input, "ttl-hours") ?? 24,
56
+ ttl_hours: flagBool(input, "permanent") ? null : (flagNum(input, "ttl-hours") ?? 24),
47
57
  project_id: flagStr(input, "project") ?? null,
48
58
  }));
49
59
  return {
@@ -58,11 +68,33 @@ export const inboxCommands = [
58
68
  summary: "List the workspace's inboxes, newest first",
59
69
  description: "Every inbox the workspace has minted with `beryl inbox create`. Expired inboxes " +
60
70
  "stop receiving and are hard-deleted by a background sweep, so they drop off " +
61
- "this list shortly after their TTL.",
71
+ "this list shortly after their TTL. Pass --permanent for just the permanent mailbox.",
72
+ scope: "workspace",
73
+ flags: [
74
+ {
75
+ name: "permanent",
76
+ type: "boolean",
77
+ description: "Only the workspace's permanent mailbox (no TTL, not run/project scoped)",
78
+ },
79
+ ],
80
+ async run(ctx, input) {
81
+ const ws = await ctx.requireWorkspace(input);
82
+ const query = flagBool(input, "permanent") ? { permanent: true } : undefined;
83
+ return { data: await ctx.client.get(`/workspaces/${ws}/inboxes`, query) };
84
+ },
85
+ },
86
+ {
87
+ name: "inbox delete",
88
+ summary: "Delete an inbox and every email it has received",
62
89
  scope: "workspace",
90
+ args: [{ name: "inbox-id", description: "Inbox id from `beryl inbox create`", required: true }],
91
+ flags: [{ name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
63
92
  async run(ctx, input) {
64
93
  const ws = await ctx.requireWorkspace(input);
65
- return { data: await ctx.client.get(`/workspaces/${ws}/inboxes`) };
94
+ const id = arg(input, "inbox-id");
95
+ await ctx.confirm(`Delete inbox ${id} and its emails?`, flagBool(input, "force"));
96
+ await ctx.client.del(`/workspaces/${ws}/inboxes/${id}`);
97
+ return { human: "Deleted." };
66
98
  },
67
99
  },
68
100
  {
@@ -118,11 +150,16 @@ export const inboxCommands = [
118
150
  args: [{ name: "inbox-id", description: "Inbox id from `beryl inbox create`", required: true }],
119
151
  flags: [
120
152
  { name: "since", type: "string", description: "Only emails received after this ISO timestamp" },
153
+ {
154
+ name: "limit",
155
+ type: "number",
156
+ description: "Return only the most recent N emails (newest first)",
157
+ },
121
158
  ],
122
159
  async run(ctx, input) {
123
160
  const ws = await ctx.requireWorkspace(input);
124
161
  return {
125
- data: await ctx.client.get(`/workspaces/${ws}/inboxes/${arg(input, "inbox-id")}/emails`, { since: flagStr(input, "since") }),
162
+ data: await ctx.client.get(`/workspaces/${ws}/inboxes/${arg(input, "inbox-id")}/emails`, { since: flagStr(input, "since"), limit: flagNum(input, "limit") }),
126
163
  };
127
164
  },
128
165
  },
@@ -3,13 +3,14 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { BERYL_TEST_SKILL, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME, } from "../beryl-test-skill.js";
6
- import { LOCAL_CONFIG_FILENAME, loadConfig } from "../config.js";
7
- import { CliError, UsageError } from "../errors.js";
6
+ import { loadConfig } from "../config.js";
7
+ import { CliError } from "../errors.js";
8
8
  import { ApiClient } from "../http.js";
9
- import { bold, cyan, dim, green, yellow } from "../output.js";
9
+ import { bold, cyan, dim, green, red, yellow } from "../output.js";
10
+ import { confirmInstall, hasPlaywrightTest, installPlaywright, PLAYWRIGHT_INSTALL_COMMANDS, } from "../playwright-install.js";
10
11
  import { cliVersion, warnIfStale } from "../version-check.js";
11
12
  import { authCommands } from "./auth.js";
12
- import { flagBool, flagStr } from "./util.js";
13
+ import { flagStr } from "./util.js";
13
14
  // Pinned to @latest (like the Playwright entry below) — an unpinned spec lets npx
14
15
  // serve whatever stale global/cache install already resolves, so the agent's MCP
15
16
  // server would silently miss newer tools. mergeMcpConfig compares entries verbatim,
@@ -26,33 +27,6 @@ const PLAYWRIGHT_SERVER_ENTRY = {
26
27
  args: ["@playwright/mcp@latest", "--headless"],
27
28
  };
28
29
  const ACTION_PLAN_SCHEMA_URL = "https://api.beryl.so/api/v1/schemas/action-plan.schema.json";
29
- // The authoring fork only appears when we create a fresh project with no tests yet; an
30
- // explicit flag wins, otherwise a TTY prompt defaults to local on enter, and a
31
- // non-interactive run defaults to local so CI never burns server exploration on a project
32
- // the user means to hand-author.
33
- async function resolveAuthoring(ctx, flag) {
34
- if (flag === "local" || flag === "agent")
35
- return flag;
36
- if (!ctx.interactive)
37
- return "local";
38
- ctx.err(`\n${bold("How do you want to author tests for this project?")}`);
39
- ctx.err(` ${green("1")}. Local / manual — with your own coding agent (Playwright MCP) or by hand ${dim("(default)")}`);
40
- ctx.err(` 2. Beryl agent explores the site and authors tests for you`);
41
- const answer = await ctx.prompt("Authoring [1-2]: ");
42
- return answer.trim() === "2" ? "agent" : "local";
43
- }
44
- async function pick(ctx, kind, items) {
45
- if (items.length === 1)
46
- return items[0];
47
- ctx.err(`\n${bold(`Which ${kind}?`)}`);
48
- items.forEach((item, i) => ctx.err(` ${i + 1}. ${item.name ?? item.root_url ?? item.id} ${dim(item.id)}`));
49
- const answer = await ctx.prompt(`${kind} [1-${items.length}]: `);
50
- const index = Number(answer) - 1;
51
- const chosen = items[index];
52
- if (!chosen)
53
- throw new UsageError(`"${answer}" is not between 1 and ${items.length}`);
54
- return chosen;
55
- }
56
30
  function mergeMcpConfig(file, withPlaywright) {
57
31
  let existing = {};
58
32
  if (fs.existsSync(file)) {
@@ -119,12 +93,10 @@ function detectEditors(cwd) {
119
93
  editors.push("cursor");
120
94
  return editors;
121
95
  }
122
- // The authoring skill lives in the vendor-neutral `.agents/skills/` dir (mirroring
123
- // Momentic), NOT `.claude/` any coding agent that reads `.agents/skills/` picks it up.
124
- // Idempotent: an identical copy is left alone; a customer-EDITED copy is never clobbered —
125
- // we notice and skip so their changes survive a re-run.
126
- function writeSkill(cwd) {
127
- const file = path.join(cwd, ".agents", "skills", BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME);
96
+ // Write the skill to one `.../beryl-test/SKILL.md` file. Idempotent: an identical copy is
97
+ // left alone; a customer-EDITED copy is never clobbered we notice and skip so their
98
+ // changes survive a re-run.
99
+ function writeSkillFile(file) {
128
100
  if (fs.existsSync(file)) {
129
101
  // Compare with line endings normalized so a CRLF checkout of our own content still
130
102
  // reads as unchanged (not falsely "customized") — we always write LF.
@@ -138,43 +110,68 @@ function writeSkill(cwd) {
138
110
  fs.writeFileSync(file, BERYL_TEST_SKILL);
139
111
  return { outcome: "wrote", file };
140
112
  }
113
+ const skillLeaf = (root) => path.join(root, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME);
114
+ // The authoring skill always lands in the vendor-neutral `.agents/skills/` dir (mirroring
115
+ // Momentic) — any coding agent that reads `.agents/skills/` picks it up. Claude Code does
116
+ // NOT index `.agents/skills/`; it auto-discovers skills from `~/.claude/skills/` (user
117
+ // scope) and the repo's `.claude/skills/` (project scope). So when `claude-code` is a
118
+ // selected editor we ALSO write the same skill string to the `.claude/skills/` location
119
+ // that matches the MCP `--scope`, or the user never sees it. Each destination is written
120
+ // with the same idempotent / never-clobber-a-customer-edit behavior.
121
+ function writeSkills(cwd, editors, scope) {
122
+ const results = [writeSkillFile(skillLeaf(path.join(cwd, ".agents", "skills")))];
123
+ if (editors.includes("claude-code")) {
124
+ const claudeRoot = scope === "user"
125
+ ? path.join(os.homedir(), ".claude", "skills")
126
+ : path.join(cwd, ".claude", "skills");
127
+ results.push(writeSkillFile(skillLeaf(claudeRoot)));
128
+ }
129
+ return results;
130
+ }
131
+ // Local authoring drives a real browser via `@playwright/test` + chromium. init wires the
132
+ // Playwright MCP but historically installed neither, so the first `beryl runs local` hit a wall.
133
+ // On a TTY we offer to install now; non-interactively we print the exact commands rather than
134
+ // running installs unprompted (which would be a surprise in CI). Never throws — a declined or
135
+ // failed install must not fail `init`, which has already done its wiring.
136
+ async function ensureLocalPlaywright(ctx, cwd) {
137
+ if (hasPlaywrightTest(cwd)) {
138
+ ctx.err(`${green("✓")} @playwright/test already installed ${dim("(local runs ready)")}`);
139
+ return;
140
+ }
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
+ try {
148
+ await installPlaywright(cwd, (line) => ctx.err(dim(line)));
149
+ ctx.err(`${green("✓")} Local Playwright installed ${dim("(local runs ready)")}`);
150
+ }
151
+ catch (err) {
152
+ ctx.err(`${red("✗")} Playwright install failed: ${err.message}`);
153
+ hint();
154
+ }
155
+ }
141
156
  export const initCommands = [
142
157
  {
143
158
  name: "init",
144
- summary: "Set up Beryl in this repo — sign in, pin a project, wire up your coding agent",
145
- description: "One-command onboarding: signs you in (emailed one-time code), pins this repo to a " +
146
- "workspace and project via .beryl.json (offering to create the project), and wires the " +
147
- "MCP servers for your coding agent. By default they're wired per-user (matching where your " +
148
- "login token lives) via `claude mcp add -s user` for Claude Code, ~/.cursor/mcp.json for " +
149
- "Cursor; pass --scope project to write a committed .mcp.json for a shared repo instead. " +
150
- "When creating a fresh project it asks how you want to author tests locally with " +
151
- "your own coding agent or by hand (the default), or by letting Beryl's agent explore and " +
152
- "author them for you. Safe to re-run; every step skips what is already set up.",
159
+ 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 the MCP servers " +
161
+ "for your coding agent, then hands off to Claude. By default they're wired per-user " +
162
+ "(matching where your login token lives) via `claude mcp add -s user` for Claude Code, " +
163
+ "~/.cursor/mcp.json for Cursor; pass --scope project to write a committed .mcp.json for a " +
164
+ "shared repo instead. No workspace/project pin and no URL prompt open your editor and ask " +
165
+ "Claude to write tests for your site; it resolves the workspace/project and sets the URL for " +
166
+ "you. Safe to re-run; every step skips what is already set up.",
153
167
  interactive: true,
154
168
  flags: [
155
- { name: "workspace", type: "string", description: "Workspace id or name to pin" },
156
- {
157
- name: "project",
158
- type: "string",
159
- description: "Project id, name, or URL to pin. With no projects yet, a URL here is the root URL of " +
160
- "the project to create — supply it (plus --authoring) to run init non-interactively in CI",
161
- },
162
- {
163
- name: "authoring",
164
- type: "string",
165
- enum: ["local", "agent"],
166
- description: "How to author tests for a newly created project: `local` (author yourself with your " +
167
- "coding agent or by hand — the project is created without server exploration) or " +
168
- "`agent` (Beryl's agent explores the site and authors tests). Default: local. " +
169
- "Pass this to skip the interactive prompt in CI",
170
- },
171
169
  {
172
170
  name: "editor-tools",
173
171
  type: "string",
174
172
  enum: ["claude-code", "cursor", "both", "none"],
175
173
  description: "Which coding agent to write MCP config for (default: auto-detect)",
176
174
  },
177
- { name: "no-pin", type: "boolean", description: "Skip writing .beryl.json" },
178
175
  {
179
176
  name: "scope",
180
177
  type: "string",
@@ -187,17 +184,16 @@ export const initCommands = [
187
184
  {
188
185
  name: "local",
189
186
  type: "boolean",
190
- description: "Also wire the Playwright MCP so your coding agent can drive a local browser " +
191
- "(for authoring tests yourself). Default: on whenever a coding agent is wired; " +
192
- "pass --no-local to skip it",
187
+ description: "Also wire the Playwright MCP so your coding agent can drive a local browser, and " +
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",
193
190
  },
194
191
  ],
195
192
  examples: [
196
193
  "npx @beryl-so/cli@latest init",
197
194
  "beryl init --editor-tools claude-code",
198
195
  "beryl init --scope project",
199
- "beryl init --project https://app.example.com --authoring agent",
200
- "beryl init --project https://app.example.com --authoring local --editor-tools none",
196
+ "beryl init --editor-tools none",
201
197
  ],
202
198
  async run(ctx, input) {
203
199
  const cwd = process.cwd();
@@ -214,59 +210,6 @@ export const initCommands = [
214
210
  throw new CliError("Login did not persist a token");
215
211
  client = new ApiClient(config.apiUrl, config.token);
216
212
  }
217
- let workspaceId;
218
- let projectId;
219
- let authoredLocally = false;
220
- if (!flagBool(input, "no-pin")) {
221
- const workspaces = (await client.get("/workspaces/"));
222
- if (workspaces.length === 0)
223
- throw new CliError("You have no workspaces yet — create one with `beryl workspaces create`");
224
- const wanted = flagStr(input, "workspace");
225
- const workspace = wanted
226
- ? workspaces.find((w) => w.id === wanted || w.name?.toLowerCase() === wanted.toLowerCase())
227
- : await pick(ctx, "workspace", workspaces);
228
- if (!workspace)
229
- throw new CliError(`No workspace named "${wanted}" found`);
230
- workspaceId = workspace.id;
231
- const projects = (await client.get(`/workspaces/${workspaceId}/projects`));
232
- const wantedProject = flagStr(input, "project");
233
- // With projects already present, --project pins an existing one; with none, it's the
234
- // root URL of the project to create — which is what makes a fresh init CI-drivable.
235
- if (wantedProject && projects.length > 0) {
236
- projectId = (await ctx.requireProject({
237
- args: {},
238
- flags: { workspace: workspaceId, project: wantedProject },
239
- })).projectId;
240
- }
241
- else if (projects.length > 0) {
242
- projectId = (await pick(ctx, "project", projects)).id;
243
- }
244
- else {
245
- const url = wantedProject ?? (await ctx.prompt("No projects yet. Root URL of the site to test: "));
246
- const authoring = await resolveAuthoring(ctx, flagStr(input, "authoring"));
247
- authoredLocally = authoring === "local";
248
- const created = (await client.post(`/workspaces/${workspaceId}/projects`, {
249
- root_url: url,
250
- skip_exploration: authoredLocally,
251
- }));
252
- projectId = created.project_id;
253
- if (authoredLocally)
254
- ctx.err(`${green("✓")} Project created for ${url} ${dim("(no server exploration — you'll author the tests)")}`);
255
- else
256
- ctx.err(`${green("✓")} Project created — the agent is exploring ${url} and authoring tests ` +
257
- `(watch with \`beryl explorations watch\`)`);
258
- }
259
- const localFile = path.join(cwd, LOCAL_CONFIG_FILENAME);
260
- let local = {};
261
- try {
262
- local = JSON.parse(fs.readFileSync(localFile, "utf8"));
263
- }
264
- catch {
265
- // no local config yet
266
- }
267
- fs.writeFileSync(localFile, JSON.stringify({ ...local, workspace: workspaceId, project: projectId }, null, 2) + "\n");
268
- ctx.err(`${green("✓")} Pinned to ${LOCAL_CONFIG_FILENAME} ${dim(`(${projectId})`)}`);
269
- }
270
213
  const choice = flagStr(input, "editor-tools") ?? "auto";
271
214
  const editors = choice === "auto"
272
215
  ? detectEditors(cwd)
@@ -311,36 +254,37 @@ export const initCommands = [
311
254
  }
312
255
  if (choice === "auto" && editors.length === 0)
313
256
  ctx.err(dim("No coding agent detected — pass --editor-tools claude-code|cursor to wire one."));
314
- // Editor-agnostic: the authoring skill goes to `.agents/skills/` regardless of which
315
- // (if any) editor MCP config we wrote, so any `.agents/skills/`-aware harness gets it.
316
- const skill = writeSkill(cwd);
317
- const skillRel = path.relative(cwd, skill.file);
318
- if (skill.outcome === "customized")
319
- ctx.err(`${dim("•")} Beryl authoring skill left as-is ${dim(`(${skillRel} you edited it; delete it to reinstall)`)}`);
320
- else
321
- ctx.err(`${green("✓")} Beryl authoring skill ${skill.outcome === "wrote" ? "installed" : "already installed"} ${dim(skillRel)}`);
322
- const nextSteps = authoredLocally
323
- ? `\n${bold("Beryl is set up — author your tests locally.")} Next steps:\n` +
324
- (local
325
- ? ` ${dim("• Playwright MCP is wired — point your coding agent at it to drive a real browser and draft a plan.")}\n`
326
- : ` ${dim(" Re-run with --editor-tools to wire the Playwright MCP for your coding agent, or author a plan by hand.")}\n`) +
327
- ` ${cyan("beryl tests lint --file plan.json")} validate a plan offline against the schema\n` +
328
- ` ${cyan("beryl tests create --file plan.json")} push a test from your plan\n` +
329
- ` ${cyan("beryl runs trigger --watch")} run the suite\n\n` +
330
- `Author against the ActionPlan JSON Schema: ${cyan(ACTION_PLAN_SCHEMA_URL)}`
331
- : `\n${bold("Beryl is set up.")} Try:\n` +
332
- ` ${cyan("beryl runs trigger --watch")} run the suite\n` +
333
- ` ${cyan("beryl explorations watch")} watch the agent work\n` +
334
- ` ${cyan("beryl tests list")} see authored tests` +
335
- (local
336
- ? `\n\nPlaywright MCP is wired — your coding agent can explore the site locally ` +
337
- `and push tests with ${cyan("beryl tests create")}.`
338
- : "");
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);
261
+ 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
+ const rel = path.relative(cwd, skill.file);
265
+ const skillWhere = rel.startsWith("..") ? skill.file : rel;
266
+ if (skill.outcome === "customized")
267
+ ctx.err(`${dim("•")} Beryl authoring skill left as-is ${dim(`(${skillWhere} — you edited it; delete it to reinstall)`)}`);
268
+ else
269
+ ctx.err(`${green("✓")} Beryl authoring skill ${skill.outcome === "wrote" ? "installed" : "already installed"} ${dim(skillWhere)}`);
270
+ }
271
+ // Local authoring needs @playwright/test + chromium on the customer's machine; wiring the
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);
276
+ const nextSteps = `\n${bold("Beryl is set up — now open your editor and ask Claude to write tests.")}\n` +
277
+ ` ${dim('• Say: "write tests for https://your-app.com" — Claude picks your workspace/project')}\n` +
278
+ ` ${dim(" and sets the URL for you (no pin, no prompt).")}\n` +
279
+ (local
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`) +
282
+ `\nAuthor against the ActionPlan JSON Schema: ${cyan(ACTION_PLAN_SCHEMA_URL)}`;
339
283
  // The .mcp.json entry is pinned to @latest, but a global install / old npx cache
340
284
  // still wins resolution — so tell the user when the CLI they just ran is stale.
341
285
  await warnIfStale(cliVersion(), (msg) => ctx.err(yellow(msg)));
342
286
  return {
343
- data: { workspace: workspaceId ?? null, project: projectId ?? null, editors },
287
+ data: { editors },
344
288
  human: nextSteps,
345
289
  };
346
290
  },
@@ -1,4 +1,3 @@
1
- import { saveGlobalConfig } from "../config.js";
2
1
  import { detectAuthGating } from "../detect.js";
3
2
  import { UsageError } from "../errors.js";
4
3
  import { dim, green } from "../output.js";
@@ -55,10 +54,22 @@ export const projectCommands = [
55
54
  },
56
55
  {
57
56
  name: "projects create",
58
- summary: "Create a project — the agent starts exploring and authoring tests immediately",
57
+ summary: "Create a project — with a URL the agent starts exploring; with just --name an empty one",
59
58
  scope: "workspace",
60
- args: [{ name: "url", description: "Root URL of the site to test", required: true }],
59
+ args: [
60
+ {
61
+ name: "url",
62
+ description: "Root URL of the site to test. Omit to create an empty project (see --name)",
63
+ required: false,
64
+ },
65
+ ],
61
66
  flags: [
67
+ {
68
+ name: "name",
69
+ type: "string",
70
+ description: "Name for an empty project when no URL is given. Add a URL later with " +
71
+ "`beryl envs update <env-id>`, or author tests over the CLI/MCP",
72
+ },
62
73
  {
63
74
  name: "auth",
64
75
  type: "string",
@@ -85,13 +96,31 @@ export const projectCommands = [
85
96
  "beryl projects create https://app.example.com --watch",
86
97
  "beryl projects create https://app.example.com --auth gated",
87
98
  "beryl projects create https://app.example.com --auth public --no-explore",
99
+ 'beryl projects create --name "Acme production"',
88
100
  ],
89
101
  async run(ctx, input) {
90
102
  const noExplore = flagBool(input, "no-explore");
91
103
  if (noExplore && flagBool(input, "watch"))
92
104
  throw new UsageError("--no-explore cannot be combined with --watch");
93
105
  const ws = await ctx.requireWorkspace(input);
94
- const url = arg(input, "url");
106
+ const rawUrl = input.args.url;
107
+ const url = typeof rawUrl === "string" ? rawUrl : "";
108
+ const name = flagStr(input, "name");
109
+ // Name-only: create a real empty project (no URL, no exploration). Set a URL
110
+ // later with `beryl envs update <env-id>` or author over the CLI/MCP.
111
+ if (!url) {
112
+ if (!name)
113
+ throw new UsageError("Provide a URL to explore, or --name to create an empty project");
114
+ const created = (await ctx.client.post(`/workspaces/${ws}/projects`, {
115
+ name,
116
+ }));
117
+ return {
118
+ data: created,
119
+ human: `${green("Project created")}: ${created.project_id} ${dim("(empty)")}\n` +
120
+ `Add a site with \`beryl envs update <env-id>\`, or author tests with ` +
121
+ `\`beryl tests create\`.`,
122
+ };
123
+ }
95
124
  const auth = flagStr(input, "auth") ?? (await resolveAuthChoice(ctx, ws, url));
96
125
  const created = (await ctx.client.post(`/workspaces/${ws}/projects`, {
97
126
  root_url: url,
@@ -198,22 +227,4 @@ export const projectCommands = [
198
227
  };
199
228
  },
200
229
  },
201
- {
202
- name: "projects use",
203
- summary: "Set the default project for future commands",
204
- scope: "workspace",
205
- args: [{ name: "project", description: "Project id, name, or URL", required: true }],
206
- async run(ctx, input) {
207
- const value = arg(input, "project");
208
- const { workspaceId, projectId } = await ctx.requireProject({
209
- args: {},
210
- flags: { ...input.flags, project: value },
211
- });
212
- saveGlobalConfig({ workspace: workspaceId, project: projectId });
213
- return {
214
- data: { workspace: workspaceId, project: projectId },
215
- human: `${green("Default project set")}: ${projectId} ${dim(`(workspace ${workspaceId})`)}`,
216
- };
217
- },
218
- },
219
230
  ];
@@ -1,8 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import { downloadRunArtifacts, failureImages, isFailing, resultsOf, } from "../artifacts.js";
3
3
  import { CliError, UsageError } from "../errors.js";
4
- import { runSpecLocally } from "../local-run.js";
4
+ import { PlaywrightMissingError, runSpecLocally } from "../local-run.js";
5
5
  import { dim, green, red, yellow } from "../output.js";
6
+ import { confirmInstall, installPlaywright } from "../playwright-install.js";
6
7
  import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
7
8
  import { watchRun } from "./watch.js";
8
9
  const MAX_FAILURE_SCREENSHOTS = 5;
@@ -71,8 +72,9 @@ export const runCommands = [
71
72
  name: "runs local",
72
73
  summary: "Run a banked test locally with your own Playwright (public flows)",
73
74
  description: "Unlike `runs trigger`, this runs on YOUR machine, not Beryl's cloud — fetches the test's " +
74
- "rendered spec, then runs it with your local @playwright/test (install it once with " +
75
- "`npm i -D @playwright/test && npx playwright install`). Point --url-override at a local " +
75
+ "rendered spec, then runs it with your local @playwright/test. On a terminal it offers to " +
76
+ "install @playwright/test + chromium for you the first time they're missing (over MCP it " +
77
+ "prints the install commands instead). Point --url-override at a local " +
76
78
  "dev server or preview, and --dir to keep the spec, artifacts, and JSON report on disk so " +
77
79
  "an agent can run-fix-run. v1 targets public/unauthenticated flows: an authenticated test " +
78
80
  "refuses to run locally (those run in Beryl's cloud, which holds the session) — no session " +
@@ -107,14 +109,34 @@ export const runCommands = [
107
109
  "session) — local runs are for public/unauthenticated flows. Run it with " +
108
110
  "`beryl runs trigger`.");
109
111
  }
112
+ const runOnce = () => runSpecLocally({
113
+ spec: script.content,
114
+ testName: testId,
115
+ dir: flagStr(input, "dir"),
116
+ onProgress: (line) => ctx.err(dim(line)),
117
+ });
110
118
  let outcome;
111
119
  try {
112
- outcome = await runSpecLocally({
113
- spec: script.content,
114
- testName: testId,
115
- dir: flagStr(input, "dir"),
116
- onProgress: (line) => ctx.err(dim(line)),
117
- });
120
+ try {
121
+ outcome = await runOnce();
122
+ }
123
+ catch (err) {
124
+ // Local Playwright missing: on a TTY offer to install it and retry, instead of only
125
+ // printing a hint the user then has to act on by hand. Non-interactively we can't
126
+ // prompt, so we re-throw and the hint surfaces as before (no unprompted install).
127
+ if (err instanceof PlaywrightMissingError && ctx.interactive) {
128
+ // confirmInstall swallows a prompt failure into `false`, so a broken prompt falls
129
+ // back to re-throwing the actionable missing-Playwright hint, not the prompt's error.
130
+ if (!(await confirmInstall(ctx.prompt)))
131
+ throw err;
132
+ await installPlaywright(process.cwd(), (line) => ctx.err(dim(line)));
133
+ ctx.err(green("✓ Local Playwright installed — running the test…"));
134
+ outcome = await runOnce();
135
+ }
136
+ else {
137
+ throw err;
138
+ }
139
+ }
118
140
  }
119
141
  catch (err) {
120
142
  // Every failure to run the spec (missing Playwright, a compile error, a customer
@@ -1,5 +1,3 @@
1
- import { saveGlobalConfig } from "../config.js";
2
- import { green } from "../output.js";
3
1
  import { arg, flagBool, flagStr } from "./util.js";
4
2
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5
3
  export const workspaceCommands = [
@@ -75,23 +73,6 @@ export const workspaceCommands = [
75
73
  return { human: "Deleted." };
76
74
  },
77
75
  },
78
- {
79
- name: "workspaces use",
80
- summary: "Set the default workspace for future commands",
81
- args: [{ name: "workspace", description: "Workspace id or name", required: true }],
82
- async run(ctx, input) {
83
- const value = arg(input, "workspace");
84
- const id = UUID_RE.test(value)
85
- ? value
86
- : await ctx.requireWorkspace({ args: {}, flags: { workspace: value } });
87
- const detail = (await ctx.client.get(`/workspaces/${id}`));
88
- saveGlobalConfig({ workspace: id, project: undefined });
89
- return {
90
- data: { workspace: id },
91
- human: `${green("Default workspace set")}: ${detail.name} (${id})`,
92
- };
93
- },
94
- },
95
76
  {
96
77
  name: "workspaces history",
97
78
  summary: "Show the workspace's action history (who did what, when)",
package/dist/config.js CHANGED
@@ -2,7 +2,6 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  export const DEFAULT_API_URL = "https://api.beryl.so";
5
- export const LOCAL_CONFIG_FILENAME = ".beryl.json";
6
5
  export function globalConfigPath(env = process.env) {
7
6
  const base = env.BERYL_CONFIG_DIR ??
8
7
  (env.XDG_CONFIG_HOME ? path.join(env.XDG_CONFIG_HOME, "beryl") : undefined) ??
@@ -17,30 +16,15 @@ function readJson(file) {
17
16
  return undefined;
18
17
  }
19
18
  }
20
- export function findLocalConfig(cwd) {
21
- let dir = path.resolve(cwd);
22
- for (;;) {
23
- const candidate = path.join(dir, LOCAL_CONFIG_FILENAME);
24
- if (fs.existsSync(candidate))
25
- return candidate;
26
- const parent = path.dirname(dir);
27
- if (parent === dir)
28
- return undefined;
29
- dir = parent;
30
- }
31
- }
32
- export function loadConfig(env = process.env, cwd = process.cwd()) {
19
+ export function loadConfig(env = process.env) {
33
20
  const globalPath = globalConfigPath(env);
34
21
  const global = readJson(globalPath) ?? {};
35
- const localPath = findLocalConfig(cwd);
36
- const local = localPath ? (readJson(localPath) ?? {}) : {};
37
22
  return {
38
- apiUrl: env.BERYL_API_URL ?? local.api_url ?? global.api_url ?? DEFAULT_API_URL,
39
- token: env.BERYL_API_KEY ?? env.BERYL_TOKEN ?? local.token ?? global.token,
40
- workspace: env.BERYL_WORKSPACE ?? local.workspace ?? global.workspace,
41
- project: env.BERYL_PROJECT ?? local.project ?? global.project,
23
+ apiUrl: env.BERYL_API_URL ?? global.api_url ?? DEFAULT_API_URL,
24
+ token: env.BERYL_API_KEY ?? env.BERYL_TOKEN ?? global.token,
25
+ workspace: env.BERYL_WORKSPACE,
26
+ project: env.BERYL_PROJECT,
42
27
  globalConfigPath: globalPath,
43
- localConfigPath: localPath,
44
28
  };
45
29
  }
46
30
  export function saveGlobalConfig(patch, env = process.env) {
package/dist/context.js CHANGED
@@ -65,8 +65,7 @@ export function createContext(options) {
65
65
  throw new CliError("You have no workspaces yet — create one with `beryl workspaces create`");
66
66
  }
67
67
  else {
68
- throw new UsageError("Multiple workspaces — pass --workspace <id|name>, set BERYL_WORKSPACE, " +
69
- "or run `beryl workspaces use <id|name>`:\n" +
68
+ throw new UsageError("Multiple workspaces — pass --workspace <id|name> or set BERYL_WORKSPACE:\n" +
70
69
  workspaces.map((w) => ` ${w.id} ${w.name ?? ""}`).join("\n"));
71
70
  }
72
71
  return workspaceCache;
@@ -87,11 +86,11 @@ export function createContext(options) {
87
86
  projectId = projects[0].id;
88
87
  }
89
88
  else if (projects.length === 0) {
90
- throw new CliError("No projects in this workspace — create one with `beryl projects create`");
89
+ throw new CliError("This workspace has no project yet — create one with " +
90
+ "`beryl projects create <url>` (add --no-explore to author tests yourself).");
91
91
  }
92
92
  else {
93
- throw new UsageError("Multiple projects — pass --project <id|name|url>, set BERYL_PROJECT, " +
94
- "or run `beryl projects use <id|name>`:\n" +
93
+ throw new UsageError("Multiple projects — pass --project <id|name|url> or set BERYL_PROJECT:\n" +
95
94
  projects.map((p) => ` ${p.id} ${p.name ?? p.root_url ?? ""}`).join("\n"));
96
95
  }
97
96
  projectCache = { workspaceId, projectId };
package/dist/local-run.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- // The customer already has @playwright/test (the MCP `init` wires it), so we invoke it
5
- // as an external rather than bundling it — the CLI's zero-runtime-dep rule. In a node_modules
6
- // install `playwright` is on PATH; otherwise fall back to `npx playwright`, exactly as the
7
- // cloud runner does.
4
+ import { PLAYWRIGHT_INSTALL_COMMANDS } from "./playwright-install.js";
5
+ // We invoke @playwright/test as an external rather than bundling it — the CLI's zero-runtime-dep
6
+ // rule. In a node_modules install `playwright` is on PATH; otherwise fall back to `npx playwright`,
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.
8
9
  export const PLAYWRIGHT_INSTALL_HINT = "Local Playwright not found. Install it in this project, then re-run:\n" +
9
- " npm i -D @playwright/test && npx playwright install chromium";
10
+ ` ${PLAYWRIGHT_INSTALL_COMMANDS}`;
10
11
  // Isolate the run from any playwright.config.ts in the customer's repo: a stray `testMatch`
11
12
  // would exclude our spec (a zero-test run that reads as a false pass), and a `use.baseURL` /
12
13
  // `use.storageState` / `globalSetup` there would silently retarget or reauth the run we mean
@@ -0,0 +1,58 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+ // The two commands that turn "nothing Playwright-related installed" into "local runs work":
5
+ // the test runner as a dev dep, then its browser binary. Kept as data so the CLI can both
6
+ // run them and print them verbatim for the non-interactive / copy-paste path.
7
+ export const INSTALL_TEST_RUNNER = ["npm", "i", "-D", "@playwright/test"];
8
+ export const INSTALL_CHROMIUM = ["npx", "playwright", "install", "chromium"];
9
+ export const PLAYWRIGHT_INSTALL_COMMANDS = `${INSTALL_TEST_RUNNER.join(" ")} && ${INSTALL_CHROMIUM.join(" ")}`;
10
+ // Resolve `@playwright/test` the way Playwright itself will at run time — from the project
11
+ // tree, not from wherever the globally-installed CLI happens to live. `createRequire` rooted
12
+ // at cwd walks up the same node_modules chain, so this is true iff a local run would find it.
13
+ export function hasPlaywrightTest(cwd) {
14
+ try {
15
+ createRequire(path.join(cwd, "package.json")).resolve("@playwright/test");
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ export const INSTALL_PROMPT = `Install local Playwright now (${PLAYWRIGHT_INSTALL_COMMANDS})? [Y/n] `;
23
+ // Ask (default-yes) whether to install. Returns false — not throwing — when there is no answer
24
+ // or the prompt fails, so callers uniformly fall back to printing the install hint.
25
+ export async function confirmInstall(prompt) {
26
+ try {
27
+ return !/^n(o)?$/i.test(await prompt(INSTALL_PROMPT));
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ const withCmdExt = (cmd) => process.platform === "win32" && (cmd === "npm" || cmd === "npx") ? `${cmd}.cmd` : cmd;
34
+ function runInherit(command, args, cwd) {
35
+ return new Promise((resolve, reject) => {
36
+ // stdio inherited: an install is slow and the user wants to watch npm/browser-download
37
+ // progress live, exactly as if they'd typed it themselves.
38
+ const child = spawn(withCmdExt(command), [...args], { cwd, stdio: "inherit" });
39
+ child.on("error", reject);
40
+ child.on("close", (code) => code === 0
41
+ ? resolve()
42
+ : reject(new Error(`\`${command} ${args.join(" ")}\` exited with code ${code}`)));
43
+ });
44
+ }
45
+ /**
46
+ * Install the local Playwright test runner + chromium into `cwd`, streaming each step's output.
47
+ * Throws if either step exits non-zero (so the caller surfaces the failure, not a silent partial
48
+ * install). Skips the `@playwright/test` step when it is already resolvable, but always ensures
49
+ * the browser binary — an installed runner with no browser still fails a real run.
50
+ */
51
+ export async function installPlaywright(cwd, onStep) {
52
+ if (!hasPlaywrightTest(cwd)) {
53
+ onStep?.(`Installing @playwright/test — ${INSTALL_TEST_RUNNER.join(" ")}`);
54
+ await runInherit(INSTALL_TEST_RUNNER[0], INSTALL_TEST_RUNNER.slice(1), cwd);
55
+ }
56
+ onStep?.(`Installing the Chromium browser — ${INSTALL_CHROMIUM.join(" ")}`);
57
+ await runInherit(INSTALL_CHROMIUM[0], INSTALL_CHROMIUM.slice(1), cwd);
58
+ }
@@ -15,14 +15,12 @@ import { workspaceCommands } from "../commands/workspaces.js";
15
15
  export const WORKSPACE_FLAG = {
16
16
  name: "workspace",
17
17
  type: "string",
18
- description: "Workspace id or name (defaults to BERYL_WORKSPACE, .beryl.json, `beryl workspaces use`, " +
19
- "or your only workspace)",
18
+ description: "Workspace id or name (defaults to BERYL_WORKSPACE, or your only workspace)",
20
19
  };
21
20
  export const PROJECT_FLAG = {
22
21
  name: "project",
23
22
  type: "string",
24
- description: "Project id, name, or URL (defaults to BERYL_PROJECT, .beryl.json, `beryl projects use`, " +
25
- "or the workspace's only project)",
23
+ description: "Project id, name, or URL (defaults to BERYL_PROJECT, or the workspace's only project)",
26
24
  };
27
25
  function withScopeFlags(spec) {
28
26
  if (!spec.scope || spec.scope === "none")
@@ -35,6 +33,24 @@ function withScopeFlags(spec) {
35
33
  extra.push(PROJECT_FLAG);
36
34
  return { ...spec, flags: [...(spec.flags ?? []), ...extra] };
37
35
  }
36
+ // Command groups kept OUT of the `beryl mcp` tool surface while staying full CLI commands:
37
+ // account/billing/team admin, Slack alerts, feedback, and the cloud-exploration mode
38
+ // (incl. `projects re-explore`) — none of it is part of authoring/editing/running tests,
39
+ // which is all an agent drives over MCP.
40
+ const MCP_HIDDEN_PREFIXES = [
41
+ "slack",
42
+ "account",
43
+ "billing",
44
+ "feedback",
45
+ "explorations",
46
+ "members",
47
+ "invites",
48
+ "projects re-explore",
49
+ ];
50
+ function hideFromMcp(spec) {
51
+ const hidden = MCP_HIDDEN_PREFIXES.some((p) => spec.name === p || spec.name.startsWith(`${p} `));
52
+ return hidden ? { ...spec, mcpHidden: true } : spec;
53
+ }
38
54
  export const commands = [
39
55
  ...initCommands,
40
56
  ...authCommands,
@@ -50,7 +66,7 @@ export const commands = [
50
66
  ...inboxCommands,
51
67
  ...accountCommands,
52
68
  ...mcpCommands,
53
- ].map(withScopeFlags);
69
+ ].map(withScopeFlags).map(hideFromMcp);
54
70
  export function findCommand(words) {
55
71
  let best;
56
72
  for (const spec of commands) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.8.0",
3
+ "version": "0.11.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",
@@ -38,5 +38,8 @@
38
38
  "tsx": "^4.23.1",
39
39
  "typescript": "^7.0.2",
40
40
  "vitest": "^4.1.10"
41
+ },
42
+ "overrides": {
43
+ "@hono/node-server": "2.0.11"
41
44
  }
42
45
  }