@beryl-so/cli 0.61.0 → 0.62.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.
@@ -1,4 +1,4 @@
1
- import { loadConfig } from "../config.js";
1
+ import { loadConfig, withFlagOverrides } from "../config.js";
2
2
  import { createContext } from "../context.js";
3
3
  import { CliError, EXIT_OK, EXIT_USAGE, UsageError } from "../errors.js";
4
4
  import { ApiClient } from "../http.js";
@@ -317,11 +317,17 @@ export async function runCli(argv) {
317
317
  process.stdout.write(commandHelp(spec) + "\n");
318
318
  return EXIT_OK;
319
319
  }
320
- const config = loadConfig();
321
- if (parsed.apiUrl)
322
- config.apiUrl = parsed.apiUrl;
323
- if (parsed.token)
324
- config.token = parsed.token;
320
+ let config;
321
+ try {
322
+ config = withFlagOverrides(loadConfig(), { apiUrl: parsed.apiUrl, token: parsed.token });
323
+ }
324
+ catch (err) {
325
+ if (err instanceof CliError) {
326
+ process.stderr.write(err.message + "\n");
327
+ return err.exitCode;
328
+ }
329
+ throw err;
330
+ }
325
331
  const client = new ApiClient(config.apiUrl, config.token);
326
332
  const ctx = createContext({ client, config, json: parsed.json, mcp: false });
327
333
  // The MCP server reports its own tool calls; every other invocation is one command.
@@ -3,7 +3,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
3
3
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
4
4
  import { instrument } from "@posthog/mcp";
5
5
  import fs from "node:fs";
6
- import { loadConfig } from "../config.js";
6
+ import { loadConfig, withFlagOverrides } from "../config.js";
7
7
  import { createContext } from "../context.js";
8
8
  import { AuthError, CliError } from "../errors.js";
9
9
  import { ApiClient, ApiError } from "../http.js";
@@ -222,7 +222,7 @@ export function currentAuth(fallback) {
222
222
  }
223
223
  if (authCache?.mtimeMs === mtimeMs)
224
224
  return authCache.auth;
