metergraph-cli 0.2.1 → 0.2.3

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.
Files changed (43) hide show
  1. package/README.md +60 -11
  2. package/assets/skill/SKILL.md +52 -3
  3. package/assets/skill/manifest.json +3 -3
  4. package/assets/skills/manifest.json +13 -13
  5. package/assets/skills/metergraph-analyze/SKILL.md +62 -4
  6. package/assets/skills/metergraph-investigate/SKILL.md +222 -35
  7. package/assets/skills/metergraph-onboarding/SKILL.md +7 -0
  8. package/assets/skills/metergraph-report/SKILL.md +19 -2
  9. package/bin/metergraph.js +1 -1
  10. package/package.json +1 -1
  11. package/src/agent-key.js +160 -0
  12. package/src/approval-handoff.js +5 -0
  13. package/src/args.js +572 -17
  14. package/src/auth-browser.js +5 -19
  15. package/src/auth-callback.js +24 -15
  16. package/src/auth-login.js +55 -12
  17. package/src/auth-oauth.js +77 -7
  18. package/src/callback-page.js +58 -0
  19. package/src/cli.js +190 -45
  20. package/src/constants.js +73 -3
  21. package/src/environment.js +169 -0
  22. package/src/experiments-contract.js +462 -0
  23. package/src/experiments-output.js +189 -0
  24. package/src/experiments.js +397 -0
  25. package/src/origin.js +6 -0
  26. package/src/output.js +437 -131
  27. package/src/pricing-args.js +163 -0
  28. package/src/pricing-output.js +206 -0
  29. package/src/pricing-reference.js +195 -0
  30. package/src/pricing.js +307 -0
  31. package/src/read-contract.js +976 -16
  32. package/src/read-output.js +449 -30
  33. package/src/read.js +312 -60
  34. package/src/setup-deployment.js +11 -3
  35. package/src/setup-interactive.js +341 -0
  36. package/src/setup-refusal.js +172 -0
  37. package/src/setup-state.js +4 -2
  38. package/src/setup.js +227 -53
  39. package/src/skill-bundle.js +2 -2
  40. package/src/style.js +101 -0
  41. package/src/transport.js +13 -7
  42. package/src/usage-aggregates.js +197 -0
  43. package/src/verify-output.js +21 -11
package/src/cli.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { runPricing } from "./pricing.js";
2
+ import { pricingText, pricingHtml } from "./pricing-output.js";
1
3
  import { READ_COMMANDS, parseArgs } from "./args.js";
2
4
  import { runLogin, runLogout } from "./auth-login.js";
3
5
  import { runDoctor } from "./doctor.js";
@@ -15,22 +17,35 @@ import {
15
17
  versionData,
16
18
  } from "./output.js";
17
19
  import { runRead } from "./read.js";
20
+ import { runExperimentStart } from "./experiments.js";
21
+ import { experimentsMessage, experimentsText } from "./experiments-output.js";
18
22
  import { readMessage, readText } from "./read-output.js";
19
23
  import { listSkillPack, runSkill, runSkillPack } from "./skill.js";
20
24
  import { runSetup } from "./setup.js";
21
25
  import { containsKnownCredential, preflightNonHostedSetup, prerequisiteGuide, resumeNonHostedRoute } from "./setup-deployment.js";
26
+ import {
27
+ ASKABLE,
28
+ agentAccessOffer,
29
+ canAsk,
30
+ commandLine,
31
+ fillSetupArgv,
32
+ offerAgentAccess,
33
+ terminalPrompter,
34
+ } from "./setup-interactive.js";
22
35
  import { VERSION } from "./constants.js";
36
+ import { createStyle, fields, headline, paragraph, render, steps, toneFor } from "./style.js";
23
37
  import { runVerify } from "./verify.js";
24
38
  import { verifyMessage, verifyText } from "./verify-output.js";
25
39
 
26
40
  // Runs one command and resolves with the exit code. With --json, stdout gets
27
41
  // exactly one JSON line and stderr stays empty. Without it, results go to
