@beryl-so/cli 0.9.1 → 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
@@ -160,7 +160,7 @@ Create and manage projects — a site Beryl explores, authors tests for, and run
160
160
  | --- | --- | --- |
161
161
  | `beryl projects list` | List projects in the workspace | `projects_list` |
162
162
  | `beryl projects get` | Show one project, including its current exploration state | `projects_get` |
163
- | `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` |
164
164
  | `beryl projects rename <name>` | Rename a project | `projects_rename` |
165
165
  | `beryl projects delete` | Delete a project and all its tests and runs | `projects_delete` |
166
166
  | `beryl projects re-explore` | Send the agent back in — run/heal existing tests and discover new flows | — |
@@ -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
 
@@ -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.
@@ -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
  },
@@ -6,7 +6,8 @@ import { BERYL_TEST_SKILL, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME, } fr
6
6
  import { loadConfig } from "../config.js";
7
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
13
  import { flagStr } from "./util.js";
@@ -92,12 +93,10 @@ function detectEditors(cwd) {
92
93
  editors.push("cursor");
93
94
  return editors;
94
95
  }
95
- // The authoring skill lives in the vendor-neutral `.agents/skills/` dir (mirroring
96
- // Momentic), NOT `.claude/` any coding agent that reads `.agents/skills/` picks it up.
97
- // Idempotent: an identical copy is left alone; a customer-EDITED copy is never clobbered —
98
- // we notice and skip so their changes survive a re-run.
99
- function writeSkill(cwd) {
100
- 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) {
101
100
  if (fs.existsSync(file)) {
102
101
  // Compare with line endings normalized so a CRLF checkout of our own content still
103
102
  // reads as unchanged (not falsely "customized") — we always write LF.
@@ -111,6 +110,49 @@ function writeSkill(cwd) {
111
110
  fs.writeFileSync(file, BERYL_TEST_SKILL);
112
111
  return { outcome: "wrote", file };
113
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
+ }
114
156
  export const initCommands = [
115
157
  {
116
158
  name: "init",
@@ -142,9 +184,9 @@ export const initCommands = [
142
184
  {
143
185
  name: "local",
144
186
  type: "boolean",
145
- description: "Also wire the Playwright MCP so your coding agent can drive a local browser " +
146
- "(for authoring tests yourself). Default: on whenever a coding agent is wired; " +
147
- "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",
148
190
  },
149
191
  ],
150
192
  examples: [
@@ -212,14 +254,25 @@ export const initCommands = [
212
254
  }
213
255
  if (choice === "auto" && editors.length === 0)
214
256
  ctx.err(dim("No coding agent detected — pass --editor-tools claude-code|cursor to wire one."));
215
- // Editor-agnostic: the authoring skill goes to `.agents/skills/` regardless of which
216
- // (if any) editor MCP config we wrote, so any `.agents/skills/`-aware harness gets it.
217
- const skill = writeSkill(cwd);
218
- const skillRel = path.relative(cwd, skill.file);
219
- if (skill.outcome === "customized")
220
- ctx.err(`${dim("•")} Beryl authoring skill left as-is ${dim(`(${skillRel} you edited it; delete it to reinstall)`)}`);
221
- else
222
- ctx.err(`${green("✓")} Beryl authoring skill ${skill.outcome === "wrote" ? "installed" : "already installed"} ${dim(skillRel)}`);
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);
223
276
  const nextSteps = `\n${bold("Beryl is set up — now open your editor and ask Claude to write tests.")}\n` +
224
277
  ` ${dim('• Say: "write tests for https://your-app.com" — Claude picks your workspace/project')}\n` +
225
278
  ` ${dim(" and sets the URL for you (no pin, no prompt).")}\n` +
@@ -54,10 +54,22 @@ export const projectCommands = [
54
54
  },
55
55
  {
56
56
  name: "projects create",
57
- 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",
58
58
  scope: "workspace",
59
- 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
+ ],
60
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
+ },
61
73
  {
62
74
  name: "auth",
63
75
  type: "string",
@@ -84,13 +96,31 @@ export const projectCommands = [
84
96
  "beryl projects create https://app.example.com --watch",
85
97
  "beryl projects create https://app.example.com --auth gated",
86
98
  "beryl projects create https://app.example.com --auth public --no-explore",
99
+ 'beryl projects create --name "Acme production"',
87
100
  ],
88
101
  async run(ctx, input) {
89
102
  const noExplore = flagBool(input, "no-explore");
90
103
  if (noExplore && flagBool(input, "watch"))
91
104
  throw new UsageError("--no-explore cannot be combined with --watch");
92
105
  const ws = await ctx.requireWorkspace(input);
93
- 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
+ }
94
124
  const auth = flagStr(input, "auth") ?? (await resolveAuthChoice(ctx, ws, url));
95
125
  const created = (await ctx.client.post(`/workspaces/${ws}/projects`, {
96
126
  root_url: url,
@@ -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
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.9.1",
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
  }