225
- const config = loadConfig();
225
+ const config = withFlagOverrides(loadConfig(), fallback.config.flagOverrides ?? {});
226
226
  const auth = {
227
227
  client: new ApiClient(config.apiUrl, config.token),
228
228
  config,
@@ -330,17 +330,17 @@ export async function serveMcp(baseCtx) {
330
330
  if (lines.length > 400)
331
331
  lines.splice(0, lines.length - 400);
332
332
  };
333
- const auth = currentAuth(baseCtx);
334
- const ctx = createContext({
335
- client: auth.client,
336
- config: auth.config,
337
- json: true,
338
- interactive: false,
339
- mcp: true,
340
- out: push,
341
- err: push,
342
- });
343
333
  try {
334
+ const auth = currentAuth(baseCtx);
335
+ const ctx = createContext({
336
+ client: auth.client,
337
+ config: auth.config,
338
+ json: true,
339
+ interactive: false,
340
+ mcp: true,
341
+ out: push,
342
+ err: push,
343
+ });
344
344
  const input = toInput(spec, request.params.arguments ?? {});
345
345
  const result = (await withTool(request.params.name, () => spec.run(ctx, input))) ?? {};
346
346
  return toolResult(result, lines);
@@ -53,7 +53,8 @@ export const explorationCommands = [
53
53
  name: "explorations watch",
54
54
  summary: "Stream an exploration live: watch the agent explore and author tests",
55
55
  description: "Replays every recorded step on connect, then follows live until the exploration " +
56
- "completes or fails.",
56
+ "ends. Exits 0 only if it completed; a failed or cancelled exploration, or a stream " +
57
+ "that keeps closing without a final status, exits 1.",
57
58
  scope: "project",
58
59
  args: [{ name: "exploration-id", description: "Exploration id", required: true }],
59
60
  flags: [{ name: "timeout", type: "number", description: "Max minutes to wait" }],
@@ -275,7 +275,8 @@ export const healthCommands = [
275
275
  "`selector`, measured values, the `viewports` it shows at and a fix hint in " +
276
276
  "`recommendation`. The `performance` category grades Google PageSpeed on mobile " +
277
277
  "and on desktop separately (`speeds`, each with its own Core Web Vitals) and takes " +
278
- "the worse of the two; one that couldn't be measured is left out. Pass --report for a single-page check started with " +
278
+ "the worse of the two; one that couldn't be measured is left out. A category's `unchecked` lists checks this run " +
279
+ "couldn't determine (e.g. robots.txt timed out): they neither pass nor fail. Pass --report for a single-page check started with " +
279
280
  "`health run --path`.",
280
281
  scope: "project",
281
282
  flags: [
@@ -321,7 +322,8 @@ export const healthCommands = [
321
322
  "With --ci it starts a CI run, waits for every page, prints a Markdown summary you can " +
322
323
  "post as the PR comment, and exits 1 when findings at or above the environment's " +
323
324
  "fail-on severity exist, a page couldn't be checked, or the run failed. Builds that the " +
324
- "environment's CI mode doesn't cover skip and exit 0. CI runs file tracker tickets only when --health-ci-tickets is on.",
325
+ "environment's CI mode doesn't cover skip and exit 0; if the stored run settings are invalid it exits 1 " +
326
+ "rather than skipping. CI runs file tracker tickets only when --health-ci-tickets is on.",
325
327
  scope: "project",
326
328
  flags: [
327
329
  ENV_FLAG,
@@ -77,26 +77,21 @@ function mergeMcpConfig(file, key, entries) {
77
77
  }
78
78
  return { beryl, playwright };
79
79
  }
80
- // A tool's config lives per-user (same as the PAT it needs, in ~/.config/beryl) — so wire the
81
- // MCP servers per-user too, not in a committed .mcp.json that 401s for every teammate until
82
- // they run `beryl login` anyway. We DON'T hand-edit ~/.claude.json (Claude Code owns it; a
83
- // corrupt write breaks the user's whole CLI) — we shell out to `claude mcp add` and let Claude
84
- // own the edit. Returns false when the `claude` binary isn't on PATH so the caller can fall
85
- // back to a copy-paste command.
86
80
  function claudeUserAdd(name, entry) {
87
81
  try {
88
82
  execFileSync("claude", ["mcp", "add", name, "-s", "user", "--", entry.command, ...entry.args], {
89
- stdio: "ignore",
83
+ stdio: ["ignore", "pipe", "pipe"],
90
84
  });
91
- return true;
85
+ return { ok: true };
92
86
  }
93
87
  catch (err) {
94
- // A non-zero exit ALSO covers "already exists" — but so does a missing binary. Distinguish:
95
- // ENOENT means no `claude` on PATH (fall back), anything else means it ran and declined
96
- // (already configured — treat as success, nothing to do).
97
- if (err.code === "ENOENT")
98
- return false;
99
- return true;
88
+ const e = err;
89
+ if (e.code === "ENOENT")
90
+ return { ok: false, reason: "missing" };
91
+ const output = `${e.stderr ?? ""}\n${e.stdout ?? ""}`.trim();
92
+ if (/already exists/i.test(output))
93
+ return { ok: true };
94
+ return { ok: false, reason: "failed", detail: output.split("\n")[0] || e.message };
100
95
  }
101
96
  }
102
97
  function claudeAddHint(name, entry) {
@@ -399,13 +394,16 @@ export const initCommands = [
399
394
  const clients = await selectClients(ctx, input.flags);
400
395
  if (clients.has("claude") && scope === "user") {
401
396
  // Claude Code owns ~/.claude.json — shell out to `claude mcp add` rather than write it.
402
- const berylOk = claudeUserAdd("beryl", MCP_SERVER_ENTRY);
403
- const playwrightOk = claudeUserAdd("playwright", PLAYWRIGHT_SERVER_ENTRY);
404
- if (berylOk && playwrightOk) {
397
+ const beryl = claudeUserAdd("beryl", MCP_SERVER_ENTRY);
398
+ const playwright = claudeUserAdd("playwright", PLAYWRIGHT_SERVER_ENTRY);
399
+ if (beryl.ok && playwright.ok) {
405
400
  ctx.err(`${green("✓")} beryl + playwright MCP configured ${dim("(user scope)")}`);
406
401
  }
407
402
  else {
408
- ctx.err(yellow("• `claude` not on PATH — run these to wire user-scope MCP servers:"));
403
+ const failure = [beryl, playwright].find((r) => !r.ok && r.reason === "failed");
404
+ ctx.err(yellow(failure
405
+ ? `• \`claude mcp add\` failed (${failure.detail}); run these to wire user-scope MCP servers:`
406
+ : "• `claude` not on PATH; run these to wire user-scope MCP servers:"));
409
407
  ctx.err(` ${cyan(claudeAddHint("beryl", MCP_SERVER_ENTRY))}`);
410
408
  ctx.err(` ${cyan(claudeAddHint("playwright", PLAYWRIGHT_SERVER_ENTRY))}`);
411
409
  }
@@ -1,7 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { downloadRunArtifacts, failureImages, isFailing, resultsOf, } from "../artifacts.js";
4
- import { CliError, UsageError } from "../errors.js";
4
+ import { AuthError, CliError, UsageError } from "../errors.js";
5
+ import { ApiError } from "../http.js";
5
6
  import { buildImportForm, establishAccountSession, executeLocalSpec, IMPORT_MAX_ERROR_LEN, specUsesThirdParty, THIRD_PARTY_USE_TRIGGER, toRunEntry, } from "../local-exec.js";
6
7
  import { countPlannedFrames, countWrittenFrames, PlaywrightMissingError, } from "../local-run.js";
7
8
  import { dim, green, red, yellow } from "../output.js";
@@ -155,7 +156,9 @@ export const runCommands = [
155
156
  "captured server-side, or that has third party integration steps (cloud only), is " +
156
157
  "skipped, with a note. Run those with `runs trigger`. Point " +
157
158
  "--url-override at a local dev server or preview, and --dir to keep specs, artifacts, " +
158
- "and reports on disk. Exits 0 only if every executed test passed.",
159
+ "and reports on disk. With no ids, a test with no plan yet is skipped quietly; one " +
160
+ "whose script can't be fetched is listed under `skipped` and fails the run. Exits 0 " +
161
+ "only if every executed test passed and every script was fetched.",
159
162
  scope: "project",
160
163
  args: [
161
164
  {
@@ -265,25 +268,34 @@ export const runCommands = [
265
268
  ...(script.login_config_error ? { loginConfigError: script.login_config_error } : {}),
266
269
  });
267
270
  const specs = [];
271
+ // With --all, a test the server can't render (no plan yet, 409) is a quiet skip;
272
+ // any other fetch failure is listed and fails the run. An explicitly-requested id
273
+ // failing to render is the caller's problem.
274
+ const fetchFailed = [];
268
275
  for (const id of ids) {
269
276
  let script;
270
277
  try {
271
278
  script = await fetchScript(id);
272
279
  }
273
280
  catch (err) {
274
- // With --all an unrenderable test (no plan yet) is a skip, not an abort;
275
- // an explicitly-requested id failing to render is the caller's problem.
276
- if (all) {
277
- const detail = err instanceof Error ? err.message : String(err);
281
+ if (!all || err instanceof AuthError)
282
+ throw err;
283
+ const detail = err instanceof Error ? err.message : String(err);
284
+ if (err instanceof ApiError && err.status === 409) {
278
285
  ctx.err(yellow(`! ${titles.get(id) ?? id}: no runnable script — skipped (${detail})`));
279
- continue;
280
286
  }
281
- throw err;
287
+ else {
288
+ fetchFailed.push(id);
289
+ ctx.err(red(`✗ ${titles.get(id) ?? id}: could not fetch its script (${detail})`));
290
+ }
291
+ continue;
282
292
  }
283
293
  specs.push(toLocalSpec(id, script));
284
294
  }
285
295
  if (specs.length === 0)
286
- throw new CliError("No runnable tests were found.");
296
+ throw new CliError(fetchFailed.length
297
+ ? `Could not fetch the script for ${fetchFailed.length} test(s); nothing ran.`
298
+ : "No runnable tests were found.");
287
299
  // A test that leans on a session this CLI cannot establish gets no session here,
288
300
  // so skip it rather than run a spec doomed at the login wall. A session-mode test
289
301
  // is NOT in that set: its account's session is established below, once, exactly as
@@ -499,6 +511,9 @@ export const runCommands = [
499
511
  : red(`${failed} test(s) failed`) + ` (${passed} passed)`;
500
512
  const human = `${summaryLine}` +
501
513
  (skipped.length ? `\n${yellow(`${skipped.length} skipped (see notes above)`)}` : "") +
514
+ (fetchFailed.length
515
+ ? `\n${red(`${fetchFailed.length} could not be fetched (see notes above)`)}`
516
+ : "") +
502
517
  (runId ? `\nSynced to Beryl as run ${runId} — \`beryl runs get ${runId}\`` : "") +
503
518
  (!sync ? `\n${dim("--no-sync: nothing recorded in Beryl")}` : "") +
504
519
  (dir ? `\n${dim(`Specs + artifacts + reports under ${path.resolve(dir)}`)}` : "");
@@ -506,13 +521,13 @@ export const runCommands = [
506
521
  data: {
507
522
  passed,
508
523
  failed,
509
- skipped: skipped.map((s) => s.id),
524
+ skipped: [...skipped.map((s) => s.id), ...fetchFailed],
510
525
  results: entries.map(({ files: _files, ...rest }) => rest),
511
526
  ...(runId ? { run_id: runId } : {}),
512
527
  },
513
528
  human,
514
- // Exit codes are a CI contract: any failing/errored test → exit 1.
515
- ...(failed > 0 ? { exitCode: 1 } : {}),
529
+ // Exit codes are a CI contract: any failing/errored or unfetchable test → exit 1.
530
+ ...(failed > 0 || fetchFailed.length > 0 ? { exitCode: 1 } : {}),
516
531
  };
517
532
  },
518
533
  },
@@ -29,7 +29,8 @@ export const thirdPartyIntegrationCommands = [
29
29
  description: "Returns the project's own destination on Beryl's provider account (for Slack, a " +
30
30
  "channel), creating it on first ask. When a test connects the app, it picks this " +
31
31
  "channel in the app's settings; `await_third_party` then waits for the app's message " +
32
- "there. `recorded_app_id` is the app whose first message was seen, null until then. " +
32
+ "there. `recorded_app_ids` maps each environment id to the app whose first message " +
33
+ "was seen there; an environment accepts only its own app once recorded. " +
33
34
  "A 409 means the provider is not set up on this deployment (see " +
34
35
  "`third-party-integrations list`).",
35
36
  scope: "project",
@@ -51,8 +52,10 @@ export const thirdPartyIntegrationCommands = [
51
52
  return {
52
53
  data: target,
53
54
  human: `#${target.external_name}\n\n` +
54
- dim(`Pick this channel in the app when the test connects ${target.provider}.` +
55
- (target.recorded_app_id ? ` Recorded app: ${target.recorded_app_id}` : "")),
55
+ dim(`Pick this channel in the app when the test connects ${target.provider}.`) +
56
+ Object.entries(target.recorded_app_ids ?? {})
57
+ .map(([env, app]) => dim(`\nRecorded app for environment ${env}: ${app}`))
58
+ .join(""),
56
59
  };
57
60
  },
58
61
  },
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
- import { CliError } from "../errors.js";
2
+ import { AuthError, CliError } from "../errors.js";
3
+ import { ApiError, NonJsonResponseError } from "../http.js";
3
4
  import { sseStream } from "../sse.js";
4
5
  import { dim, green, red, statusColor, yellow } from "../output.js";
5
6
  import { projectPath } from "./util.js";
@@ -34,6 +35,15 @@ function runPageUrl(apiUrl, projectId, runId) {
34
35
  }
35
36
  const escapeAnnotationData = (s) => s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
36
37
  const escapeAnnotationProp = (s) => escapeAnnotationData(s).replace(/:/g, "%3A").replace(/,/g, "%2C");
38
+ // Auth, client and wrong-host errors fail identically on every retry; only a network
39
+ // blip, a timeout, throttling or a 5xx is worth waiting out.
40
+ function isRetryable(e) {
41
+ if (e instanceof AuthError || e instanceof NonJsonResponseError)
42
+ return false;
43
+ if (e instanceof ApiError)
44
+ return e.status >= 500 || e.status === 408 || e.status === 429;
45
+ return true;
46
+ }
37
47
  function sleep(ms) {
38
48
  return new Promise((r) => setTimeout(r, ms));
39
49
  }
@@ -97,9 +107,10 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes, opts = {
97
107
  backoffMs = 2_000;
98
108
  }
99
109
  catch (e) {
110
+ if (!isRetryable(e))
111
+ throw e;
100
112
  clearStatusLine();
101
- if (!ctx.json)
102
- ctx.err(dim(`watch request failed (${e.message}); retrying`));
113
+ ctx.err(dim(`watch request failed (${e.message}); retrying`));
103
114
  await sleep(backoffMs);
104
115
  backoffMs = Math.min(backoffMs * 2, 30_000);
105
116
  continue;
@@ -118,7 +129,11 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes, opts = {
118
129
  run = (await ctx.client.get(`${path}/runs/${runId}`));
119
130
  backoffMs = 2_000;
120
131
  }
121
- catch {
132
+ catch (e) {
133
+ if (!isRetryable(e))
134
+ throw e;
135
+ clearStatusLine();
136
+ ctx.err(dim(`run detail request failed (${e.message}); retrying`));
122
137
  // Same backoff as the poll: a terminal run answers the next poll
123
138
  // instantly, so retrying without a delay would busy-loop both endpoints.
124
139
  await sleep(backoffMs);
@@ -148,9 +163,10 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes, opts = {
148
163
  try {
149
164
  await ctx.client.post(`${path}/runs/${runId}/cancel`);
150
165
  }
151
- catch {
152
- // Best-effort: the cancel may race the worker or the run may already be
153
- // terminal; the failure that triggered it decides the exit code anyway.
166
+ catch (e) {
167
+ // The failure that triggered the cancel decides the exit code; this only
168
+ // warns that the run may still be consuming minutes.
169
+ ctx.err(yellow(`fail-fast: could not cancel run ${runId} (${e.message})`));
154
170
  }
155
171
  break;
156
172
  }
@@ -279,7 +295,7 @@ export async function watchExploration(ctx, ws, project, explorationId, timeoutM
279
295
  }
280
296
  return {
281
297
  data: { exploration_id: explorationId, status: finalStatus, test_run_id: handedOffRunId, error },
282
- exitCode: finalStatus === "failed" ? 1 : 0,
298
+ exitCode: finalStatus === "completed" ? 0 : 1,
283
299
  };
284
300
  }
285
301
  export async function pollCurrentExploration(ctx, ws, project, timeoutSeconds = 180) {
package/dist/config.js CHANGED
@@ -2,20 +2,44 @@ import { randomUUID } from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { CliError } from "./errors.js";
5
6
  export const DEFAULT_API_URL = "https://api.beryl.so";
7
+ export function withFlagOverrides(config, flags) {
8
+ return {
9
+ ...config,
10
+ ...(flags.apiUrl ? { apiUrl: flags.apiUrl } : {}),
11
+ ...(flags.token ? { token: flags.token } : {}),
12
+ flagOverrides: flags,
13
+ };
14
+ }
6
15
  export function globalConfigPath(env = process.env) {
7
16
  const base = env.BERYL_CONFIG_DIR ??
8
17
  (env.XDG_CONFIG_HOME ? path.join(env.XDG_CONFIG_HOME, "beryl") : undefined) ??
9
18
  path.join(os.homedir(), ".config", "beryl");
10
19
  return path.join(base, "config.json");
11
20
  }
21
+ // Only a missing file means "no config". Anything else must stop the command: treating
22
+ // it as empty would drop the token and api_url, then the next write would overwrite it.
12
23
  function readJson(file) {
24
+ let text;
13
25
  try {
14
- return JSON.parse(fs.readFileSync(file, "utf8"));
26
+ text = fs.readFileSync(file, "utf8");
15
27
  }
16
- catch {
17
- return undefined;
28
+ catch (err) {
29
+ if (err.code === "ENOENT")
30
+ return undefined;
31
+ throw new CliError(`Could not read ${file}: ${err.message}`);
32
+ }
33
+ let parsed;
34
+ try {
35
+ parsed = JSON.parse(text);
36
+ }
37
+ catch (err) {
38
+ throw new CliError(`${file} is not valid JSON (${err.message}). Fix or delete it, then retry.`);
18
39
  }
40
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
41
+ throw new CliError(`${file} must hold a JSON object. Fix or delete it, then retry.`);
42
+ return parsed;
19
43
  }
20
44
  export function loadConfig(env = process.env) {
21
45
  const globalPath = globalConfigPath(env);
package/dist/http.js CHANGED
@@ -11,6 +11,8 @@ export class ApiError extends CliError {
11
11
  this.detail = detail;
12
12
  }
13
13
  }
14
+ export class NonJsonResponseError extends CliError {
15
+ }
14
16
  export class ApiClient {
15
17
  baseUrl;
16
18
  token;
@@ -85,7 +87,9 @@ export class ApiClient {
85
87
  return JSON.parse(text);
86
88
  }
87
89
  catch {
88
- return text;
90
+ const type = response.headers.get("content-type") ?? "no content-type";
91
+ throw new NonJsonResponseError(`${this.baseUrl} answered ${method} ${path} with non-JSON (${type}); ` +
92
+ "is the API URL pointing at the Beryl API?");
89
93
  }
90
94
  }
91
95
  get(path, query) {
package/dist/sse.js CHANGED
@@ -69,7 +69,7 @@ export async function* sseStream(client, path, signal) {
69
69
  // Clean server-side close without a terminal event: reconnect and replay.
70
70
  failures += 1;
71
71
  if (failures >= MAX_CONSECUTIVE_FAILURES)
72
- return;
72
+ throw new CliError(`Stream closed ${failures} times without a terminal event; its status is unknown`);
73
73
  await sleep(1000, signal);
74
74
  }
75
75
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.61.0",
3
+ "version": "0.62.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",