@beryl-so/cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -122,6 +122,8 @@ Every non-interactive command below is exposed as an MCP tool with the same name
122
122
 
123
123
  ### projects
124
124
 
125
+ `beryl projects` with no subcommand runs `projects list`.
126
+
125
127
  | Command | Summary |
126
128
  | --- | --- |
127
129
  | `beryl projects list` | List projects in the workspace |
@@ -154,13 +156,19 @@ Every non-interactive command below is exposed as an MCP tool with the same name
154
156
 
155
157
  ### tests
156
158
 
159
+ `beryl tests` with no subcommand runs `tests list`.
160
+
157
161
  | Command | Summary |
158
162
  | --- | --- |
163
+ | `beryl tests lint` | Validate a plan JSON file offline, before sending it to the server |
159
164
  | `beryl tests list` | List the project's tests with their latest result |
160
165
  | `beryl tests get <test-id>` | Show one test |
161
166
  | `beryl tests plan <test-id>` | Print a test's current step plan (JSON) |
162
167
  | `beryl tests create` | Create a test case from a JSON action plan — for tests authored locally, e.g. by your coding agent |
163
168
  | `beryl tests set-plan <test-id>` | Replace a test's step plan from a JSON file (creates a new version) |
169
+ | `beryl tests rename <test-id> <title>` | Rename a test |
170
+ | `beryl tests quarantine <test-id> <state>` | Mute a flaky test: it keeps running, but its failures stop failing the run |
171
+ | `beryl tests delete <test-id>` | Delete a test, its version history, and its results |
164
172
  | `beryl tests recompile <test-id>` | Validate + verify an edited plan against the live site before persisting |
165
173
  | `beryl tests versions <test-id>` | List a test's version history |
166
174
  | `beryl tests version <test-id> <version-no>` | Show one specific version of a test (including its plan) |
@@ -174,6 +182,8 @@ Every non-interactive command below is exposed as an MCP tool with the same name
174
182
 
175
183
  ### runs
176
184
 
185
+ `beryl runs` with no subcommand runs `runs list`.
186
+
177
187
  | Command | Summary |
178
188
  | --- | --- |
179
189
  | `beryl runs trigger` | Trigger a test run (whole suite, a subset, or one environment) |
@@ -182,7 +192,7 @@ Every non-interactive command below is exposed as an MCP tool with the same name
182
192
  | `beryl runs watch <run-id>` | Attach to a run and stream progress until it finishes |
183
193
  | `beryl runs cancel <run-id>` | Cancel an in-flight run |
184
194
  | `beryl runs report <run-id>` | Show the generated report for a run |
185
- | `beryl runs download <run-id>` | Download a run's full results as JSON |
195
+ | `beryl runs download <run-id>` | Download a run's results, with its artifacts, to disk |
186
196
  | `beryl runs explain <result-id>` | AI explanation of why a test result failed |
187
197
 
188
198
  ### explorations
@@ -90,6 +90,10 @@ export function parseArgv(spec, tokens) {
90
90
  throw new UsageError(`Unknown flag --${name} for \`beryl ${spec.name}\``);
91
91
  }
92
92
  }
