@beryl-so/cli 0.5.0 → 0.7.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 +41 -0
- package/dist/adapters/cli.js +35 -15
- package/dist/adapters/mcp.js +4 -1
- package/dist/beryl-test-skill.js +45 -2
- package/dist/commands/auth.js +61 -7
- package/dist/commands/config-vars.js +68 -8
- package/dist/commands/credentials.js +33 -1
- package/dist/commands/explorations.js +14 -0
- package/dist/commands/inboxes.js +129 -0
- package/dist/commands/init.js +10 -2
- package/dist/commands/slack.js +82 -0
- package/dist/commands/tests.js +27 -6
- package/dist/commands/util.js +14 -1
- package/dist/registry/index.js +4 -0
- package/dist/version-check.js +65 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,6 +26,10 @@ npx @beryl-so/cli@latest … # or run one-off without installing
|
|
|
26
26
|
|
|
27
27
|
From the monorepo (development): `cd cli && npm install && npm run build && npm link`.
|
|
28
28
|
|
|
29
|
+
`beryl init` and `beryl mcp` warn (stderr, best-effort) when the running CLI is behind the
|
|
30
|
+
latest npm release — a stale MCP server silently exposes fewer tools. Set
|
|
31
|
+
`BERYL_NO_UPDATE_CHECK=1` to opt out.
|
|
32
|
+
|
|
29
33
|
## Authenticate
|
|
30
34
|
|
|
31
35
|
```bash
|
|
@@ -73,6 +77,14 @@ Authenticate the CLI with your Beryl account
|
|
|
73
77
|
| --- | --- | --- |
|
|
74
78
|
| `beryl login` | Authenticate the CLI with your Beryl account | — |
|
|
75
79
|
|
|
80
|
+
### signup
|
|
81
|
+
|
|
82
|
+
Register a new Beryl account (emails a 6-digit verification code)
|
|
83
|
+
|
|
84
|
+
| Command | Summary | MCP tool |
|
|
85
|
+
| --- | --- | --- |
|
|
86
|
+
| `beryl signup` | Register a new Beryl account (emails a 6-digit verification code) | `signup` |
|
|
87
|
+
|
|
76
88
|
### logout
|
|
77
89
|
|
|
78
90
|
Remove the stored token from the CLI config
|
|
@@ -232,6 +244,7 @@ Inspect the agent's exploration runs — how it crawled a site and authored its
|
|
|
232
244
|
| `beryl explorations list` | List the agent's exploration passes for a project | `explorations_list` |
|
|
233
245
|
| `beryl explorations get <exploration-id>` | Show one exploration: authored tests, abandoned flows, coverage, frontier | `explorations_get` |
|
|
234
246
|
| `beryl explorations steps <exploration-id>` | List every step the agent took in an exploration | `explorations_steps` |
|
|
247
|
+
| `beryl explorations cancel <exploration-id>` | Cancel an in-flight exploration | `explorations_cancel` |
|
|
235
248
|
| `beryl explorations watch <exploration-id>` | Stream an exploration live — watch the agent explore and author tests | `explorations_watch` |
|
|
236
249
|
|
|
237
250
|
### config
|
|
@@ -242,15 +255,31 @@ Manage the variables, secrets, and files the agent can use while exploring and r
|
|
|
242
255
|
| --- | --- | --- |
|
|
243
256
|
| `beryl config vars list` | List the project's config variables (visible to the agent during runs) | `config_vars_list` |
|
|
244
257
|
| `beryl config vars set <key> <value>` | Create or update a config variable | `config_vars_set` |
|
|
258
|
+
| `beryl config vars get <key>` | Show one config variable | `config_vars_get` |
|
|
245
259
|
| `beryl config vars delete <key>` | Delete a config variable | `config_vars_delete` |
|
|
246
260
|
| `beryl config secrets list` | List the project's secrets (values are never returned) | `config_secrets_list` |
|
|
247
261
|
| `beryl config secrets set <key> <value>` | Create a secret (write-only; re-setting a key replaces it) | `config_secrets_set` |
|
|
262
|
+
| `beryl config secrets get <key>` | Show one secret's metadata (the value is never returned) | `config_secrets_get` |
|
|
248
263
|
| `beryl config secrets delete <key>` | Delete a secret | `config_secrets_delete` |
|
|
249
264
|
| `beryl config files list` | List files uploaded for the agent to use (e.g. CSVs, upload fixtures) | `config_files_list` |
|
|
265
|
+
| `beryl config files get <file>` | Show one uploaded file's metadata | `config_files_get` |
|
|
250
266
|
| `beryl config files upload <file>` | Upload a file | `config_files_upload` |
|
|
251
267
|
| `beryl config files download <file-id>` | Get a short-lived download URL for a file | `config_files_download` |
|
|
252
268
|
| `beryl config files delete <file-id>` | Delete an uploaded file | `config_files_delete` |
|
|
253
269
|
|
|
270
|
+
### slack
|
|
271
|
+
|
|
272
|
+
Send run outcomes to a Slack channel via a per-project incoming webhook.
|
|
273
|
+
|
|
274
|
+
`beryl slack` with no subcommand runs `slack show`.
|
|
275
|
+
|
|
276
|
+
| Command | Summary | MCP tool |
|
|
277
|
+
| --- | --- | --- |
|
|
278
|
+
| `beryl slack show` | Show the project's Slack alert config (webhook is masked) | `slack_show` |
|
|
279
|
+
| `beryl slack set-webhook <url>` | Set the Slack incoming-webhook URL run alerts post to | `slack_set_webhook` |
|
|
280
|
+
| `beryl slack clear` | Remove the project's Slack webhook (stops all alerts) | `slack_clear` |
|
|
281
|
+
| `beryl slack test` | Post a sample alert to the configured webhook | `slack_test` |
|
|
282
|
+
|
|
254
283
|
### credentials
|
|
255
284
|
|
|
256
285
|
Manage saved logins Beryl reuses to test behind authentication, and attach them to projects.
|
|
@@ -273,10 +302,22 @@ Drive a browser session that captures a target-site login for Beryl to reuse.
|
|
|
273
302
|
| Command | Summary | MCP tool |
|
|
274
303
|
| --- | --- | --- |
|
|
275
304
|
| `beryl auth-capture start` | Start a login-capture browser session for the project (non-interactive) | `auth_capture_start` |
|
|
305
|
+
| `beryl auth-capture login <session-id>` | Log into the target site headlessly with credentials (no human at the browser) | `auth_capture_login` |
|
|
276
306
|
| `beryl auth-capture capture <session-id>` | Capture the session after the user has logged in via the live-view URL | `auth_capture_capture` |
|
|
277
307
|
| `beryl auth-capture refresh <session-id>` | Capture a refreshed session for a project whose login is expiring | `auth_capture_refresh` |
|
|
278
308
|
| `beryl auth-capture release <session-id>` | Release a login-capture browser session without capturing | `auth_capture_release` |
|
|
279
309
|
|
|
310
|
+
### inbox
|
|
311
|
+
|
|
312
|
+
Disposable email inboxes for testing flows that send mail — signups, OTPs, receipts.
|
|
313
|
+
|
|
314
|
+
| Command | Summary | MCP tool |
|
|
315
|
+
| --- | --- | --- |
|
|
316
|
+
| `beryl inbox create` | Mint a disposable email inbox that Beryl receives mail for | `inbox_create` |
|
|
317
|
+
| `beryl inbox list` | List the workspace's inboxes, newest first | `inbox_list` |
|
|
318
|
+
| `beryl inbox read <inbox-id>` | Read the latest email from an inbox (waits for one to arrive) | `inbox_read` |
|
|
319
|
+
| `beryl inbox emails <inbox-id>` | List the emails an inbox has received | `inbox_emails` |
|
|
320
|
+
|
|
280
321
|
### account
|
|
281
322
|
|
|
282
323
|
View and update your personal account profile.
|
package/dist/adapters/cli.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
1
|
import { loadConfig } from "../config.js";
|
|
3
2
|
import { createContext } from "../context.js";
|
|
4
3
|
import { CliError, EXIT_OK, EXIT_USAGE, UsageError } from "../errors.js";
|
|
5
4
|
import { ApiClient } from "../http.js";
|
|
6
5
|
import { autoFormat, bold, cyan, dim } from "../output.js";
|
|
7
6
|
import { commandGroups, findCommand, groupSummary } from "../registry/index.js";
|
|
7
|
+
import { cliVersion } from "../version-check.js";
|
|
8
8
|
import { mcpToolFor } from "./mcp.js";
|
|
9
9
|
export const GLOBAL_FLAGS = [
|
|
10
10
|
{ name: "version", description: "Print the CLI version and exit", alias: "V" },
|
|
@@ -13,15 +13,6 @@ export const GLOBAL_FLAGS = [
|
|
|
13
13
|
{ name: "token", description: "Personal access token (overrides config/BERYL_API_KEY)", value: true },
|
|
14
14
|
{ name: "help", description: "Show help", alias: "h" },
|
|
15
15
|
];
|
|
16
|
-
export function cliVersion() {
|
|
17
|
-
try {
|
|
18
|
-
const pkg = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
19
|
-
return pkg.version;
|
|
20
|
-
}
|
|
21
|
-
catch {
|
|
22
|
-
return "unknown";
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
16
|
export function parseArgv(spec, tokens) {
|
|
26
17
|
const flags = {};
|
|
27
18
|
const positional = [];
|
|
@@ -222,21 +213,47 @@ function groupHelp(group, specs) {
|
|
|
222
213
|
lines.push("", `Run ${cyan(`beryl ${group} <subcommand> --help`)} for details.`);
|
|
223
214
|
return lines.join("\n");
|
|
224
215
|
}
|
|
216
|
+
// Whether argv[i] is a recognised global flag, and how many tokens it spans (2 when it
|
|
217
|
+
// takes its value from the next token, 1 for boolean/alias/inline `--name=value` forms).
|
|
218
|
+
function globalFlagSpan(argv, i) {
|
|
219
|
+
const tok = argv[i];
|
|
220
|
+
const eq = tok.indexOf("=");
|
|
221
|
+
const name = (eq === -1 ? tok : tok.slice(0, eq)).replace(/^--?/, "");
|
|
222
|
+
const flag = GLOBAL_FLAGS.find((g) => (tok.startsWith("--") ? g.name === name : "alias" in g && g.alias === name));
|
|
223
|
+
if (!flag)
|
|
224
|
+
return 0;
|
|
225
|
+
return "value" in flag && flag.value && eq === -1 ? 2 : 1;
|
|
226
|
+
}
|
|
225
227
|
export async function runCli(argv) {
|
|
228
|
+
// Global flags are position-independent: collect command words by skipping over
|
|
229
|
+
// recognised global flags (leaving them in place for parseArgv), and stop only at a
|
|
230
|
+
// flag we don't recognise as global — that one belongs to the command.
|
|
226
231
|
const words = [];
|
|
227
|
-
|
|
228
|
-
for (
|
|
229
|
-
|
|
232
|
+
const wordIndices = [];
|
|
233
|
+
for (let i = 0; i < argv.length; i++) {
|
|
234
|
+
const tok = argv[i];
|
|
235
|
+
if (tok === "--")
|
|
230
236
|
break;
|
|
237
|
+
if (tok.startsWith("-") && tok !== "-") {
|
|
238
|
+
const span = globalFlagSpan(argv, i);
|
|
239
|
+
if (span === 0)
|
|
240
|
+
break;
|
|
241
|
+
i += span - 1;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
231
244
|
words.push(tok);
|
|
245
|
+
wordIndices.push(i);
|
|
232
246
|
}
|
|
233
|
-
|
|
247
|
+
let rest = argv;
|
|
248
|
+
let restWordIndices = wordIndices;
|
|
249
|
+
if (argv.includes("--version") || argv.includes("-V") || words[0] === "version") {
|
|
234
250
|
process.stdout.write(cliVersion() + "\n");
|
|
235
251
|
return EXIT_OK;
|
|
236
252
|
}
|
|
237
253
|
if (words[0] === "help") {
|
|
238
254
|
words.shift();
|
|
239
255
|
rest = [...words, "--help"];
|
|
256
|
+
restWordIndices = words.map((_, i) => i);
|
|
240
257
|
}
|
|
241
258
|
if (words.length === 0) {
|
|
242
259
|
process.stdout.write(rootHelp() + "\n");
|
|
@@ -266,7 +283,10 @@ export async function runCli(argv) {
|
|
|
266
283
|
return EXIT_USAGE;
|
|
267
284
|
}
|
|
268
285
|
const { spec } = found;
|
|
269
|
-
|
|
286
|
+
// Drop the consumed command words wherever they sit — global flags before or between
|
|
287
|
+
// them stay in place for parseArgv.
|
|
288
|
+
const consumedIndices = new Set(restWordIndices.slice(0, found.consumed));
|
|
289
|
+
const tokens = rest.filter((_, i) => !consumedIndices.has(i));
|
|
270
290
|
let parsed;
|
|
271
291
|
try {
|
|
272
292
|
parsed = parseArgv(spec, tokens);
|
package/dist/adapters/mcp.js
CHANGED
|
@@ -4,7 +4,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextpro
|
|
|
4
4
|
import { createContext } from "../context.js";
|
|
5
5
|
import { CliError } from "../errors.js";
|
|
6
6
|
import { commands } from "../registry/index.js";
|
|
7
|
-
import { cliVersion } from "
|
|
7
|
+
import { cliVersion, warnIfStale } from "../version-check.js";
|
|
8
8
|
export function toolName(spec) {
|
|
9
9
|
return spec.name.replace(/ /g, "_").replace(/-/g, "_");
|
|
10
10
|
}
|
|
@@ -72,6 +72,9 @@ function toInput(spec, params) {
|
|
|
72
72
|
return { args, flags };
|
|
73
73
|
}
|
|
74
74
|
export async function serveMcp(baseCtx) {
|
|
75
|
+
// Fire-and-forget staleness warning: a stale MCP server silently exposes fewer
|
|
76
|
+
// tools, and stderr is the one channel a stdio MCP server can safely log to.
|
|
77
|
+
void warnIfStale(cliVersion(), (msg) => console.error(msg));
|
|
75
78
|
const server = new Server({ name: "beryl", version: cliVersion() }, { capabilities: { tools: {} } });
|
|
76
79
|
server.setRequestHandler(ListToolsRequestSchema, () => ({
|
|
77
80
|
tools: mcpTools().map((spec) => ({
|
package/dist/beryl-test-skill.js
CHANGED
|
@@ -5,6 +5,20 @@
|
|
|
5
5
|
// the CLI and another in the docs. Edit here; `init` writes it verbatim.
|
|
6
6
|
export const BERYL_TEST_SKILL_FILENAME = "SKILL.md";
|
|
7
7
|
export const BERYL_TEST_SKILL_DIR = "beryl-test";
|
|
8
|
+
// The example plan the skill shows verbatim. Exported so the test suite lints it with
|
|
9
|
+
// lintPlan — the skill's documented shape must pass `beryl tests lint` on the first try.
|
|
10
|
+
export const BERYL_TEST_SKILL_EXAMPLE_PLAN = {
|
|
11
|
+
steps: [
|
|
12
|
+
{ action: "goto", url: "https://app.example.com/pricing" },
|
|
13
|
+
{
|
|
14
|
+
action: "expect",
|
|
15
|
+
expect_kind: "have_text",
|
|
16
|
+
selector: "h1",
|
|
17
|
+
expect_text: "Pricing",
|
|
18
|
+
},
|
|
19
|
+
{ action: "expect", expect_kind: "visible", selector: "text=Pro plan" },
|
|
20
|
+
],
|
|
21
|
+
};
|
|
8
22
|
export const BERYL_TEST_SKILL = `---
|
|
9
23
|
name: beryl-test
|
|
10
24
|
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\`.
|
|
@@ -33,6 +47,28 @@ assertion**, a strong **natural-language intent**, and the **local run-fix loop*
|
|
|
33
47
|
- **at least one step is an \`expect\`** (a test that asserts nothing is not a test).
|
|
34
48
|
Optional \`before\` / \`after\` arrays hold setup and teardown; \`after\` runs even when a
|
|
35
49
|
main step fails, so a create/update/delete flow can clean up the record it made.
|
|
50
|
+
|
|
51
|
+
An \`expect\` step's shape is \`{action: "expect", expect_kind, selector, expect_text}\`:
|
|
52
|
+
- \`expect_kind\` (required) is one of \`visible\`, \`attached\`, \`hidden\`, \`checked\`,
|
|
53
|
+
\`enabled\`, \`disabled\`, \`have_text\`, \`have_value\`, \`have_url\`, \`have_title\`,
|
|
54
|
+
\`have_count\`, \`persisted\`, \`gone\`, \`count_delta\`.
|
|
55
|
+
- \`selector\` is required for every kind except the page-level \`have_url\` /
|
|
56
|
+
\`have_title\` (those assert on the page, not an element).
|
|
57
|
+
- \`expect_text\` carries the expected string for \`have_text\` / \`have_value\` (exact
|
|
58
|
+
match on the element) and \`have_url\` / \`have_title\` (substring match on the page).
|
|
59
|
+
There is no \`value\` field on an expect and no bare \`text\` / \`url\` kind.
|
|
60
|
+
- Two kinds take other fields instead: \`have_count\` needs \`expect_count\`, and
|
|
61
|
+
\`count_delta\` needs \`capture_ref\` + \`expect_delta\` (vs a baseline banked by an
|
|
62
|
+
earlier \`capture_count\` step).
|
|
63
|
+
|
|
64
|
+
A fully valid minimal plan ("the pricing page renders"):
|
|
65
|
+
|
|
66
|
+
\`\`\`json
|
|
67
|
+
${JSON.stringify(BERYL_TEST_SKILL_EXAMPLE_PLAN, null, 2)
|
|
68
|
+
.split("\n")
|
|
69
|
+
.map((line) => ` ${line}`)
|
|
70
|
+
.join("\n")}
|
|
71
|
+
\`\`\`
|
|
36
72
|
3. **Validate offline, then create:**
|
|
37
73
|
\`\`\`
|
|
38
74
|
beryl tests lint --file plan.json # check the plan against the schema, no network
|
|
@@ -60,8 +96,15 @@ observable proof the flow worked. Get this right and everything else follows.
|
|
|
60
96
|
"the URL is /pricing" passes even on a blank or broken page that never rendered.
|
|
61
97
|
Reserve a URL-only assertion for when the URL *is* the outcome (a form that lands on
|
|
62
98
|
\`/thank-you\`) and no distinctive destination content is available.
|
|
63
|
-
-
|
|
64
|
-
|
|
99
|
+
- The usual outcome kinds: \`visible\` (the success element showed up), \`have_text\` (an
|
|
100
|
+
element's text matches), \`have_url\` (the URL contains a value), \`gone\` (an element
|
|
101
|
+
disappeared — e.g. a spinner, or the item you just deleted). §1 has the full
|
|
102
|
+
\`expect_kind\` list and the step shape.
|
|
103
|
+
- **\`have_text\` is an EXACT full-text match on the selector's element** — asserting
|
|
104
|
+
\`have_text: "Documentation"\` on \`body\` fails, because \`body\`'s text includes all the
|
|
105
|
+
nav chrome. Target the specific element that carries the text (the \`h1\`, the toast),
|
|
106
|
+
or assert the page instead (\`have_title\`, which is a substring match). To check "this
|
|
107
|
+
string is visible somewhere", use \`expect_kind: "visible"\` with a \`text=…\` selector.
|
|
65
108
|
- **If you can't name a success signal, the flow is not test-worthy.** Don't bank a test
|
|
66
109
|
that verifies nothing. Explore a different flow instead.
|
|
67
110
|
- **Don't work around a real app failure to make a test go green.** If the flow is
|
package/dist/commands/auth.js
CHANGED
|
@@ -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.
|
|
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: [
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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",
|
|
@@ -3,13 +3,16 @@ import path from "node:path";
|
|
|
3
3
|
import { UsageError } from "../errors.js";
|
|
4
4
|
import { arg, flagBool, flagStr, projectPath } from "./util.js";
|
|
5
5
|
const configPath = (ws, p) => `${projectPath(ws, p)}/config`;
|
|
6
|
-
|
|
6
|
+
function findByKey(rows, value, kind) {
|
|
7
7
|
const byId = rows.find((r) => r.id === value);
|
|
8
8
|
if (byId)
|
|
9
|
-
return byId
|
|
9
|
+
return byId;
|
|
10
10
|
const byKey = rows.filter((r) => r.key === value);
|
|
11
11
|
if (byKey.length === 1)
|
|
12
|
-
return byKey[0]
|
|
12
|
+
return byKey[0];
|
|
13
|
+
if (byKey.length > 1) {
|
|
14
|
+
throw new UsageError(`Multiple ${kind}s have key "${value}" (per-environment copies) — use the id instead`);
|
|
15
|
+
}
|
|
13
16
|
throw new UsageError(`No ${kind} with key or id "${value}"`);
|
|
14
17
|
}
|
|
15
18
|
export const configCommands = [
|
|
@@ -51,17 +54,30 @@ export const configCommands = [
|
|
|
51
54
|
};
|
|
52
55
|
},
|
|
53
56
|
},
|
|
57
|
+
{
|
|
58
|
+
name: "config vars get",
|
|
59
|
+
summary: "Show one config variable",
|
|
60
|
+
scope: "project",
|
|
61
|
+
args: [{ name: "key", description: "Variable key or id", required: true }],
|
|
62
|
+
async run(ctx, input) {
|
|
63
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
64
|
+
const rows = (await ctx.client.get(`${configPath(workspaceId, projectId)}/variables`));
|
|
65
|
+
return { data: findByKey(rows, arg(input, "key"), "variable") };
|
|
66
|
+
},
|
|
67
|
+
},
|
|
54
68
|
{
|
|
55
69
|
name: "config vars delete",
|
|
56
70
|
summary: "Delete a config variable",
|
|
57
71
|
scope: "project",
|
|
58
72
|
args: [{ name: "key", description: "Variable key or id", required: true }],
|
|
73
|
+
flags: [{ name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
|
|
59
74
|
async run(ctx, input) {
|
|
60
75
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
61
76
|
const base = `${configPath(workspaceId, projectId)}/variables`;
|
|
62
77
|
const rows = (await ctx.client.get(base));
|
|
63
|
-
const
|
|
64
|
-
await ctx.
|
|
78
|
+
const row = findByKey(rows, arg(input, "key"), "variable");
|
|
79
|
+
await ctx.confirm(`Delete config variable ${row.key}?`, flagBool(input, "force"));
|
|
80
|
+
await ctx.client.del(`${base}/${row.id}`);
|
|
65
81
|
return { human: "Deleted." };
|
|
66
82
|
},
|
|
67
83
|
},
|
|
@@ -94,17 +110,30 @@ export const configCommands = [
|
|
|
94
110
|
return { data: await ctx.client.post(base, { key, value, environment_id: envId }) };
|
|
95
111
|
},
|
|
96
112
|
},
|
|
113
|
+
{
|
|
114
|
+
name: "config secrets get",
|
|
115
|
+
summary: "Show one secret's metadata (the value is never returned)",
|
|
116
|
+
scope: "project",
|
|
117
|
+
args: [{ name: "key", description: "Secret key or id", required: true }],
|
|
118
|
+
async run(ctx, input) {
|
|
119
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
120
|
+
const rows = (await ctx.client.get(`${configPath(workspaceId, projectId)}/secrets`));
|
|
121
|
+
return { data: findByKey(rows, arg(input, "key"), "secret") };
|
|
122
|
+
},
|
|
123
|
+
},
|
|
97
124
|
{
|
|
98
125
|
name: "config secrets delete",
|
|
99
126
|
summary: "Delete a secret",
|
|
100
127
|
scope: "project",
|
|
101
128
|
args: [{ name: "key", description: "Secret key or id", required: true }],
|
|
129
|
+
flags: [{ name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
|
|
102
130
|
async run(ctx, input) {
|
|
103
131
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
104
132
|
const base = `${configPath(workspaceId, projectId)}/secrets`;
|
|
105
133
|
const rows = (await ctx.client.get(base));
|
|
106
|
-
const
|
|
107
|
-
await ctx.
|
|
134
|
+
const row = findByKey(rows, arg(input, "key"), "secret");
|
|
135
|
+
await ctx.confirm(`Delete secret ${row.key}?`, flagBool(input, "force"));
|
|
136
|
+
await ctx.client.del(`${base}/${row.id}`);
|
|
108
137
|
return { human: "Deleted." };
|
|
109
138
|
},
|
|
110
139
|
},
|
|
@@ -117,6 +146,27 @@ export const configCommands = [
|
|
|
117
146
|
return { data: await ctx.client.get(`${configPath(workspaceId, projectId)}/files`) };
|
|
118
147
|
},
|
|
119
148
|
},
|
|
149
|
+
{
|
|
150
|
+
name: "config files get",
|
|
151
|
+
summary: "Show one uploaded file's metadata",
|
|
152
|
+
scope: "project",
|
|
153
|
+
args: [{ name: "file", description: "File name or id", required: true }],
|
|
154
|
+
async run(ctx, input) {
|
|
155
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
156
|
+
const rows = (await ctx.client.get(`${configPath(workspaceId, projectId)}/files`));
|
|
157
|
+
const value = arg(input, "file");
|
|
158
|
+
const byId = rows.find((r) => r.id === value);
|
|
159
|
+
if (byId)
|
|
160
|
+
return { data: byId };
|
|
161
|
+
const byName = rows.filter((r) => r.name === value);
|
|
162
|
+
if (byName.length === 1)
|
|
163
|
+
return { data: byName[0] };
|
|
164
|
+
if (byName.length > 1) {
|
|
165
|
+
throw new UsageError(`Multiple files are named "${value}" (per-environment copies) — use the id instead`);
|
|
166
|
+
}
|
|
167
|
+
throw new UsageError(`No file with name or id "${value}"`);
|
|
168
|
+
},
|
|
169
|
+
},
|
|
120
170
|
{
|
|
121
171
|
name: "config files upload",
|
|
122
172
|
summary: "Upload a file",
|
|
@@ -126,8 +176,18 @@ export const configCommands = [
|
|
|
126
176
|
async run(ctx, input) {
|
|
127
177
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
128
178
|
const file = arg(input, "file");
|
|
179
|
+
let bytes;
|
|
180
|
+
try {
|
|
181
|
+
bytes = new Uint8Array(fs.readFileSync(file));
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
const code = err.code;
|
|
185
|
+
if (code === "ENOENT")
|
|
186
|
+
throw new UsageError(`file not found: ${file}`);
|
|
187
|
+
throw new UsageError(`cannot read ${file} (${code ?? err.message})`);
|
|
188
|
+
}
|
|
129
189
|
const form = new FormData();
|
|
130
|
-
form.set("file", new Blob([
|
|
190
|
+
form.set("file", new Blob([bytes]), path.basename(file));
|
|
131
191
|
const envId = flagStr(input, "env");
|
|
132
192
|
if (envId)
|
|
133
193
|
form.set("environment_id", envId);
|
|
@@ -1,5 +1,6 @@
|
|
|
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
|
{
|
|
@@ -110,6 +111,37 @@ export const credentialCommands = [
|
|
|
110
111
|
return { data: await ctx.client.post(capturePath(workspaceId, projectId)) };
|
|
111
112
|
},
|
|
112
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
|
+
},
|
|
113
145
|
{
|
|
114
146
|
name: "auth-capture capture",
|
|
115
147
|
summary: "Capture the session after the user has logged in via the live-view URL",
|
|
@@ -35,6 +35,20 @@ export const explorationCommands = [
|
|
|
35
35
|
};
|
|
36
36
|
},
|
|
37
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
|
+
},
|
|
38
52
|
{
|
|
39
53
|
name: "explorations watch",
|
|
40
54
|
summary: "Stream an exploration live — watch the agent explore and author tests",
|
|
@@ -0,0 +1,129 @@
|
|
|
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 list",
|
|
58
|
+
summary: "List the workspace's inboxes, newest first",
|
|
59
|
+
description: "Every inbox the workspace has minted with `beryl inbox create`. Expired inboxes " +
|
|
60
|
+
"stop receiving and are hard-deleted by a background sweep, so they drop off " +
|
|
61
|
+
"this list shortly after their TTL.",
|
|
62
|
+
scope: "workspace",
|
|
63
|
+
async run(ctx, input) {
|
|
64
|
+
const ws = await ctx.requireWorkspace(input);
|
|
65
|
+
return { data: await ctx.client.get(`/workspaces/${ws}/inboxes`) };
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: "inbox read",
|
|
70
|
+
summary: "Read the latest email from an inbox (waits for one to arrive)",
|
|
71
|
+
description: "Waits up to --timeout-s for a matching email and returns it (one blocking request; " +
|
|
72
|
+
"the server caps the wait at 50s — re-run to keep waiting). With --extract-code, " +
|
|
73
|
+
"also pulls the one-time code (4-8 digits) out of the body/subject — handy for " +
|
|
74
|
+
"completing `beryl login --email <addr> --code <code>` unattended. Exits non-zero " +
|
|
75
|
+
"if nothing arrives before the timeout.",
|
|
76
|
+
scope: "workspace",
|
|
77
|
+
args: [{ name: "inbox-id", description: "Inbox id from `beryl inbox create`", required: true }],
|
|
78
|
+
flags: [
|
|
79
|
+
{
|
|
80
|
+
name: "timeout-s",
|
|
81
|
+
type: "number",
|
|
82
|
+
description: "Seconds to wait for a matching email (0 = don't wait; max 50, default 30)",
|
|
83
|
+
},
|
|
84
|
+
{ name: "since", type: "string", description: "Only emails received after this ISO timestamp" },
|
|
85
|
+
{ name: "from-contains", type: "string", description: "Only emails whose sender contains this" },
|
|
86
|
+
{
|
|
87
|
+
name: "subject-contains",
|
|
88
|
+
type: "string",
|
|
89
|
+
description: "Only emails whose subject contains this",
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "extract-code",
|
|
93
|
+
type: "boolean",
|
|
94
|
+
description: "Also return the one-time code found in the email as `code`",
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
examples: [
|
|
98
|
+
"beryl inbox read ibx_123 --timeout-s 45 --json",
|
|
99
|
+
"beryl inbox read ibx_123 --subject-contains code --extract-code --json",
|
|
100
|
+
],
|
|
101
|
+
async run(ctx, input) {
|
|
102
|
+
const ws = await ctx.requireWorkspace(input);
|
|
103
|
+
const email = (await ctx.client.get(`/workspaces/${ws}/inboxes/${arg(input, "inbox-id")}/emails/latest`, {
|
|
104
|
+
timeout_s: flagNum(input, "timeout-s"),
|
|
105
|
+
since: flagStr(input, "since"),
|
|
106
|
+
from_contains: flagStr(input, "from-contains"),
|
|
107
|
+
subject_contains: flagStr(input, "subject-contains"),
|
|
108
|
+
}));
|
|
109
|
+
if (!flagBool(input, "extract-code"))
|
|
110
|
+
return { data: email };
|
|
111
|
+
return { data: { ...email, code: extractCode(email) } };
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: "inbox emails",
|
|
116
|
+
summary: "List the emails an inbox has received",
|
|
117
|
+
scope: "workspace",
|
|
118
|
+
args: [{ name: "inbox-id", description: "Inbox id from `beryl inbox create`", required: true }],
|
|
119
|
+
flags: [
|
|
120
|
+
{ name: "since", type: "string", description: "Only emails received after this ISO timestamp" },
|
|
121
|
+
],
|
|
122
|
+
async run(ctx, input) {
|
|
123
|
+
const ws = await ctx.requireWorkspace(input);
|
|
124
|
+
return {
|
|
125
|
+
data: await ctx.client.get(`/workspaces/${ws}/inboxes/${arg(input, "inbox-id")}/emails`, { since: flagStr(input, "since") }),
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
];
|
package/dist/commands/init.js
CHANGED
|
@@ -4,12 +4,17 @@ import { BERYL_TEST_SKILL, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME, } fr
|
|
|
4
4
|
import { LOCAL_CONFIG_FILENAME, loadConfig } from "../config.js";
|
|
5
5
|
import { CliError, UsageError } from "../errors.js";
|
|
6
6
|
import { ApiClient } from "../http.js";
|
|
7
|
-
import { bold, cyan, dim, green } from "../output.js";
|
|
7
|
+
import { bold, cyan, dim, green, yellow } from "../output.js";
|
|
8
|
+
import { cliVersion, warnIfStale } from "../version-check.js";
|
|
8
9
|
import { authCommands } from "./auth.js";
|
|
9
10
|
import { flagBool, flagStr } from "./util.js";
|
|
11
|
+
// Pinned to @latest (like the Playwright entry below) — an unpinned spec lets npx
|
|
12
|
+
// serve whatever stale global/cache install already resolves, so the agent's MCP
|
|
13
|
+
// server would silently miss newer tools. mergeMcpConfig compares entries verbatim,
|
|
14
|
+
// so re-running init also upgrades an old unpinned entry in place.
|
|
10
15
|
const MCP_SERVER_ENTRY = {
|
|
11
16
|
command: "npx",
|
|
12
|
-
args: ["-y", "@beryl-so/cli", "mcp"],
|
|
17
|
+
args: ["-y", "@beryl-so/cli@latest", "mcp"],
|
|
13
18
|
};
|
|
14
19
|
const PLAYWRIGHT_SERVER_ENTRY = {
|
|
15
20
|
command: "npx",
|
|
@@ -263,6 +268,9 @@ export const initCommands = [
|
|
|
263
268
|
? `\n\nPlaywright MCP is wired — your coding agent can explore the site locally ` +
|
|
264
269
|
`and push tests with ${cyan("beryl tests create")}.`
|
|
265
270
|
: "");
|
|
271
|
+
// The .mcp.json entry is pinned to @latest, but a global install / old npx cache
|
|
272
|
+
// still wins resolution — so tell the user when the CLI they just ran is stale.
|
|
273
|
+
await warnIfStale(cliVersion(), (msg) => ctx.err(yellow(msg)));
|
|
266
274
|
return {
|
|
267
275
|
data: { workspace: workspaceId ?? null, project: projectId ?? null, editors },
|
|
268
276
|
human: nextSteps,
|
|
@@ -0,0 +1,82 @@
|
|
|
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/commands/tests.js
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { UsageError } from "../errors.js";
|
|
3
3
|
import { lintPlan } from "../lint.js";
|
|
4
|
+
import { table } from "../output.js";
|
|
4
5
|
import { ACTION_PLAN_SCHEMA } from "../schema.generated.js";
|
|
5
6
|
import { arg, argList, flagBool, flagNum, flagStr, projectPath, readJsonFlag } from "./util.js";
|
|
6
7
|
const testPath = (ws, p, id) => `${projectPath(ws, p)}/tests/${id}`;
|
|
8
|
+
// The concise human table for `tests list` — the full TestResponse is a 24-column
|
|
9
|
+
// firehose of internal ids that wraps unreadably in a normal terminal. `--wide`
|
|
10
|
+
// (and `--json`) still expose every field.
|
|
11
|
+
const LIST_COLUMNS = ["title", "status", "id", "last_result_status", "last_run_at"];
|
|
12
|
+
function conciseTestRow(row) {
|
|
13
|
+
return {
|
|
14
|
+
title: row.nl_title,
|
|
15
|
+
status: row.quarantined ? "quarantined" : row.is_active ? "active" : "inactive",
|
|
16
|
+
id: row.id,
|
|
17
|
+
last_result_status: row.last_result_status,
|
|
18
|
+
last_run_at: row.last_run_at,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
7
21
|
export const testCommands = [
|
|
8
22
|
{
|
|
9
23
|
name: "tests lint",
|
|
@@ -33,17 +47,24 @@ export const testCommands = [
|
|
|
33
47
|
{
|
|
34
48
|
name: "tests list",
|
|
35
49
|
summary: "List the project's tests with their latest result",
|
|
50
|
+
description: "Prints a concise table by default (title / status / id / last result / last run). " +
|
|
51
|
+
"Pass --wide for every field, or --json for the raw records.",
|
|
36
52
|
scope: "project",
|
|
37
53
|
groupDefault: true,
|
|
38
54
|
groupSummary: "Author, inspect, version, and heal a project's tests — the checks Beryl runs on each run.",
|
|
39
|
-
flags: [
|
|
55
|
+
flags: [
|
|
56
|
+
{ name: "env", type: "string", description: "Filter by environment id" },
|
|
57
|
+
{ name: "wide", type: "boolean", description: "Show all columns, not the concise default" },
|
|
58
|
+
],
|
|
40
59
|
async run(ctx, input) {
|
|
41
60
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
61
|
+
const data = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests`, {
|
|
62
|
+
environment_id: flagStr(input, "env"),
|
|
63
|
+
}));
|
|
64
|
+
if (flagBool(input, "wide"))
|
|
65
|
+
return { data };
|
|
66
|
+
// `data` stays the full records so --json is unchanged; only the human table is trimmed.
|
|
67
|
+
return { data, human: table(data.map(conciseTestRow), LIST_COLUMNS) };
|
|
47
68
|
},
|
|
48
69
|
},
|
|
49
70
|
{
|
package/dist/commands/util.js
CHANGED
|
@@ -29,11 +29,24 @@ export function flagNum(input, name) {
|
|
|
29
29
|
throw new UsageError(`--${name} must be a number`);
|
|
30
30
|
return n;
|
|
31
31
|
}
|
|
32
|
+
/** Read a local file for a flag/arg, turning fs errors (ENOENT, EACCES, EISDIR…) into a
|
|
33
|
+
* clean usage message instead of a raw Node stack. */
|
|
34
|
+
export function readFileArg(path, label) {
|
|
35
|
+
try {
|
|
36
|
+
return fs.readFileSync(path === "-" ? 0 : path, "utf8");
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
const code = err.code;
|
|
40
|
+
if (code === "ENOENT")
|
|
41
|
+
throw new UsageError(`${label}: file not found: ${path}`);
|
|
42
|
+
throw new UsageError(`${label}: cannot read ${path} (${code ?? err.message})`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
32
45
|
export function readJsonFlag(input, name) {
|
|
33
46
|
const file = flagStr(input, name);
|
|
34
47
|
if (!file)
|
|
35
48
|
throw new UsageError(`--${name} <file> is required`);
|
|
36
|
-
const text =
|
|
49
|
+
const text = readFileArg(file, `--${name}`);
|
|
37
50
|
try {
|
|
38
51
|
return JSON.parse(text);
|
|
39
52
|
}
|
package/dist/registry/index.js
CHANGED
|
@@ -4,10 +4,12 @@ import { configCommands } from "../commands/config-vars.js";
|
|
|
4
4
|
import { credentialCommands } from "../commands/credentials.js";
|
|
5
5
|
import { environmentCommands } from "../commands/environments.js";
|
|
6
6
|
import { explorationCommands } from "../commands/explorations.js";
|
|
7
|
+
import { inboxCommands } from "../commands/inboxes.js";
|
|
7
8
|
import { initCommands } from "../commands/init.js";
|
|
8
9
|
import { mcpCommands } from "../commands/mcp.js";
|
|
9
10
|
import { projectCommands } from "../commands/projects.js";
|
|
10
11
|
import { runCommands } from "../commands/runs.js";
|
|
12
|
+
import { slackCommands } from "../commands/slack.js";
|
|
11
13
|
import { testCommands } from "../commands/tests.js";
|
|
12
14
|
import { workspaceCommands } from "../commands/workspaces.js";
|
|
13
15
|
export const WORKSPACE_FLAG = {
|
|
@@ -43,7 +45,9 @@ export const commands = [
|
|
|
43
45
|
...runCommands,
|
|
44
46
|
...explorationCommands,
|
|
45
47
|
...configCommands,
|
|
48
|
+
...slackCommands,
|
|
46
49
|
...credentialCommands,
|
|
50
|
+
...inboxCommands,
|
|
47
51
|
...accountCommands,
|
|
48
52
|
...mcpCommands,
|
|
49
53
|
].map(withScopeFlags);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
// Best-effort "are we stale?" check against the npm registry. The `.mcp.json` entry
|
|
3
|
+
// `init` writes pins `@beryl-so/cli@latest`, but a user with a global install (or an
|
|
4
|
+
// old npx cache) can still be running a stale CLI — and an MCP server that's behind
|
|
5
|
+
// silently exposes fewer tools. So `init` and `beryl mcp` warn when the running
|
|
6
|
+
// version is behind the registry's `latest`. Never fatal: offline, a slow registry,
|
|
7
|
+
// or an unparseable response just skips the warning.
|
|
8
|
+
const REGISTRY_LATEST_URL = "https://registry.npmjs.org/@beryl-so/cli/latest";
|
|
9
|
+
const TIMEOUT_MS = 1500;
|
|
10
|
+
export function cliVersion() {
|
|
11
|
+
try {
|
|
12
|
+
const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
13
|
+
return pkg.version;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return "unknown";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// Numeric-triple compare; returns true only when `current` is strictly behind
|
|
20
|
+
// `latest`. Anything non-numeric (dev builds, "unknown", dist-tags) compares as
|
|
21
|
+
// not-behind so we never nag on unparseable versions.
|
|
22
|
+
export function isBehind(current, latest) {
|
|
23
|
+
const parse = (v) => {
|
|
24
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
|
|
25
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
26
|
+
};
|
|
27
|
+
const cur = parse(current);
|
|
28
|
+
const lat = parse(latest);
|
|
29
|
+
if (!cur || !lat)
|
|
30
|
+
return false;
|
|
31
|
+
for (let i = 0; i < 3; i++) {
|
|
32
|
+
if (cur[i] !== lat[i])
|
|
33
|
+
return cur[i] < lat[i];
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
/** The registry's `latest` version, or null when it can't be fetched quickly. */
|
|
38
|
+
export async function fetchLatestVersion() {
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch(REGISTRY_LATEST_URL, {
|
|
41
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
42
|
+
});
|
|
43
|
+
if (!res.ok)
|
|
44
|
+
return null;
|
|
45
|
+
const body = (await res.json());
|
|
46
|
+
return typeof body.version === "string" ? body.version : null;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Warn (via `warn`) when `current` is behind the registry's `latest`.
|
|
54
|
+
* Best-effort and quiet on any failure — safe to await on every startup.
|
|
55
|
+
* BERYL_NO_UPDATE_CHECK=1 opts out entirely (no network call).
|
|
56
|
+
*/
|
|
57
|
+
export async function warnIfStale(current, warn) {
|
|
58
|
+
if (process.env.BERYL_NO_UPDATE_CHECK)
|
|
59
|
+
return;
|
|
60
|
+
const latest = await fetchLatestVersion();
|
|
61
|
+
if (latest && isBehind(current, latest)) {
|
|
62
|
+
warn(`@beryl-so/cli ${current} is behind the latest release ${latest} — ` +
|
|
63
|
+
`some commands/MCP tools may be missing. Upgrade with: npm i -g @beryl-so/cli@latest`);
|
|
64
|
+
}
|
|
65
|
+
}
|
package/package.json
CHANGED