@beryl-so/cli 0.34.3 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,6 +30,9 @@ From the monorepo (development): `cd cli && npm install && npm run build && npm
30
30
  latest npm release; a stale MCP server silently exposes fewer tools. Set
31
31
  `BERYL_NO_UPDATE_CHECK=1` to opt out.
32
32
 
33
+ The CLI reports which commands run (never their arguments or output) so onboarding can
34
+ be measured; set `BERYL_TELEMETRY=0` or `DO_NOT_TRACK=1` to send nothing.
35
+
33
36
  ## Authenticate
34
37
 
35
38
  ```bash
@@ -4,7 +4,7 @@ import { CliError, EXIT_OK, EXIT_USAGE, UsageError } from "../errors.js";
4
4
  import { ApiClient } from "../http.js";
5
5
  import { autoFormat, bold, cyan, dim } from "../output.js";
6
6
  import { commandGroups, findCommand, groupSummary } from "../registry/index.js";
7
- import { withTool } from "../telemetry.js";
7
+ import { captureCliEvent, shutdownTelemetry, withTool, } from "../telemetry.js";
8
8
  import { cliVersion } from "../version-check.js";
9
9
  import { mcpToolFor } from "./mcp.js";
10
10
  export const GLOBAL_FLAGS = [
@@ -324,6 +324,10 @@ export async function runCli(argv) {
324
324
  config.token = parsed.token;
325
325
  const client = new ApiClient(config.apiUrl, config.token);
326
326
  const ctx = createContext({ client, config, json: parsed.json, mcp: false });
327
+ // The MCP server reports its own tool calls; every other invocation is one command.
328
+ if (spec.name !== "mcp") {
329
+ captureCliEvent("cli_command", config.apiUrl, { command: spec.name });
330
+ }
327
331
  try {
328
332
  const result = (await withTool(spec.name, () => spec.run(ctx, parsed.input))) ?? {};
329
333
  if (parsed.json) {
@@ -345,4 +349,7 @@ export async function runCli(argv) {
345
349
  }
346
350
  throw err;
347
351
  }
352
+ finally {
353
+ await shutdownTelemetry();
354
+ }
348
355
  }
@@ -18,29 +18,68 @@ export function mcpTools() {
18
18
  return commands.filter((c) => !c.interactive && !c.hidden && !c.mcpHidden && c.name !== "mcp");
19
19
  }
20
20
  const mcpToolNames = new Set(mcpTools().map(toolName));
21
+ const toolByCommand = new Map(mcpTools().map((spec) => [spec.name, toolName(spec)]));
22
+ const flagNames = new Set(commands.flatMap((spec) => (spec.flags ?? []).map((f) => f.name)));
21
23
  /** The tool name a command is exposed as under `beryl mcp`, or undefined if it isn't exposed. */
22
24
  export function mcpToolFor(spec) {
23
25
  const name = toolName(spec);
24
26
  return mcpToolNames.has(name) ? name : undefined;
25
27
  }
28
+ /** `[beryl] tests list ...` as its tool name plus the rest, or undefined when the words are
29
+ * no MCP tool (login, init, mcp): those are real terminal instructions and must stay. */
30
+ function commandToTool(text) {
31
+ const words = text.trim().split(/\s+/);
32
+ if (words[0] === "beryl")
33
+ words.shift();
34
+ for (const n of [3, 2, 1]) {
35
+ const tool = toolByCommand.get(words.slice(0, n).join(" "));
36
+ if (tool)
37
+ return [tool, ...words.slice(n)].join(" ");
38
+ }
39
+ return undefined;
40
+ }
41
+ function flagsToParams(text, wrap) {
42
+ return text
43
+ .replace(/--json\b/g, "JSON output (always on over MCP)")
44
+ .replace(/--no-([a-z][a-z-]*)/g, (_, name) => flagNames.has(`no-${name}`) ? wrap(`no-${name}`) : wrap(`${name}: false`))
45
+ .replace(/--([a-z][a-z-]*)/g, (_, name) => wrap(name));
46
+ }
47
+ /** Registry text is written for `beryl --help`; the agent sees JSON parameters and tool
48
+ * names, so `--wide` becomes `wide`, `beryl groups list` becomes `groups_list`, and a
49
+ * command that only exists in a terminal is left verbatim. */
50
+ export function toMcpDialect(text) {
51
+ return text.replace(/`[^`]*`|[^`]+/g, (segment) => {
52
+ if (segment.startsWith("`")) {
53
+ const inner = segment.slice(1, -1);
54
+ const asTool = commandToTool(inner);
55
+ if (asTool === undefined && inner.startsWith("beryl "))
56
+ return segment;
57
+ return `\`${flagsToParams(asTool ?? inner, (n) => n)}\``;
58
+ }
59
+ const prose = segment.replace(/\bberyl( [a-z][a-z-]*){1,3}(?=$|[^a-z-])/g, (command) => commandToTool(command) ?? command);
60
+ return flagsToParams(prose, (n) => `\`${n}\``);
61
+ });
62
+ }
26
63
  export function toolInputSchema(spec) {
27
64
  const properties = {};
28
65
  const required = [];
29
66
  for (const a of spec.args ?? []) {
67
+ const description = toMcpDialect(a.description);
30
68
  properties[a.name] = a.variadic
31
- ? { type: "array", items: { type: "string" }, description: a.description }
32
- : { type: "string", description: a.description };
69
+ ? { type: "array", items: { type: "string" }, description }
70
+ : { type: "string", description };
33
71
  if (a.required)
34
72
  required.push(a.name);
35
73
  }
36
74
  for (const f of spec.flags ?? []) {
37
75
  const withDefault = f.default !== undefined ? { default: f.default } : {};
76
+ const description = toMcpDialect(f.mcpDescription ?? f.description);
38
77
  properties[f.name] =
39
78
  f.type === "strings"
40
- ? { type: "array", items: { type: "string" }, description: f.description, ...withDefault }
79
+ ? { type: "array", items: { type: "string" }, description, ...withDefault }
41
80
  : {
42
81
  type: f.type,
43
- description: f.description,
82
+ description,
44
83
  ...(f.enum ? { enum: f.enum } : {}),
45
84
  ...withDefault,
46
85
  };
@@ -124,7 +163,7 @@ export function exampleArgs(spec, example) {
124
163
  return Object.keys(out).length ? out : null;
125
164
  }
126
165
  export function toolDescription(spec) {
127
- const base = spec.description ? `${spec.summary}. ${spec.description}` : spec.summary;
166
+ const base = toMcpDialect(spec.description ? `${spec.summary}. ${spec.description}` : spec.summary);
128
167
  const lines = (spec.examples ?? [])
129
168
  .map((e) => exampleArgs(spec, e))
130
169
  .filter((a) => a !== null)
@@ -211,14 +250,7 @@ export function identifyUser(baseCtx) {
211
250
  if (me?.id) {
212
251
  identity = {
213
252
  distinctId: me.id,
214
- // Internal dogfooding is stamped rather than dropped: unlike the webapp we
215
- // want our own MCP sessions visible when debugging, so dashboards exclude
216
- // them by cohort instead.
217
- properties: {
218
- email: me.email,
219
- name: me.name,
220
- is_internal: Boolean(me.is_vibemonitor),
221
- },
253
+ properties: { email: me.email, name: me.name },
222
254
  };
223
255
  }
224
256
  }
@@ -251,7 +283,9 @@ export function mcpInstructions() {
251
283
  "run-fix loop) ships as both the beryl-test skill and the `guide` tool — same " +
252
284
  `content. If a beryl-test skill stating v${version} is already loaded, do not ` +
253
285
  "call `guide`; if no beryl-test skill is available or it states another version, " +
254
- "call `guide` before authoring your first test plan.");
286
+ "call `guide` before authoring your first test plan. " +
287
+ "An account can hold several workspaces and projects: call `projects_list` first " +
288
+ "and pass `project` (an id is enough) on every project-scoped tool, or the call fails.");
255
289
  }
256
290
  export async function serveMcp(baseCtx) {
257
291
  setSurface("mcp");
@@ -300,7 +334,7 @@ export async function serveMcp(baseCtx) {
300
334
  return toolResult(result, lines);
301
335
  }
302
336
  catch (err) {
303
- const message = err instanceof CliError ? err.message : String(err);
337
+ const message = toMcpDialect(err instanceof CliError ? err.message : String(err));
304
338
  return {
305
339
  content: [{ type: "text", text: [...lines, message].join("\n") }],
306
340
  isError: true,
@@ -5,6 +5,7 @@ import { deviceLogin, SWITCH_TO_OTP } from "../device-login.js";
5
5
  import { CliError, UsageError } from "../errors.js";
6
6
  import { ApiClient } from "../http.js";
7
7
  import { dim, green } from "../output.js";
8
+ import { aliasMachineToAccount, flushTelemetry } from "../telemetry.js";
8
9
  import { arg, flagBool, flagStr } from "./util.js";
9
10
  function gitEmail() {
10
11
  try {
@@ -172,6 +173,8 @@ export const authCommands = [
172
173
  token,
173
174
  api_url: apiUrl === "https://api.beryl.so" ? undefined : apiUrl,
174
175
  });
176
+ aliasMachineToAccount(me.id);
177
+ flushTelemetry();
175
178
  const human = `${green("Logged in")} as ${me.name} <${me.email}>` +
176
179
  `\n${dim(`Token saved to ${saved}`)}`;
177
180
  return {
@@ -186,7 +189,7 @@ export const authCommands = [
186
189
  description: "Creates a passwordless account for the email and sends it a 6-digit code. " +
187
190
  "Finish with `beryl login --email <addr> --code <the 6 digits>`, which verifies " +
188
191
  "the account, creates its workspace, and signs the CLI in. With an inbox from " +
189
- "`beryl inbox create` as the address, an agent can provision a fresh account " +
192
+ "`beryl mailbox create` as the address, an agent can provision a fresh account " +
190
193
  "end-to-end with no human at a prompt.",
191
194
  flags: [
192
195
  {
@@ -8,7 +8,7 @@ import { AuthError, CliError } from "../errors.js";
8
8
  import { ApiClient } from "../http.js";
9
9
  import { bold, cyan, dim, green, red, yellow } from "../output.js";
10
10
  import { anyGap, describeGaps, installCommandsFor, installPlaywright, playwrightGaps, } from "../playwright-install.js";
11
- import { aliasSessionToAccount, captureCliEvent, flushTelemetry, shutdownTelemetry, } from "../telemetry.js";
11
+ import { aliasMachineToAccount, captureCliEvent, flushTelemetry, shutdownTelemetry, } from "../telemetry.js";
12
12
  import { cliVersion, warnIfStale } from "../version-check.js";
13
13
  import { authCommands } from "./auth.js";
14
14
  import { flagStr } from "./util.js";
@@ -227,7 +227,7 @@ export const initCommands = [
227
227
  await signIn();
228
228
  }
229
229
  if (accountId) {
230
- aliasSessionToAccount(accountId);
230
+ aliasMachineToAccount(accountId);
231
231
  flushTelemetry();
232
232
  }
233
233
  const report = (label, fresh, where) => ctx.err(`${green("✓")} ${label} ${fresh ? "configured" : "already configured"} ${dim(where)}`);
package/dist/config.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
@@ -45,3 +46,13 @@ export function saveGlobalConfig(patch, env = process.env) {
45
46
  }
46
47
  return file;
47
48
  }
49
+ // One id per install: every CLI event from this machine lands on it, and a login aliases
50
+ // it onto the account, so an install joins the same PostHog person as the signup.
51
+ export function machineId(env = process.env) {
52
+ const current = readJson(globalConfigPath(env)) ?? {};
53
+ if (current.machine_id)
54
+ return current.machine_id;
55
+ const id = randomUUID();
56
+ saveGlobalConfig({ machine_id: id }, env);
57
+ return id;
58
+ }
package/dist/context.js CHANGED
@@ -18,6 +18,23 @@ async function pickOne(ctx, candidates, kind, usageMessage) {
18
18
  }
19
19
  return candidates[n - 1].id;
20
20
  }
21
+ // Over MCP there is no --flag and no env var to set: the only fix is a JSON parameter on
22
+ // every call, and a project id alone already resolves its workspace.
23
+ function ambiguousMessage(ctx, kind, candidates) {
24
+ const rows = candidates.map((c) => ` ${c.id} ${c.name ?? c.root_url ?? ""}`).join("\n");
25
+ let how;
26
+ if (ctx.mcp) {
27
+ how =
28
+ kind === "workspace"
29
+ ? 'pass "workspace": "<id>" on each call, or "project": "<project id>", which implies its workspace:'
30
+ : 'pass "project": "<id>" on every project-scoped call (a project id alone is enough, no workspace needed):';
31
+ }
32
+ else {
33
+ const forms = kind === "workspace" ? "<id|name>" : "<id|name|url>";
34
+ how = `pass --${kind} ${forms} or set BERYL_${kind.toUpperCase()}:`;
35
+ }
36
+ return `Multiple ${kind}s: ${how}\n${rows}`;
37
+ }
21
38
  function matchByName(candidates, value, kind) {
22
39
  const lower = value.toLowerCase();
23
40
  const matches = candidates.filter((c) => c.name?.toLowerCase() === lower ||
@@ -82,8 +99,7 @@ export function createContext(options) {
82
99
  throw new CliError("You have no workspaces yet — create one with `beryl workspaces create`");
83
100
  }
84
101
  else {
85
- workspaceCache = await pickOne(ctx, workspaces, "workspace", "Multiple workspaces — pass --workspace <id|name> or set BERYL_WORKSPACE:\n" +
86
- workspaces.map((w) => ` ${w.id} ${w.name ?? ""}`).join("\n"));
102
+ workspaceCache = await pickOne(ctx, workspaces, "workspace", ambiguousMessage(ctx, "workspace", workspaces));
87
103
  }
88
104
  return workspaceCache;
89
105
  },
@@ -121,8 +137,7 @@ export function createContext(options) {
121
137
  "`beryl projects create <url>` (add --no-explore to author tests yourself).");
122
138
  }
123
139
  else {
124
- projectId = await pickOne(ctx, projects, "project", "Multiple projects — pass --project <id|name|url> or set BERYL_PROJECT:\n" +
125
- projects.map((p) => ` ${p.id} ${p.name ?? p.root_url ?? ""}`).join("\n"));
140
+ projectId = await pickOne(ctx, projects, "project", ambiguousMessage(ctx, "project", projects));
126
141
  }
127
142
  projectCache = { workspaceId, projectId };
128
143
  return projectCache;
@@ -17,11 +17,15 @@ export const WORKSPACE_FLAG = {
17
17
  name: "workspace",
18
18
  type: "string",
19
19
  description: "Workspace id or name (defaults to BERYL_WORKSPACE, or your only workspace)",
20
+ mcpDescription: "Workspace id or name. Needed only when the account has more than one workspace and " +
21
+ "no project id is passed (a project id implies its workspace)",
20
22
  };
21
23
  export const PROJECT_FLAG = {
22
24
  name: "project",
23
25
  type: "string",
24
26
  description: "Project id, name, or URL (defaults to BERYL_PROJECT, or the workspace's only project)",
27
+ mcpDescription: "Project id, name, or URL. Pass it on every call unless the account has exactly one " +
28
+ "project; a project id alone is enough, its workspace is looked up",
25
29
  };
26
30
  function withScopeFlags(spec) {
27
31
  if (!spec.scope || spec.scope === "none")
package/dist/telemetry.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { PostHog } from "posthog-node";
4
+ import { machineId } from "./config.js";
4
5
  import { cliVersion } from "./version-check.js";
5
6
  // The webapp already ships this public (write-only) project key in its JS bundle, and
6
7
  // dev/prod deploys share it — environments are separated by the `environment` property
@@ -229,22 +230,38 @@ export function baseEventProperties(apiUrl) {
229
230
  };
230
231
  }
231
232
  let posthog;
233
+ export function telemetryDisabled(env = process.env) {
234
+ return env.BERYL_TELEMETRY === "0" || env.DO_NOT_TRACK === "1";
235
+ }
232
236
  export function createPostHogClient() {
233
- posthog ??= new PostHog(POSTHOG_KEY, { host: POSTHOG_HOST, disableGeoip: false });
237
+ posthog ??= new PostHog(POSTHOG_KEY, {
238
+ host: POSTHOG_HOST,
239
+ disableGeoip: false,
240
+ disabled: telemetryDisabled(),
241
+ });
234
242
  return posthog;
235
243
  }
236
- // `beryl init` starts before there is an account to attribute to: its events land on the
237
- // process session id, and the session is aliased onto the account once init has signed in,
238
- // so the install joins the same person as the signup and the later MCP work.
244
+ // CLI events land on the install's machine id, which a login aliases onto the account:
245
+ // an install made before there is an account still joins the person who later signs up.
239
246
  export function captureCliEvent(event, apiUrl, properties = {}) {
240
- createPostHogClient().capture({
241
- distinctId: sessionId,
242
- event,
243
- properties: { ...baseEventProperties(apiUrl), ...properties },
244
- });
247
+ try {
248
+ createPostHogClient().capture({
249
+ distinctId: machineId(),
250
+ event,
251
+ properties: { ...baseEventProperties(apiUrl), ...properties },
252
+ });
253
+ }
254
+ catch {
255
+ // telemetry must never break a command
256
+ }
245
257
  }
246
- export function aliasSessionToAccount(userId) {
247
- createPostHogClient().alias({ distinctId: userId, alias: sessionId });
258
+ export function aliasMachineToAccount(userId) {
259
+ try {
260
+ createPostHogClient().alias({ distinctId: userId, alias: machineId() });
261
+ }
262
+ catch {
263
+ // telemetry must never break a command
264
+ }
248
265
  }
249
266
  export function flushTelemetry() {
250
267
  // Fire-and-forget: init keeps running (login, installs) while the batch goes out, and a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.34.3",
3
+ "version": "0.35.0",
4
4
  "description": "Beryl on the command line — projects, runs, the exploring agent, and an MCP server over the same commands.",
5
5
  "license": "MIT",
6
6
  "type": "module",