@beryl-so/cli 0.24.1 → 0.27.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 +2 -15
- package/dist/commands/account.js +1 -1
- package/dist/commands/mailboxes.js +17 -4
- package/dist/email-pump.js +34 -3
- package/dist/registry/index.js +1 -4
- package/package.json +1 -1
- package/dist/commands/slack.js +0 -82
- package/dist/email-extract.js +0 -99
package/README.md
CHANGED
|
@@ -60,7 +60,7 @@ claude mcp add beryl -- beryl mcp # or, with the CLI installe
|
|
|
60
60
|
|
|
61
61
|
Most non-interactive commands below are exposed as an MCP tool with the same name
|
|
62
62
|
(spaces and dashes become underscores) — see the MCP tool column. `—` marks commands
|
|
63
|
-
kept CLI-only: account/billing/team admin,
|
|
63
|
+
kept CLI-only: account/billing/team admin, feedback, and cloud exploration.
|
|
64
64
|
|
|
65
65
|
## Commands
|
|
66
66
|
|
|
@@ -277,19 +277,6 @@ Manage the variables, secrets, and files the agent can use while exploring and r
|
|
|
277
277
|
| `beryl config files download <file-id>` | Get a short-lived download URL for a file | `config_files_download` |
|
|
278
278
|
| `beryl config files delete <file-id>` | Delete an uploaded file | `config_files_delete` |
|
|
279
279
|
|
|
280
|
-
### slack
|
|
281
|
-
|
|
282
|
-
Send run outcomes to a Slack channel via a per-project incoming webhook.
|
|
283
|
-
|
|
284
|
-
`beryl slack` with no subcommand runs `slack show`.
|
|
285
|
-
|
|
286
|
-
| Command | Summary | MCP tool |
|
|
287
|
-
| --- | --- | --- |
|
|
288
|
-
| `beryl slack show` | Show the project's Slack alert config (webhook is masked) | — |
|
|
289
|
-
| `beryl slack set-webhook <url>` | Set the Slack incoming-webhook URL run alerts post to | — |
|
|
290
|
-
| `beryl slack clear` | Remove the project's Slack webhook (stops all alerts) | — |
|
|
291
|
-
| `beryl slack test` | Post a sample alert to the configured webhook | — |
|
|
292
|
-
|
|
293
280
|
### mailbox
|
|
294
281
|
|
|
295
282
|
The project's standing email addresses — where its tests receive sign-in mail.
|
|
@@ -346,7 +333,7 @@ Review a workspace's plan usage, subscription, and invoices.
|
|
|
346
333
|
|
|
347
334
|
| Command | Summary | MCP tool |
|
|
348
335
|
| --- | --- | --- |
|
|
349
|
-
| `beryl billing usage` | Show plan usage:
|
|
336
|
+
| `beryl billing usage` | Show plan usage: monthly cloud run minutes | — |
|
|
350
337
|
| `beryl billing subscription` | Show the workspace's subscription | — |
|
|
351
338
|
| `beryl billing invoices` | List recent invoices | — |
|
|
352
339
|
| `beryl billing portal` | Get a Stripe billing-portal link for the workspace | — |
|
package/dist/commands/account.js
CHANGED
|
@@ -45,7 +45,7 @@ export const accountCommands = [
|
|
|
45
45
|
},
|
|
46
46
|
{
|
|
47
47
|
name: "billing usage",
|
|
48
|
-
summary: "Show plan usage:
|
|
48
|
+
summary: "Show plan usage: monthly cloud run minutes",
|
|
49
49
|
scope: "workspace",
|
|
50
50
|
groupSummary: "Review a workspace's plan usage, subscription, and invoices.",
|
|
51
51
|
async run(ctx, input) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ApiError } from "../http.js";
|
|
2
2
|
import { dim, green, table } from "../output.js";
|
|
3
3
|
import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
|
|
4
4
|
const mailboxPath = (ws) => `/workspaces/${ws}/mailboxes`;
|
|
@@ -89,7 +89,8 @@ export const mailboxCommands = [
|
|
|
89
89
|
summary: "Read the latest email in a mailbox (waits for one to arrive)",
|
|
90
90
|
description: "Waits up to --timeout-s for a matching email and returns it (one blocking request; " +
|
|
91
91
|
"the server caps the wait at 50s — re-run to keep waiting). With --extract-code, " +
|
|
92
|
-
"also
|
|
92
|
+
"also asks the server to pull the one-time code out of the email (AI-assisted when " +
|
|
93
|
+
"the email is ambiguous; `code` is null if none was found). Use " +
|
|
93
94
|
"--recipient-contains to read only one `+tag` alias's mail when several identities " +
|
|
94
95
|
"share the mailbox. Exits non-zero if nothing arrives before the timeout. Waits for " +
|
|
95
96
|
"and returns ONE latest matching email — `mailbox emails` lists what has already " +
|
|
@@ -126,7 +127,8 @@ export const mailboxCommands = [
|
|
|
126
127
|
],
|
|
127
128
|
async run(ctx, input) {
|
|
128
129
|
const { workspaceId } = await ctx.requireProject(input);
|
|
129
|
-
const
|
|
130
|
+
const mailboxId = arg(input, "mailbox-id");
|
|
131
|
+
const email = (await ctx.client.get(`${mailboxPath(workspaceId)}/${mailboxId}/emails/latest`, {
|
|
130
132
|
timeout_s: flagNum(input, "timeout-s"),
|
|
131
133
|
since: flagStr(input, "since"),
|
|
132
134
|
from_contains: flagStr(input, "from-contains"),
|
|
@@ -135,7 +137,18 @@ export const mailboxCommands = [
|
|
|
135
137
|
}));
|
|
136
138
|
if (!flagBool(input, "extract-code"))
|
|
137
139
|
return { data: email };
|
|
138
|
-
|
|
140
|
+
// Extraction is the server's job (the same brain runs use, AI included); a 422
|
|
141
|
+
// means the email genuinely carries no code.
|
|
142
|
+
try {
|
|
143
|
+
const extracted = (await ctx.client.post(`${mailboxPath(workspaceId)}/${mailboxId}/emails/${email.id}/extract`, { extract: "code" }));
|
|
144
|
+
return { data: { ...email, code: extracted.value } };
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
if (err instanceof ApiError && err.status === 422) {
|
|
148
|
+
return { data: { ...email, code: null } };
|
|
149
|
+
}
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
139
152
|
},
|
|
140
153
|
},
|
|
141
154
|
{
|
package/dist/email-pump.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import { extractValue } from "./email-extract.js";
|
|
3
2
|
import { ApiError } from "./http.js";
|
|
4
3
|
const SIDECAR_POLL_MS = 500;
|
|
5
4
|
// The server caps one blocking wait at 50s; stay under it and loop.
|
|
6
5
|
const SERVER_WAIT_MAX_S = 45;
|
|
7
6
|
const DEFAULT_WAIT_S = 30;
|
|
7
|
+
const EXTRACT_RETRY_MS = 1000;
|
|
8
8
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
9
9
|
export function startEmailPump(opts) {
|
|
10
10
|
let stopped = false;
|
|
@@ -29,11 +29,36 @@ export function startEmailPump(opts) {
|
|
|
29
29
|
throw err;
|
|
30
30
|
}
|
|
31
31
|
};
|
|
32
|
+
// A 422 is a real verdict — "this email carries no artifact" — and sends the wait on
|
|
33
|
+
// to the next email; anything else is transport trouble, retried until the step's own
|
|
34
|
+
// deadline does the failing.
|
|
35
|
+
const extractOnServer = async (emailId, req, deadline) => {
|
|
36
|
+
while (!stopped && Date.now() < deadline) {
|
|
37
|
+
try {
|
|
38
|
+
const res = (await opts.client.post(`/workspaces/${opts.workspaceId}/mailboxes/${opts.inboxId}/emails/${emailId}/extract`, { extract: req.extract ?? "code", extract_pattern: req.pattern }));
|
|
39
|
+
if (res.source === "ai")
|
|
40
|
+
opts.onEvent?.("await_email: the AI extractor answered");
|
|
41
|
+
return { ok: true, value: res.value };
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
if (err instanceof ApiError && err.status === 422) {
|
|
45
|
+
const detail = typeof err.detail === "string" ? err.detail : err.message;
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
error: detail.replace(/^EMAIL_EXTRACTION_FAILED:\s*/, ""),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
await sleep(EXTRACT_RETRY_MS);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { ok: false, error: "extraction did not complete before the wait deadline" };
|
|
55
|
+
};
|
|
32
56
|
const resolveRequest = async (req) => {
|
|
33
57
|
// Wait exactly wait_s, like the cloud resolver — the spec's own grace window
|
|
34
58
|
// covers this side's poll tick + round trip.
|
|
35
59
|
const waitS = Math.max(1, Number(req.wait_s) || DEFAULT_WAIT_S);
|
|
36
60
|
const deadline = Date.now() + waitS * 1000;
|
|
61
|
+
let lastExtractError;
|
|
37
62
|
while (!stopped && Date.now() < deadline) {
|
|
38
63
|
const remainingS = Math.ceil((deadline - Date.now()) / 1000);
|
|
39
64
|
const email = await fetchLatest(req, Math.min(SERVER_WAIT_MAX_S, remainingS));
|
|
@@ -48,9 +73,15 @@ export function startEmailPump(opts) {
|
|
|
48
73
|
consumed.add(email.id);
|
|
49
74
|
since = email.received_at;
|
|
50
75
|
opts.onEvent?.(`await_email: matched "${email.subject ?? "(no subject)"}"`);
|
|
51
|
-
|
|
76
|
+
const extracted = await extractOnServer(email.id, req, deadline);
|
|
77
|
+
if (extracted.ok)
|
|
78
|
+
return extracted.value;
|
|
79
|
+
// Same move as the cloud resolver: an email without the artifact (a welcome mail
|
|
80
|
+
// racing the code mail) is consumed and the wait continues for the next one.
|
|
81
|
+
lastExtractError = extracted.error;
|
|
82
|
+
opts.onEvent?.(`await_email: ${extracted.error} — waiting for the next email`);
|
|
52
83
|
}
|
|
53
|
-
throw new Error(`no matching email arrived in the run inbox within ${waitS}s`);
|
|
84
|
+
throw new Error(lastExtractError ?? `no matching email arrived in the run inbox within ${waitS}s`);
|
|
54
85
|
};
|
|
55
86
|
const writeResponse = (response) => {
|
|
56
87
|
let box = {};
|
package/dist/registry/index.js
CHANGED
|
@@ -9,7 +9,6 @@ import { initCommands } from "../commands/init.js";
|
|
|
9
9
|
import { mcpCommands } from "../commands/mcp.js";
|
|
10
10
|
import { projectCommands } from "../commands/projects.js";
|
|
11
11
|
import { runCommands } from "../commands/runs.js";
|
|
12
|
-
import { slackCommands } from "../commands/slack.js";
|
|
13
12
|
import { testCommands } from "../commands/tests.js";
|
|
14
13
|
import { workspaceCommands } from "../commands/workspaces.js";
|
|
15
14
|
export const WORKSPACE_FLAG = {
|
|
@@ -34,11 +33,10 @@ function withScopeFlags(spec) {
|
|
|
34
33
|
return { ...spec, flags: [...(spec.flags ?? []), ...extra] };
|
|
35
34
|
}
|
|
36
35
|
// Command groups kept OUT of the `beryl mcp` tool surface while staying full CLI commands:
|
|
37
|
-
// account/billing/team admin,
|
|
36
|
+
// account/billing/team admin, feedback, and the cloud-exploration mode
|
|
38
37
|
// (incl. `projects re-explore`) — none of it is part of authoring/editing/running tests,
|
|
39
38
|
// which is all an agent drives over MCP.
|
|
40
39
|
const MCP_HIDDEN_PREFIXES = [
|
|
41
|
-
"slack",
|
|
42
40
|
"account",
|
|
43
41
|
"billing",
|
|
44
42
|
"feedback",
|
|
@@ -61,7 +59,6 @@ export const commands = [
|
|
|
61
59
|
...runCommands,
|
|
62
60
|
...explorationCommands,
|
|
63
61
|
...configCommands,
|
|
64
|
-
...slackCommands,
|
|
65
62
|
...mailboxCommands,
|
|
66
63
|
...testAccountCommands,
|
|
67
64
|
...accountCommands,
|
package/package.json
CHANGED
package/dist/commands/slack.js
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import { arg, flagBool, projectPath } from "./util.js";
|
|
2
|
-
const slackPath = (ws, p) => `${projectPath(ws, p)}/slack`;
|
|
3
|
-
export const slackCommands = [
|
|
4
|
-
{
|
|
5
|
-
name: "slack show",
|
|
6
|
-
summary: "Show the project's Slack alert config (webhook is masked)",
|
|
7
|
-
groupSummary: "Send run outcomes to a Slack channel via a per-project incoming webhook.",
|
|
8
|
-
scope: "project",
|
|
9
|
-
groupDefault: true,
|
|
10
|
-
async run(ctx, input) {
|
|
11
|
-
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
12
|
-
return { data: await ctx.client.get(slackPath(workspaceId, projectId)) };
|
|
13
|
-
},
|
|
14
|
-
},
|
|
15
|
-
{
|
|
16
|
-
name: "slack set-webhook",
|
|
17
|
-
summary: "Set the Slack incoming-webhook URL run alerts post to",
|
|
18
|
-
scope: "project",
|
|
19
|
-
args: [
|
|
20
|
-
{
|
|
21
|
-
name: "url",
|
|
22
|
-
description: "Slack incoming-webhook URL (https://hooks.slack.com/…)",
|
|
23
|
-
required: true,
|
|
24
|
-
},
|
|
25
|
-
],
|
|
26
|
-
flags: [
|
|
27
|
-
{
|
|
28
|
-
name: "all-runs",
|
|
29
|
-
type: "boolean",
|
|
30
|
-
description: "Post on every run, not only on a new regression",
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
name: "failures-only",
|
|
34
|
-
type: "boolean",
|
|
35
|
-
description: "Post only on a new regression (the default)",
|
|
36
|
-
},
|
|
37
|
-
],
|
|
38
|
-
async run(ctx, input) {
|
|
39
|
-
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
40
|
-
const path = slackPath(workspaceId, projectId);
|
|
41
|
-
// Preserve the stored notify preference unless the caller states one, so
|
|
42
|
-
// rotating the webhook doesn't silently reset an existing all-runs opt-in.
|
|
43
|
-
let notifyAllRuns;
|
|
44
|
-
if (flagBool(input, "all-runs")) {
|
|
45
|
-
notifyAllRuns = true;
|
|
46
|
-
}
|
|
47
|
-
else if (flagBool(input, "failures-only")) {
|
|
48
|
-
notifyAllRuns = false;
|
|
49
|
-
}
|
|
50
|
-
else {
|
|
51
|
-
const current = (await ctx.client.get(path));
|
|
52
|
-
notifyAllRuns = current?.notify_all_runs ?? false;
|
|
53
|
-
}
|
|
54
|
-
return {
|
|
55
|
-
data: await ctx.client.put(path, {
|
|
56
|
-
webhook_url: arg(input, "url"),
|
|
57
|
-
notify_all_runs: notifyAllRuns,
|
|
58
|
-
}),
|
|
59
|
-
};
|
|
60
|
-
},
|
|
61
|
-
},
|
|
62
|
-
{
|
|
63
|
-
name: "slack clear",
|
|
64
|
-
summary: "Remove the project's Slack webhook (stops all alerts)",
|
|
65
|
-
scope: "project",
|
|
66
|
-
async run(ctx, input) {
|
|
67
|
-
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
68
|
-
await ctx.client.del(slackPath(workspaceId, projectId));
|
|
69
|
-
return { human: "Cleared." };
|
|
70
|
-
},
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
name: "slack test",
|
|
74
|
-
summary: "Post a sample alert to the configured webhook",
|
|
75
|
-
scope: "project",
|
|
76
|
-
async run(ctx, input) {
|
|
77
|
-
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
78
|
-
await ctx.client.post(`${slackPath(workspaceId, projectId)}/test`);
|
|
79
|
-
return { human: "Sent a test message to Slack." };
|
|
80
|
-
},
|
|
81
|
-
},
|
|
82
|
-
];
|
package/dist/email-extract.js
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import vm from "node:vm";
|
|
2
|
-
// A labelled digit run ("your code is 654321") beats the bare fenced pattern, because a
|
|
3
|
-
// real sign-in mail is full of innocent 4-8 digit runs — "© 2026", a support number —
|
|
4
|
-
// and the bare fence would happily return the first of them.
|
|
5
|
-
const CODE_PATTERN = /(?<!\d)(\d{4,8})(?!\d)/;
|
|
6
|
-
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;
|
|
7
|
-
const LINK_PATTERN = /https:\/\/[^\s"'<>)\]]+/;
|
|
8
|
-
// An anchor's href is the click target in an HTML mail; a bare URL scan over raw markup
|
|
9
|
-
// would return a CSS background or tracking pixel instead.
|
|
10
|
-
const HREF_PATTERN = /<a\b[^>]*\bhref\s*=\s*["']?(https:\/\/[^\s"'>]+)/gi;
|
|
11
|
-
// Footer furniture a sign-in mail carries but nobody means.
|
|
12
|
-
const LINK_NOISE = [
|
|
13
|
-
"unsubscribe",
|
|
14
|
-
"/privacy",
|
|
15
|
-
"/terms",
|
|
16
|
-
"list-manage",
|
|
17
|
-
"mailchimp",
|
|
18
|
-
"sendgrid.net",
|
|
19
|
-
"/track/",
|
|
20
|
-
"/wf/open",
|
|
21
|
-
"twitter.com",
|
|
22
|
-
"facebook.com",
|
|
23
|
-
"linkedin.com",
|
|
24
|
-
];
|
|
25
|
-
// Matches the server's EMAIL_PATTERN_MAX_BODY_CHARS bound: backtracking cost scales with
|
|
26
|
-
// haystack length, and no real sign-in code lives past the first few KB of an email.
|
|
27
|
-
const PATTERN_MAX_BODY_CHARS = 20_000;
|
|
28
|
-
const PATTERN_TIMEOUT_MS = 2_000;
|
|
29
|
-
// An author-supplied regex against a sender-supplied body is the textbook ReDoS setup,
|
|
30
|
-
// and native RegExp cannot be interrupted once it starts. Running the match inside a vm
|
|
31
|
-
// context gives it a real wall-clock deadline — V8 services the timeout interrupt while
|
|
32
|
-
// backtracking — matching the server's regex-engine timeout, whatever the pattern is.
|
|
33
|
-
function matchWithDeadline(haystack, pattern) {
|
|
34
|
-
try {
|
|
35
|
-
return vm.runInNewContext("haystack.match(new RegExp(pattern))", { haystack, pattern }, { timeout: PATTERN_TIMEOUT_MS });
|
|
36
|
-
}
|
|
37
|
-
catch (err) {
|
|
38
|
-
const code = err?.code;
|
|
39
|
-
if (code === "ERR_SCRIPT_EXECUTION_TIMEOUT" || /timed out/i.test(String(err))) {
|
|
40
|
-
throw new Error("the extract_pattern took too long to match this email — it backtracks " +
|
|
41
|
-
"explosively (an ambiguous quantifier like (a+)+ or (a|a)+ does this). " +
|
|
42
|
-
"Rewrite it to match unambiguously.");
|
|
43
|
-
}
|
|
44
|
-
throw err;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
export function visibleText(html) {
|
|
48
|
-
return html
|
|
49
|
-
.replace(/<(style|script|head)\b[\s\S]*?<\/\1>/gi, " ")
|
|
50
|
-
.replace(/<[^>]+>/g, " ");
|
|
51
|
-
}
|
|
52
|
-
function bodyOf(email) {
|
|
53
|
-
if (email.body_text)
|
|
54
|
-
return email.body_text;
|
|
55
|
-
if (email.body_html)
|
|
56
|
-
return visibleText(email.body_html);
|
|
57
|
-
return "";
|
|
58
|
-
}
|
|
59
|
-
export function extractCode(email) {
|
|
60
|
-
for (const pattern of [LABELLED_CODE_PATTERN, CODE_PATTERN]) {
|
|
61
|
-
for (const text of [bodyOf(email), email.subject ?? ""]) {
|
|
62
|
-
const match = text.match(pattern);
|
|
63
|
-
if (match)
|
|
64
|
-
return match[1];
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
export function extractLink(email) {
|
|
70
|
-
const html = email.body_html ?? "";
|
|
71
|
-
const hrefs = [...html.matchAll(HREF_PATTERN)].map((m) => m[1]);
|
|
72
|
-
const candidates = hrefs.length > 0 ? hrefs : (bodyOf(email).match(LINK_PATTERN) ?? []);
|
|
73
|
-
for (const url of candidates) {
|
|
74
|
-
if (!LINK_NOISE.some((noise) => url.toLowerCase().includes(noise)))
|
|
75
|
-
return url;
|
|
76
|
-
}
|
|
77
|
-
if (candidates.length > 0) {
|
|
78
|
-
throw new Error("the email's only links look like footer/unsubscribe links, not a sign-in link");
|
|
79
|
-
}
|
|
80
|
-
throw new Error("the email arrived but carried no https link to follow");
|
|
81
|
-
}
|
|
82
|
-
export function extractValue(email, extract, pattern) {
|
|
83
|
-
if (extract === "link")
|
|
84
|
-
return extractLink(email);
|
|
85
|
-
const body = bodyOf(email);
|
|
86
|
-
if (extract === "pattern") {
|
|
87
|
-
if (!pattern)
|
|
88
|
-
throw new Error("extract=pattern needs an extract_pattern");
|
|
89
|
-
const match = matchWithDeadline(body.slice(0, PATTERN_MAX_BODY_CHARS), pattern);
|
|
90
|
-
if (!match) {
|
|
91
|
-
throw new Error("the email arrived but nothing in it matched the extract_pattern");
|
|
92
|
-
}
|
|
93
|
-
return match[1] ?? match[0];
|
|
94
|
-
}
|
|
95
|
-
const code = extractCode(email);
|
|
96
|
-
if (!code)
|
|
97
|
-
throw new Error("the email arrived but carried no 4-8 digit sign-in code");
|
|
98
|
-
return code;
|
|
99
|
-
}
|