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/read.js CHANGED
@@ -1,23 +1,34 @@
1
+ import { agentKeySource, keySession, keyTarget, readAgentKey } from "./agent-key.js";
1
2
  import { readBinding, resolveProject } from "./auth-binding.js";
2
3
  import { checkDeployment } from "./doctor.js";
3
4
  import { sessionFor } from "./auth-session.js";
4
- import { Stop, openStore, resolveConfigDir } from "./auth-store.js";
5
- import { MAX_BODY_BYTES, METADATA_SCOPE, READ_MAX_BYTES, READ_PATHS } from "./constants.js";
5
+ import { Stop, openStore, readCredential, resolveConfigDir } from "./auth-store.js";
6
+ import { DEFAULT_ORIGIN, MAX_BODY_BYTES, METADATA_SCOPE, READ_MAX_BYTES, READ_MAX_LIMIT, READ_PATHS } from "./constants.js";
6
7
  import { parseJsonObject } from "./http.js";
7
8
  import {
9
+ readQuery,
10
+ groupedRequest,
11
+ READ_ENVIRONMENTS,
8
12
  CAPABILITY_NAMES,
9
13
  capabilitySummary,
10
14
  routesReport,
11
15
  tracesReport,
16
+ usageAggregatesReport,
12
17
  usageReport,
13
18
  workspaceContext,
14
19
  } from "./read-contract.js";
20
+ import { compareReports, priorWindow, rankReport } from "./usage-aggregates.js";
15
21
  import { deadline, failureOf, send, trapSignals } from "./transport.js";
22
+ import { experimentNextAction, experimentRead } from "./experiments.js";
23
+ import { agentKeyAction, classifyEnvironment, handoffPages } from "./environment.js";
16
24
 
17
25
  // The read commands: status, context, capabilities, usage, routes and traces.
18
- // Each one uses the project's existing verified session (never a browser,
19
- // never a new grant), shares one total deadline across every request,
20
- // including a token refresh, and sends GET requests only, to fixed paths on
26
+ // Each one uses either the project's existing verified session or a
27
+ // coding-agent key with Metadata access (agent-key.js); never a browser,
28
+ // never a new grant. A saved project grant is used when one exists, unless
29
+ // --agent-token-file names a key explicitly; otherwise a key in
30
+ // METERGRAPH_AGENT_TOKEN is used. Each one shares one total deadline across
31
+ // every request, including a token refresh, and sends GET requests only, to fixed paths on
21
32
  // the bound origin. Nothing is written except what the session helper writes
22
33
  // when it refreshes the grant. Each returns { outcome, reason, data } where
23
34
  // data holds only validated fields and fixed tokens, and never a credential
@@ -29,12 +40,21 @@ const READS = Object.freeze({
29
40
  routes: { capability: "routes", path: READ_PATHS.routes, validate: routesReport },
30
41
  traces: { capability: "trace_metadata", path: READ_PATHS.traces, validate: tracesReport },
31
42
  });
43
+ const GROUPED_USAGE = Object.freeze({
44
+ capability: "usage_aggregates",
45
+ path: READ_PATHS.usageAggregates,
46
+ validate: usageAggregatesReport,
47
+ });
32
48
 
