metergraph-cli 0.2.0 → 0.2.2

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