@beryl-so/cli 0.2.0 → 0.6.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.
@@ -0,0 +1,136 @@
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
3
+ // embedded string so it ships in the published package (`files: ["dist"]`) with no
4
+ // build-time asset copy, and so there is ONE source for the guidance — not a copy in
5
+ // the CLI and another in the docs. Edit here; `init` writes it verbatim.
6
+ export const BERYL_TEST_SKILL_FILENAME = "SKILL.md";
7
+ export const BERYL_TEST_SKILL_DIR = "beryl-test";
8
+ export const BERYL_TEST_SKILL = `---
9
+ name: beryl-test
10
+ description: Author durable, healable end-to-end tests for a web app with Beryl. Use when writing, running, or fixing a Beryl test locally with your own coding agent — drafting the plan over the Playwright MCP, writing the natural-language intent, and running it with \`beryl runs local\`.
11
+ ---
12
+
13
+ # Authoring Beryl tests
14
+
15
+ Beryl is automated web-app testing: a test drives a real browser through a flow and
16
+ asserts the flow reached its meaningful outcome. \`beryl init\` has already wired two MCP
17
+ servers for you — **beryl** (create/list/run tests) and **playwright** (drive a real
18
+ browser). Your job is to author tests that keep passing as the app's markup drifts,
19
+ because Beryl can **heal** them — but only when you give it what it needs to.
20
+
21
+ Read this before authoring. The three ideas that make a test durable: a real **outcome
22
+ assertion**, a strong **natural-language intent**, and the **local run-fix loop**.
23
+
24
+ ## 1. Author locally over the Playwright MCP
25
+
26
+ 1. **Drive the flow in a real browser first.** Use the Playwright MCP to open the app and
27
+ walk the flow by hand — log in, fill the form, submit, whatever the flow is. You act on
28
+ elements by their accessibility ref from the latest page snapshot, not a guessed
29
+ selector. Watch what actually happens; don't author from imagination.
30
+ 2. **Write it as an ActionPlan** — a JSON object whose \`steps\` are
31
+ \`{action, selector, url, value, ...}\`. Two structural rules the plan must satisfy:
32
+ - the **first executed step is a \`goto\`** (the flow has to start by navigating somewhere), and
33
+ - **at least one step is an \`expect\`** (a test that asserts nothing is not a test).
34
+ Optional \`before\` / \`after\` arrays hold setup and teardown; \`after\` runs even when a
35
+ main step fails, so a create/update/delete flow can clean up the record it made.
36
+ 3. **Validate offline, then create:**
37
+ \`\`\`
38
+ beryl tests lint --file plan.json # check the plan against the schema, no network
39
+ beryl tests create --title "Log in" --file plan.json \\
40
+ --description "<the intent — see §3>"
41
+ \`\`\`
42
+ By default \`create\` verifies the plan in a real browser before accepting it. The full
43
+ ActionPlan JSON Schema is at
44
+ https://api.beryl.so/api/v1/schemas/action-plan.schema.json.
45
+
46
+ ### The outcome assertion is the whole game
47
+
48
+ A flow is only worth banking if you can point at the **success signal** — the one
49
+ observable proof the flow worked. Get this right and everything else follows.
50
+
51
+ - The signal must be **true only if the flow succeeded**. A confirmation message that
52
+ appeared, an element that showed up or disappeared, content unique to where the flow
53
+ landed.
54
+ - **Never assert global chrome** — the nav bar, logo, footer, or cookie banner is on every
55
+ page, so asserting it tests nothing. "Was there anyway" means site-wide chrome, NOT the
56
+ destination's own distinctive content.
57
+ - For a **navigation** flow, the strongest signal is that the destination actually
58
+ **rendered**: assert its unique heading or a piece of content specific to that page (for
59
+ \`/pricing\`, the "Pricing" H1 or a plan name). Prefer that over the URL alone — a bare
60
+ "the URL is /pricing" passes even on a blank or broken page that never rendered.
61
+ Reserve a URL-only assertion for when the URL *is* the outcome (a form that lands on
62
+ \`/thank-you\`) and no distinctive destination content is available.
63
+ - \`expect\` kinds: \`text\` (a string is visible), \`url\` (the URL contains a value), \`gone\`
64
+ (an element disappeared — e.g. a spinner, or the item you just deleted).
65
+ - **If you can't name a success signal, the flow is not test-worthy.** Don't bank a test
66
+ that verifies nothing. Explore a different flow instead.
67
+ - **Don't work around a real app failure to make a test go green.** If the flow is
68
+ genuinely broken in the app, that's a finding to report — not something to paper over
69
+ with a weaker assertion.
70
+
71
+ ## 2. What "durable" and "healable" mean here
72
+
73
+ Beryl's cloud runs your test on a schedule. When the app's markup drifts and a selector
74
+ stops matching, a heal-vs-fail agent decides whether to **heal** the test (silently
75
+ re-derive the selector/trajectory and keep it green) or **fail** it (surface a real
76
+ regression). It decides that against your test's **intent**:
77
+
78
+ - **The natural-language intent is the immutable anchor. Beryl never rewrites it.** It's
79
+ the description of what the test proves — the load-bearing statement the heal agent
80
+ judges every future run against.
81
+ - **Selectors and the trajectory are the healable "how".** A button moved, a class name
82
+ changed, a step needs an extra click — those are mechanics Beryl can re-derive on its
83
+ own, because your intent tells it what the flow was *for*.
84
+ - **A failed outcome assertion is a real regression Beryl will NOT silently heal green.**
85
+ If the success signal from §1 stops holding — the confirmation never appears, the page
86
+ never renders — that's the app breaking, and the test fails loudly. That is the point.
87
+
88
+ So a test is *healable* exactly when you gave it **a strong intent + a real outcome
89
+ assertion**. A test with a vague intent and a chrome-only assertion is brittle: Beryl
90
+ can't tell a real regression from cosmetic drift, so it either heals over real breakage or
91
+ fails on noise.
92
+
93
+ ## 3. Writing the natural-language intent
94
+
95
+ Pass the intent as \`--description\` on \`beryl tests create\` (or \`tests set-plan\` when you
96
+ re-author). 1–3 sentences. This is the immutable anchor from §2 — write it well.
97
+
98
+ - **State the purpose, not the steps.** Not "clicks Sign in, types email and password,
99
+ clicks submit" — that's the trajectory, which Beryl already has and which will change.
100
+ Instead: *what does a green run prove is true about the app?*
101
+ - **Name the one observable outcome** that is true only if the flow worked — the same
102
+ success signal you asserted in §1, in words.
103
+ - **Never describe global chrome.** The intent is about the flow's destination and
104
+ outcome, not "the header is present".
105
+
106
+ Good:
107
+ > "Proves a returning user can sign in: after submitting valid credentials, the
108
+ > authenticated dashboard with the user's name in the header loads."
109
+
110
+ Weak (describes steps + asserts nothing meaningful):
111
+ > "Goes to /login, fills the form, and checks the page loaded."
112
+
113
+ ## 4. The local run-fix loop
114
+
115
+ Iterate on your machine before you rely on the cloud. \`beryl runs local\` fetches the
116
+ test's rendered spec and runs it with your local \`@playwright/test\` — no cloud, no waiting
117
+ for a scheduled run.
118
+
119
+ \`\`\`
120
+ npm i -D @playwright/test && npx playwright install # once
121
+ beryl runs local <test-id> --url-override http://localhost:3000 --dir ./beryl-local
122
+ \`\`\`
123
+
124
+ - \`--url-override\` points the run at a local dev server or a preview deploy.
125
+ - \`--dir\` keeps the **spec, artifacts, and a JSON \`report.json\`** on disk so you (or your
126
+ coding agent) can read exactly what happened and iterate: read the report, see which step
127
+ or assertion failed and why, fix the plan, \`beryl tests set-plan\`, run again.
128
+ - It exits **0** if every test passed, **1** on a failure — so it drops straight into a
129
+ run-fix-run loop.
130
+ - **v1 is public / unauthenticated flows only.** A test that signs in first runs only in
131
+ Beryl's cloud (which holds the encrypted session — it's never handed to your disk);
132
+ \`runs local\` refuses it. Run those with \`beryl runs trigger\`.
133
+
134
+ Once the test passes locally against a real outcome, it's ready to bank and let Beryl run
135
+ and heal it.
136
+ `;
@@ -4,6 +4,7 @@ export const accountCommands = [
4
4
  {
5
5
  name: "account get",
6
6
  summary: "Show your account profile",
7
+ groupSummary: "View and update your personal account profile.",
7
8
  async run(ctx) {
8
9
  return { data: await ctx.client.get("/account/") };
9
10
  },
@@ -36,6 +37,7 @@ export const accountCommands = [
36
37
  {
37
38
  name: "feedback send",
38
39
  summary: "Send product feedback to the Beryl team",
40
+ groupSummary: "Send product feedback to the Beryl team.",
39
41
  args: [{ name: "message", description: "Your feedback", required: true }],
40
42
  async run(ctx, input) {
41
43
  return { data: await ctx.client.post("/feedback", { message: arg(input, "message") }) };
@@ -45,6 +47,7 @@ export const accountCommands = [
45
47
  name: "billing usage",
46
48
  summary: "Show plan usage: services and weekly AI units",
47
49
  scope: "workspace",
50
+ groupSummary: "Review a workspace's plan usage, subscription, and invoices.",
48
51
  async run(ctx, input) {
49
52
  const ws = await ctx.requireWorkspace(input);
50
53
  return { data: await ctx.client.get(`/workspaces/${ws}/billing/usage`) };
@@ -54,7 +54,9 @@ export const authCommands = [
54
54
  summary: "Authenticate the CLI with your Beryl account",
55
55
  description: "Signs in with an emailed one-time code and mints a personal access token, " +
56
56
  "which is stored in the CLI config. Pass --token to use an existing token " +
57
- "from Account → API tokens instead. In CI, prefer the BERYL_API_KEY environment variable.",
57
+ "from Account → API tokens instead. Pass --email plus --code (the 6 digits " +
58
+ "from the email, e.g. read from a `beryl inbox`) to complete the OTP flow " +
59
+ "without a prompt. In CI, prefer the BERYL_API_KEY environment variable.",
58
60
  interactive: true,
59
61
  flags: [
60
62
  { name: "token", type: "string", description: "Use an existing personal access token" },
@@ -64,26 +66,48 @@ export const authCommands = [
64
66
  description: "Email for the one-time code sign-in (default: your last sign-in, or your git " +
65
67
  "user.email — offered as the prompt default)",
66
68
  },
69
+ {
70
+ name: "code",
71
+ type: "string",
72
+ description: "The emailed 6-digit code — skips the prompt for non-interactive use " +
73
+ "(requires --email; also skips sending a fresh code, so pair it with a " +
74
+ "code already requested via `beryl login`, `beryl signup`, or the API)",
75
+ },
67
76
  {
68
77
  name: "token-name",
69
78
  type: "string",
70
79
  description: "Name for the minted token (default: CLI on <hostname>)",
71
80
  },
72
81
  ],
73
- examples: ["beryl login", "beryl login --token beryl_pat_…", "beryl login --email you@example.com"],
82
+ examples: [
83
+ "beryl login",
84
+ "beryl login --token beryl_pat_…",
85
+ "beryl login --email you@example.com",
86
+ "beryl login --email agent@example.com --code 123456 --json",
87
+ ],
74
88
  async run(ctx, input) {
75
89
  const apiUrl = ctx.client.baseUrl;
76
90
  let token = flagStr(input, "token");
77
91
  if (!token) {
92
+ const codeFlag = flagStr(input, "code")?.replace(/\s/g, "");
93
+ if (codeFlag !== undefined) {
94
+ if (!/^\d{6}$/.test(codeFlag))
95
+ throw new UsageError("--code must be the 6 digits from the email");
96
+ if (!flagStr(input, "email"))
97
+ throw new UsageError("--code requires --email");
98
+ }
78
99
  const email = flagStr(input, "email") ?? (await promptForEmail(ctx));
79
100
  if (!email.includes("@"))
80
101
  throw new UsageError(`"${email}" is not an email address`);
81
102
  const anon = new ApiClient(apiUrl);
82
- await anon.post("/auth/request-login-otp", { email });
83
- ctx.err(dim(`Sent a 6-digit code to ${email}`));
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);
103
+ let code = codeFlag;
104
+ if (!code) {
105
+ await anon.post("/auth/request-login-otp", { email });
106
+ ctx.err(dim(`Sent a 6-digit code to ${email}`));
107
+ ctx.err(dim("Not arriving? Check spam; if you're new to Beryl, sign up at https://beryl.so first — " +
108
+ "or use `beryl login --token` with a token from beryl.so → Account → API tokens."));
109
+ code = await promptForOtpCode(ctx);
110
+ }
87
111
  const login = (await anon.post("/auth/verify-otp", { email, code }));
88
112
  if (!login.access_token)
89
113
  throw new CliError("Login did not return an access token");
@@ -125,6 +149,36 @@ export const authCommands = [
125
149
  };
126
150
  },
127
151
  },
152
+ {
153
+ name: "signup",
154
+ summary: "Register a new Beryl account (emails a 6-digit verification code)",
155
+ description: "Creates a passwordless account for the email and sends it a 6-digit code. " +
156
+ "Finish with `beryl login --email <addr> --code <the 6 digits>` — that verifies " +
157
+ "the account, creates its workspace, and signs the CLI in. With an inbox from " +
158
+ "`beryl inbox create` as the address, an agent can provision a fresh account " +
159
+ "end-to-end with no human at a prompt.",
160
+ flags: [
161
+ {
162
+ name: "email",
163
+ type: "string",
164
+ required: true,
165
+ description: "Email address for the new account",
166
+ },
167
+ ],
168
+ examples: ["beryl signup --email agent+ci@example.com --json"],
169
+ async run(ctx, input) {
170
+ const email = flagStr(input, "email") ?? "";
171
+ if (!email.includes("@"))
172
+ throw new UsageError(`"${email}" is not an email address`);
173
+ const anon = new ApiClient(ctx.client.baseUrl);
174
+ const created = (await anon.post("/auth/signup", { email }));
175
+ return {
176
+ data: created,
177
+ human: `${green("Signup started")} for ${email}\n` +
178
+ dim(`Sent a 6-digit code to ${email} — finish with: beryl login --email ${email} --code <code>`),
179
+ };
180
+ },
181
+ },
128
182
  {
129
183
  name: "logout",
130
184
  summary: "Remove the stored token from the CLI config",
@@ -169,6 +223,7 @@ export const authCommands = [
169
223
  {
170
224
  name: "tokens list",
171
225
  summary: "List your personal access tokens",
226
+ groupSummary: "Manage the personal access tokens that authenticate the CLI and CI.",
172
227
  async run(ctx) {
173
228
  const tokens = (await ctx.client.get("/account/tokens"));
174
229
  return { data: tokens };
@@ -17,6 +17,7 @@ export const configCommands = [
17
17
  name: "config vars list",
18
18
  summary: "List the project's config variables (visible to the agent during runs)",
19
19
  scope: "project",
20
+ groupSummary: "Manage the variables, secrets, and files the agent can use while exploring and running.",
20
21
  async run(ctx, input) {
21
22
  const { workspaceId, projectId } = await ctx.requireProject(input);
22
23
  return { data: await ctx.client.get(`${configPath(workspaceId, projectId)}/variables`) };
@@ -1,11 +1,13 @@
1
+ import { UsageError } from "../errors.js";
1
2
  import { dim, green, yellow } from "../output.js";
2
- import { arg, flagBool } from "./util.js";
3
+ import { arg, flagBool, flagStr } from "./util.js";
3
4
  const capturePath = (ws, p) => `/auth-capture/workspaces/${ws}/projects/${p}/sessions`;
4
5
  export const credentialCommands = [
5
6
  {
6
7
  name: "credentials list",
7
8
  summary: "List the workspace's saved logins",
8
9
  scope: "workspace",
10
+ groupSummary: "Manage saved logins Beryl reuses to test behind authentication, and attach them to projects.",
9
11
  async run(ctx, input) {
10
12
  const ws = await ctx.requireWorkspace(input);
11
13
  return { data: await ctx.client.get(`/workspaces/${ws}/credentials`) };
@@ -103,11 +105,43 @@ export const credentialCommands = [
103
105
  name: "auth-capture start",
104
106
  summary: "Start a login-capture browser session for the project (non-interactive)",
105
107
  scope: "project",
108
+ groupSummary: "Drive a browser session that captures a target-site login for Beryl to reuse.",
106
109
  async run(ctx, input) {
107
110
  const { workspaceId, projectId } = await ctx.requireProject(input);
108
111
  return { data: await ctx.client.post(capturePath(workspaceId, projectId)) };
109
112
  },
110
113
  },
114
+ {
115
+ name: "auth-capture login",
116
+ summary: "Log into the target site headlessly with credentials (no human at the browser)",
117
+ description: "Drives the login inside the capture session started by `auth-capture start`, so " +
118
+ "an agent can complete start → login → capture with zero human intervention. The " +
119
+ "credentials are sent to the server, typed into the target site over the wire, and " +
120
+ "never stored, logged, or returned — the captured session stays encrypted " +
121
+ "server-side. Follow with `auth-capture capture` to snapshot the authenticated session.",
122
+ scope: "project",
123
+ args: [
124
+ { name: "session-id", description: "Session id from auth-capture start", required: true },
125
+ ],
126
+ flags: [
127
+ { name: "username", type: "string", description: "Login username / email", required: true },
128
+ { name: "password", type: "string", description: "Login password", required: true },
129
+ {
130
+ name: "login-url",
131
+ type: "string",
132
+ description: "Explicit login page URL (defaults to the session's current page)",
133
+ },
134
+ ],
135
+ async run(ctx, input) {
136
+ const { workspaceId, projectId } = await ctx.requireProject(input);
137
+ const username = flagStr(input, "username");
138
+ const password = flagStr(input, "password");
139
+ if (!username || !password)
140
+ throw new UsageError("--username and --password are required");
141
+ await ctx.client.post(`${capturePath(workspaceId, projectId)}/${arg(input, "session-id")}/login`, { username, password, login_url: flagStr(input, "login-url") ?? null });
142
+ return { human: "Logged in." };
143
+ },
144
+ },
111
145
  {
112
146
  name: "auth-capture capture",
113
147
  summary: "Capture the session after the user has logged in via the live-view URL",
@@ -5,6 +5,7 @@ export const environmentCommands = [
5
5
  name: "envs list",
6
6
  summary: "List a project's environments",
7
7
  scope: "project",
8
+ groupSummary: "Manage a project's environments — the URLs and auth Beryl runs tests against.",
8
9
  async run(ctx, input) {
9
10
  const { workspaceId, projectId } = await ctx.requireProject(input);
10
11
  return { data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/environments`) };
@@ -95,6 +96,7 @@ export const environmentCommands = [
95
96
  name: "schedule get",
96
97
  summary: "Show the project's daily/weekly run schedule",
97
98
  scope: "project",
99
+ groupSummary: "View and set the schedule on which Beryl runs a project's tests automatically.",
98
100
  async run(ctx, input) {
99
101
  const { workspaceId, projectId } = await ctx.requireProject(input);
100
102
  return { data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/schedule`) };
@@ -3,6 +3,7 @@ import { watchExploration } from "./watch.js";
3
3
  export const explorationCommands = [
4
4
  {
5
5
  name: "explorations list",
6
+ groupSummary: "Inspect the agent's exploration runs — how it crawled a site and authored its tests.",
6
7
  summary: "List the agent's exploration passes for a project",
7
8
  scope: "project",
8
9
  async run(ctx, input) {
@@ -34,6 +35,20 @@ export const explorationCommands = [
34
35
  };
35
36
  },
36
37
  },
38
+ {
39
+ name: "explorations cancel",
40
+ summary: "Cancel an in-flight exploration",
41
+ description: "Stops a running exploration without touching the project or its tests — the " +
42
+ "lever for a runaway/non-convergent pass burning agent budget.",
43
+ scope: "project",
44
+ args: [{ name: "exploration-id", description: "Exploration id", required: true }],
45
+ async run(ctx, input) {
46
+ const { workspaceId, projectId } = await ctx.requireProject(input);
47
+ return {
48
+ data: await ctx.client.post(`${projectPath(workspaceId, projectId)}/explorations/${arg(input, "exploration-id")}/cancel`),
49
+ };
50
+ },
51
+ },
37
52
  {
38
53
  name: "explorations watch",
39
54
  summary: "Stream an exploration live — watch the agent explore and author tests",
@@ -0,0 +1,117 @@
1
+ import { dim, green } from "../output.js";
2
+ import { arg, flagBool, flagNum, flagStr } from "./util.js";
3
+ // Mirrors the API's own extractor (email_inbox/step_resolver.py): a labelled digit run
4
+ // ("your code is 654321") beats the bare fenced pattern, because a real sign-in mail is
5
+ // full of innocent 4-8 digit runs — "© 2026", a support number — and the bare fence
6
+ // would happily return the first of them.
7
+ const CODE_PATTERN = /(?<!\d)(\d{4,8})(?!\d)/;
8
+ const LABELLED_CODE_PATTERN = /(?:verification|security|one[\s-]?time|login|sign[\s-]?in|access|confirmation)?\s*(?:code|otp|passcode|pin)\b[^0-9]{0,20}(?<!\d)(\d{4,8})(?!\d)/i;
9
+ function visibleText(html) {
10
+ return html
11
+ .replace(/<(style|script|head)\b[\s\S]*?<\/\1>/gi, " ")
12
+ .replace(/<[^>]+>/g, " ");
13
+ }
14
+ function extractCode(email) {
15
+ const body = email.body_text || (email.body_html ? visibleText(email.body_html) : "");
16
+ for (const pattern of [LABELLED_CODE_PATTERN, CODE_PATTERN]) {
17
+ for (const text of [body, email.subject ?? ""]) {
18
+ const match = text.match(pattern);
19
+ if (match)
20
+ return match[1];
21
+ }
22
+ }
23
+ return null;
24
+ }
25
+ export const inboxCommands = [
26
+ {
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.",
30
+ description: "Creates a receiving address under Beryl's inbound email domain and returns it. " +
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`.",
33
+ scope: "workspace",
34
+ flags: [
35
+ {
36
+ name: "ttl-hours",
37
+ type: "number",
38
+ description: "Hours before the inbox expires and stops receiving (1-168, default 24)",
39
+ },
40
+ { name: "project", type: "string", description: "Attach the inbox to a project id" },
41
+ ],
42
+ examples: ["beryl inbox create --json", "beryl inbox create --ttl-hours 2"],
43
+ async run(ctx, input) {
44
+ const ws = await ctx.requireWorkspace(input);
45
+ const inbox = (await ctx.client.post(`/workspaces/${ws}/inboxes`, {
46
+ ttl_hours: flagNum(input, "ttl-hours") ?? 24,
47
+ project_id: flagStr(input, "project") ?? null,
48
+ }));
49
+ return {
50
+ data: inbox,
51
+ human: `${green("Created")} inbox ${inbox.id}\n\n ${inbox.address}\n\n` +
52
+ dim(`Read it with: beryl inbox read ${inbox.id}`),
53
+ };
54
+ },
55
+ },
56
+ {
57
+ name: "inbox read",
58
+ summary: "Read the latest email from an inbox (waits for one to arrive)",
59
+ description: "Waits up to --timeout-s for a matching email and returns it (one blocking request; " +
60
+ "the server caps the wait at 50s — re-run to keep waiting). With --extract-code, " +
61
+ "also pulls the one-time code (4-8 digits) out of the body/subject — handy for " +
62
+ "completing `beryl login --email <addr> --code <code>` unattended. Exits non-zero " +
63
+ "if nothing arrives before the timeout.",
64
+ scope: "workspace",
65
+ args: [{ name: "inbox-id", description: "Inbox id from `beryl inbox create`", required: true }],
66
+ flags: [
67
+ {
68
+ name: "timeout-s",
69
+ type: "number",
70
+ description: "Seconds to wait for a matching email (0 = don't wait; max 50, default 30)",
71
+ },
72
+ { name: "since", type: "string", description: "Only emails received after this ISO timestamp" },
73
+ { name: "from-contains", type: "string", description: "Only emails whose sender contains this" },
74
+ {
75
+ name: "subject-contains",
76
+ type: "string",
77
+ description: "Only emails whose subject contains this",
78
+ },
79
+ {
80
+ name: "extract-code",
81
+ type: "boolean",
82
+ description: "Also return the one-time code found in the email as `code`",
83
+ },
84
+ ],
85
+ examples: [
86
+ "beryl inbox read ibx_123 --timeout-s 45 --json",
87
+ "beryl inbox read ibx_123 --subject-contains code --extract-code --json",
88
+ ],
89
+ async run(ctx, input) {
90
+ const ws = await ctx.requireWorkspace(input);
91
+ const email = (await ctx.client.get(`/workspaces/${ws}/inboxes/${arg(input, "inbox-id")}/emails/latest`, {
92
+ timeout_s: flagNum(input, "timeout-s"),
93
+ since: flagStr(input, "since"),
94
+ from_contains: flagStr(input, "from-contains"),
95
+ subject_contains: flagStr(input, "subject-contains"),
96
+ }));
97
+ if (!flagBool(input, "extract-code"))
98
+ return { data: email };
99
+ return { data: { ...email, code: extractCode(email) } };
100
+ },
101
+ },
102
+ {
103
+ name: "inbox emails",
104
+ summary: "List the emails an inbox has received",
105
+ scope: "workspace",
106
+ args: [{ name: "inbox-id", description: "Inbox id from `beryl inbox create`", required: true }],
107
+ flags: [
108
+ { name: "since", type: "string", description: "Only emails received after this ISO timestamp" },
109
+ ],
110
+ async run(ctx, input) {
111
+ const ws = await ctx.requireWorkspace(input);
112
+ return {
113
+ data: await ctx.client.get(`/workspaces/${ws}/inboxes/${arg(input, "inbox-id")}/emails`, { since: flagStr(input, "since") }),
114
+ };
115
+ },
116
+ },
117
+ ];