@beryl-so/cli 0.27.0 → 0.29.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
@@ -245,6 +245,17 @@ Trigger a run of a project's tests (e.g. in CI), then watch, inspect, and downlo
245
245
  | `beryl runs download <run-id>` | Download a run's results, with its artifacts, to disk | `runs_download` |
246
246
  | `beryl runs explain <result-id>` | Explain, with AI, why a test result failed | `runs_explain` |
247
247
 
248
+ ### health
249
+
250
+ 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.
251
+
252
+ `beryl health` with no subcommand runs `health get`.
253
+
254
+ | Command | Summary | MCP tool |
255
+ | --- | --- | --- |
256
+ | `beryl health get` | Show the latest Site Health report for a project environment | `health_get` |
257
+ | `beryl health run` | Run a fresh Site Health check for a project environment | `health_run` |
258
+
248
259
  ### explorations
249
260
 
250
261
  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
  }
@@ -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
+ ];
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,7 @@ 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 { healthCommands } from "../commands/health.js";
7
8
  import { mailboxCommands } from "../commands/mailboxes.js";
8
9
  import { initCommands } from "../commands/init.js";
9
10
  import { mcpCommands } from "../commands/mcp.js";
@@ -57,6 +58,7 @@ export const commands = [
57
58
  ...environmentCommands,
58
59
  ...testCommands,
59
60
  ...runCommands,
61
+ ...healthCommands,
60
62
  ...explorationCommands,
61
63
  ...configCommands,
62
64
  ...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.29.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",