@beryl-so/cli 0.1.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,101 @@
1
+ import readline from "node:readline/promises";
2
+ import { CliError, UsageError } from "./errors.js";
3
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4
+ function matchByName(candidates, value, kind) {
5
+ const lower = value.toLowerCase();
6
+ const matches = candidates.filter((c) => c.name?.toLowerCase() === lower ||
7
+ c.root_url?.toLowerCase().replace(/^https?:\/\//, "").replace(/\/$/, "") ===
8
+ lower.replace(/^https?:\/\//, "").replace(/\/$/, ""));
9
+ if (matches.length === 1)
10
+ return matches[0].id;
11
+ if (matches.length > 1) {
12
+ throw new CliError(`Multiple ${kind}s match "${value}" — use the id instead:\n` +
13
+ matches.map((m) => ` ${m.id} ${m.name ?? m.root_url ?? ""}`).join("\n"));
14
+ }
15
+ throw new CliError(`No ${kind} named "${value}" found`);
16
+ }
17
+ export function createContext(options) {
18
+ const { client, config, json } = options;
19
+ let workspaceCache;
20
+ let projectCache;
21
+ const ctx = {
22
+ client,
23
+ config,
24
+ json,
25
+ interactive: options.interactive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY),
26
+ out: options.out ?? ((text) => process.stdout.write(text + "\n")),
27
+ err: options.err ?? ((text) => process.stderr.write(text + "\n")),
28
+ async prompt(question) {
29
+ if (!ctx.interactive) {
30
+ throw new CliError(`Interactive input required (${question}) but stdin is not a TTY`);
31
+ }
32
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
33
+ try {
34
+ return (await rl.question(question)).trim();
35
+ }
36
+ finally {
37
+ rl.close();
38
+ }
39
+ },
40
+ async confirm(question, force) {
41
+ if (force)
42
+ return;
43
+ if (!ctx.interactive) {
44
+ throw new CliError(`Refusing without confirmation: ${question} (pass --force to proceed)`);
45
+ }
46
+ const answer = await ctx.prompt(`${question} [y/N] `);
47
+ if (!/^y(es)?$/i.test(answer))
48
+ throw new CliError("Aborted", 1);
49
+ },
50
+ async requireWorkspace(input) {
51
+ const requested = input.flags.workspace ?? config.workspace;
52
+ if (requested && UUID_RE.test(requested))
53
+ return requested;
54
+ if (workspaceCache && !requested)
55
+ return workspaceCache;
56
+ const workspaces = (await client.get("/workspaces/"));
57
+ if (requested) {
58
+ workspaceCache = matchByName(workspaces, requested, "workspace");
59
+ }
60
+ else if (workspaces.length === 1) {
61
+ workspaceCache = workspaces[0].id;
62
+ }
63
+ else if (workspaces.length === 0) {
64
+ throw new CliError("You have no workspaces yet — create one with `beryl workspaces create`");
65
+ }
66
+ else {
67
+ throw new UsageError("Multiple workspaces — pass --workspace <id|name>, set BERYL_WORKSPACE, " +
68
+ "or run `beryl workspaces use <id|name>`:\n" +
69
+ workspaces.map((w) => ` ${w.id} ${w.name ?? ""}`).join("\n"));
70
+ }
71
+ return workspaceCache;
72
+ },
73
+ async requireProject(input) {
74
+ const requested = input.flags.project ?? config.project;
75
+ const workspaceId = await ctx.requireWorkspace(input);
76
+ if (requested && UUID_RE.test(requested))
77
+ return { workspaceId, projectId: requested };
78
+ if (projectCache && !requested)
79
+ return projectCache;
80
+ const projects = (await client.get(`/workspaces/${workspaceId}/projects`));
81
+ let projectId;
82
+ if (requested) {
83
+ projectId = matchByName(projects, requested, "project");
84
+ }
85
+ else if (projects.length === 1) {
86
+ projectId = projects[0].id;
87
+ }
88
+ else if (projects.length === 0) {
89
+ throw new CliError("No projects in this workspace — create one with `beryl projects create`");
90
+ }
91
+ else {
92
+ throw new UsageError("Multiple projects — pass --project <id|name|url>, set BERYL_PROJECT, " +
93
+ "or run `beryl projects use <id|name>`:\n" +
94
+ projects.map((p) => ` ${p.id} ${p.name ?? p.root_url ?? ""}`).join("\n"));
95
+ }
96
+ projectCache = { workspaceId, projectId };
97
+ return projectCache;
98
+ },
99
+ };
100
+ return ctx;
101
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,21 @@
1
+ export const EXIT_OK = 0;
2
+ export const EXIT_FAILURE = 1;
3
+ export const EXIT_USAGE = 2;
4
+ export const EXIT_AUTH = 3;
5
+ export class CliError extends Error {
6
+ exitCode;
7
+ constructor(message, exitCode = EXIT_FAILURE) {
8
+ super(message);
9
+ this.exitCode = exitCode;
10
+ }
11
+ }
12
+ export class UsageError extends CliError {
13
+ constructor(message) {
14
+ super(message, EXIT_USAGE);
15
+ }
16
+ }
17
+ export class AuthError extends CliError {
18
+ constructor(message) {
19
+ super(message, EXIT_AUTH);
20
+ }
21
+ }
package/dist/http.js ADDED
@@ -0,0 +1,98 @@
1
+ import { AuthError, CliError } from "./errors.js";
2
+ export const API_PREFIX = "/api/v1";
3
+ export class ApiError extends CliError {
4
+ status;
5
+ detail;
6
+ constructor(status, detail) {
7
+ const text = typeof detail === "string" ? detail : (JSON.stringify(detail) ?? `HTTP ${status}`);
8
+ super(`API error ${status}: ${text}`);
9
+ this.status = status;
10
+ this.detail = detail;
11
+ }
12
+ }
13
+ export class ApiClient {
14
+ baseUrl;
15
+ token;
16
+ constructor(baseUrl, token) {
17
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
18
+ this.token = token;
19
+ }
20
+ url(path, query) {
21
+ const u = new URL(this.baseUrl + API_PREFIX + path);
22
+ for (const [k, v] of Object.entries(query ?? {})) {
23
+ if (v !== undefined && v !== null)
24
+ u.searchParams.set(k, String(v));
25
+ }
26
+ return u.toString();
27
+ }
28
+ authHeaders() {
29
+ return this.token ? { Authorization: `Bearer ${this.token}` } : {};
30
+ }
31
+ async request(method, path, opts = {}) {
32
+ const headers = { ...this.authHeaders(), ...opts.headers };
33
+ let body;
34
+ if (opts.form) {
35
+ body = opts.form;
36
+ }
37
+ else if (opts.body !== undefined) {
38
+ headers["Content-Type"] = "application/json";
39
+ body = JSON.stringify(opts.body);
40
+ }
41
+ let response;
42
+ try {
43
+ response = await fetch(this.url(path, opts.query), {
44
+ method,
45
+ headers,
46
+ body,
47
+ signal: opts.signal,
48
+ });
49
+ }
50
+ catch (err) {
51
+ throw new CliError(`Could not reach ${this.baseUrl}: ${err.message}`);
52
+ }
53
+ if (response.status === 401) {
54
+ throw new AuthError(this.token
55
+ ? "Authentication failed — the token is invalid, expired, or revoked. Run `beryl login`."
56
+ : "Not logged in. Run `beryl login` or set BERYL_API_KEY.");
57
+ }
58
+ if (!response.ok) {
59
+ let detail;
60
+ const text = await response.text();
61
+ try {
62
+ detail = JSON.parse(text).detail ?? text;
63
+ }
64
+ catch {
65
+ detail = text;
66
+ }
67
+ throw new ApiError(response.status, detail);
68
+ }
69
+ if (opts.raw)
70
+ return response;
71
+ if (response.status === 204)
72
+ return null;
73
+ const text = await response.text();
74
+ if (!text)
75
+ return null;
76
+ try {
77
+ return JSON.parse(text);
78
+ }
79
+ catch {
80
+ return text;
81
+ }
82
+ }
83
+ get(path, query) {
84
+ return this.request("GET", path, { query });
85
+ }
86
+ post(path, body, query) {
87
+ return this.request("POST", path, { body, query });
88
+ }
89
+ patch(path, body) {
90
+ return this.request("PATCH", path, { body });
91
+ }
92
+ put(path, body) {
93
+ return this.request("PUT", path, { body });
94
+ }
95
+ del(path, body) {
96
+ return this.request("DELETE", path, { body });
97
+ }
98
+ }
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "./adapters/cli.js";
3
+ runCli(process.argv.slice(2)).then((code) => {
4
+ process.exitCode = code;
5
+ }, (err) => {
6
+ process.stderr.write((err instanceof Error ? (err.stack ?? err.message) : String(err)) + "\n");
7
+ process.exitCode = 1;
8
+ });
package/dist/output.js ADDED
@@ -0,0 +1,80 @@
1
+ const colorEnabled = () => Boolean(process.stdout.isTTY) && !process.env.NO_COLOR && process.env.TERM !== "dumb";
2
+ const wrap = (open, close) => (text) => colorEnabled() ? `[${open}m${text}[${close}m` : text;
3
+ export const bold = wrap(1, 22);
4
+ export const dim = wrap(2, 22);
5
+ export const red = wrap(31, 39);
6
+ export const green = wrap(32, 39);
7
+ export const yellow = wrap(33, 39);
8
+ export const cyan = wrap(36, 39);
9
+ export function statusColor(status) {
10
+ const s = status.trim().toLowerCase();
11
+ if (["passed", "completed", "active", "ok", "fresh", "enabled"].includes(s))
12
+ return green(status);
13
+ if (["failed", "errored", "error", "expired", "canceled"].includes(s))
14
+ return red(status);
15
+ if (["running", "queued", "pending", "exploring", "stale"].includes(s))
16
+ return yellow(status);
17
+ return status;
18
+ }
19
+ const MAX_CELL = 60;
20
+ function cell(value) {
21
+ if (value === null || value === undefined)
22
+ return "";
23
+ let text;
24
+ if (typeof value === "object")
25
+ text = JSON.stringify(value);
26
+ else
27
+ text = String(value);
28
+ text = text.replace(/\s+/g, " ");
29
+ return text.length > MAX_CELL ? text.slice(0, MAX_CELL - 1) + "…" : text;
30
+ }
31
+ export function table(rows, columns) {
32
+ if (rows.length === 0)
33
+ return dim("(none)");
34
+ const cols = columns ?? Object.keys(rows[0] ?? {});
35
+ const widths = cols.map((c) => Math.max(c.length, ...rows.map((r) => cell(r[c]).length)));
36
+ const header = cols
37
+ .map((c, i) => bold(c.toUpperCase().padEnd(widths[i] ?? 0)))
38
+ .join(" ")
39
+ .trimEnd();
40
+ const body = rows.map((r) => cols
41
+ .map((c, i) => {
42
+ const padded = cell(r[c]).padEnd(widths[i] ?? 0);
43
+ return c.includes("status") && padded.trim() ? statusColor(padded) : padded;
44
+ })
45
+ .join(" ")
46
+ .trimEnd());
47
+ return [header, ...body].join("\n");
48
+ }
49
+ export function keyValues(obj) {
50
+ const keys = Object.keys(obj);
51
+ const width = Math.max(0, ...keys.map((k) => k.length));
52
+ return keys
53
+ .map((k) => {
54
+ const v = obj[k];
55
+ const text = v === null || v === undefined
56
+ ? dim("—")
57
+ : typeof v === "object"
58
+ ? JSON.stringify(v, null, 2).replace(/\n/g, "\n" + " ".repeat(width + 2))
59
+ : k.includes("status")
60
+ ? statusColor(String(v))
61
+ : String(v);
62
+ return `${dim(k.padEnd(width))} ${text}`;
63
+ })
64
+ .join("\n");
65
+ }
66
+ export function autoFormat(data) {
67
+ if (data === null || data === undefined)
68
+ return dim("(no output)");
69
+ if (Array.isArray(data)) {
70
+ if (data.length === 0)
71
+ return dim("(none)");
72
+ if (typeof data[0] === "object" && data[0] !== null) {
73
+ return table(data);
74
+ }
75
+ return data.map(String).join("\n");
76
+ }
77
+ if (typeof data === "object")
78
+ return keyValues(data);
79
+ return String(data);
80
+ }
@@ -0,0 +1,74 @@
1
+ import { accountCommands } from "../commands/account.js";
2
+ import { authCommands } from "../commands/auth.js";
3
+ import { configCommands } from "../commands/config-vars.js";
4
+ import { credentialCommands } from "../commands/credentials.js";
5
+ import { environmentCommands } from "../commands/environments.js";
6
+ import { explorationCommands } from "../commands/explorations.js";
7
+ import { initCommands } from "../commands/init.js";
8
+ import { mcpCommands } from "../commands/mcp.js";
9
+ import { projectCommands } from "../commands/projects.js";
10
+ import { runCommands } from "../commands/runs.js";
11
+ import { testCommands } from "../commands/tests.js";
12
+ import { workspaceCommands } from "../commands/workspaces.js";
13
+ export const WORKSPACE_FLAG = {
14
+ name: "workspace",
15
+ type: "string",
16
+ description: "Workspace id or name (defaults to BERYL_WORKSPACE, .beryl.json, `beryl workspaces use`, " +
17
+ "or your only workspace)",
18
+ };
19
+ export const PROJECT_FLAG = {
20
+ name: "project",
21
+ type: "string",
22
+ description: "Project id, name, or URL (defaults to BERYL_PROJECT, .beryl.json, `beryl projects use`, " +
23
+ "or the workspace's only project)",
24
+ };
25
+ function withScopeFlags(spec) {
26
+ if (!spec.scope || spec.scope === "none")
27
+ return spec;
28
+ const extra = [];
29
+ const has = (name) => spec.flags?.some((f) => f.name === name);
30
+ if (!has("workspace"))
31
+ extra.push(WORKSPACE_FLAG);
32
+ if (spec.scope === "project" && !has("project"))
33
+ extra.push(PROJECT_FLAG);
34
+ return { ...spec, flags: [...(spec.flags ?? []), ...extra] };
35
+ }
36
+ export const commands = [
37
+ ...initCommands,
38
+ ...authCommands,
39
+ ...workspaceCommands,
40
+ ...projectCommands,
41
+ ...environmentCommands,
42
+ ...testCommands,
43
+ ...runCommands,
44
+ ...explorationCommands,
45
+ ...configCommands,
46
+ ...credentialCommands,
47
+ ...accountCommands,
48
+ ...mcpCommands,
49
+ ].map(withScopeFlags);
50
+ export function findCommand(words) {
51
+ let best;
52
+ for (const spec of commands) {
53
+ const parts = spec.name.split(" ");
54
+ if (parts.length > words.length)
55
+ continue;
56
+ if (parts.every((p, i) => p === words[i])) {
57
+ if (!best || parts.length > best.consumed)
58
+ best = { spec, consumed: parts.length };
59
+ }
60
+ }
61
+ return best;
62
+ }
63
+ export function commandGroups() {
64
+ const groups = new Map();
65
+ for (const spec of commands) {
66
+ if (spec.hidden)
67
+ continue;
68
+ const group = spec.name.split(" ")[0];
69
+ const list = groups.get(group) ?? [];
70
+ list.push(spec);
71
+ groups.set(group, list);
72
+ }
73
+ return groups;
74
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/sse.js ADDED
@@ -0,0 +1,98 @@
1
+ import { CliError } from "./errors.js";
2
+ export function parseSseChunk(buffer) {
3
+ const events = [];
4
+ let rest = buffer;
5
+ for (;;) {
6
+ const sep = rest.search(/\n\n|\r\n\r\n/);
7
+ if (sep === -1)
8
+ return { events, rest };
9
+ const block = rest.slice(0, sep);
10
+ rest = rest.slice(sep + (rest.startsWith("\r\n\r\n", sep) ? 4 : 2));
11
+ const data = block
12
+ .split(/\r?\n/)
13
+ .filter((line) => line.startsWith("data:"))
14
+ .map((line) => line.slice(5).trimStart())
15
+ .join("\n");
16
+ if (data)
17
+ events.push(data);
18
+ }
19
+ }
20
+ const MAX_CONSECUTIVE_FAILURES = 5;
21
+ /**
22
+ * Yields parsed JSON events from an SSE endpoint. Reconnects on transient
23
+ * network drops (the backend replays state on connect); the consumer decides
24
+ * when the stream is terminal and breaks out.
25
+ */
26
+ export async function* sseStream(client, path, signal) {
27
+ let failures = 0;
28
+ for (;;) {
29
+ let response;
30
+ try {
31
+ response = (await client.request("GET", path, {
32
+ raw: true,
33
+ headers: { Accept: "text/event-stream" },
34
+ signal,
35
+ }));
36
+ }
37
+ catch (err) {
38
+ if (signal?.aborted)
39
+ return;
40
+ failures += 1;
41
+ if (failures >= MAX_CONSECUTIVE_FAILURES)
42
+ throw err;
43
+ await sleep(Math.min(1000 * 2 ** failures, 15_000), signal);
44
+ continue;
45
+ }
46
+ if (!response.body)
47
+ throw new CliError("Stream response had no body");
48
+ const reader = response.body.getReader();
49
+ const decoder = new TextDecoder();
50
+ let buffer = "";
51
+ try {
52
+ for (;;) {
53
+ const { done, value } = await reader.read();
54
+ if (done)
55
+ break;
56
+ buffer += decoder.decode(value, { stream: true });
57
+ const { events, rest } = parseSseChunk(buffer);
58
+ buffer = rest;
59
+ for (const data of events) {
60
+ failures = 0;
61
+ try {
62
+ yield JSON.parse(data);
63
+ }
64
+ catch {
65
+ // non-JSON frame (comment/heartbeat) — skip
66
+ }
67
+ }
68
+ }
69
+ // Clean server-side close without a terminal event: reconnect and replay.
70
+ failures += 1;
71
+ if (failures >= MAX_CONSECUTIVE_FAILURES)
72
+ return;
73
+ await sleep(1000, signal);
74
+ }
75
+ catch (err) {
76
+ if (signal?.aborted)
77
+ return;
78
+ failures += 1;
79
+ if (failures >= MAX_CONSECUTIVE_FAILURES)
80
+ throw err;
81
+ await sleep(Math.min(1000 * 2 ** failures, 15_000), signal);
82
+ }
83
+ finally {
84
+ reader.cancel().catch(() => { });
85
+ }
86
+ if (signal?.aborted)
87
+ return;
88
+ }
89
+ }
90
+ function sleep(ms, signal) {
91
+ return new Promise((resolve) => {
92
+ const timer = setTimeout(resolve, ms);
93
+ signal?.addEventListener("abort", () => {
94
+ clearTimeout(timer);
95
+ resolve();
96
+ });
97
+ });
98
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@beryl-so/cli",
3
+ "version": "0.1.0",
4
+ "description": "Beryl on the command line — projects, runs, the exploring agent, and an MCP server over the same commands.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "homepage": "https://beryl.so/docs/cli",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Vibe-Monitor/beryl.git",
11
+ "directory": "cli"
12
+ },
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "bin": {
17
+ "beryl": "./dist/index.js"
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "engines": {
23
+ "node": ">=20.19.0"
24
+ },
25
+ "scripts": {
26
+ "build": "tsc -p tsconfig.build.json",
27
+ "prepublishOnly": "npm run typecheck && npm test && npm run build",
28
+ "typecheck": "tsc --noEmit",
29
+ "test": "vitest run",
30
+ "dev": "tsx src/index.ts",
31
+ "docs": "tsx scripts/gen-docs.ts"
32
+ },
33
+ "dependencies": {
34
+ "@modelcontextprotocol/sdk": "^1.29.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^26.1.1",
38
+ "tsx": "^4.23.1",
39
+ "typescript": "^7.0.2",
40
+ "vitest": "^4.1.10"
41
+ }
42
+ }