@beryl-so/cli 0.27.0 → 0.32.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
@@ -191,13 +191,14 @@ Manage a project's environments — the URLs and auth Beryl runs tests against.
191
191
 
192
192
  ### schedule
193
193
 
194
- View and set the schedule on which Beryl runs a project's tests automatically.
194
+ View and set the schedule on which Beryl runs a project's tests automatically — the whole project, or one group with --group.
195
195
 
196
196
  | Command | Summary | MCP tool |
197
197
  | --- | --- | --- |
198
- | `beryl schedule get` | Show the project's daily/weekly run schedule | `schedule_get` |
198
+ | `beryl schedule get` | Show the project's run schedule and any per-group schedules | `schedule_get` |
199
199
  | `beryl schedule set` | Enable scheduled runs (daily, or weekly on a given day) | `schedule_set` |
200
200
  | `beryl schedule disable` | Turn scheduled runs off | `schedule_disable` |
201
+ | `beryl schedule remove` | Remove a group's schedule entirely (the group itself stays) | `schedule_remove` |
201
202
 
202
203
  ### tests
203
204
 
@@ -214,6 +215,7 @@ Author, inspect, version, and heal a project's tests — the checks Beryl runs o
214
215
  | `beryl tests create` | Create a test case from a JSON action plan — for tests authored locally, e.g. by your coding agent | `tests_create` |
215
216
  | `beryl tests set-plan <test-id>` | Replace a test's step plan from a JSON file (creates a new version) | `tests_set_plan` |
216
217
  | `beryl tests rename <test-id> <title>` | Rename a test | `tests_rename` |
218
+ | `beryl tests set-groups <test-id>` | Replace the groups a test belongs to | `tests_set_groups` |
217
219
  | `beryl tests quarantine <test-id> <state>` | Mute a flaky test: it keeps running, but its failures stop failing the run | `tests_quarantine` |
218
220
  | `beryl tests delete <test-id>` | Delete a test, its version history, and its results | `tests_delete` |
219
221
  | `beryl tests recompile <test-id>` | Validate + verify an edited plan against the live site before persisting | `tests_recompile` |
@@ -227,6 +229,19 @@ Author, inspect, version, and heal a project's tests — the checks Beryl runs o
227
229
  | `beryl tests script [test-id]` | Print the rendered Playwright spec for a test (or an unbanked plan file) | `tests_script` |
228
230
  | `beryl tests export <test-ids...>` | Export tests as Playwright .spec.ts files in a ZIP | `tests_export` |
229
231
 
232
+ ### groups
233
+
234
+ Manage a project's test groups — labels a test can carry any number of, used to filter, run, or schedule a slice of the suite. Groups are created only here (or in Settings → Groups); assigning a test to an unknown name is an error.
235
+
236
+ `beryl groups` with no subcommand runs `groups list`.
237
+
238
+ | Command | Summary | MCP tool |
239
+ | --- | --- | --- |
240
+ | `beryl groups list` | List the project's test groups and how many tests each holds | `groups_list` |
241
+ | `beryl groups create <name>` | Create a group | `groups_create` |
242
+ | `beryl groups rename <group> <name>` | Rename a group (tests keep their membership) | `groups_rename` |
243
+ | `beryl groups delete <group>` | Delete a group — its tests stay, its schedule (if any) goes with it | `groups_delete` |
244
+
230
245
  ### runs
231
246
 
232
247
  Trigger a run of a project's tests (e.g. in CI), then watch, inspect, and download results.
@@ -245,6 +260,17 @@ Trigger a run of a project's tests (e.g. in CI), then watch, inspect, and downlo
245
260
  | `beryl runs download <run-id>` | Download a run's results, with its artifacts, to disk | `runs_download` |
246
261
  | `beryl runs explain <result-id>` | Explain, with AI, why a test result failed | `runs_explain` |
247
262
 
263
+ ### health
264
+
265
+ Site Health — how your site reads to search engines and visitors: content, speed, mobile, links and security, graded from a real check of your live pages.
266
+
267
+ `beryl health` with no subcommand runs `health get`.
268
+
269
+ | Command | Summary | MCP tool |
270
+ | --- | --- | --- |
271
+ | `beryl health get` | Show the latest Site Health report for a project environment | `health_get` |
272
+ | `beryl health run` | Run a fresh Site Health check for a project environment | `health_run` |
273
+
248
274
  ### explorations
249
275
 
250
276
  Inspect the agent's exploration runs — how it crawled a site and authored its tests.
@@ -4,6 +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
8
  import { cliVersion } from "../version-check.js";
8
9
  import { mcpToolFor } from "./mcp.js";