28
- // stdout and usage or internal errors go to stderr. Never reads stdin.
29
- export async function main(argv, { stdout, stderr }) {
42
+ // stdout and usage or internal errors go to stderr. Only setup reads stdin,
43
+ // and only to ask a person at a terminal (see setup-interactive.js).
44
+ export async function main(argv, { stdout, stderr, stdin = null }) {
30
45
  const json = argv.includes("--json");
31
46
  let result;
32
47
  try {
33
- result = await run(argv, { stdout, stderr });
48
+ result = await run(argv, { stdout, stderr, stdin });
34
49
  } catch {
35
50
  result = envelope({
36
51
  command: null,
@@ -44,8 +59,33 @@ export async function main(argv, { stdout, stderr }) {
44
59
  return result.exit_code;
45
60
  }
46
61
 
47
- async function run(argv, { stdout, stderr }) {
48
- const parsed = parseArgs(argv);
62
+ async function run(argv, { stdout, stderr, stdin }) {
63
+ let parsed = parseArgs(argv);
64
+ const style = createStyle(stdout);
65
+ const say = (...lines) => stdout.write(lines.map((line) => `${line}\n`).join(""));
66
+ const asking = canAsk(argv, { stdin, stdout });
67
+
68
+ // A person at a terminal is asked for what setup is missing; the answers
69
+ // become flags and go through the same parsing as a script's.
70
+ if (parsed.command === "setup" && !parsed.ok && ASKABLE.has(parsed.code) && asking) {
71
+ const prompter = terminalPrompter(stdin, stdout);
72
+ let filled;
73
+ try {
74
+ filled = await fillSetupArgv(argv, { ask: prompter.ask, say });
75
+ } finally {
76
+ prompter.close();
77
+ }
78
+ if (filled === null) {
79
+ stderr.write("Setup was cancelled. Nothing was changed.\n");
80
+ return envelope({ command: "setup", outcome: "cancelled", reason: "questions_cancelled",
81
+ message: "Setup was cancelled. Nothing was changed." });
82
+ }
83
+ argv = filled;
84
+ parsed = parseArgs(argv);
85
+ // Only a command that parses is shown: a value the parser refuses may be
86
+ // a mistyped credential, and its error never echoes input.
87
+ if (parsed.ok) say("", `Running: ${commandLine(filled)}`, "");
88
+ }
49
89
 
50
90
  if (!parsed.ok) {
51
91
  const result = envelope({
@@ -55,13 +95,13 @@ async function run(argv, { stdout, stderr }) {
55
95
  message: parsed.message,
56
96
  });
57
97
  if (parsed.json) stdout.write(toJsonLine(result));
58
- else stderr.write(`Error: ${parsed.message}\n`);
98
+ else stderr.write(`${createStyle(stderr).red("Error:")} ${parsed.message}\n`);
59
99
  return result;
60
100
  }
61
101
 
62
102
  if (parsed.command === "help") {
63
103
  const result = envelope({ command: "help", outcome: "ok", data: helpData(parsed.topic) });
64
- stdout.write(parsed.json ? toJsonLine(result) : helpText(parsed.topic));
104
+ stdout.write(parsed.json ? toJsonLine(result) : helpText(parsed.topic, style));
65
105
  return result;
66
106
  }
67
107
 
@@ -71,6 +111,14 @@ async function run(argv, { stdout, stderr }) {
71
111
  return result;
72
112
  }
73
113
 
114
+ if (parsed.command === "pricing") {
115
+ const response = await runPricing(parsed);
116
+ const result = envelope({ command: `pricing ${parsed.action}`, ...response });
117
+ stdout.write(parsed.json ? toJsonLine(result) :
118
+ parsed.format === "html" && result.ok ? pricingHtml(result.data) : pricingText(result, parsed.format, style));
119
+ return result;
120
+ }
121
+
74
122
  if (parsed.command === "login" || parsed.command === "logout") {
75
123
  // Human progress goes to stderr, never with --json, so stdout carries
76
124
  // only the final result.
@@ -87,7 +135,7 @@ async function run(argv, { stdout, stderr }) {
87
135
  data,
88
136
  message: outcome === "ok" ? null : message,
89
137
  });
90
- stdout.write(parsed.json ? toJsonLine(result) : authText(result, message));
138
+ stdout.write(parsed.json ? toJsonLine(result) : authText(result, message, style));
91
139
  return result;
92
140
  }
93
141
 
@@ -104,22 +152,30 @@ async function run(argv, { stdout, stderr }) {
104
152
  ? await runSetup({ ...options, expectedProfile: routed.profile ?? null, announce: pending.announce }, progress)
105
153
  : routed;
106
154
  if (pending.result) return pending.result;
107
- const { outcome, reason, data } = response;
155
+ const { outcome, reason } = response;
156
+ // The customer-local bundle can give the agent more than Metadata
157
+ // without a sign in; say how.
158
+ const offer = outcome === "ok" && routed.profile === "local";
159
+ const data = offer ? { ...response.data, agent_access: agentAccessOffer(options.origin) } : response.data;
108
160
  let result = envelope({ command: "setup", outcome, reason, data,
109
161
  message: outcome === "ok" ? null : "Setup did not complete. Review the reason and retry safely." });
110
162
  if (containsKnownCredential(result, routed)) {
111
163
  result = envelope({ command: "setup", outcome: "unsupported", reason: "credential_echo" });
112
164
  }
113
- const receipt = result.data?.receipt;
114
- const summary = receipt ? `Workspace: ${receipt.workspace_id}\nDeployment: ${receipt.deployment_profile} at ${receipt.origin}\nCompleted: ${receipt.completed_steps.join(", ")}\nPending: ${receipt.pending_steps.join(", ")}\n` : "";
115
- const identity = repositoryLine(result.data?.repository);
116
- const guide = handoffLines(result.data);
117
- const human = `Metergraph setup: ${result.data?.status ?? "not_ready"}\n${summary}${identity}${guide}Application traffic verified: no\n${result.ok ? "Next: instrument your application and verify an exact trace.\n" : `Result: ${result.outcome} (${result.error.reason})\n`}`;
165
+ const human = setupText(result, style, { asking });
118
166
  if (containsKnownCredential(human, routed)) {
119
167
  result = envelope({ command: "setup", outcome: "unsupported", reason: "credential_echo" });
120
168
  stdout.write(parsed.json ? toJsonLine(result) : "Setup stopped.\n");
121
- } else {
122
- stdout.write(parsed.json ? toJsonLine(result) : human);
169
+ return result;
170
+ }
171
+ stdout.write(parsed.json ? toJsonLine(result) : human);
172
+ if (offer && asking && result.ok) {
173
+ const prompter = terminalPrompter(stdin, stdout);
174
+ try {
175
+ await offerAgentAccess({ client: options.client, origin: options.origin, prompter, say });
176
+ } finally {
177
+ prompter.close();
178
+ }
123
179
  }
124
180
  return result;
125
181
  }
@@ -134,7 +190,23 @@ async function run(argv, { stdout, stderr }) {
134
190
  data,
135
191
  message: outcome === "ok" ? null : message,
136
192
  });
137
- stdout.write(parsed.json ? toJsonLine(result) : readText(result, message));
193
+ stdout.write(parsed.json ? toJsonLine(result) : readText(result, message, style));
194
+ return result;
195
+ }
196
+
197
+ if (parsed.command === "experiments") {
198
+ // start is the one write: its own key session (experiments.js). The other
199
+ // actions are Metadata reads through runRead's shared session.
200
+ const { outcome, reason, data } = parsed.action === "start" ? await runExperimentStart(parsed) : await runRead(parsed);
201
+ const message = experimentsMessage(outcome, reason);
202
+ const result = envelope({
203
+ command: `experiments ${parsed.action}`,
204
+ outcome,
205
+ reason,
206
+ data,
207
+ message: outcome === "ok" ? null : message,
208
+ });
209
+ stdout.write(parsed.json ? toJsonLine(result) : experimentsText(result, message, style, parsed.action));
138
210
  return result;
139
211
  }
140
212
 
@@ -142,7 +214,7 @@ async function run(argv, { stdout, stderr }) {
142
214
  const { outcome, reason, data } = await runVerify(parsed);
143
215
  const message = verifyMessage(outcome, reason);
144
216
  const result = envelope({ command: "verify", outcome, reason, data, message: outcome === "ok" ? null : message });
145
- stdout.write(parsed.json ? toJsonLine(result) : verifyText(result));
217
+ stdout.write(parsed.json ? toJsonLine(result) : verifyText(result, style));
146
218
  return result;
147
219
  }
148
220
 
@@ -155,7 +227,7 @@ async function run(argv, { stdout, stderr }) {
155
227
  data,
156
228
  message: outcome === "ok" ? null : message,
157
229
  });
158
- stdout.write(parsed.json ? toJsonLine(result) : skillText(result, message));
230
+ stdout.write(parsed.json ? toJsonLine(result) : skillText(result, message, style));
159
231
  return result;
160
232
  }
161
233
 
@@ -169,7 +241,7 @@ async function run(argv, { stdout, stderr }) {
169
241
  data,
170
242
  message: outcome === "ok" ? null : message,
171
243
  });
172
- stdout.write(parsed.json ? toJsonLine(result) : skillsText(result, message));
244
+ stdout.write(parsed.json ? toJsonLine(result) : skillsText(result, message, style));
173
245
  return result;
174
246
  }
175
247
 
@@ -184,7 +256,7 @@ async function run(argv, { stdout, stderr }) {
184
256
  data: report,
185
257
  message: doctorMessage(outcome, reason),
186
258
  });