93
+ // `--help` wins over every completeness check below: you ask for help precisely when you
94
+ // don't yet know which flags/args the command needs.
95
+ if (help)
96
+ return { input: { args: {}, flags }, json, apiUrl, token, help };
93
97
  for (const f of specFlags) {
94
98
  if (flags[f.name] === undefined && f.default !== undefined)
95
99
  flags[f.name] = f.default;
@@ -184,7 +188,8 @@ export function rootHelp() {
184
188
  function groupHelp(group, specs) {
185
189
  const lines = [`${bold("beryl " + group)} — subcommands:`, ""];
186
190
  for (const spec of specs) {
187
- lines.push(` ${cyan(spec.name.padEnd(28))} ${spec.summary}`);
191
+ const suffix = spec.groupDefault ? dim(` (default — \`beryl ${group}\` runs this)`) : "";
192
+ lines.push(` ${cyan(spec.name.padEnd(28))} ${spec.summary}${suffix}`);
188
193
  }
189
194
  lines.push("", `Run ${cyan(`beryl ${group} <subcommand> --help`)} for details.`);
190
195
  return lines.join("\n");
@@ -207,15 +212,25 @@ export async function runCli(argv) {
207
212
  }
208
213
  if (words.length === 0) {
209
214
  process.stdout.write(rootHelp() + "\n");
210
- return argv.length === 0 || argv.includes("--help") || argv.includes("-h")
215
+ return rest.length === 0 || rest.includes("--help") || rest.includes("-h")
211
216
  ? EXIT_OK
212
217
  : EXIT_USAGE;
213
218
  }
219
+ // A group of subcommands, as opposed to a single-word command like `init`, which
220
+ // commandGroups() also keys under its own name.
221
+ const groupSpecs = words.length === 1 ? commandGroups().get(words[0]) : undefined;
222
+ const group = groupSpecs && !(groupSpecs.length === 1 && groupSpecs[0].name === words[0])
223
+ ? groupSpecs
224
+ : undefined;
225
+ // `beryl tests --help` asks about the group, not about the subcommand `beryl tests`
226
+ // happens to default to — so group help wins over the default's own help.
227
+ if (group && (rest.includes("--help") || rest.includes("-h"))) {
228
+ process.stdout.write(groupHelp(words[0], group) + "\n");
229
+ return EXIT_OK;
230
+ }
214
231
  const found = findCommand(words);
215
232
  if (!found) {
216
- const groups = commandGroups();
217
- const group = groups.get(words[0]);
218
- if (group && words.length === 1) {
233
+ if (group) {
219
234
  process.stdout.write(groupHelp(words[0], group) + "\n");
220
235
  return EXIT_OK;
221
236
  }
@@ -245,7 +260,7 @@ export async function runCli(argv) {
245
260
  if (parsed.token)
246
261
  config.token = parsed.token;
247
262
  const client = new ApiClient(config.apiUrl, config.token);
248
- const ctx = createContext({ client, config, json: parsed.json });
263
+ const ctx = createContext({ client, config, json: parsed.json, mcp: false });
249
264
  try {
250
265
  const result = (await spec.run(ctx, parsed.input)) ?? {};
251
266
  if (parsed.json) {
@@ -31,6 +31,24 @@ export function toolInputSchema(spec) {
31
31
  }
32
32
  return { type: "object", properties, ...(required.length ? { required } : {}) };
33
33
  }
34
+ export function toolResult(result, lines) {
35
+ const parts = [...lines];
36
+ if (result.data !== undefined)
37
+ parts.push(JSON.stringify(result.data, null, 2));
38
+ else if (result.human)
39
+ parts.push(result.human);
40
+ return {
41
+ content: [
42
+ { type: "text", text: parts.join("\n") || "ok" },
43
+ ...(result.images ?? []).map((image) => ({
44
+ type: "image",
45
+ data: image.data,
46
+ mimeType: image.mimeType,
47
+ })),
48
+ ],
49
+ isError: result.exitCode !== undefined && result.exitCode !== 0,
50
+ };
51
+ }
34
52
  function toInput(spec, params) {
35
53
  const args = {};
36
54
  const flags = {};
@@ -76,20 +94,13 @@ export async function serveMcp(baseCtx) {
76
94
  config: baseCtx.config,
77
95
  json: true,
78
96
  interactive: false,
97
+ mcp: true,
79
98
  out: push,
80
99
  err: push,
81
100
  });
82
101
  try {
83
102
  const result = (await spec.run(ctx, toInput(spec, request.params.arguments ?? {}))) ?? {};
84
- const parts = [...lines];
85
- if (result.data !== undefined)
86
- parts.push(JSON.stringify(result.data, null, 2));
87
- else if (result.human)
88
- parts.push(result.human);
89
- return {
90
- content: [{ type: "text", text: parts.join("\n") || "ok" }],
91
- isError: result.exitCode !== undefined && result.exitCode !== 0,
92
- };
103
+ return toolResult(result, lines);
93
104
  }
94
105
  catch (err) {
95
106
  const message = err instanceof CliError ? err.message : String(err);
@@ -0,0 +1,132 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { CliError } from "./errors.js";
4
+ // Base64 inflates by 4/3, and the block is spent from the agent's context window —
5
+ // a few MB of screenshot would blow it. Skipped shots are reported, never dropped.
6
+ export const MAX_IMAGE_BYTES = 1_500_000;
7
+ export const FAILING_STATUSES = new Set(["failed", "errored", "error"]);
8
+ export function isFailing(status) {
9
+ return FAILING_STATUSES.has((status ?? "").toLowerCase());
10
+ }
11
+ export function resultsOf(run) {
12
+ return run.tests ?? run.test_results ?? [];
13
+ }
14
+ /** Directory name for one result. Sanitised: the id reaches us over the wire, and it
15
+ * is joined onto a user-supplied path — a `../` in it must not escape the output dir. */
16
+ export function resultId(result) {
17
+ const raw = result.test_result_id ?? result.id ?? result.test_case_id ?? "result";
18
+ const safe = raw.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[.]+/, "");
19
+ return safe || "result";
20
+ }
21
+ function extensionFor(url, fallback) {
22
+ // Presigned S3 URLs carry the signature in the query string — the extension is
23
+ // on the path, so parse rather than regex the whole URL.
24
+ try {
25
+ const ext = path.extname(new URL(url).pathname);
26
+ return ext || fallback;
27
+ }
28
+ catch {
29
+ return fallback;
30
+ }
31
+ }
32
+ function mimeFor(url) {
33
+ const ext = extensionFor(url, ".png").toLowerCase();
34
+ if (ext === ".jpg" || ext === ".jpeg")
35
+ return "image/jpeg";
36
+ if (ext === ".webp")
37
+ return "image/webp";
38
+ return "image/png";
39
+ }
40
+ async function fetchBytes(url) {
41
+ // Presigned URLs authenticate via their own signature — never attach the API
42
+ // bearer token, and never route them through ApiClient (it forces the API base).
43
+ let response;
44
+ try {
45
+ response = await fetch(url);
46
+ }
47
+ catch (err) {
48
+ throw new CliError(`Could not fetch artifact: ${err.message}`);
49
+ }
50
+ if (!response.ok) {
51
+ throw new CliError(`Could not fetch artifact (HTTP ${response.status}) — presigned URLs are ` +
52
+ "short-lived; re-run the command to get fresh ones.");
53
+ }
54
+ return Buffer.from(await response.arrayBuffer());
55
+ }
56
+ export async function fetchImage(url) {
57
+ const bytes = await fetchBytes(url);
58
+ if (bytes.byteLength > MAX_IMAGE_BYTES) {
59
+ throw new CliError(`Screenshot is ${bytes.byteLength} bytes — too large to inline ` +
60
+ `(limit ${MAX_IMAGE_BYTES}); fetch it from its URL instead.`);
61
+ }
62
+ return { data: bytes.toString("base64"), mimeType: mimeFor(url) };
63
+ }
64
+ /** Failure screenshots as MCP image content. Never fails the command — an agent
65
+ * losing a picture must still get the run JSON — but it does say what it lost. */
66
+ export async function failureImages(run, limit) {
67
+ const failing = resultsOf(run).filter((r) => isFailing(r.status) && r.screenshot_url);
68
+ const skipped = [];
69
+ if (failing.length > limit) {
70
+ skipped.push(`${failing.length - limit} more failure screenshot(s) not attached (limit ${limit}) — ` +
71
+ "fetch them from the screenshot_url values above.");
72
+ }
73
+ const settled = await Promise.allSettled(failing.slice(0, limit).map((r) => fetchImage(r.screenshot_url)));
74
+ const images = [];
75
+ settled.forEach((s, i) => {
76
+ if (s.status === "fulfilled")
77
+ images.push(s.value);
78
+ else
79
+ skipped.push(`${resultId(failing[i])}: ${s.reason.message}`);
80
+ });
81
+ return { images, skipped };
82
+ }
83
+ function pendingFor(result, withFrames) {
84
+ const id = resultId(result);
85
+ const items = [];
86
+ const add = (url, kind, name) => {
87
+ if (url)
88
+ items.push({ url, file: path.join(id, name), kind, testResultId: id });
89
+ };
90
+ const ext = (url, fallback) => extensionFor(url ?? "", fallback);
91
+ add(result.screenshot_url, "screenshot", `screenshot${ext(result.screenshot_url, ".png")}`);
92
+ add(result.dom_snapshot_url, "dom_snapshot", `dom-snapshot${ext(result.dom_snapshot_url, ".html")}`);
93
+ add(result.trace_url, "trace", `trace${ext(result.trace_url, ".zip")}`);
94
+ if (withFrames) {
95
+ (result.frame_urls ?? []).forEach((url, i) => {
96
+ const n = String(i + 1).padStart(3, "0");
97
+ add(url, "frame", path.join("frames", `${n}${extensionFor(url, ".png")}`));
98
+ });
99
+ }
100
+ return items;
101
+ }
102
+ const DOWNLOAD_CONCURRENCY = 6;
103
+ export async function downloadRunArtifacts(run, directory, options = {}) {
104
+ const manifest = path.join(directory, "run.json");
105
+ fs.mkdirSync(directory, { recursive: true });
106
+ fs.writeFileSync(manifest, JSON.stringify(run, null, 2) + "\n");
107
+ // A green test's filmstrip is bulk nobody reads, and every URL here expires within
108
+ // the hour — so by default only failing tests bring their frames down.
109
+ const pending = resultsOf(run).flatMap((r) => pendingFor(r, options.allFrames === true || isFailing(r.status)));
110
+ const artifacts = [];
111
+ const failures = [];
112
+ let next = 0;
113
+ const worker = async () => {
114
+ while (next < pending.length) {
115
+ const item = pending[next++];
116
+ const target = path.join(directory, item.file);
117
+ try {
118
+ const bytes = await fetchBytes(item.url);
119
+ fs.mkdirSync(path.dirname(target), { recursive: true });
120
+ fs.writeFileSync(target, bytes);
121
+ artifacts.push({ test_result_id: item.testResultId, kind: item.kind, path: target });
122
+ options.onProgress?.(`${item.file} (${bytes.byteLength} bytes)`);
123
+ }
124
+ catch (err) {
125
+ // One expired/missing artifact must not cost us the rest of the bundle.
126
+ failures.push({ url: item.url, error: err.message });
127
+ }
128
+ }
129
+ };
130
+ await Promise.all(Array.from({ length: Math.min(DOWNLOAD_CONCURRENCY, pending.length) }, worker));
131
+ return { directory, manifest, artifacts, failures };
132
+ }
@@ -1,9 +1,53 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import os from "node:os";
2
3
  import { saveGlobalConfig } from "../config.js";
3
4
  import { CliError, UsageError } from "../errors.js";
4
5
  import { ApiClient } from "../http.js";
5
6
  import { dim, green } from "../output.js";
6
7
  import { arg, flagBool, flagStr } from "./util.js";
8
+ function gitEmail() {
9
+ try {
10
+ const email = execFileSync("git", ["config", "--get", "user.email"], {
11
+ encoding: "utf8",
12
+ stdio: ["ignore", "pipe", "ignore"],
13
+ }).trim();
14
+ return email.includes("@") ? email : undefined;
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
20
+ // The address the user most likely wants: whoever the stored token belongs to, else the
21
+ // git identity. Only ever a prompt default — never signs anyone in without a confirm.
22
+ async function suggestedEmail(ctx) {
23
+ if (ctx.config.token) {
24
+ try {
25
+ const me = (await ctx.client.get("/account/"));
26
+ if (me.email)
27
+ return me.email;
28
+ }
29
+ catch {
30
+ // a stale/invalid token is exactly why they're logging in again
31
+ }
32
+ }
33
+ return gitEmail();
34
+ }
35
+ async function promptForEmail(ctx) {
36
+ const suggestion = await suggestedEmail(ctx);
37
+ const answer = await ctx.prompt(suggestion ? `Email [${suggestion}]: ` : "Email: ");
38
+ return answer || suggestion || "";
39
+ }
40
+ export async function promptForOtpCode(ctx) {
41
+ let code = "";
42
+ for (let attempt = 0; attempt < 3 && !/^\d{6}$/.test(code); attempt++) {
43
+ if (attempt > 0)
44
+ ctx.err("The code is the 6 digits from the email.");
45
+ code = (await ctx.prompt("Code: ")).replace(/\s/g, "");
46
+ }
47
+ if (!/^\d{6}$/.test(code))
48
+ throw new UsageError("No valid 6-digit code entered");
49
+ return code;
50
+ }
7
51
  export const authCommands = [
8
52
  {
9
53
  name: "login",
@@ -14,7 +58,12 @@ export const authCommands = [
14
58
  interactive: true,
15
59
  flags: [
16
60
  { name: "token", type: "string", description: "Use an existing personal access token" },
17
- { name: "email", type: "string", description: "Email for the one-time code sign-in" },
61
+ {
62
+ name: "email",
63
+ type: "string",
64
+ description: "Email for the one-time code sign-in (default: your last sign-in, or your git " +
65
+ "user.email — offered as the prompt default)",
66
+ },
18
67
  {
19
68
  name: "token-name",
20
69
  type: "string",
@@ -26,13 +75,15 @@ export const authCommands = [
26
75
  const apiUrl = ctx.client.baseUrl;
27
76
  let token = flagStr(input, "token");
28
77
  if (!token) {
29
- const email = flagStr(input, "email") ?? (await ctx.prompt("Email: "));
78
+ const email = flagStr(input, "email") ?? (await promptForEmail(ctx));
30
79
  if (!email.includes("@"))
31
80
  throw new UsageError(`"${email}" is not an email address`);
32
81
  const anon = new ApiClient(apiUrl);
33
82
  await anon.post("/auth/request-login-otp", { email });
34
83
  ctx.err(dim(`Sent a 6-digit code to ${email}`));
35
- const code = await ctx.prompt("Code: ");
84
+ ctx.err(dim("Not arriving? Check spam; if you're new to Beryl, sign up at https://beryl.so first — " +
85
+ "or use `beryl login --token` with a token from beryl.so → Account → API tokens."));
86
+ const code = await promptForOtpCode(ctx);
36
87
  const login = (await anon.post("/auth/verify-otp", { email, code }));
37
88
  if (!login.access_token)
38
89
  throw new CliError("Login did not return an access token");
@@ -44,13 +95,33 @@ export const authCommands = [
44
95
  }
45
96
  const authed = new ApiClient(apiUrl, token);
46
97
  const me = (await authed.get("/account/"));
98
+ let sole;
99
+ let workspaceCount = 0;
100
+ try {
101
+ const workspaces = (await authed.get("/workspaces/"));
102
+ workspaceCount = workspaces.length;
103
+ if (workspaces.length === 1)
104
+ sole = workspaces[0];
105
+ }
106
+ catch {
107
+ // pinning a default is a nicety — never fail the login over it
108
+ }
47
109
  const saved = saveGlobalConfig({
48
110
  token,
49
111
  api_url: apiUrl === "https://api.beryl.so" ? undefined : apiUrl,
112
+ ...(sole && sole.id !== ctx.config.workspace
113
+ ? { workspace: sole.id, project: undefined }
114
+ : {}),
50
115
  });
116
+ let human = `${green("Logged in")} as ${me.name} <${me.email}>`;
117
+ if (sole)
118
+ human += `\n${dim(`Default workspace: ${sole.name} (${sole.id})`)}`;
119
+ else if (workspaceCount > 1 && !ctx.config.workspace)
120
+ human += `\n${dim("Pick a default workspace with `beryl workspaces use <id>`")}`;
121
+ human += `\n${dim(`Token saved to ${saved}`)}`;
51
122
  return {
52
- data: { email: me.email, name: me.name, config: saved },
53
- human: `${green("Logged in")} as ${me.name} <${me.email}>\n${dim(`Token saved to ${saved}`)}`,
123
+ data: { email: me.email, name: me.name, workspace: sole?.id ?? null, config: saved },
124
+ human,
54
125
  };
55
126
  },
56
127
  },
@@ -86,7 +86,8 @@ export const initCommands = [
86
86
  name: "local",
87
87
  type: "boolean",
88
88
  description: "Also wire the Playwright MCP so your coding agent can drive a local browser " +
89
- "(for authoring tests yourself)",
89
+ "(for authoring tests yourself). Default: on whenever a coding agent is wired; " +
90
+ "pass --no-local to skip it",
90
91
  },
91
92
  ],
92
93
  examples: [
@@ -161,7 +162,10 @@ export const initCommands = [
161
162
  : choice === "none"
162
163
  ? []
163
164
  : [choice];
164
- const local = flagBool(input, "local");
165
+ // Wiring an editor's MCP config at all implies the user wants to author tests there,
166
+ // and local authoring needs the Playwright MCP — so default it on. `--no-local` (parsed
167
+ // as an explicit false) opts out.
168
+ const local = input.flags.local ?? editors.length > 0;
165
169
  for (const editor of editors) {
166
170
  const file = editor === "claude-code"
167
171
  ? path.join(cwd, ".mcp.json")
@@ -1,13 +1,43 @@
1
1
  import { saveGlobalConfig } from "../config.js";
2
+ import { detectAuthGating } from "../detect.js";
2
3
  import { UsageError } from "../errors.js";
3
4
  import { dim, green } from "../output.js";
4
5
  import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
5
6
  import { pollCurrentExploration, watchExploration } from "./watch.js";
7
+ // The API treats the auth choice as the visitor's answer and never infers it — a null
8
+ // choice leaves the project waiting on a login capture that never comes — so resolve it
9
+ // here. A saved login for the host settles it; otherwise probe the site.
10
+ async function resolveAuthChoice(ctx, workspaceId, url) {
11
+ let reusable;
12
+ try {
13
+ reusable = (await ctx.client.get(`/workspaces/${workspaceId}/projects/reusable-auth`, {
14
+ url,
15
+ }));
16
+ }
17
+ catch {
18
+ // a probe is still worth trying if the lookup fails
19
+ }
20
+ if (reusable?.available) {
21
+ ctx.err(dim("Detected a gated site — a saved login for this host is already on file."));
22
+ return "gated";
23
+ }
24
+ const detected = await detectAuthGating(url);
25
+ if (detected.choice) {
26
+ ctx.err(dim(`Detected a ${detected.choice} site — ${detected.reason}.`));
27
+ return detected.choice;
28
+ }
29
+ const question = `Could not tell whether ${url} needs a login — ${detected.reason}.`;
30
+ if (!ctx.interactive)
31
+ throw new UsageError(`${question}\n\nPass --auth public or --auth gated to say which it is.`);
32
+ const answer = await ctx.prompt(`${question}\nDoes testing it require signing in? [y/N] `);
33
+ return /^y(es)?$/i.test(answer) ? "gated" : "public";
34
+ }
6
35
  export const projectCommands = [
7
36
  {
8
37
  name: "projects list",
9
38
  summary: "List projects in the workspace",
10
39
  scope: "workspace",
40
+ groupDefault: true,
11
41
  async run(ctx, input) {
12
42
  const ws = await ctx.requireWorkspace(input);
13
43
  return { data: await ctx.client.get(`/workspaces/${ws}/projects`) };
@@ -32,7 +62,8 @@ export const projectCommands = [
32
62
  name: "auth",
33
63
  type: "string",
34
64
  enum: ["public", "gated"],
35
- description: "Whether the site needs a login (gated) or not (public)",
65
+ description: "Whether the site needs a login (gated) or not (public). Default: detected from the " +
66
+ "site — only asked when detection is genuinely unsure",
36
67
  },
37
68
  {
38
69
  name: "allow-mutations",
@@ -50,7 +81,7 @@ export const projectCommands = [
50
81
  { name: "timeout", type: "number", description: "With --watch: max minutes to wait" },
51
82
  ],
52
83
  examples: [
53
- "beryl projects create https://app.example.com --auth public --watch",
84
+ "beryl projects create https://app.example.com --watch",
54
85
  "beryl projects create https://app.example.com --auth gated",
55
86
  "beryl projects create https://app.example.com --auth public --no-explore",
56
87
  ],
@@ -59,10 +90,11 @@ export const projectCommands = [
59
90
  if (noExplore && flagBool(input, "watch"))
60
91
  throw new UsageError("--no-explore cannot be combined with --watch");
61
92
  const ws = await ctx.requireWorkspace(input);
62
- const auth = flagStr(input, "auth");
93
+ const url = arg(input, "url");
94
+ const auth = flagStr(input, "auth") ?? (await resolveAuthChoice(ctx, ws, url));
63
95
  const created = (await ctx.client.post(`/workspaces/${ws}/projects`, {
64
- root_url: arg(input, "url"),
65
- requires_auth_choice: auth ?? null,
96
+ root_url: url,
97
+ requires_auth_choice: auth,
66
98
  mutation_choice: flagBool(input, "allow-mutations") ? "allow_mutations" : null,
67
99
  force_new_login: flagBool(input, "force-new-login"),
68
100
  skip_exploration: noExplore,
@@ -73,6 +105,15 @@ export const projectCommands = [
73
105
  human: `${green("Project created")}: ${created.project_id} ${dim("(no exploration started)")}\n` +
74
106
  `Ready for your own tests — author a plan and push it with \`beryl tests create\`.`,
75
107
  };
108
+ // The agent cannot start until a login is captured, so there is nothing to watch.
109
+ if (created.auth_status === "needs_capture")
110
+ return {
111
+ data: created,
112
+ human: `${green("Project created")}: ${created.project_id}\n` +
113
+ `The site needs a login before the agent can explore it. Capture one with ` +
114
+ `\`beryl credentials capture --project ${created.project_id}\` — the exploration ` +
115
+ `starts as soon as you do.`,
116
+ };
76
117
  if (!flagBool(input, "watch"))
77
118
  return { data: created };
78
119
  ctx.err(dim(`project ${created.project_id} created — waiting for the agent to start…`));