9
10
  export const GLOBAL_FLAGS = [
@@ -324,7 +325,7 @@ export async function runCli(argv) {
324
325
  const client = new ApiClient(config.apiUrl, config.token);
325
326
  const ctx = createContext({ client, config, json: parsed.json, mcp: false });
326
327
  try {
327
- const result = (await spec.run(ctx, parsed.input)) ?? {};
328
+ const result = (await withTool(spec.name, () => spec.run(ctx, parsed.input))) ?? {};
328
329
  if (parsed.json) {
329
330
  if (result.data !== undefined)
330
331
  process.stdout.write(JSON.stringify(result.data) + "\n");
@@ -1,13 +1,15 @@
1
1
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
3
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
4
+ import { instrument } from "@posthog/mcp";
4
5
  import fs from "node:fs";
5
6
  import { loadConfig } from "../config.js";
6
7
  import { createContext } from "../context.js";
7
- import { CliError } from "../errors.js";
8
- import { ApiClient } from "../http.js";
8
+ import { AuthError, CliError } from "../errors.js";
9
+ import { ApiClient, ApiError } from "../http.js";
9
10
  import { parseArgv } from "./cli.js";
10
11
  import { commands } from "../registry/index.js";
12
+ import { analyticsOptions, createPostHogClient, setClient, setSurface, shutdownTelemetry, withTool, } from "../telemetry.js";
11
13
  import { cliVersion, warnIfStale } from "../version-check.js";
12
14
  export function toolName(spec) {
13
15
  return spec.name.replace(/ /g, "_").replace(/-/g, "_");
@@ -190,6 +192,48 @@ export function currentAuth(fallback) {
190
192
  }
191
193
  export function __resetAuthCacheForTests() {
192
194
  authCache = undefined;
195
+ identifyCache = undefined;
196
+ }
197
+ let identifyCache;
198
+ /** Resolves the signed-in account once per token so MCP events land on the same PostHog
199
+ * person as that user's webapp sessions. Anonymous (null) when unauthenticated: the
200
+ * session is still counted, just not attributed. */
201
+ export function identifyUser(baseCtx) {
202
+ return async () => {
203
+ const auth = currentAuth(baseCtx);
204
+ const cached = identifyCache;
205
+ if (cached && cached.token === auth.config.token)
206
+ return cached.identity;
207
+ let identity = null;
208
+ if (auth.config.token) {
209
+ try {
210
+ const me = (await auth.client.get("/account/"));
211
+ if (me?.id) {
212
+ identity = {
213
+ 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
+ },
222
+ };
223
+ }
224
+ }
225
+ catch (err) {
226
+ // A rejected token is a settled answer worth caching; a network blip or a 5xx is
227
+ // not. This server outlives the blip, so caching one would leave every later tool
228
+ // call in the session anonymous with no way back.
229
+ const settled = err instanceof AuthError || (err instanceof ApiError && err.status < 500);
230
+ if (!settled)
231
+ return null;
232
+ }
233
+ }
234
+ identifyCache = { token: auth.config.token, identity };
235
+ return identity;
236
+ };
193
237
  }
194
238
  // The running version is stated up front because this server is long-lived and never
195
239
  // hot-reloads: a session can sit on a days-old build while `@latest` has moved, and
@@ -210,6 +254,7 @@ export function mcpInstructions() {
210
254
  "call `guide` before authoring your first test plan.");
211
255
  }
212
256
  export async function serveMcp(baseCtx) {
257
+ setSurface("mcp");
213
258
  // Fire-and-forget staleness warning: a stale MCP server silently exposes fewer
214
259
  // tools, and stderr is the one channel a stdio MCP server can safely log to.
215
260
  void warnIfStale(cliVersion(), (msg) => console.error(msg));
@@ -250,7 +295,8 @@ export async function serveMcp(baseCtx) {
250
295
  err: push,
251
296
  });
252
297
  try {
253
- const result = (await spec.run(ctx, toInput(spec, request.params.arguments ?? {}))) ?? {};
298
+ const input = toInput(spec, request.params.arguments ?? {});
299
+ const result = (await withTool(request.params.name, () => spec.run(ctx, input))) ?? {};
254
300
  return toolResult(result, lines);
255
301
  }
256
302
  catch (err) {
@@ -261,8 +307,25 @@ export async function serveMcp(baseCtx) {
261
307
  };
262
308
  }
263
309
  });
310
+ server.oninitialized = () => setClient(server.getClientVersion());
311
+ instrument(server, createPostHogClient(), analyticsOptions(baseCtx.config.apiUrl, identifyUser(baseCtx)));
312
+ // A coding agent ends an MCP server either by closing the pipe or by signalling it, and
313
+ // only the first path reaches onclose — without these the last batch of events dies with
314
+ // the process. Bounded inside shutdownTelemetry, so a wedged flush can't hold up exit.
315
+ const flushAndExit = (signal) => {
316
+ void shutdownTelemetry().then(() => {
317
+ process.kill(process.pid, signal);
318
+ });
319
+ };
320
+ for (const signal of ["SIGINT", "SIGTERM"]) {
321
+ process.once(signal, () => {
322
+ process.removeAllListeners(signal);
323
+ flushAndExit(signal);
324
+ });
325
+ }
264
326
  await server.connect(new StdioServerTransport());
265
327
  await new Promise((resolve) => {
266
328
  server.onclose = resolve;
267
329
  });
330
+ await shutdownTelemetry();
268
331
  }
@@ -289,6 +289,9 @@ ${JSON.stringify(BERYL_TEST_SKILL_EXAMPLE_PLAN, null, 2)
289
289
  description. Inspect the exact spec with \`beryl tests script --file plan.json\`.
290
290
  Full ActionPlan JSON Schema:
291
291
  https://api.beryl.so/api/v1/schemas/action-plan.schema.json.
292
+ Groups are optional and never block: after the test is banked, if \`beryl groups list\`
293
+ shows any, offer them once (\`tests set-groups <id> --group <name>\`); no answer = leave
294
+ it ungrouped and move on. Never create a group yourself — that's the user's call.
292
295
 
293
296
  ### The outcome assertion is the whole game
294
297
 
@@ -1,5 +1,6 @@
1
1
  import { UsageError } from "../errors.js";
2
- import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
2
+ import { arg, findGroup, flagBool, flagNum, flagStr, projectPath } from "./util.js";
3
+ const SCHEDULE_GROUP_FLAG = "Schedule one group (by name or id) instead of the whole project; the run covers the group's active tests at fire time";
3
4
  export const environmentCommands = [
4
5
  {
5
6
  name: "envs list",
@@ -92,9 +93,9 @@ export const environmentCommands = [
92
93
  },
93
94
  {
94
95
  name: "schedule get",
95
- summary: "Show the project's daily/weekly run schedule",
96
+ summary: "Show the project's run schedule and any per-group schedules",
96
97
  scope: "project",
97
- groupSummary: "View and set the schedule on which Beryl runs a project's tests automatically.",
98
+ groupSummary: "View and set the schedule on which Beryl runs a project's tests automatically — the whole project, or one group with --group.",
98
99
  async run(ctx, input) {
99
100
  const { workspaceId, projectId } = await ctx.requireProject(input);
100
101
  return { data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/schedule`) };
@@ -115,12 +116,16 @@ export const environmentCommands = [
115
116
  { name: "hour", type: "number", description: "Hour of day 0-23" },
116
117
  { name: "minute", type: "number", description: "Minute 0-59" },
117
118
  { name: "tz", type: "string", description: "IANA timezone (e.g. America/Los_Angeles)" },
119
+ { name: "group", type: "string", description: SCHEDULE_GROUP_FLAG },
120
+ ],
121
+ examples: [
122
+ "beryl schedule set --frequency daily --hour 6 --tz UTC",
123
+ "beryl schedule set --group Smoke --frequency weekly --day 0 --hour 9 --tz UTC",
118
124
  ],