187
- stdout.write(parsed.json ? toJsonLine(result) : doctorText(result));
259
+ stdout.write(parsed.json ? toJsonLine(result) : doctorText(result, style));
188
260
  return result;
189
261
  }
190
262
 
@@ -209,39 +281,112 @@ function announcer(parsed, stdout, screen) {
209
281
  return pending;
210
282
  }
211
283
 
212
- // What an operator handoff is waiting for, in fixed text: the route, each
213
- // unconfirmed prerequisite with its guidance, and the next action.
214
- function handoffLines(data) {
215
- if (data?.status !== "operator_handoff") return "";
216
- let text = data.origin ? `Deployment: ${data.deployment_profile} at ${data.origin}\n` : "";
217
- if (data.workspace_id) text += `Workspace: ${data.workspace_id}\n`;
284
+ const SETUP_TITLES = {
285
+ ready_for_instrumentation: "Setup complete, ready to instrument",
286
+ operator_handoff: "Setup is waiting on the operator",
287
+ };
288
+
289
+ function setupText(result, style, { asking = false } = {}) {
290
+ const data = result.data;
291
+ const status = data?.status ?? "not_ready";
292
+ const title = SETUP_TITLES[status] ?? (result.ok ? `Setup: ${status}` : "Setup did not complete");
293
+ const tone = status === "operator_handoff" ? "warn" : toneFor(result.outcome);
294
+ const lines = [headline(style, tone, title, result.outcome), ""];
295
+ const receipt = data?.receipt;
296
+ const rows = [];
297
+ if (receipt) {
298
+ rows.push(
299
+ ["Workspace", receipt.workspace_id],
300
+ ["Deployment", `${receipt.deployment_profile} at ${receipt.origin}`],
301
+ ["Completed", receipt.completed_steps.join(", ") || "none"],
302
+ ["Pending", receipt.pending_steps.join(", ") || "none"],
303
+ );
304
+ }
305
+ const repository = repositoryValue(data?.repository);
306
+ if (repository !== null) rows.push(["Repository", repository]);
307
+ if (status === "operator_handoff") {
308
+ if (data.origin) rows.push(["Deployment", `${data.deployment_profile} at ${data.origin}`]);
309
+ if (data.workspace_id) rows.push(["Workspace", data.workspace_id]);
310
+ }
311
+ rows.push(["Application traffic verified", "no"]);
312
+ lines.push(...fields(style, rows), "");
313
+ if (!result.ok) lines.push(...paragraph(style, result.error.message), ` ${style.dim(`Reason: ${result.error.reason}`)}`, "");
314
+ lines.push(...handoffLines(data, style));
315
+ lines.push(...remoteLines(data, style));
316
+ lines.push(...budgetLines(data, style));
317
+ if (result.ok) lines.push(...steps(style, [["", "Instrument your application and verify an exact trace."]]));
318
+ // At a terminal the offer is a question asked next instead.
319
+ if (data?.agent_access && !asking) lines.push(...agentAccessLines(data.agent_access, style));
320
+ return render(lines);
321
+ }
322
+
323
+ // How to give the agent more than Metadata on the customer-local bundle.
324
+ function agentAccessLines(access, style) {
325
+ const offer = access.offer;
326
+ return ["", ...steps(style, [
327
+ ["", "Your coding agent has Metadata access. To let it write evaluations and start analyses, " +
328
+ "run this in the bundle directory. It shows what it grants and asks you to confirm, then writes " +
329
+ "the key to that file without printing it."],
330
+ ["In the bundle directory", style.bold(offer.command)],
331
+ ["Bundle without bin/agent-key", offer.keys_page_url],
332
+ ]), ""];
333
+ }
334
+
335
+ // What an operator handoff is waiting for, in fixed text: each unconfirmed
336
+ // prerequisite with its guidance, and the next action.
337
+ function handoffLines(data, style) {
338
+ if (data?.status !== "operator_handoff" || data.next_action?.kind === "run_on_local_machine") return [];
339
+ const lines = [];
218
340
  if (data.pending_prerequisites.length > 0) {
219
- text += "Confirm each prerequisite, then rerun with --confirm-prerequisites:\n";
341
+ lines.push("Confirm each prerequisite, then rerun with --confirm-prerequisites:");
220
342
  for (const name of data.pending_prerequisites) {
221
- text += ` - ${name}: ${prerequisiteGuide(name, data.deployment_profile) ?? "see the guide"}\n`;
343
+ lines.push(` ${style.yellow("•")} ${style.bold(name)}`,
344
+ ...paragraph(style, prerequisiteGuide(name, data.deployment_profile) ?? "See the guide.", " "));
222
345
  }
346
+ lines.push("");
223
347
  }
224
348
  const next = data.next_action;
225
- if (next?.message && !(next.kind === "complete_prerequisite" && data.pending_prerequisites.includes(next.prerequisite))) {
226
- text += `Next: ${next.message}\n`;
227
- }
228
- if (next?.url) text += `Guide: ${next.url}\n`;
229
- return text;
349
+ const message = next?.message && !(next.kind === "complete_prerequisite" && data.pending_prerequisites.includes(next.prerequisite))
350
+ ? next.message : null;
351
+ lines.push(...steps(style, [["", message], ["Guide", next?.url ?? null]]), "");
352
+ return lines;
353
+ }
354
+
355
+ // A refused key issuance: wait for the next window, or revoke unused keys.
356
+ function budgetLines(data, style) {
357
+ const next = data?.next_action;
358
+ if (next?.kind !== "retry_later" && next?.kind !== "revoke_keys") return [];
359
+ return [...steps(style, [["", next.message], ["Keys page", next.url ?? null]]), ""];
360
+ }
361
+
362
+ // A remote session's handoff: where setup runs instead, and how this
363
+ // environment can still make Metadata reads with a separate agent key.
364
+ function remoteLines(data, style) {
365
+ const next = data?.next_action;
366
+ if (next?.kind !== "run_on_local_machine") return [];
367
+ const keys = next.metadata_reads?.person_steps.find((step) => step.action === "create_metadata_key")?.url ?? null;
368
+ return [...steps(style, [
369
+ ["", next.message],
370
+ ["On the person's own machine", style.bold(next.command)],
371
+ ["Metadata key for reads here", keys],
372
+ ["Then run here", next.metadata_reads ? style.bold(next.metadata_reads.then_run.join(", then ")) : null],
373
+ ["Guide", next.url],
374
+ ]), ""];
230
375
  }
231
376
 
232
- // One line on the repository identity setup found or recorded. The value is
233
- // owner/name only; a remote URL is never shown.
234
- function repositoryLine(repository) {
235
- if (!repository) return "";
377
+ // The repository identity setup found or recorded. The value is owner/name
378
+ // only; a remote URL is never shown.
379
+ function repositoryValue(repository) {
380
+ if (!repository) return null;
236
381
  const where = repository.path ? ` in ${repository.path}` : repository.source === "env_file" ? " in the env file" : "";
237
382
  switch (repository.status) {
238
- case "written": return `Repository: ${repository.repository} (recorded${where}; commit it)\n`;
383
+ case "written": return `${repository.repository} (recorded${where}; commit it)`;
239
384
  case "existing": return repository.repository === null
240
- ? `Repository: already set${where}\n` : `Repository: ${repository.repository} (already set${where})\n`;
241
- case "mismatch": return `Repository: ${repository.repository} (already set${where}; differs from ${repository.expected}, left unchanged)\n`;
242
- case "invalid_config": return `Repository: not recorded (${repository.path} is not a usable config; left unchanged)\n`;
243
- case "skipped": return "Repository: not recorded (--no-repository)\n";
244
- case "write_failed": return `Repository: not recorded (could not write ${repository.path})\n`;
245
- default: return "Repository: not recorded (no owner/name git remote; pass --repository OWNER/NAME)\n";
385
+ ? `already set${where}` : `${repository.repository} (already set${where})`;
386
+ case "mismatch": return `${repository.repository} (already set${where}; differs from ${repository.expected}, left unchanged)`;
387
+ case "invalid_config": return `not recorded (${repository.path} is not a usable config; left unchanged)`;
388
+ case "skipped": return "not recorded (--no-repository)";
389
+ case "write_failed": return `not recorded (could not write ${repository.path})`;
390
+ default: return "not recorded (no owner/name git remote; pass --repository OWNER/NAME)";
246
391
  }
247
392
  }
package/src/constants.js CHANGED
@@ -95,15 +95,68 @@ export const AUTH_PATHS = Object.freeze({
95
95
  // --limit locally.
96
96
  export const READ_PATHS = Object.freeze({
97
97
  usage: "/v1/agent/usage",
98
+ usageAggregates: "/v1/agent/usage/aggregates",
98
99
  routes: "/v1/agent/routes",
99
100
  traces: "/v1/agent/traces",
100
101
  });
102
+ const PRICING_QUERY_KEYS = Object.freeze(["model", "provider", "channel", "region", "at", "limit",
103
+ "input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens", "cache_write_5m_tokens", "cache_write_1h_tokens", "batch"]);
101
104
  export const READ_QUERY_KEYS = Object.freeze({
102
- "/v1/agent/usage": Object.freeze(["days", "limit"]),
103
- "/v1/agent/traces": Object.freeze(["days", "limit", "route", "status", "cursor"]),
105
+ "/v1/pricing/lookup": PRICING_QUERY_KEYS,
106
+ "/v1/pricing/cost": PRICING_QUERY_KEYS,
107
+ "/v1/pricing/history": PRICING_QUERY_KEYS,
108
+ "/v1/pricing/market": PRICING_QUERY_KEYS,
109
+ "/v1/agent/usage": Object.freeze(["days", "limit", "metadata"]),
110
+ "/v1/agent/experiments": Object.freeze(["workload_id", "limit"]),
111
+ "/v1/agent/usage/aggregates": Object.freeze([
112
+ "group_by", "days", "since", "until", "limit", "route", "provider", "model", "environment", "metadata",
113
+ ]),
114
+ "/v1/agent/traces": Object.freeze(["days", "limit", "route", "status", "cursor", "metadata"]),
104
115
  "/v1/cli/setup/credential": Object.freeze(["family_id"]),
105
116
  });
106
117
 
118
+ // Query keys that may repeat on a path, one value each, as the service reads
119
+ // list parameters. Every other key may appear at most once.
120
+ export const READ_QUERY_REPEATABLE = Object.freeze({
121
+ // One group_by per dimension, so a metadata key is never split on a comma.
122
+ "/v1/agent/usage/aggregates": Object.freeze(["group_by", "route", "provider", "model", "environment"]),
123
+ });
124
+
125
+ // Grouped usage (GET /v1/agent/usage/aggregates). Dimensions the service can
126
+ // group by (plus at most one "metadata.<key>"), the most values one filter may name, and how far apart the two
127
+ // ends of an explicit window may be.
128
+ export const USAGE_DIMENSIONS = Object.freeze(["provider", "model", "route", "day"]);
129
+ export const USAGE_DEFAULT_GROUP_BY = Object.freeze(["provider", "model"]);
130
+ export const USAGE_MAX_FILTER_VALUES = 20;
131
+ export const USAGE_SORTS = Object.freeze(["cost", "cost-per-call", "calls", "errors", "avg-latency", "p95-latency", "day"]);
132
+ export const USAGE_COMPARISONS = Object.freeze(["previous", "previous-same-time"]);
133
+
134
+ // Experiments: replay a workload's recent production calls on 1 to 3
135
+ // treatments and compare them with production. The list, estimate and
136
+ // summary are Metadata reads at these fixed paths (one experiment is
137
+ // EXPERIMENT_PATHS.list plus "/" and its UUID). Starting one is the only
138
+ // write this CLI makes to a workspace: it needs a separate coding-agent key
139
+ // with Debug access and the EXPERIMENT_START_SCOPE grant, used for that one
140
+ // request and nothing else. The limits match the service's own.
141
+ export const EXPERIMENT_PATHS = Object.freeze({
142
+ list: "/v1/agent/experiments",
143
+ estimate: "/v1/agent/experiments/estimate",
144
+ start: "/v1/agent/experiments",
145
+ });
146
+ export const EXPERIMENT_START_SCOPE = "agent:analysis-run";
147
+ // Exactly the scopes a start key carries: Debug and the start grant. A key
148
+ // with any other scope (Replay, other workflow grants, Metadata, ingest) is
149
+ // refused, so the key in an agent's environment can do no more than this.
150
+ export const EXPERIMENT_START_KEY_SCOPES = Object.freeze(["agent:read", EXPERIMENT_START_SCOPE]);
151
+ export const EXPERIMENT_LAST_N = Object.freeze({ min: 10, max: 40, default: 20 });
152
+ export const EXPERIMENT_MAX_TREATMENTS = 3;
153
+ export const EXPERIMENT_MAX_TRACKED_FIELDS = 10;
154
+ export const EXPERIMENT_LIST_DEFAULT_LIMIT = 20;
155
+ export const EXPERIMENT_LIST_MAX_LIMIT = 50;
156
+ export const EXPERIMENT_TEXT_LIMITS = Object.freeze({ workload: 500, provider: 60, model: 200, label: 40, path: 200 });
157
+ export const EXPERIMENT_MAX_TOKENS = 32000;
158
+ export const EXPERIMENT_REASONING_EFFORTS = Object.freeze(["none", "minimal", "low", "medium", "high", "xhigh", "max"]);
159
+
107
160
  // Read commands share one total deadline for every request they make,
108
161
  // including a token refresh. It is never reset per request or page.
109
162
  export const READ_DEFAULT_TIMEOUT_MS = 15000;
@@ -119,6 +172,15 @@ export const READ_DEFAULT_LIMIT = 50;
119
172
  export const TRACES_DEFAULT_LIMIT = 20;
120
173
  export const MAX_CURSOR_LENGTH = 512;
121
174
  export const MAX_FILTER_LENGTH = 256;
175
+ // Exact customer-metadata filters, matching the service's bounds. The service
176
+ // also refuses credential-like keys; the CLI checks the same rule first.
177
+ export const MAX_METADATA_FILTERS = 5;
178
+ export const MAX_METADATA_KEY_LENGTH = 200;
179
+ export const MAX_METADATA_VALUE_LENGTH = 1000;
180
+ // The longest query string the transport sends. Read arguments are checked
181
+ // against the exact query they produce, so a long one is an input error
182
+ // before any request rather than a failure after sign in.
183
+ export const MAX_QUERY_LENGTH = 2048;
122
184
 
123
185
  // One exit code per outcome. Documented in README.md; changing a value is a
124
186
  // breaking change for scripts. New outcomes are appended.
@@ -142,6 +204,8 @@ export const EXIT_CODES = Object.freeze({
142
204
  rate_limited: 16,
143
205
  cancelled: 17,
144
206
  action_required: 18,
207
+ refused: 19,
208
+ unconfirmed: 20,
145
209
  });
146
210
 
147
211
  export const EXIT_CODE_MEANINGS = Object.freeze({
@@ -175,11 +239,17 @@ export const EXIT_CODE_MEANINGS = Object.freeze({
175
239
  "The service does not make this read available to the project's Metadata grant. No data was read.",
176
240
  permission_denied:
177
241
  "The service refused this read for the signed in grant, for example for a missing scope or permission.",
178
- rate_limited: "The service asked the CLI to slow down. Nothing was retried. Try again later.",
242
+ rate_limited: "The service asked the CLI to slow down. Try again later. Only setup retries, after a short Retry-After, to keep its approval.",
179
243
  cancelled:
180
244
  "A read command was interrupted before it finished. Read commands never change workspace configuration or telemetry.",
181
245
  action_required:
182
246
  "A person must act before this command can finish; data.next_action says what to do. " +
183
247
  "With --json --no-browser the command exits as soon as the approval URL exists, and a background " +
184
248
  "process waits for approval until --timeout-ms. After approval, run the same command again to continue.",
249
+ refused:
250
+ "The service refused the experiment for a stated reason, such as its budget, the daily cap, experiments " +
251
+ "already running or a request it cannot price; data.refusal says which. Nothing was started.",
252
+ unconfirmed:
253
+ "An experiment start reached the service but its result was not confirmed, so an experiment may have " +
254
+ "been recorded and may run. List the workload's experiments before starting again: each start creates one.",
185
255
  });
@@ -0,0 +1,169 @@
1
+ import fs from "node:fs";
2
+
3
+ import { CONNECTION_GUIDE_URL, DEFAULT_ORIGIN } from "./constants.js";
4
+
5
+ // Where this CLI runs, for the sign in and setup path matrix. Only the
6
+ // presence of a variable or file is checked; no value is ever read into
7
+ // output.
8
+ //
9
+ // local_shell A terminal on the person's own machine. Browser sign in
10
+ // and setup work here.
11
+ // local_container A container on the person's machine (Docker, Podman, a
12
+ // local dev container) with no remote marker. It is not
13
+ // treated as remote: browser sign in works when the
14
+ // container's loopback port is forwarded, and --no-browser
15
+ // prints the URL to open.
16
+ // ssh_session A shell reached over SSH. The person's browser is on
17
+ // another machine.
18
+ // cloud_workspace A hosted development environment (Codespaces, Gitpod,
19
+ // Cloud Shell, or a remote dev container). The person's
20
+ // browser is on another machine.
21
+ // ci A CI job. No person is present.
22
+ //
23
+ // Remote markers win over container markers: a cloud workspace usually runs
24
+ // in a container, and it is still remote.
25
+
26
+ const REMOTE_MARKERS = [
27
+ ["ssh_session", ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"]],
28
+ ["cloud_workspace", ["CODESPACES", "GITPOD_WORKSPACE_ID", "CLOUD_SHELL"]],
29
+ ["ci", ["CI"]],
30
+ ];
31
+ const CONTAINER_VARIABLES = ["REMOTE_CONTAINERS", "DEVCONTAINER", "container"];
32
+ const CONTAINER_FILES = ["/.dockerenv", "/run/.containerenv"];
33
+
34
+ // The fixed reason each remote kind reports. These are the reasons login and
35
+ // setup have always used, so scripts that match them keep working.
36
+ const REMOTE_REASONS = Object.freeze({
37
+ ssh_session: "ssh_session",
38
+ cloud_workspace: "cloud_workspace",
39
+ ci: "ci_environment",
40
+ });
41
+
42
+ // The secret name an agent reads its Metadata key from. It matches the name
43
+ // the hosted Keys page and the Agent Access MCP setup already use.
44
+ export const AGENT_TOKEN_ENV = "METERGRAPH_AGENT_TOKEN";
45
+ // The origin an agent key is sent to, when no project binding and no --url
46
+ // names one. It matches the MCP setup's variable.
47
+ export const AGENT_URL_ENV = "METERGRAPH_URL";
48
+
49
+ function present(value) {
50
+ return typeof value === "string" && value !== "" && value !== "0" && value.toLowerCase() !== "false";
51
+ }
52
+
53
+ function defaultFileExists(file) {
54
+ try {
55
+ return fs.existsSync(file);
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ // Returns { kind, remote, reason } where reason is null for a local kind.
62
+ export function classifyEnvironment(env = process.env, { fileExists = defaultFileExists } = {}) {
63
+ for (const [kind, names] of REMOTE_MARKERS) {
64
+ if (names.some((name) => present(env[name]))) return { kind, remote: true, reason: REMOTE_REASONS[kind] };
65
+ }
66
+ const container =
67
+ CONTAINER_VARIABLES.some((name) => present(env[name])) || CONTAINER_FILES.some((file) => fileExists(file));
68
+ return { kind: container ? "local_container" : "local_shell", remote: false, reason: null };
69
+ }
70
+
71
+ // Hosted pages a person opens on their own device. The Keys page creates a
72
+ // coding-agent key in the workspace the person is signed in to, which is how
73
+ // the person chooses the workspace. Sign up exists only on the hosted service.
74
+ export function handoffPages(origin) {
75
+ const hosted = origin === DEFAULT_ORIGIN;
76
+ return {
77
+ sign_up_url: hosted ? `${origin}/v1/auth/signup?return_to=${encodeURIComponent("/#api-keys")}` : null,
78
+ keys_url: `${origin}/#api-keys`,
79
+ };
80
+ }
81
+
82
+ // The structured next action for an agent that cannot finish browser sign in
83
+ // where it runs, or has no credential yet. It names what a person does on
84
+ // their own device, where the key goes, and the exact commands the agent runs
85
+ // afterwards. The key itself never passes through the agent's chat, a command
86
+ // line or a file in the project.
87
+ export function agentKeyAction(environment, origin) {
88
+ const pages = handoffPages(origin);
89
+ const where = environment.kind === "ci"
90
+ ? `a CI secret exposed to this job as ${AGENT_TOKEN_ENV}`
91
+ : `this environment's secret settings as ${AGENT_TOKEN_ENV}`;
92
+ const steps = [];
93
+ if (pages.sign_up_url !== null) {
94
+ steps.push({ action: "sign_up_or_sign_in", url: pages.sign_up_url });
95
+ } else {
96
+ steps.push({ action: "request_invitation", url: null });
97
+ }
98
+ steps.push({ action: "create_metadata_key", url: pages.keys_url });
99
+ steps.push({ action: "store_secret", name: AGENT_TOKEN_ENV });
100
+ // A key for a deployment other than the hosted service must only ever be
101
+ // sent there, so the commands name its origin; without it they would use
102
+ // the hosted default.
103
+ const hosted = origin === DEFAULT_ORIGIN;
104
+ const url = hosted ? "" : ` --url ${shellArg(origin)}`;
105
+ return {
106
+ kind: "use_agent_key",
107
+ environment: environment.kind,
108
+ credential_env: AGENT_TOKEN_ENV,
109
+ origin,
110
+ person_steps: steps,
111
+ then_run: [`metergraph status${url} --json`, `metergraph usage${url} --json`],
112
+ message:
113
+ (environment.remote === false
114
+ ? "If browser sign in cannot finish here, use a Metadata key instead. "
115
+ : "Browser sign in cannot finish here. ") +
116
+ "Ask the person to sign in on their own device, open the Keys page " +
117
+ "in the workspace this agent should read, create a coding-agent key with Metadata access, and save it in " +
118
+ `${where}. Do not ask them to paste the key into chat or a command. Then run the listed commands; ` +
119
+ "they verify the origin, workspace, scope and capabilities before reading." +
120
+ (hosted
121
+ ? ""
122
+ : " On the open-source self-hosted server there is no Keys page: the operator provides one of its " +
123
+ "MG_AGENT_TOKENS values instead."),
124
+ alternative: { kind: "run_on_local_machine", command: "metergraph login --runtime local" },
125
+ url: CONNECTION_GUIDE_URL,
126
+ };
127
+ }
128
+
129
+ // The next action for setup in a remote session. Ingest setup writes the
130
+ // application's own key and needs a local browser approval, so it runs on the
131
+ // person's machine. Metadata reads can still use an agent key here; that key
132
+ // is separate from the ingest key and never replaces it.
133
+ // options are the parsed setup options; the command repeats the caller's
134
+ // deployment, origin, workspace and skill choice so it runs as given.
135
+ export function remoteSetupAction(environment, origin, options = {}) {
136
+ return {
137
+ kind: "run_on_local_machine",
138
+ environment: environment.kind,
139
+ command: setupCommand(origin, options),
140
+ message:
141
+ "Setup approves an ingest key in a browser on the same machine as the CLI, so it cannot finish here. " +
142
+ "Run it on the person's own machine, or follow the connection guide. For Metadata reads from this " +
143
+ "environment, follow metadata_reads.",
144
+ metadata_reads: agentKeyAction(environment, origin),
145
+ url: CONNECTION_GUIDE_URL,
146
+ };
147
+ }
148
+
149
+ // Agents run these commands in a shell, and an origin may come from a
150
+ // committed project file, so every interpolated value is quoted even though
151
+ // the parsers already restrict it. Without a client choice the command skips
152
+ // the skill rather than show a placeholder a shell would misread.
153
+ function setupCommand(origin, options) {
154
+ const parts = ["metergraph setup --runtime local"];
155
+ if (options.deployment && options.deployment !== "managed") parts.push(`--deployment ${shellArg(options.deployment)}`);
156
+ if (origin !== DEFAULT_ORIGIN) parts.push(`--url ${shellArg(origin)}`);
157
+ if (options.workspace) parts.push(`--workspace ${shellArg(options.workspace)}`);
158
+ if (options.skipSkill || !options.client) parts.push("--skip-skill");
159
+ else parts.push(`--client ${shellArg(options.client)}`);
160
+ return parts.join(" ");
161
+ }
162
+
163
+ // A value as one POSIX shell word: as is when it holds only characters no
164
+ // shell treats specially, otherwise single-quoted.
165
+ export function shellArg(value) {
166
+ const text = String(value);
167
+ if (/^[A-Za-z0-9._:/=-]+$/.test(text)) return text;
168
+ return `'${text.replace(/'/g, "'\\''")}'`;
169
+ }