33
49
  export async function runRead(options) {
50
+ const env = options.env ?? process.env;
34
51
  const ctx = {
35
52
  command: options.command,
53
+ environment: options.environment ?? classifyEnvironment(env),
54
+ credential: null,
36
55
  origin: null,
37
56
  workspaceId: null,
57
+ intendedWorkspace: null,
38
58
  profile: null,
39
59
  configured: false,
40
60
  reachable: null,
@@ -49,14 +69,19 @@ export async function runRead(options) {
49
69
  const limit = deadline(options.timeoutMs, trap.signal);
50
70
  // Token values this run holds. Filled after the session is verified.
51
71
  let known = [];
72
+ const grouped = options.command === "usage" && options.grouped !== undefined && options.grouped !== null;
52
73
  const build = (outcome, report) =>
53
- options.command === "status" ? statusData(ctx, notices, outcome) : readData(ctx, report, notices, outcome);
74
+ options.command === "status"
75
+ ? statusData(ctx, notices, outcome, options)
76
+ : readData(ctx, report, notices, outcome, options, { grouped, report });
54
77
  const end = (outcome, reason, report = null) => {
78
+ ctx.reason = reason;
55
79
  const data = build(outcome, report);
56
80
  // Validated metadata can still be any string the service chose. If any
57
81
  // printed string holds a credential this run knows, nothing is printed.
58
82
  if (holdsKnown(data, known)) {
59
83
  notices.clear();
84
+ ctx.reason = "credential_in_metadata_response";
60
85
  return {
61
86
  outcome: "verification_failed",
62
87
  reason: "credential_in_metadata_response",
@@ -80,18 +105,57 @@ export async function runRead(options) {
80
105
  const root = resolveProject(options.project);
81
106
  const configDir = resolveConfigDir(options.configDir);
82
107
  const found = readBinding(root);
83
- if (found === null) return end("login_required", "not_signed_in");
84
- const binding = found.binding;
108
+ const binding = found?.binding ?? null;
109
+ const source = agentKeySource(options, env);
110
+ // The private store matters only for a bound project. With a key at
111
+ // hand, a store that cannot be read means no grant here; without one,
112
+ // its error is the answer, as before.
113
+ let store = null;
114
+ let grantSaved = false;
115
+ let originConfirmed = false;
116
+ if (binding !== null) {
117
+ try {
118
+ store = openStore(configDir, { create: false });
119
+ const record = store === null ? null : readCredential(store, binding.credential_slot);
120
+ grantSaved = record !== null;
121
+ originConfirmed = record !== null && record.origin === binding.origin;
122
+ } catch (error) {
123
+ if (!(error instanceof Stop) || source === null) throw error;
124
+ store = null;
125
+ }
126
+ }
127
+ // An explicit key file wins. Otherwise a saved grant wins over the
128
+ // environment variable, so a project signed in on this machine behaves as
129
+ // it always has; a binding without a grant here (for example one
130
+ // committed to the repository and cloned into a remote workspace) uses
131
+ // the key, pinned to the binding.
132
+ const useKey = source !== null && (source.kind === "file" || !grantSaved);
133
+ if (!useKey && binding === null) return end("login_required", "not_signed_in");
134
+ if (!useKey && source !== null) notices.add("agent_key_not_used");
135
+
136
+ ctx.credential = useKey ? "agent_key" : "oauth_grant";
137
+ // A key in the environment is checked for shape before any request,
138
+ // including status's unauthenticated probes.
139
+ if (useKey && source.kind === "env") {
140
+ const shape = readAgentKey(source, env);
141
+ if (!shape.ok) return end(shape.outcome, shape.reason);
142
+ }
143
+ // --url, METERGRAPH_URL (for a key only) and --workspace may repeat the
144
+ // binding but never contradict it.
145
+ const target = keyTarget({ binding, options, env: useKey ? env : {}, originConfirmed: originConfirmed || !useKey });
146
+ if (!target.ok) return end(target.outcome, target.reason);
85
147
  Object.assign(ctx, {
86
- origin: binding.origin,
87
- workspaceId: binding.workspace_id,
88
- profile: binding.deployment_profile,
89
- configured: true,
148
+ origin: target.origin,
149
+ workspaceId: target.workspaceId,
150
+ intendedWorkspace: target.workspaceId,
151
+ profile: target.profile,
152
+ // configured means this project is bound; a key alone binds nothing.
153
+ configured: binding !== null,
90
154
  });
91
- const store = openStore(configDir, { create: false });
92
155
 
156
+ let verifiedProfile = null;
93
157
  if (options.command === "status") {
94
- const health = await checkHealth(binding.origin, limit.signal);
158
+ const health = await checkHealth(ctx.origin, limit.signal);
95
159
  ctx.reachable = health.reachable;
96
160
  ctx.healthy = health.healthy;
97
161
  if (!health.ok) {
@@ -101,22 +165,31 @@ export async function runRead(options) {
101
165
  // The service's own unauthenticated profile report must be the bound
102
166
  // profile before the grant is even tried.
103
167
  const deployment = checkDeployment(
104
- await send(binding.origin, "/v1/deployment", { signal: limit.signal, maxBytes: MAX_BODY_BYTES }),
168
+ await send(ctx.origin, "/v1/deployment", { signal: limit.signal, maxBytes: MAX_BODY_BYTES }),
105
169
  );
106
170
  if (deployment.outcome !== null) {
107
171
  if (deployment.outcome === "unhealthy") ctx.healthy = false;
108
172
  const settled = settle(deployment);
109
173
  return end(settled.outcome, settled.reason);
110
174
  }
111
- ctx.profileVerified = deployment.profile === binding.deployment_profile;
175
+ verifiedProfile = deployment.profile;
176
+ ctx.profileVerified = ctx.profile === null || deployment.profile === ctx.profile;
112
177
  if (!ctx.profileVerified) return end("verification_failed", "profile_mismatch");
178
+ ctx.profile = deployment.profile;
113
179
  }
114
180
 
115
- const session = await sessionFor(binding, store, limit.signal);
181
+ const session = useKey
182
+ ? await keySession({ target, source, env, signal: limit.signal, verifiedProfile })
183
+ : await sessionFor(binding, store, limit.signal);
116
184
  if (!session.ok) {
117
185
  const settled = settle(session);
118
186
  return end(settled.outcome, settled.reason);
119
187
  }
188
+ ctx.profile = session.session.profile;
189
+ if (ctx.workspaceId === null) {
190
+ ctx.workspaceId = session.session.workspaceId;
191
+ notices.add("workspace_not_pinned");
192
+ }
120
193
  known = session.knownCredentials;
121
194
  ctx.authenticated = true;
122
195
  const { documents } = session;
@@ -139,25 +212,84 @@ export async function runRead(options) {
139
212
  return end("ok", null, context.value);
140
213
  }
141
214
  if (options.command === "capabilities") return end("ok", null, capabilities.value);
215
+ if (options.command === "experiments") {
216
+ const read = await experimentRead(session.session, options, ctx, documents, limit.signal, notices);
217
+ if (!read.ok) {
218
+ const settled = read.outcome === "refused" ? read : settle(read);
219
+ const report = read.refusal ? { refusal: read.refusal }
220
+ : read.retryAfter !== undefined ? { retry_after_seconds: read.retryAfter } : null;
221
+ return end(settled.outcome, settled.reason, report);
222
+ }
223
+ return end("ok", null, read.value);
224
+ }
142
225
 
143
- const read = READS[options.command];
226
+ const read = grouped ? GROUPED_USAGE : READS[options.command];
144
227
  const entry = capabilities.value.agent[read.capability];
145
- if (entry === null || entry.available !== true) return end("capability_unavailable", "capability_unavailable");
228
+ if (entry === null || entry.available !== true) {
229
+ return end("capability_unavailable", grouped ? "usage_aggregates_unavailable" : "capability_unavailable");
230
+ }
146
231
  // The service's own bounds; a request beyond them is refused, never
147
232
  // clamped into a different question.
148
233
  const { bounds } = capabilities.value;
149
- if ((options.days !== null && options.days > bounds.max_days) || options.limit > bounds.max_rows) {
234
+ // An explicit grouped window is held to the same day bound as --days.
235
+ const explicitDays = grouped && options.grouped.since !== null
236
+ ? ((options.grouped.until === null ? Date.now() : Date.parse(options.grouped.until)) -
237
+ Date.parse(options.grouped.since)) / (24 * 60 * 60 * 1000)
238
+ : null;
239
+ if (
240
+ (options.days !== null && options.days > bounds.max_days) ||
241
+ (explicitDays !== null && explicitDays > bounds.max_days) ||
242
+ options.limit > bounds.max_rows
243
+ ) {
150
244
  return end("unsupported", "exceeds_service_bounds");
151
245
  }
152
246
 
153
- const response = await fetchRead(session.session, read.path, queryFor(options), limit.signal);
154
- if (!response.ok) {
155
- const settled = settle(response);
156
- return end(settled.outcome, settled.reason, response.retryAfter === undefined ? null : { retry_after_seconds: response.retryAfter });
247
+ const fetchOne = async (query, request, into) => {
248
+ // When this read was asked for, to check a window that ends "now".
249
+ request = { ...request, requestedAt: Date.now() };
250
+ const response = await fetchRead(session.session, read.path, query, limit.signal);
251
+ if (!response.ok) {
252
+ // A key refused moments after it was verified was revoked meanwhile.
253
+ if (ctx.credential === "agent_key" && response.reason === "access_revoked") response.reason = "agent_key_rejected";
254
+ const settled = settle(response);
255
+ return {
256
+ ok: false,
257
+ ...settled,
258
+ detail: response.retryAfter === undefined ? null : { retry_after_seconds: response.retryAfter },
259
+ };
260
+ }
261
+ return read.validate(response.body, ctx, request, into);
262
+ };
263
+ if (!grouped) {
264
+ const report = await fetchOne(readQuery(options), requestOf(options), notices);
265
+ if (!report.ok) return end(report.outcome, report.reason, report.detail ?? null);
266
+ return end("ok", null, report.value);
157
267
  }
158
- const report = read.validate(response.body, ctx, requestOf(options), notices);
159
- if (!report.ok) return end(report.outcome, report.reason);
160
- return end("ok", null, report.value);
268
+
269
+ // Grouped usage: the current window, then for --compare the prior
270
+ // window computed from the window the service actually read.
271
+ const request = groupedRequest(options);
272
+ const current = await fetchOne(readQuery(options, request), request, notices);
273
+ if (!current.ok) return end(current.outcome, current.reason, current.detail ?? null);
274
+ let report = rankReport(current.value, options.grouped.sort, notices);
275
+ if (options.grouped.compare !== null) {
276
+ const window = priorWindow(current.value.window, options.grouped.compare);
277
+ const priorRequest = { ...request, days: null, since: window.since, until: window.until, limit: options.limit };
278
+ // The prior read's own coverage notices describe the prior window; they
279
+ // are kept apart and summarised by the comparison.
280
+ const priorNotices = new Set();
281
+ const prior = await fetchOne(readQuery(options, priorRequest), priorRequest, priorNotices);
282
+ if (!prior.ok) return end(prior.outcome, prior.reason, prior.detail ?? null);
283
+ // Both windows must cover the same environments, or the change compares
284
+ // different populations.
285
+ if (JSON.stringify(prior.value[READ_ENVIRONMENTS]) !== JSON.stringify(current.value[READ_ENVIRONMENTS])) {
286
+ return end("verification_failed", "filter_mismatch");
287
+ }
288
+ for (const token of priorNotices) if (token !== "current_day_partial") notices.add(`prior_${token}`);
289
+ // The ranked report, so comparison rows follow the requested --sort.
290
+ report = { ...report, comparison: compareReports(report, prior.value, options.grouped.compare, notices) };
291
+ }
292
+ return end("ok", null, report);
161
293
  } catch (error) {
162
294
  if (error instanceof Stop) {
163
295
  const settled = settle(error);
@@ -176,23 +308,11 @@ function requestOf(options) {
176
308
  route: options.route ?? null,
177
309
  status: options.status ?? null,
178
310
  cursor: options.cursor ?? null,
311
+ metadata: options.metadata ?? null,
312
+ groupByMetadata: options.groupByMetadata ?? null,
179
313
  };
180
314
  }
181
315
 
182
- // The query string is built here from validated options only. The routes
183
- // endpoint takes none.
184
- function queryFor(options) {
185
- if (options.command === "routes") return null;
186
- const query = new URLSearchParams();
187
- query.set("days", String(options.days));
188
- query.set("limit", String(options.limit));
189
- if (options.command === "traces") {
190
- if (options.route !== null) query.set("route", options.route);
191
- if (options.status !== null) query.set("status", options.status);
192
- if (options.cursor !== null) query.set("cursor", options.cursor);
193
- }
194
- return query;
195
- }
196
316
 
197
317
  // Unauthenticated GET /healthz on the bound origin, so status can tell an
198
318
  // unreachable service from a refused grant. status then checks GET
@@ -219,7 +339,7 @@ const INSUFFICIENT_SCOPE = /(?:^|[\s,])error\s*=\s*"?insufficient_scope"?(?:$|[\
219
339
  // True when any string in value (keys included) contains one of the known
220
340
  // credential values. An exact containment check against values this run
221
341
  // holds, not a guess at what looks secret.
222
- function holdsKnown(value, known) {
342
+ export function holdsKnown(value, known) {
223
343
  if (known.length === 0) return false;
224
344
  if (typeof value === "string") return known.some((secret) => value.includes(secret));
225
345
  if (value === null || typeof value !== "object") return false;
@@ -229,6 +349,14 @@ function holdsKnown(value, known) {
229
349
  return false;
230
350
  }
231
351
 
352
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
353
+
354
+ function retryAfterOf(response) {
355
+ const header = response.headers["retry-after"];
356
+ const seconds = typeof header === "string" && RETRY_AFTER.test(header.trim()) ? Number(header.trim()) : null;
357
+ return seconds !== null && seconds <= 86400 ? seconds : null;
358
+ }
359
+
232
360
  // One GET with the session's access token. A refused token is never
233
361
  // refreshed and retried here: the grant was valid moments ago, so a refusal
234
362
  // means it was revoked or access was lost.
@@ -251,16 +379,18 @@ async function fetchRead(session, path, query, signal) {
251
379
  return { ok: false, outcome: "permission_denied", reason: scope ? "insufficient_scope" : "forbidden" };
252
380
  }
253
381
  if (status === 429) {
254
- const header = response.headers["retry-after"];
255
- const seconds = typeof header === "string" && RETRY_AFTER.test(header.trim()) ? Number(header.trim()) : null;
256
- return {
257
- ok: false,
258
- outcome: "rate_limited",
259
- reason: "rate_limited",
260
- retryAfter: seconds !== null && seconds <= 86400 ? seconds : null,
261
- };
382
+ return { ok: false, outcome: "rate_limited", reason: "rate_limited", retryAfter: retryAfterOf(response) };
262
383
  }
263
384
  if (status === 404) return { ok: false, outcome: "unsupported", reason: "endpoint_unavailable" };
385
+ if (status === 413) return { ok: false, outcome: "unsupported", reason: "response_too_large" };
386
+ if (status === 503) {
387
+ // The service's read budget (time or pool) was exceeded. Nothing is
388
+ // retried here; the result says when to retry and how to narrow it.
389
+ const body = parseJsonObject(response);
390
+ if (isObject(body?.error) && body.error.code === "temporarily_unavailable") {
391
+ return { ok: false, outcome: "unhealthy", reason: "temporarily_unavailable", retryAfter: retryAfterOf(response) };
392
+ }
393
+ }
264
394
  if (status === 400 || status === 422) return { ok: false, outcome: "unsupported", reason: "request_rejected" };
265
395
  }
266
396
  const failure = failureOf(response);
@@ -272,27 +402,33 @@ async function fetchRead(session, path, query, signal) {
272
402
  }
273
403
 
274
404
  // The shape shared by context, capabilities, usage, routes and traces.
275
- function readData(ctx, report, notices, outcome) {
405
+ function readData(ctx, report, notices, outcome, options, extra = {}) {
276
406
  return {
277
407
  origin: ctx.origin,
278
408
  workspace: ctx.workspaceId === null ? null : { id: ctx.workspaceId },
279
409
  deployment_profile: ctx.profile,
410
+ environment: ctx.environment.kind,
411
+ credential: ctx.credential,
280
412
  authenticated: ctx.authenticated,
281
413
  scopes: ctx.authenticated ? [METADATA_SCOPE] : [],
282
414
  result: outcome === "ok" ? report : null,
283
- retry_after_seconds: outcome === "rate_limited" ? report?.retry_after_seconds ?? null : null,
415
+ retry_after_seconds: outcome === "rate_limited" || report?.retry_after_seconds !== undefined
416
+ ? report?.retry_after_seconds ?? null : null,
417
+ ...(options.command === "experiments" ? { refusal: outcome === "refused" ? report?.refusal ?? null : null } : {}),
284
418
  notices: [...notices].sort(),
285
- next_action: nextAction(outcome),
419
+ next_action: nextAction(outcome, ctx, options, extra),
286
420
  };
287
421
  }
288
422
 
289
423
  // status keeps configured, reachable, authenticated, the bound and verified
290
424
  // workspace and the capability flags apart. Application traffic is never
291
425
  // claimed: existing data or a configured SDK does not prove it.
292
- function statusData(ctx, notices, outcome) {
426
+ function statusData(ctx, notices, outcome, options) {
293
427
  const agent = ctx.capabilities?.agent ?? null;
294
428
  return {
295
429
  origin: ctx.origin,
430
+ environment: ctx.environment.kind,
431
+ credential: ctx.credential,
296
432
  configured: ctx.configured,
297
433
  reachable: ctx.reachable,
298
434
  healthy: ctx.healthy,
@@ -301,10 +437,15 @@ function statusData(ctx, notices, outcome) {
301
437
  // true when GET /v1/deployment reported exactly the bound profile, false
302
438
  // when it reported another one, null when it was not checked.
303
439
  deployment_profile_verified: ctx.profileVerified,
440
+ // intended is null for an agent key with no binding and no --workspace;
441
+ // match is then null too, and the notices say workspace_not_pinned.
304
442
  workspace: {
305
- intended: ctx.workspaceId,
443
+ intended: ctx.intendedWorkspace,
306
444
  actual: ctx.verifiedWorkspace,
307
- match: ctx.verifiedWorkspace === null ? null : ctx.verifiedWorkspace === ctx.workspaceId,
445
+ match:
446
+ ctx.verifiedWorkspace === null || ctx.intendedWorkspace === null
447
+ ? null
448
+ : ctx.verifiedWorkspace === ctx.intendedWorkspace,
308
449
  },
309
450
  scopes: ctx.authenticated ? [METADATA_SCOPE] : [],
310
451
  capabilities:
@@ -312,14 +453,125 @@ function statusData(ctx, notices, outcome) {
312
453
  content_access: false,
313
454
  application_traffic_verified: false,
314
455
  notices: [...notices].sort(),
315
- next_action: nextAction(outcome),
456
+ next_action: nextAction(outcome, ctx, options),
316
457
  };
317
458
  }
318
459
 
319
- function nextAction(outcome) {
460
+ const NARROW =
461
+ "Narrow the read: a shorter --days or --since/--until window, exact --route, --provider or --model filters, " +
462
+ "fewer --group-by dimensions or a lower --limit.";
463
+
464
+ // The next action for a read, by credential and environment. Where browser
465
+ // sign in cannot finish (SSH, a cloud workspace, CI), the answer is always
466
+ // the agent key handoff, never "run login". Grouped usage adds its own steps
467
+ // (a supported read, retry, narrow, raise the limit, compare like for like);
468
+ // their text is fixed except a window and limit computed from validated values.
469
+ function nextAction(outcome, ctx, options, { grouped = false, report = null } = {}) {
470
+ const origin = ctx.origin ?? options.origin ?? DEFAULT_ORIGIN;
471
+ const keyed = ctx.credential === "agent_key";
472
+ const reason = ctx.reason;
473
+ const capabilities = ctx.capabilities;
474
+ if (outcome === "rate_limited") return { kind: "retry_later", message: "Wait, then run the same command again." };
475
+ const experiment = experimentNextAction(outcome, ctx, options, report);
476
+ if (experiment !== undefined) return experiment;
477
+ if (reason === "usage_aggregates_unavailable" || (grouped && reason === "endpoint_unavailable")) {
478
+ // Offer the route-by-day read only when this grant actually has it.
479
+ if (capabilities?.agent?.usage?.available !== true) return null;
480
+ return {
481
+ kind: "use_supported_read",
482
+ message:
483
+ "This deployment does not offer grouped usage to this grant. \"metergraph usage --days N\" still lists " +
484
+ "cost per route and day over the last N x 24 hours. Grouping by provider, model, UTC day or a metadata key " +
485
+ "needs a service that offers grouped usage; nothing was read.",
486
+ command: "metergraph usage --days 7 --json",
487
+ };
488
+ }
489
+ if (grouped && reason === "temporarily_unavailable") {
490
+ return { kind: "retry_later", message: `The service stopped the read at its time budget. Wait, then retry. ${NARROW}` };
491
+ }
492
+ if (grouped && reason === "response_too_large") return { kind: "narrow_read", message: NARROW };
493
+ if (outcome === "ok" && report?.sort && !report.sort.complete && report.matched_groups !== null) {
494
+ const needed = report.matched_groups;
495
+ // The largest --limit this deployment accepts, not only the CLI's own.
496
+ const maxLimit = Math.min(READ_MAX_LIMIT, capabilities?.bounds?.max_rows ?? READ_MAX_LIMIT);
497
+ return needed <= maxLimit
498
+ ? {
499
+ kind: "raise_limit",
500
+ message: `The ranking covers the ${report.rows} highest-cost groups of ${needed}. ` +
501
+ `Run the same command with --limit ${needed} to rank every group.`,
502
+ }
503
+ : { kind: "narrow_read", message: `The ranking covers the ${report.rows} highest-cost groups of ${needed}. ${NARROW}` };
504
+ }
505
+ if (outcome === "ok" && report?.comparison?.partial_day_compared_with_full) {
506
+ const window = report.comparison.like_for_like_window;
507
+ return {
508
+ kind: "compare_like_for_like",
509
+ message:
510
+ "The current window ends part way through today (UTC) and the prior period is whole days. " +
511
+ "For equal lengths, run the same command with --compare previous-same-time " +
512
+ `(prior window ${window.since} to ${window.until}).`,
513
+ };
514
+ }
515
+ if (keyed && (reason === "agent_key_rejected" || reason === "agent_key_insufficient_scope")) {
516
+ return {
517
+ kind: "replace_agent_key",
518
+ url: handoffPages(origin).keys_url,
519
+ message:
520
+ "The service refused this key: it is unknown, expired or revoked, or it lacks Metadata access. Ask the " +
521
+ "person to create a new coding-agent key with Metadata access on the Keys page and replace the secret. " +
522
+ "Do not ask for the key in chat.",
523
+ };
524
+ }
525
+ if (keyed && (reason === "scope_mismatch" || reason === "content_access_granted")) {
526
+ return {
527
+ kind: "use_metadata_only_key",
528
+ url: handoffPages(origin).keys_url,
529
+ message:
530
+ "This key grants more than Metadata access, so the CLI does not use it. Ask the person for a " +
531
+ "coding-agent key limited to Metadata access.",
532
+ };
533
+ }
534
+ if (keyed && reason === "workspace_context_mismatch") {
535
+ return {
536
+ kind: "use_workspace_key",
537
+ url: handoffPages(origin).keys_url,
538
+ workspace: ctx.intendedWorkspace === null ? null : { id: ctx.intendedWorkspace },
539
+ message:
540
+ "This key belongs to a different workspace than this project or --workspace names. Ask the person to " +
541
+ "create the key in the intended workspace, or pass the intended workspace with --workspace.",
542
+ };
543
+ }
544
+ if (keyed && reason === "agent_key_origin_unconfirmed") {
545
+ return {
546
+ kind: "confirm_origin",
547
+ message:
548
+ "This project's binding names a deployment other than the hosted service. A Metadata key is sent there " +
549
+ "only when you confirm the same address: set METERGRAPH_URL or pass --url with it.",
550
+ };
551
+ }
552
+ if (keyed && (reason === "bound_to_other_origin" || reason === "bound_to_other_workspace")) {
553
+ return {
554
+ kind: "match_project_binding",
555
+ message:
556
+ "This project's .metergraph/project.json names a different origin or workspace. Remove --url, " +
557
+ "--workspace or METERGRAPH_URL, or use a key for the bound workspace.",
558
+ };
559
+ }
560
+ if (keyed && reason?.startsWith("agent_token")) {
561
+ return {
562
+ kind: "fix_agent_key",
563
+ message:
564
+ "The key could not be used as given. METERGRAPH_AGENT_TOKEN must hold exactly one key with no label " +
565
+ "or spaces; --agent-token-file must name a private (0600) file owned by this user.",
566
+ };
567
+ }
320
568
  if (outcome === "login_required") {
321
- return { kind: "login", message: 'Run "metergraph login --runtime local" in this project to sign in again.' };
569
+ if (ctx.environment.remote) return agentKeyAction(ctx.environment, origin);
570
+ return {
571
+ kind: "login",
572
+ message: 'Run "metergraph login --runtime local" in this project to sign in again.',
573
+ alternative: agentKeyAction(ctx.environment, origin),
574
+ };
322
575
  }
323
- if (outcome === "rate_limited") return { kind: "retry_later", message: "Wait, then run the same command again." };
324
576
  return null;
325
577
  }
@@ -1,4 +1,4 @@
1
- import { detectRemoteSession } from "./auth-browser.js";
1
+ import { classifyEnvironment, remoteSetupAction } from "./environment.js";
2
2
  import { runDoctor } from "./doctor.js";
3
3
  import { action, planDeploymentRoute, verifyDeploymentRoute } from "./deployment-route.js";
4
4
  import { AUTH_HTTP_TIMEOUT_MS } from "./constants.js";
@@ -64,6 +64,13 @@ const ACTION_GUIDE = Object.freeze({
64
64
  retry: "Rerun the same command.",
65
65
  });
66
66
 
67
+ // Each prerequisite a non-hosted route asserts, with its guidance, for a
68
+ // person confirming them at a terminal.
69
+ export function prerequisitesFor(deployment) {
70
+ const profile = EXPECTED_PROFILE[deployment];
71
+ return (PREREQUISITES[deployment] ?? []).map((name) => ({ name, guide: prerequisiteGuide(name, profile) }));
72
+ }
73
+
67
74
  export function prerequisiteGuide(name, profile) {
68
75
  if (name === "metadata_agent_credential") return METADATA_CREDENTIAL_GUIDE[profile] ?? null;
69
76
  return PREREQUISITE_GUIDE[name] ?? null;
@@ -164,8 +171,9 @@ export async function preflightNonHostedSetup(options, { env = process.env } = {
164
171
  // Setup itself refuses a remote session, but only after this preflight.
165
172
  // Refuse here first, so a remote shell sends no request and never reads the
166
173
  // separate agent token file.
167
- if (detectRemoteSession(env) !== null) {
168
- return handoff("unsupported", "run_on_local_machine", action("run_on_customer_machine"), {
174
+ const environment = classifyEnvironment(env);
175
+ if (environment.remote) {
176
+ return handoff("unsupported", "run_on_local_machine", remoteSetupAction(environment, plan.origin, options), {
169
177
  profile: plan.deployment_profile, origin: plan.origin, workspaceId: plan.workspace_id,
170
178
  });
171
179
  }