119
- examples: ["beryl schedule set --frequency daily --hour 6 --tz UTC"],
120
125
  async run(ctx, input) {
121
126
  const { workspaceId, projectId } = await ctx.requireProject(input);
122
127
  return {
123
- data: await ctx.client.put(`${projectPath(workspaceId, projectId)}/schedule`, {
128
+ data: await ctx.client.put(await schedulePath(ctx, input, workspaceId, projectId), {
124
129
  enabled: true,
125
130
  frequency: flagStr(input, "frequency") ?? "daily",
126
131
  day_of_week: flagNum(input, "day") ?? null,
@@ -135,13 +140,40 @@ export const environmentCommands = [
135
140
  name: "schedule disable",
136
141
  summary: "Turn scheduled runs off",
137
142
  scope: "project",
143
+ flags: [{ name: "group", type: "string", description: SCHEDULE_GROUP_FLAG }],
138
144
  async run(ctx, input) {
139
145
  const { workspaceId, projectId } = await ctx.requireProject(input);
140
146
  return {
141
- data: await ctx.client.put(`${projectPath(workspaceId, projectId)}/schedule`, {
147
+ data: await ctx.client.put(await schedulePath(ctx, input, workspaceId, projectId), {
142
148
  enabled: false,
143
149
  }),
144
150
  };
145
151
  },
146
152
  },
153
+ {
154
+ name: "schedule remove",
155
+ summary: "Remove a group's schedule entirely (the group itself stays)",
156
+ scope: "project",
157
+ flags: [{ name: "group", type: "string", description: "Group name or id", required: true }],
158
+ async run(ctx, input) {
159
+ const { workspaceId, projectId } = await ctx.requireProject(input);
160
+ if (!flagStr(input, "group"))
161
+ throw new UsageError("--group is required");
162
+ await ctx.client.del(await schedulePath(ctx, input, workspaceId, projectId));
163
+ return { human: "Removed." };
164
+ },
165
+ },
147
166
  ];
167
+ async function schedulePath(ctx, input, workspaceId, projectId) {
168
+ const base = `${projectPath(workspaceId, projectId)}/schedule`;
169
+ const ref = flagStr(input, "group");
170
+ if (!ref)
171
+ return base;
172
+ const groups = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/groups`));
173
+ const group = findGroup(groups, ref);
174
+ if (!group) {
175
+ const names = groups.map((g) => g.name).join(", ") || "none yet";
176
+ throw new UsageError(`No group '${ref}' in this project (existing: ${names}).`);
177
+ }
178
+ return `${projectPath(workspaceId, projectId)}/groups/${group.id}/schedule`;
179
+ }
@@ -0,0 +1,69 @@
1
+ import { UsageError } from "../errors.js";
2
+ import { arg, findGroup, flagBool, projectPath } from "./util.js";
3
+ async function resolveGroup(ctx, workspaceId, projectId, ref) {
4
+ const groups = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/groups`));
5
+ const match = findGroup(groups, ref);
6
+ if (!match) {
7
+ const names = groups.map((g) => g.name).join(", ") || "none yet";
8
+ throw new UsageError(`No group '${ref}' in this project (existing: ${names}).`);
9
+ }
10
+ return match;
11
+ }
12
+ export const groupCommands = [
13
+ {
14
+ name: "groups list",
15
+ summary: "List the project's test groups and how many tests each holds",
16
+ scope: "project",
17
+ groupDefault: true,
18
+ groupSummary: "Manage a project's test groups — labels a test can carry any number of, used to filter, run, or schedule a slice of the suite. Groups are created only here (or in Settings → Groups); assigning a test to an unknown name is an error.",
19
+ async run(ctx, input) {
20
+ const { workspaceId, projectId } = await ctx.requireProject(input);
21
+ return { data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/groups`) };
22
+ },
23
+ },
24
+ {
25
+ name: "groups create",
26
+ summary: "Create a group",
27
+ scope: "project",
28
+ args: [{ name: "name", description: "Group name (unique per project)", required: true }],
29
+ examples: ["beryl groups create Smoke"],
30
+ async run(ctx, input) {
31
+ const { workspaceId, projectId } = await ctx.requireProject(input);
32
+ return {
33
+ data: await ctx.client.post(`${projectPath(workspaceId, projectId)}/groups`, {
34
+ name: arg(input, "name"),
35
+ }),
36
+ };
37
+ },
38
+ },
39
+ {
40
+ name: "groups rename",
41
+ summary: "Rename a group (tests keep their membership)",
42
+ scope: "project",
43
+ args: [
44
+ { name: "group", description: "Current group name or id", required: true },
45
+ { name: "name", description: "New name", required: true },
46
+ ],
47
+ async run(ctx, input) {
48
+ const { workspaceId, projectId } = await ctx.requireProject(input);
49
+ const group = await resolveGroup(ctx, workspaceId, projectId, arg(input, "group"));
50
+ return {
51
+ data: await ctx.client.patch(`${projectPath(workspaceId, projectId)}/groups/${group.id}`, { name: arg(input, "name") }),
52
+ };
53
+ },
54
+ },
55
+ {
56
+ name: "groups delete",
57
+ summary: "Delete a group — its tests stay, its schedule (if any) goes with it",
58
+ scope: "project",
59
+ args: [{ name: "group", description: "Group name or id", required: true }],
60
+ flags: [{ name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
61
+ async run(ctx, input) {
62
+ const { workspaceId, projectId } = await ctx.requireProject(input);
63
+ const group = await resolveGroup(ctx, workspaceId, projectId, arg(input, "group"));
64
+ await ctx.confirm(`Delete group '${group.name}' (${group.test_count ?? 0} tests leave it)?`, flagBool(input, "force"));
65
+ await ctx.client.del(`${projectPath(workspaceId, projectId)}/groups/${group.id}`);
66
+ return { human: "Deleted." };
67
+ },
68
+ },
69
+ ];
@@ -0,0 +1,43 @@
1
+ import { flagStr, projectPath } from "./util.js";
2
+ const ENV_FLAG = {
3
+ name: "env",
4
+ type: "string",
5
+ description: "Environment id (defaults to the project's default environment)",
6
+ };
7
+ export const healthCommands = [
8
+ {
9
+ name: "health get",
10
+ groupDefault: true,
11
+ groupSummary: "Site Health — how your site reads to search engines and visitors: content, " +
12
+ "speed, mobile, links and security, graded from a real check of your live pages.",
13
+ summary: "Show the latest Site Health report for a project environment",
14
+ description: "A check runs automatically when a project or environment gets its URL. While one " +
15
+ "is in flight the report comes back with status queued/running and no grades yet; " +
16
+ "call again to pick up the finished result.",
17
+ scope: "project",
18
+ flags: [ENV_FLAG],
19
+ async run(ctx, input) {
20
+ const { workspaceId, projectId } = await ctx.requireProject(input);
21
+ return {
22
+ data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/seo-report`, {
23
+ environment_id: flagStr(input, "env"),
24
+ }),
25
+ };
26
+ },
27
+ },
28
+ {
29
+ name: "health run",
30
+ summary: "Run a fresh Site Health check for a project environment",
31
+ description: "Queues a new check and returns immediately. Returns the in-flight report instead " +
32
+ "of stacking a second one when a check is already running.",
33
+ scope: "project",
34
+ flags: [ENV_FLAG],
35
+ examples: ["beryl health run", "beryl health run --env 4f…"],
36
+ async run(ctx, input) {
37
+ const { workspaceId, projectId } = await ctx.requireProject(input);
38
+ return {
39
+ data: await ctx.client.post(`${projectPath(workspaceId, projectId)}/seo-report`, undefined, { environment_id: flagStr(input, "env") }),
40
+ };
41
+ },
42
+ },
43
+ ];
@@ -7,7 +7,7 @@ import { countPlannedFrames, countWrittenFrames, PlaywrightMissingError, } from
7
7
  import { dim, green, red, yellow } from "../output.js";
8
8
  import { anyGap, confirmInstall, describeGaps, installCommandsFor, installPlaywright, playwrightGaps, } from "../playwright-install.js";
9
9
  import { ProgressBar } from "../progress.js";
10
- import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
10
+ import { arg, flagBool, flagNum, flagStr, idsInGroup, projectPath } from "./util.js";
11
11
  import { watchRun } from "./watch.js";
12
12
  const MAX_FAILURE_SCREENSHOTS = 5;
13
13
  // One filesystem check before a single spec is fetched. A missing browser binary otherwise
@@ -50,10 +50,19 @@ export const runCommands = [
50
50
  name: "runs trigger",
51
51
  summary: "Trigger a test run (whole suite, a subset, or one environment)",
52
52
  description: "Runs execute in Beryl's cloud. With --watch the CLI streams live progress and " +
53
- "exits 0 only if every test passed — wire it straight into CI.",
53
+ "exits 0 only if every test passed — wire it straight into CI. A run with a " +
54
+ "heal-eligible failure completes only after Beryl has tried to heal it: a repaired " +
55
+ "test is re-run inside the same run and counts as passed (reported as `healed`), so " +
56
+ "the final counts, the report and the completion email all reflect the repair.",
54
57
  scope: "project",
55
58
  flags: [
56
59
  { name: "test", type: "strings", description: "Run only these test ids (repeatable)" },
60
+ {
61
+ name: "group",
62
+ type: "string",
63
+ description: "Run only the active tests in this group (by name) — resolved to ids before the run, " +
64
+ "so the run records exactly what it ran. Cannot be combined with --test.",
65
+ },
57
66
  { name: "env", type: "string", description: "Environment id to run against" },
58
67
  { name: "url-override", type: "string", description: "Replace the base URL (preview deploys)" },
59
68
  {
@@ -75,11 +84,22 @@ export const runCommands = [
75
84
  "beryl runs trigger --url-override https://preview-123.example.com --watch --timeout 30",
76
85
  "beryl runs trigger --url-override https://preview-123.example.com --header x-vercel-protection-bypass=<token> --watch",
77
86
  "beryl runs trigger --test 4f… --test 9a…",
87
+ "beryl runs trigger --group Smoke --watch",
78
88
  "beryl runs trigger --retries 0 --watch",
79
89
  ],
80
90
  async run(ctx, input) {
81
91
  const { workspaceId, projectId } = await ctx.requireProject(input);
82
- const tests = input.flags.test;
92
+ let tests = input.flags.test;
93
+ const group = flagStr(input, "group");
94
+ if (group && tests && tests.length > 0)
95
+ throw new UsageError("--group cannot be combined with --test");
96
+ if (group) {
97
+ const listed = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests`));
98
+ tests = idsInGroup(listed, group);
99
+ // Exiting 0 here would be a green CI run that tested nothing.
100
+ if (tests.length === 0)
101
+ throw new UsageError(`No active tests in group '${group}'.`);
102
+ }
83
103
  const extraHeaders = parseHeaders(input.flags.header);
84
104
  const created = (await ctx.client.post(`${projectPath(workspaceId, projectId)}/runs`, {
85
105
  test_case_ids: tests && tests.length > 0 ? tests : null,
@@ -131,6 +151,11 @@ export const runCommands = [
131
151
  type: "boolean",
132
152
  description: "Run every active test in the project (the default when no ids are given)",
133
153
  },
154
+ {
155
+ name: "group",
156
+ type: "string",
157
+ description: "Run only the active tests in this group (by name). Cannot be combined with test ids.",
158
+ },
134
159
  {
135
160
  name: "url-override",
136
161
  type: "string",
@@ -162,15 +187,23 @@ export const runCommands = [
162
187
  "beryl runs local 4f… --no-sync --dir ./beryl-local",
163
188
  ],
164
189
  async run(ctx, input) {
165
- await ensureRunnableLocally(ctx);
166
- const { workspaceId, projectId } = await ctx.requireProject(input);
190
+ // Argument validation runs BEFORE the Chromium/Playwright precondition: a bad flag
191
+ // combination should say so on any machine, not report a missing browser.
167
192
  // Deduped: a repeated id would put the same test twice in one imported run,
168
193
  // which the import manifest rejects.
169
194
  const explicitIds = [...new Set(input.args["test-ids"] ?? [])];
170
- // No ids means the whole suite — `beryl runs local` alone is a complete local run.
171
- const all = explicitIds.length === 0;
195
+ const group = flagStr(input, "group");
196
+ if (group && explicitIds.length > 0)
197
+ throw new UsageError("--group cannot be combined with explicit test ids");
172
198
  if (explicitIds.length > 0 && flagBool(input, "all"))
173
199
  throw new UsageError("--all cannot be combined with explicit test ids");
200
+ if (group && flagBool(input, "all"))
201
+ throw new UsageError("--all cannot be combined with --group");
202
+ // No ids and no group means the whole suite — `beryl runs local` alone is a
203
+ // complete local run.
204
+ const all = explicitIds.length === 0 && !group;
205
+ await ensureRunnableLocally(ctx);
206
+ const { workspaceId, projectId } = await ctx.requireProject(input);
174
207
  const sync = input.flags.sync !== false;
175
208
  const urlOverride = flagStr(input, "url-override");
176
209
  const dir = flagStr(input, "dir");
@@ -180,9 +213,15 @@ export const runCommands = [
180
213
  // The list rows carry the authored name as nl_title (title is the customer's
181
214
  // rename, usually unset) — fall through so the terminal shows names, not ids.
182
215
  const titles = new Map(listed.map((t) => [t.id, t.title || t.nl_title || t.id.slice(0, 8)]));
183
- const ids = all ? listed.filter((t) => t.is_active !== false).map((t) => t.id) : explicitIds;
216
+ const ids = all
217
+ ? listed.filter((t) => t.is_active !== false).map((t) => t.id)
218
+ : group
219
+ ? idsInGroup(listed, group)
220
+ : explicitIds;
184
221
  if (ids.length === 0)
185
- throw new CliError("This project has no tests to run.");
222
+ throw group
223
+ ? new UsageError(`No active tests in group '${group}'.`)
224
+ : new CliError("This project has no tests to run.");
186
225
  const fetchScript = (id) => ctx.client.get(`${projectPath(workspaceId, projectId)}/tests/${id}/script`, {
187
226
  // frames only matter when the run will be imported: they become the replay.
188
227
  // environment_id keeps the baked login_email on the same environment the
@@ -454,15 +493,36 @@ export const runCommands = [
454
493
  {
455
494
  name: "runs list",
456
495
  summary: "List recent runs",
496
+ description: "Returns the 50 most recent runs unless --page is given; pass --page to walk the " +
497
+ "full history a slice at a time.",
457
498
  scope: "project",
458
499
  groupDefault: true,
459
500
  groupSummary: "Trigger a run of a project's tests (e.g. in CI), then watch, inspect, and download results.",
460
- flags: [{ name: "env", type: "string", description: "Filter by environment id" }],
501
+ flags: [
502
+ { name: "env", type: "string", description: "Filter by environment id" },
503
+ {
504
+ name: "page",
505
+ type: "number",
506
+ description: "Return only this 1-indexed page instead of the 50 most recent runs",
507
+ },
508
+ {
509
+ name: "page-size",
510
+ type: "number",
511
+ description: "Runs per page when --page is given (default 8, max 100)",
512
+ },
513
+ ],
461
514
  async run(ctx, input) {
462
515
  const { workspaceId, projectId } = await ctx.requireProject(input);
463
- const rows = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/runs`, {
464
- environment_id: flagStr(input, "env"),
465
- }));
516
+ const page = flagNum(input, "page");
517
+ const rows = page === undefined
518
+ ? (await ctx.client.get(`${projectPath(workspaceId, projectId)}/runs`, {
519
+ environment_id: flagStr(input, "env"),
520
+ }))
521
+ : (await ctx.client.get(`${projectPath(workspaceId, projectId)}/runs/page`, {
522
+ page,
523
+ page_size: flagNum(input, "page-size"),
524
+ environment_id: flagStr(input, "env"),
525
+ })).items;
466
526
  return { data: rows.map(({ test_results: _omit, ...row }) => row) };
467
527
  },
468
528
  },
@@ -7,7 +7,7 @@ import { PlaywrightMissingError } from "../local-run.js";
7
7
  import { dim, green, red, table, yellow } from "../output.js";
8
8
  import { confirmInstall, installPlaywright } from "../playwright-install.js";
9
9
  import { ACTION_PLAN_SCHEMA } from "../schema.generated.js";
10
- import { arg, argList, flagBool, flagNum, flagStr, projectPath, readJsonFlag } from "./util.js";
10
+ import { arg, argList, flagBool, flagNum, flagStr, flagStrings, inGroup, projectPath, readJsonFlag, } from "./util.js";
11
11
  const testPath = (ws, p, id) => `${projectPath(ws, p)}/tests/${id}`;
12
12
  // A duplicate-title 409 after a green replay is idempotent when the existing row
13
13
  // holds the same plan (a retry after a lost response) — success, not an error.
@@ -106,19 +106,62 @@ export const testCommands = [
106
106
  name: "tests list",
107
107
  summary: "List the project's tests with their latest result",
108
108
  description: "Prints a concise table by default (title / status / id / last result / last run). " +
109
- "Pass --wide for every field, or --json for the raw records.",
109
+ "Pass --wide for every field, or --json for the raw records. Returns every test " +
110
+ "unless --page is given; pass --page to walk a large project a slice at a time.",
110
111
  scope: "project",
111
112
  groupDefault: true,
112
113
  groupSummary: "Author, inspect, version, and heal a project's tests — the checks Beryl runs on each run.",
113
114
  flags: [
114
115
  { name: "env", type: "string", description: "Filter by environment id" },
116
+ {
117
+ name: "group",
118
+ type: "string",
119
+ description: "Show only tests in this group (by name; see `beryl groups list`)",
120
+ },
115
121
  { name: "wide", type: "boolean", description: "Show all columns, not the concise default" },
122
+ {
123
+ name: "page",
124
+ type: "number",
125
+ description: "Return only this 1-indexed page instead of every test",
126
+ },
127
+ {
128
+ name: "page-size",
129
+ type: "number",
130
+ description: "Tests per page when --page is given (default 20, max 100)",
131
+ },
132
+ {
133
+ name: "status",
134
+ type: "string",
135
+ description: "With --page, show only this bucket: passed, failed, or blocked",
136
+ },
137
+ {
138
+ name: "ungrouped",
139
+ type: "boolean",
140
+ description: "With --page, show only tests carrying no group",
141
+ },
116
142
  ],
117
143
  async run(ctx, input) {
118
144
  const { workspaceId, projectId } = await ctx.requireProject(input);
119
- const data = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests`, {
120
- environment_id: flagStr(input, "env"),
121
- }));
145
+ const page = flagNum(input, "page");
146
+ const group = flagStr(input, "group");
147
+ let data;
148
+ if (page === undefined) {
149
+ data = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests`, {
150
+ environment_id: flagStr(input, "env"),
151
+ }));
152
+ // GET /tests takes no group param; the paged route below filters server-side.
153
+ if (group)
154
+ data = data.filter((t) => inGroup(t, group));
155
+ }
156
+ else {
157
+ data = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests/page`, {
158
+ page,
159
+ page_size: flagNum(input, "page-size"),
160
+ status: flagStr(input, "status"),
161
+ group: flagBool(input, "ungrouped") ? "__ungrouped__" : group,
162
+ environment_id: flagStr(input, "env"),
163
+ })).items;
164
+ }
122
165
  if (flagBool(input, "wide"))
123
166
  return { data };
124
167
  // `data` stays the full records so --json is unchanged; only the human table is trimmed.
@@ -201,6 +244,12 @@ export const testCommands = [
201
244
  description: "Replay against this base URL instead of the environment's (e.g. http://localhost:3000). " +
202
245
  "The banked test is then unproven against its real environment — the CLI says so.",
203
246
  },
247
+ {
248
+ name: "group",
249
+ type: "strings",
250
+ description: "Put the test in an existing project group (by name), repeatable. An unknown " +
251
+ "name is an error — create groups with `beryl groups create`. Omit for no group.",
252
+ },
204
253
  { name: "env", type: "string", description: "Environment id to compile and prove against" },
205
254
  {
206
255
  name: "sync",
@@ -230,10 +279,12 @@ export const testCommands = [
230
279
  const urlOverride = flagStr(input, "url-override");
231
280
  const env = flagStr(input, "env");
232
281
  const sync = input.flags.sync !== false;
282
+ const groups = flagStrings(input, "group");
233
283
  const bank = (extra) => ctx.client.post(`${projectPath(workspaceId, projectId)}/tests`, {
234
284
  title,
235
285
  plan,
236
286
  description,
287
+ groups,
237
288
  ...extra,
238
289
  });
239
290
  if (flagBool(input, "no-verify")) {
@@ -472,6 +523,35 @@ export const testCommands = [
472
523
  };
473
524
  },
474
525
  },
526
+ {
527
+ name: "tests set-groups",
528
+ summary: "Replace the groups a test belongs to",
529
+ description: "Groups are project-defined labels used to filter, run, or schedule a slice of the " +
530
+ "suite (`beryl runs trigger --group <name>`). This REPLACES the whole set — pass every " +
531
+ "group you want, or none to clear it. Names must already exist in the project " +
532
+ "(`beryl groups list`); an unknown name is an error, never a new group.",
533
+ scope: "project",
534
+ args: [{ name: "test-id", description: "Test id", required: true }],
535
+ flags: [
536
+ {
537
+ name: "group",
538
+ type: "strings",
539
+ description: "Group name, repeatable. Omit entirely to clear the set.",
540
+ },
541
+ ],
542
+ examples: [
543
+ "beryl tests set-groups 4f… --group Checkout --group Smoke",
544
+ "beryl tests set-groups 4f…",
545
+ ],
546
+ async run(ctx, input) {
547
+ const { workspaceId, projectId } = await ctx.requireProject(input);
548
+ return {
549
+ data: await ctx.client.patch(testPath(workspaceId, projectId, arg(input, "test-id")), {
550
+ groups: flagStrings(input, "group") ?? [],
551
+ }),
552
+ };
553
+ },
554
+ },
475
555
  {
476
556
  name: "tests quarantine",
477
557
  summary: "Mute a flaky test: it keeps running, but its failures stop failing the run",
@@ -54,3 +54,23 @@ export function readJsonFlag(input, name) {
54
54
  throw new UsageError(`--${name}: ${file} is not valid JSON (${err.message})`);
55
55
  }
56
56
  }
57
+ export function flagStrings(input, name) {
58
+ const v = input.flags[name];
59
+ return v && v.length > 0 ? v : undefined;
60
+ }
61
+ export function inGroup(row, group) {
62
+ const want = group.trim().toLowerCase();
63
+ return (row.groups ?? []).some((g) => g.toLowerCase() === want);
64
+ }
65
+ /** Resolve a --group name to the ids of the project's ACTIVE tests in it.
66
+ * Client-side on purpose: the run endpoint keeps one selection mechanism
67
+ * (test_case_ids), so a run always records the exact ids it ran even if group
68
+ * membership changes later. */
69
+ export function idsInGroup(rows, group) {
70
+ return rows.filter((t) => t.is_active !== false && inGroup(t, group)).map((t) => t.id);
71
+ }
72
+ /** Match a `--group` value against the project's groups by name (case-insensitive) or id. */
73
+ export function findGroup(groups, ref) {
74
+ const want = ref.trim().toLowerCase();
75
+ return groups.find((g) => g.id === ref || g.name.toLowerCase() === want);
76
+ }
@@ -68,7 +68,8 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes) {
68
68
  // is reported too, so a suite that only stays green by retrying can't hide it.
69
69
  const extra = (counters.cancelled ? yellow(`, ${counters.cancelled} cancelled`) : "") +
70
70
  (counters.quarantined ? yellow(`, ${counters.quarantined} quarantined`) : "") +
71
- (counters.flaky ? yellow(`, ${counters.flaky} flaky`) : "");
71
+ (counters.flaky ? yellow(`, ${counters.flaky} flaky`) : "") +
72
+ (counters.healed ? yellow(`, ${counters.healed} healed`) : "");
72
73
  const summary = failed > 0
73
74
  ? red(`${failed} failed`) + `, ${passed} passed` + extra
74
75
  : green(`${passed} passed`) + extra;
package/dist/http.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { AuthError, CliError } from "./errors.js";
2
+ import { attributionHeaders } from "./telemetry.js";
2
3
  export const API_PREFIX = "/api/v1";
3
4
  export class ApiError extends CliError {
4
5
  status;
@@ -29,7 +30,11 @@ export class ApiClient {
29
30
  return this.token ? { Authorization: `Bearer ${this.token}` } : {};
30
31
  }
31
32
  async request(method, path, opts = {}) {
32
- const headers = { ...this.authHeaders(), ...opts.headers };
33
+ const headers = {
34
+ ...attributionHeaders(),
35
+ ...this.authHeaders(),
36
+ ...opts.headers,
37
+ };
33
38
  let body;
34
39
  if (opts.form) {
35
40
  body = opts.form;
@@ -4,6 +4,8 @@ import { authCommands } from "../commands/auth.js";
4
4
  import { configCommands } from "../commands/config-vars.js";
5
5
  import { environmentCommands } from "../commands/environments.js";
6
6
  import { explorationCommands } from "../commands/explorations.js";
7
+ import { groupCommands } from "../commands/groups.js";
8
+ import { healthCommands } from "../commands/health.js";
7
9
  import { mailboxCommands } from "../commands/mailboxes.js";
8
10
  import { initCommands } from "../commands/init.js";
9
11
  import { mcpCommands } from "../commands/mcp.js";
@@ -56,7 +58,9 @@ export const commands = [
56
58
  ...projectCommands,
57
59
  ...environmentCommands,
58
60
  ...testCommands,
61
+ ...groupCommands,
59
62
  ...runCommands,
63
+ ...healthCommands,
60
64
  ...explorationCommands,
61
65
  ...configCommands,
62
66
  ...mailboxCommands,
@@ -0,0 +1,272 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { randomUUID } from "node:crypto";
3
+ import { PostHog } from "posthog-node";
4
+ import { cliVersion } from "./version-check.js";
5
+ // The webapp already ships this public (write-only) project key in its JS bundle, and
6
+ // dev/prod deploys share it — environments are separated by the `environment` property
7
+ // on every event, not by project. Nothing here is configurable on a customer machine.
8
+ const POSTHOG_KEY = "phc_JRs3q08uFEuLKGR6llGAEBpwAuVHtPoEXAj9IcFFhRH";
9
+ const POSTHOG_HOST = "https://us.i.posthog.com";
10
+ // One process = one session, for both surfaces: a CLI invocation is a one-shot session,
11
+ // an MCP server lives as long as the coding agent keeps it spawned.
12
+ export const sessionId = randomUUID();
13
+ let surface = "cli";
14
+ let client;
15
+ export function setSurface(value) {
16
+ surface = value;
17
+ }
18
+ export function setClient(value) {
19
+ client = value;
20
+ }
21
+ export function currentSurface() {
22
+ return surface;
23
+ }
24
+ export function currentClient() {
25
+ return client;
26
+ }
27
+ // The MCP SDK dispatches tool calls concurrently, so "which tool is running" has to be
28
+ // per-async-context, not a module global.
29
+ const toolStore = new AsyncLocalStorage();
30
+ export function withTool(tool, fn) {
31
+ return toolStore.run(tool, fn);
32
+ }
33
+ export function currentTool() {
34
+ return toolStore.getStore();
35
+ }
36
+ export function userAgent() {
37
+ const parts = [surface];
38
+ if (client)
39
+ parts.push(client.version ? `${client.name}/${client.version}` : client.name);
40
+ parts.push(`${process.platform} ${process.arch}`, `node/${process.versions.node}`);
41
+ return `beryl-cli/${cliVersion()} (${parts.join("; ")})`;
42
+ }
43
+ export function attributionHeaders() {
44
+ const headers = {
45
+ "User-Agent": userAgent(),
46
+ "X-Beryl-Session": sessionId,
47
+ };
48
+ const tool = currentTool();
49
+ if (tool)
50
+ headers["X-Beryl-Tool"] = tool;
51
+ return headers;
52
+ }
53
+ export function analyticsEnvironment(apiUrl) {
54
+ let host;
55
+ try {
56
+ host = new URL(apiUrl).hostname;
57
+ }
58
+ catch {
59
+ return "local";
60
+ }
61
+ if (host === "api.beryl.so")
62
+ return "prod";
63
+ if (host === "dev.beryl.so")
64
+ return "dev";
65
+ return "local";
66
+ }
67
+ // Values kept verbatim: ids, references, enum choices and other low-cardinality
68
+ // selectors that make the Sessions view readable. Everything else is reduced to a
69
+ // shape, so free-text arguments (plans, credentials, mail bodies) never leave the
70
+ // machine. Keys are the MCP tool's parameter names, hyphens and all.
71
+ const KEEP_VALUE_KEYS = new Set([
72
+ "account",
73
+ "auth",
74
+ "env",
75
+ "env-id",
76
+ "environment",
77
+ "format",
78
+ "frequency",
79
+ "id",
80
+ "limit",
81
+ "login-method",
82
+ "mailbox",
83
+ "name",
84
+ "project",
85
+ "role",
86
+ "run",
87
+ "scope",
88
+ "status",
89
+ "test",
90
+ "type",
91
+ "url",
92
+ "workspace",
93
+ ]);
94
+ // Catch-all for keys we never enumerated. Deliberately a loose substring match, which
95
+ // makes it fire on innocent names too (`login-method`, `--force-new-login`, `--otp`) —
96
+ // so the allowlist above and booleans, both settled decisions, are checked first, and
97
+ // this only decides the keys nobody classified. It still guards a real `--otp 123456`,
98
+ // because numbers are kept only after it has had its say.
99
+ const SECRET_KEY = /pass|secret|token|key|otp|code|credential|login|value|blob|cookie|email/i;
100
+ const PAT_PREFIX = "beryl_pat_";
101
+ const REDACTED = "<redacted>";
102
+ function shapeOf(value) {
103
+ if (typeof value === "string")
104
+ return `<string:${value.length}>`;
105
+ if (Array.isArray(value))
106
+ return `<array:${value.length}>`;
107
+ if (typeof value === "object")
108
+ return "<object>";
109
+ return `<${typeof value}>`;
110
+ }
111
+ function containsSecret(value) {
112
+ if (typeof value === "string")
113
+ return value.includes(PAT_PREFIX);
114
+ if (Array.isArray(value))
115
+ return value.some(containsSecret);
116
+ if (value && typeof value === "object")
117
+ return Object.values(value).some(containsSecret);
118
+ return false;
119
+ }
120
+ function isRecord(value) {
121
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
122
+ }
123
+ export function redactArguments(args) {
124
+ if (!isRecord(args))
125
+ return shapeOf(args);
126
+ const out = {};
127
+ for (const [key, value] of Object.entries(args)) {
128
+ if (value === null || value === undefined)
129
+ continue;
130
+ if (containsSecret(value)) {
131
+ out[key] = REDACTED;
132
+ }
133
+ else if (KEEP_VALUE_KEYS.has(key) && typeof value === "string") {
134
+ out[key] = value;
135
+ }
136
+ else if (typeof value === "boolean") {
137
+ // true/false cannot carry a credential, whatever the key is called.
138
+ out[key] = value;
139
+ }
140
+ else if (SECRET_KEY.test(key)) {
141
+ out[key] = shapeOf(value);
142
+ }
143
+ else if (typeof value === "number") {
144
+ out[key] = value;
145
+ }
146
+ else {
147
+ out[key] = shapeOf(value);
148
+ }
149
+ }
150
+ return out;
151
+ }
152
+ // The SDK reports parameters as the JSON-RPC envelope — {request:{id,jsonrpc,method,
153
+ // params:{name,arguments,_meta}}} — so the caller's values live at request.params.arguments.
154
+ // An envelope we don't recognise is treated as arguments wholesale rather than passed
155
+ // through: an unfamiliar shape must fail closed, not leak.
156
+ //
157
+ // `_meta` gets the same treatment as arguments. It is the protocol's open extension point,
158
+ // so its contents are whatever the connected client decided to put there (today, Claude
159
+ // Code's tool-use and progress ids) — unknown by definition, and not ours to forward.
160
+ export function redactCapturedParameters(captured) {
161
+ if (!isRecord(captured))
162
+ return shapeOf(captured);
163
+ const request = captured.request;
164
+ const params = isRecord(request) ? request.params : undefined;
165
+ if (!isRecord(request) || !isRecord(params))
166
+ return redactArguments(captured);
167
+ const redacted = { ...params };
168
+ for (const key of ["arguments", "_meta"]) {
169
+ if (params[key] !== undefined)
170
+ redacted[key] = redactArguments(params[key]);
171
+ }
172
+ return { ...captured, request: { ...request, params: redacted } };
173
+ }
174
+ // How much a tool answered with, never what. Reads lengths off strings the SDK already
175
+ // built — constant time whatever the payload — so this must not serialize the result.
176
+ export function responseShape(response) {
177
+ if (!isRecord(response))
178
+ return undefined;
179
+ const content = response.content;
180
+ if (!Array.isArray(content))
181
+ return undefined;
182
+ let bytes = 0;
183
+ for (const part of content) {
184
+ if (!isRecord(part))
185
+ continue;
186
+ // text / image+audio / embedded resource, whose payload hangs one level deeper.
187
+ const resource = isRecord(part.resource) ? part.resource : undefined;
188
+ const payload = part.text ?? part.data ?? resource?.text ?? resource?.blob;
189
+ if (typeof payload === "string")
190
+ bytes += payload.length;
191
+ }
192
+ return { response_parts: content.length, response_bytes: bytes };
193
+ }
194
+ // Agent-written free text: it is told not to include secrets, but that is a prompt, not a
195
+ // guarantee — so it gets the same PAT check as any argument, and a hard length cap.
196
+ const MAX_INTENT = 500;
197
+ function redactIntent(intent) {
198
+ if (typeof intent !== "string")
199
+ return undefined;
200
+ if (intent.includes(PAT_PREFIX))
201
+ return REDACTED;
202
+ return intent.length > MAX_INTENT ? intent.slice(0, MAX_INTENT) : intent;
203
+ }
204
+ // Tool responses carry plans, mailbox contents and run artifacts wholesale — there is no
205
+ // key-level policy that makes them safe, so only their size survives.
206
+ export const beforeSend = (event) => {
207
+ const props = event.properties;
208
+ Object.assign(props, responseShape(props.$mcp_response));
209
+ delete props.$mcp_response;
210
+ if (props.$mcp_parameters !== undefined) {
211
+ props.$mcp_parameters = redactCapturedParameters(props.$mcp_parameters);
212
+ }
213
+ if (props.$mcp_intent !== undefined) {
214
+ props.$mcp_intent = redactIntent(props.$mcp_intent);
215
+ }
216
+ return event;
217
+ };
218
+ export function baseEventProperties(apiUrl) {
219
+ return {
220
+ environment: analyticsEnvironment(apiUrl),
221
+ cli_version: cliVersion(),
222
+ os: process.platform,
223
+ arch: process.arch,
224
+ node_version: process.versions.node,
225
+ api_url: apiUrl,
226
+ // Same uuid as the X-Beryl-Session header, so a PostHog session and the Sentry tags
227
+ // on the API requests it caused describe the same thing.
228
+ beryl_session_id: sessionId,
229
+ };
230
+ }
231
+ let posthog;
232
+ export function createPostHogClient() {
233
+ posthog ??= new PostHog(POSTHOG_KEY, { host: POSTHOG_HOST, disableGeoip: false });
234
+ return posthog;
235
+ }
236
+ export function analyticsOptions(apiUrl, identify) {
237
+ const properties = baseEventProperties(apiUrl);
238
+ return {
239
+ identify,
240
+ beforeSend,
241
+ eventProperties: () => properties,
242
+ // Worth its cost: without the agent's own reason for a call, a session reads as a list
243
+ // of tool names and you cannot tell deliberate work from a loop. The description is
244
+ // ours and deliberately terse — the SDK's default one is ~4x longer, and every word is
245
+ // paid for on all 86 advertised tools on every request (measured: +12.8k tokens vs +3k).
246
+ context: {
247
+ description: "Why this call is being made and how it serves the user's goal, in under 15 words. " +
248
+ "Third person. Never include credentials, tokens or personal data.",
249
+ },
250
+ // Would inject a second parameter into every tool to survive reconnects. A stdio
251
+ // process is already one session, and a user's work is stitched by distinct_id
252
+ // anyway — not worth another schema rewrite for tidier grouping.
253
+ enableConversationId: false,
254
+ reportMissing: false,
255
+ // Tool failures are already returned to the agent as isError and reported by the
256
+ // API's own Sentry; a second $exception stream would double-count them.
257
+ enableExceptionAutocapture: false,
258
+ logger: (message) => process.stderr.write(`[beryl telemetry] ${message}\n`),
259
+ };
260
+ }
261
+ export async function shutdownTelemetry() {
262
+ if (!posthog)
263
+ return;
264
+ const client = posthog;
265
+ posthog = undefined;
266
+ try {
267
+ await client.shutdown(2000);
268
+ }
269
+ catch {
270
+ // Telemetry must never delay or fail the process it is observing.
271
+ }
272
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.27.0",
3
+ "version": "0.32.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",
@@ -31,7 +31,9 @@
31
31
  "docs": "tsx scripts/gen-docs.ts"
32
32
  },
33
33
  "dependencies": {
34
- "@modelcontextprotocol/sdk": "^1.29.0"
34
+ "@modelcontextprotocol/sdk": "^1.29.0",
35
+ "@posthog/mcp": "^0.11.6",
36
+ "posthog-node": "^5.49.1"
35
37
  },
36
38
  "devDependencies": {
37
39
  "@playwright/test": "^1.61.1",