metergraph-cli 0.2.2 → 0.2.4

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 (66) hide show
  1. package/README.md +121 -13
  2. package/assets/skill/SKILL.md +124 -31
  3. package/assets/skill/manifest.json +3 -3
  4. package/assets/skills/manifest.json +31 -31
  5. package/assets/skills/metergraph-analyze/SKILL.md +161 -58
  6. package/assets/skills/metergraph-candidates/SKILL.md +50 -8
  7. package/assets/skills/metergraph-evals/SKILL.md +83 -16
  8. package/assets/skills/metergraph-investigate/SKILL.md +90 -34
  9. package/assets/skills/metergraph-iterate/SKILL.md +82 -12
  10. package/assets/skills/metergraph-local-bundle/SKILL.md +146 -128
  11. package/assets/skills/metergraph-model-swap/SKILL.md +142 -20
  12. package/assets/skills/metergraph-onboarding/SKILL.md +93 -49
  13. package/assets/skills/metergraph-report/SKILL.md +60 -15
  14. package/assets/skills/metergraph-workloads/SKILL.md +97 -13
  15. package/bin/metergraph.js +1 -1
  16. package/native/README.md +85 -0
  17. package/native/anchored-fs.c +243 -0
  18. package/native/bin/anchored-fs-darwin-arm64 +0 -0
  19. package/native/bin/anchored-fs-darwin-x64 +0 -0
  20. package/native/bin/anchored-fs-linux-arm64 +0 -0
  21. package/native/bin/anchored-fs-linux-x64 +0 -0
  22. package/native/build.mjs +55 -0
  23. package/native/manifest.json +9 -0
  24. package/native/targets.mjs +24 -0
  25. package/package.json +10 -3
  26. package/src/action-help.js +130 -0
  27. package/src/agent-key.js +22 -12
  28. package/src/anchored-fs.js +163 -0
  29. package/src/args.js +335 -31
  30. package/src/auth-binding.js +12 -7
  31. package/src/auth-callback.js +10 -2
  32. package/src/auth-login.js +111 -28
  33. package/src/auth-oauth.js +133 -36
  34. package/src/auth-session.js +19 -12
  35. package/src/auth-store.js +28 -7
  36. package/src/cli.js +147 -15
  37. package/src/constants.js +70 -1
  38. package/src/experiments-contract.js +462 -0
  39. package/src/experiments-output.js +190 -0
  40. package/src/experiments.js +406 -0
  41. package/src/output.js +374 -68
  42. package/src/pricing-args.js +163 -0
  43. package/src/pricing-output.js +206 -0
  44. package/src/pricing-reference.js +195 -0
  45. package/src/pricing.js +307 -0
  46. package/src/read-contract.js +64 -10
  47. package/src/read-output.js +66 -13
  48. package/src/read.js +105 -16
  49. package/src/repository-identity.js +2 -2
  50. package/src/setup-deployment.js +7 -0
  51. package/src/setup-env-git.js +1 -1
  52. package/src/setup-env.js +16 -9
  53. package/src/setup-interactive.js +356 -0
  54. package/src/setup-refusal.js +2 -2
  55. package/src/setup-state.js +9 -4
  56. package/src/setup.js +15 -3
  57. package/src/skill-bundle.js +2 -2
  58. package/src/skill-trusted.js +56 -0
  59. package/src/skill.js +28 -9
  60. package/src/style.js +33 -7
  61. package/src/tools-output.js +73 -0
  62. package/src/tools.js +249 -0
  63. package/src/trace-contract.js +1 -1
  64. package/src/transport.js +26 -3
  65. package/src/verify-output.js +2 -2
  66. package/src/verify.js +5 -2
package/src/pricing.js ADDED
@@ -0,0 +1,307 @@
1
+ import { send } from "./transport.js";
2
+ import { parseJsonObject } from "./http.js";
3
+ import { validDate } from "./pricing-args.js";
4
+ import { runReferencePricing, RATE_KEYS } from "./pricing-reference.js";
5
+
6
+ const fail = (
7
+ outcome,
8
+ reason,
9
+ message = "Pricing could not be read. Check the reason and retry with supported inputs.",
10
+ ) => ({ outcome, reason, message });
11
+ const text = (v) =>
12
+ typeof v === "string" &&
13
+ v.length > 0 &&
14
+ v.length <= 500 &&
15
+ !/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/u.test(v);
16
+ const currency = (v) => typeof v === "string" && /^[A-Z]{3}$/.test(v);
17
+ const decimal = (v) => typeof v === "string" && /^(0|[1-9]\d{0,14})(\.\d{1,16})?$/.test(v);
18
+ const instant = (v) =>
19
+ typeof v === "string" &&
20
+ /^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,6})?(?:Z|\+00:00)$/.test(v) &&
21
+ validDate(v.slice(0, 10)) &&
22
+ Number.isFinite(Date.parse(v));
23
+ const date = (v) => validDate(v) || instant(v);
24
+ const strings = (v) => Array.isArray(v) && v.length <= 10000 && v.every(text);
25
+ const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
26
+ const source = (v) => {
27
+ if (!text(v)) return false;
28
+ try {
29
+ const u = new URL(v);
30
+ return u.protocol === "https:" && !u.username && !u.password;
31
+ } catch {
32
+ return false;
33
+ }
34
+ };
35
+ const units = (v) => {
36
+ const [a, b = ""] = v.split(".");
37
+ return BigInt(a) * 10n ** 16n + BigInt(b.padEnd(16, "0"));
38
+ };
39
+ function price(p) {
40
+ return (
41
+ object(p) &&
42
+ ["id", "model_id", "channel", "region"].every((k) => text(p[k])) &&
43
+ currency(p.currency) &&
44
+ date(p.effective_from) &&
45
+ (p.effective_to === null || (date(p.effective_to) && Date.parse(p.effective_to) > Date.parse(p.effective_from))) &&
46
+ source(p.source_url) &&
47
+ object(p.rules) &&
48
+ RATE_KEYS.every((k) => p[k] === null || decimal(p[k]))
49
+ );
50
+ }
51
+ export function validateModels(d) {
52
+ return (
53
+ object(d) &&
54
+ instant(d.as_of) &&
55
+ object(d.catalog) &&
56
+ text(d.catalog.version) &&
57
+ currency(d.catalog.currency) &&
58
+ validDate(d.catalog.pricing_verified_at) &&
59
+ ["formats", "providers", "channels", "regions", "service_tiers"].every((k) => strings(d[k])) &&
60
+ Array.isArray(d.models) &&
61
+ d.models.length <= 10000 &&
62
+ new Set(d.models.map((m) => m?.model_id)).size === d.models.length &&
63
+ d.models.every(
64
+ (m) =>
65
+ object(m) &&
66
+ text(m.model_id) &&
67
+ text(m.publisher) &&
68
+ Array.isArray(m.aliases) &&
69
+ m.aliases.every((a) => object(a) && ["provider", "model", "channel"].every((k) => text(a[k]))) &&
70
+ Array.isArray(m.prices) &&
71
+ m.prices.every(
72
+ (p) =>
73
+ object(p) &&
74
+ text(p.channel) &&
75
+ text(p.region) &&
76
+ currency(p.currency) &&
77
+ date(p.effective_from) &&
78
+ strings(p.dimensions) &&
79
+ strings(p.service_tiers),
80
+ ),
81
+ )
82
+ );
83
+ }
84
+ export function validatePublicResult(d, action, q, now = Date.now()) {
85
+ if (!object(d) || !Array.isArray(d.results) || d.results.length !== 1) return false;
86
+ const r = d.results[0];
87
+ if (
88
+ !object(r) ||
89
+ !["priced", "partial", "unpriced"].includes(r.status) ||
90
+ !instant(r.effective_at) ||
91
+ !strings(r.missing_dimensions) ||
92
+ new Set(r.missing_dimensions).size !== r.missing_dimensions.length
93
+ )
94
+ return false;
95
+ if (
96
+ q.at ? Date.parse(r.effective_at) !== Date.parse(q.at) : Math.abs(Date.parse(r.effective_at) - now) > 15 * 60 * 1000
97
+ )
98
+ return false;
99
+ if (r.status === "priced" && r.missing_dimensions.length) return false;
100
+ if (r.status === "unpriced") {
101
+ if (r.model_id !== null || r.price !== null || r.currency !== null) return false;
102
+ } else if (
103
+ !price(r.price) ||
104
+ r.model_id !== r.price.model_id ||
105
+ r.currency !== r.price.currency ||
106
+ (q.channel && r.price.channel !== q.channel) ||
107
+ (q.region && ![q.region, "global", "*"].includes(r.price.region)) ||
108
+ Date.parse(r.price.effective_from) > Date.parse(r.effective_at) ||
109
+ (r.price.effective_to !== null && Date.parse(r.price.effective_to) <= Date.parse(r.effective_at))
110
+ )
111
+ return false;
112
+ if (action === "cost" || action === "calculate") {
113
+ if (r.status === "unpriced") return r.cost === null && r.components === null;
114
+ if (
115
+ !decimal(r.cost) ||
116
+ !object(r.components) ||
117
+ Object.keys(r.components).sort().join() !== "cache_read,cache_write,input,output,request" ||
118
+ !Object.values(r.components).every(decimal) ||
119
+ Object.values(r.components).reduce((n, v) => n + units(v), 0n) !== units(r.cost)
120
+ )
121
+ return false;
122
+ if (["input_tokens", "output_tokens"].some((k) => !Object.hasOwn(q, k) && !r.missing_dimensions.includes(k)))
123
+ return false;
124
+ }
125
+ return true;
126
+ }
127
+ export function pricingPayload(o) {
128
+ const q = o.query;
129
+ const context = Object.fromEntries(
130
+ ["provider", "channel", "region"].filter((k) => q[k] !== undefined).map((k) => [k, q[k]]),
131
+ );
132
+ if (q.at) context.at = `${q.at}T00:00:00Z`;
133
+ if (q.batch) context.service_tier = "batch";
134
+ if (o.action === "lookup") return { model: q.model, context };
135
+ const usage = {};
136
+ const put = (key, value) => {
137
+ if (value !== undefined) usage[key] = value;
138
+ };
139
+ let trace;
140
+ if (o.traceFormat === "anthropic.messages") {
141
+ put("input_tokens", q.input_tokens);
142
+ put("output_tokens", q.output_tokens);
143
+ put("cache_read_input_tokens", q.cache_read_tokens);
144
+ put("cache_creation_input_tokens", q.cache_write_tokens);
145
+ if (q.cache_write_5m_tokens !== undefined || q.cache_write_1h_tokens !== undefined)
146
+ usage.cache_creation = {
147
+ ...(q.cache_write_5m_tokens === undefined ? {} : { ephemeral_5m_input_tokens: q.cache_write_5m_tokens }),
148
+ ...(q.cache_write_1h_tokens === undefined ? {} : { ephemeral_1h_input_tokens: q.cache_write_1h_tokens }),
149
+ };
150
+ trace = { model: q.model, usage };
151
+ } else if (o.traceFormat.startsWith("openai.")) {
152
+ const chat = o.traceFormat === "openai.chat_completions";
153
+ put(chat ? "prompt_tokens" : "input_tokens", q.input_tokens);
154
+ put(chat ? "completion_tokens" : "output_tokens", q.output_tokens);
155
+ if (q.cache_read_tokens !== undefined)
156
+ usage[chat ? "prompt_tokens_details" : "input_tokens_details"] = { cached_tokens: q.cache_read_tokens };
157
+ trace = { model: q.model, usage };
158
+ } else {
159
+ usage["gen_ai.request.model"] = q.model;
160
+ put("gen_ai.usage.input_tokens", q.input_tokens);
161
+ put("gen_ai.usage.output_tokens", q.output_tokens);
162
+ put("gen_ai.usage.cache_read.input_tokens", q.cache_read_tokens);
163
+ trace = { attributes: usage };
164
+ }
165
+ return { format: o.traceFormat, trace, context };
166
+ }
167
+ export async function runPricing(o) {
168
+ if (o.api === "reference") return runReferencePricing(o);
169
+ if (o.action === "history")
170
+ return fail(
171
+ "unsupported",
172
+ "pricing_history_unavailable",
173
+ "The public API documents no history endpoint. Historical charts remain available with --api reference and a loopback --url; no public history request was made.",
174
+ );
175
+ if (o.action === "market") return runPublicMarket(o);
176
+ const path =
177
+ o.action === "models"
178
+ ? "/v1/pricing/models"
179
+ : o.action === "lookup"
180
+ ? "/v1/pricing/lookup"
181
+ : "/v1/pricing/calculate";
182
+ const res = await send(o.origin, path, {
183
+ method: o.action === "models" ? "GET" : "POST",
184
+ json: o.action === "models" ? null : pricingPayload(o),
185
+ maxBytes: 1024 * 1024,
186
+ signal: AbortSignal.timeout(o.timeoutMs),
187
+ useGlobalAgent: true,
188
+ });
189
+ const failure = responseFailure(res);
190
+ if (failure) return failure;
191
+ const d = parseJsonObject(res);
192
+ if (o.action === "models") {
193
+ if (!validateModels(d)) return fail("verification_failed", "pricing_contract_mismatch");
194
+ const q = o.query;
195
+ const matches = d.models.filter(
196
+ (m) =>
197
+ (!q.model || m.model_id === q.model || m.aliases.some((a) => a.model === q.model)) &&
198
+ (!q.provider || m.aliases.some((a) => a.provider === q.provider && (!q.channel || a.channel === q.channel))) &&
199
+ (!q.channel || m.prices.some((p) => p.channel === q.channel && (!q.region || p.region === q.region))) &&
200
+ (!q.region || m.prices.some((p) => p.region === q.region && (!q.channel || p.channel === q.channel))),
201
+ );
202
+ return {
203
+ outcome: "ok",
204
+ data: {
205
+ api: "public",
206
+ action: o.action,
207
+ request: q,
208
+ catalog: d.catalog,
209
+ as_of: d.as_of,
210
+ formats: d.formats,
211
+ providers: d.providers,
212
+ channels: d.channels,
213
+ regions: d.regions,
214
+ service_tiers: d.service_tiers,
215
+ result: {
216
+ models: matches.slice(0, q.limit),
217
+ matched: matches.length,
218
+ total_models: d.models.length,
219
+ truncated: matches.length > q.limit,
220
+ },
221
+ },
222
+ };
223
+ }
224
+ if (!validatePublicResult(d, o.action, o.query)) return fail("verification_failed", "pricing_contract_mismatch");
225
+ const r = d.results[0];
226
+ return {
227
+ outcome: "ok",
228
+ data: {
229
+ api: "public",
230
+ action: o.action,
231
+ request: o.query,
232
+ ...(o.action === "lookup" ? {} : { trace_format: o.traceFormat }),
233
+ catalog: null,
234
+ result: r,
235
+ },
236
+ };
237
+ }
238
+
239
+ function responseFailure(res) {
240
+ if (res.kind === "error") return fail("connection_failed", res.reason);
241
+ if (res.status >= 300 && res.status < 400) return fail("redirect_rejected", "pricing_redirect_rejected");
242
+ if (res.status === 429) return fail("rate_limited", "pricing_rate_limited");
243
+ if (res.status >= 500) return fail("unhealthy", "pricing_service_unhealthy");
244
+ if ([401, 403, 404, 405].includes(res.status)) return fail("unsupported", "public_pricing_unavailable");
245
+ if (res.tooLarge) return fail("unsupported", "pricing_response_too_large");
246
+ if ([400, 422].includes(res.status)) return fail("invalid_input", "pricing_request_rejected");
247
+ if (res.status !== 200) return fail("unsupported", "pricing_response_unsupported");
248
+ return null;
249
+ }
250
+
251
+ async function runPublicMarket(o) {
252
+ if (o.query.at)
253
+ return fail(
254
+ "unsupported",
255
+ "market_date_unsupported",
256
+ "The public market board uses current discovery and lookups. Use lookup --at for one historical price; historical board coverage is unavailable.",
257
+ );
258
+ const signal = AbortSignal.timeout(o.timeoutMs);
259
+ const options = { signal, maxBytes: 1024 * 1024, useGlobalAgent: true };
260
+ const discovery = await send(o.origin, "/v1/pricing/models", options);
261
+ const error = responseFailure(discovery);
262
+ if (error) return error;
263
+ const d = parseJsonObject(discovery);
264
+ if (!validateModels(d)) return fail("verification_failed", "pricing_contract_mismatch");
265
+ const q = o.query;
266
+ const matches = d.models.filter((m) => m.prices.some((p) => p.channel === q.channel && p.region === q.region));
267
+ const selected = matches.slice(0, q.limit),
268
+ items = new Array(selected.length);
269
+ let next = 0,
270
+ failure = null;
271
+ const worker = async () => {
272
+ while (!failure && next < selected.length) {
273
+ const index = next++,
274
+ model = selected[index].model_id;
275
+ const request = { model, channel: q.channel, region: q.region };
276
+ const res = await send(o.origin, "/v1/pricing/lookup", {
277
+ ...options,
278
+ method: "POST",
279
+ json: { model, context: { channel: q.channel, region: q.region } },
280
+ });
281
+ const error = responseFailure(res);
282
+ if (error) {
283
+ failure = error;
284
+ break;
285
+ }
286
+ const result = parseJsonObject(res);
287
+ if (!validatePublicResult(result, "lookup", request)) {
288
+ failure = fail("verification_failed", "pricing_contract_mismatch");
289
+ break;
290
+ }
291
+ items[index] = { requested_model: model, ...result.results[0] };
292
+ }
293
+ };
294
+ await Promise.all(Array.from({ length: Math.min(3, selected.length) }, worker));
295
+ if (failure) return failure;
296
+ return {
297
+ outcome: "ok",
298
+ data: {
299
+ api: "public",
300
+ action: "market",
301
+ request: q,
302
+ catalog: d.catalog,
303
+ as_of: d.as_of,
304
+ result: { items, matched: matches.length, truncated: matches.length > items.length, history_available: false },
305
+ },
306
+ };
307
+ }
@@ -1,4 +1,4 @@
1
- import { normalizeUuid } from "./auth-oauth.js";
1
+ import { credentialAccess, normalizeUuid } from "./auth-oauth.js";
2
2
  import {
3
3
  AGENT_CONTRACT_VERSION,
4
4
  MAX_CURSOR_LENGTH,
@@ -16,8 +16,9 @@ import { traceLink } from "./trace-contract.js";
16
16
  // { ok: true, value } or { ok: false, outcome, reason }
17
17
  // with fixed tokens. notices is a Set that collects fixed notice tokens.
18
18
 
19
- // Every capability name the contract defines, in its order. The last three
20
- // read content or replay and are never available to a Metadata grant.
19
+ // Every capability name the contract defines. Content, replay and start
20
+ // entries are never available to a Metadata grant; a Debug credential with
21
+ // workflow permissions may be offered content and starts.
21
22
  export const CAPABILITY_NAMES = Object.freeze([
22
23
  "workspace_context",
23
24
  "capability_discovery",
@@ -30,11 +31,16 @@ export const CAPABILITY_NAMES = Object.freeze([
30
31
  "reports",
31
32
  "report_detail",
32
33
  "report_evidence",
34
+ "experiments",
35
+ "experiment_estimate",
36
+ "experiment",
37
+ "experiment_cases",
38
+ "experiment_start",
33
39
  "trace_content",
34
40
  "trace_replay",
35
41
  ]);
36
42
  const PRIVACY_CLASSES = Object.freeze(["metadata", "content", "replay"]);
37
- const SCOPES = Object.freeze(["agent:metadata", "agent:read", "agent:replay"]);
43
+ const SCOPES = Object.freeze(["agent:metadata", "agent:read", "agent:replay", "agent:analysis-run"]);
38
44
 
39
45
  const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:/+-]{0,127}$/;
40
46
  const IDENT = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
@@ -1106,6 +1112,33 @@ export function combinePages(pages) {
1106
1112
  };
1107
1113
  }
1108
1114
 
1115
+ const SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;
1116
+ const MAX_WORKSPACE_NAME = 200;
1117
+
1118
+ // A workspace document's slug and name, each kept only when it is safe to
1119
+ // print (a well-formed slug; a name with no control or bidirectional
1120
+ // characters), else null. This is how output names a workspace: never by ID.
1121
+ export function workspaceLabel(workspace) {
1122
+ const slug = typeof workspace?.slug === "string" && SLUG.test(workspace.slug) ? workspace.slug : null;
1123
+ const raw = workspace?.name;
1124
+ const name = typeof raw === "string" && raw.trim() !== "" && raw.length <= MAX_WORKSPACE_NAME && !hasUnsafeText(raw)
1125
+ ? raw : null;
1126
+ return { slug, name };
1127
+ }
1128
+
1129
+ // Whether a workspace document is the one a person named with --workspace.
1130
+ // Slugs are unique, names are not, and anyone who owns a workspace chooses
1131
+ // its name, including a workspace a person was invited to. So the match is
1132
+ // strict, for sign in and for every credential pin: text shaped like a slug
1133
+ // matches only the slug, and other text only the exact name, ignoring case.
1134
+ export function workspacePinned(workspace, text) {
1135
+ const { slug, name } = workspaceLabel(workspace);
1136
+ const wanted = text.trim().toLowerCase();
1137
+ if (SLUG.test(wanted)) return slug === wanted;
1138
+ return name !== null && name.trim().toLowerCase() === wanted;
1139
+ }
1140
+
1141
+
1109
1142
  // GET /v1/agent/workspace, already checked by checkWorkspace during the
1110
1143
  // session. Returns the approved workspace fields only.
1111
1144
  export function workspaceContext(body, ctx, notices) {
@@ -1123,7 +1156,8 @@ export function workspaceContext(body, ctx, notices) {
1123
1156
  throw new Invalid("verification_failed", "content_access_granted");
1124
1157
  }
1125
1158
  const scopes = body.access?.scopes;
1126
- if (!Array.isArray(scopes) || scopes.length !== 1 || scopes[0] !== METADATA_SCOPE) {
1159
+ const access = credentialAccess(scopes);
1160
+ if (access === null || access !== (ctx.access ?? "metadata")) {
1127
1161
  throw new Invalid("verification_failed", "scope_mismatch");
1128
1162
  }
1129
1163
  return {
@@ -1136,12 +1170,15 @@ export function workspaceContext(body, ctx, notices) {
1136
1170
  },
1137
1171
  retention: { metadata_days: body.retention.metadata_days },
1138
1172
  content: { captured: body.content.captured, included: false },
1139
- access: { scopes: [METADATA_SCOPE] },
1173
+ access: { scopes: [...scopes].sort() },
1140
1174
  };
1141
1175
  });
1142
1176
  }
1143
1177
 
1144
- function capabilityEntry(entry, reason) {
1178
+ // access is the credential's: a Metadata credential is never offered a
1179
+ // sensitive capability; a Debug credential may be, and read commands still
1180
+ // never use one.
1181
+ function capabilityEntry(entry, reason, access = "metadata") {
1145
1182
  if (
1146
1183
  !isObject(entry) ||
1147
1184
  typeof entry.available !== "boolean" ||
@@ -1161,8 +1198,10 @@ function capabilityEntry(entry, reason) {
1161
1198
  entry.privacy_class === "metadata" &&
1162
1199
  entry.required_scope === METADATA_SCOPE;
1163
1200
  // checkCapabilities already refuses this; it is checked again here so a
1164
- // summary can never show a sensitive capability as available.
1165
- if (!metadataOnly && entry.available) throw new Invalid("verification_failed", "content_access_granted");
1201
+ // summary can never show a sensitive capability as available to Metadata.
1202
+ if (!metadataOnly && entry.available && access === "metadata") {
1203
+ throw new Invalid("verification_failed", "content_access_granted");
1204
+ }
1166
1205
  return {
1167
1206
  available: entry.available,
1168
1207
  content: entry.content,
@@ -1189,7 +1228,7 @@ export function capabilitySummary(body, ctx, notices) {
1189
1228
  if (!isObject(body.agent)) throw new Invalid("verification_failed", reason);
1190
1229
  const agent = {};
1191
1230
  for (const name of CAPABILITY_NAMES) {
1192
- agent[name] = Object.hasOwn(body.agent, name) ? capabilityEntry(body.agent[name], reason) : null;
1231
+ agent[name] = Object.hasOwn(body.agent, name) ? capabilityEntry(body.agent[name], reason, ctx.access) : null;
1193
1232
  }
1194
1233
  if (Object.keys(body.agent).some((name) => !CAPABILITY_NAMES.includes(name))) {
1195
1234
  notices.add("unrecognized_capabilities_ignored");
@@ -1547,3 +1586,18 @@ function checkFilters(body, rows, request) {
1547
1586
  }
1548
1587
  }
1549
1588
  }
1589
+
1590
+ // Shared with the experiments validators (experiments-contract.js), which
1591
+ // hold their documents to the same rules.
1592
+ export {
1593
+ Invalid,
1594
+ displayText,
1595
+ isAmount,
1596
+ isCount,
1597
+ isObject,
1598
+ isTimestamp,
1599
+ isToken,
1600
+ provenance,
1601
+ rowObject,
1602
+ run,
1603
+ };
@@ -1,5 +1,6 @@
1
+ import { WORKFLOW_PERMISSIONS } from "./constants.js";
1
2
  import { authMessage } from "./output.js";
2
- import { PLAIN, fields, headline, paragraph, render, steps, toneFor } from "./style.js";
3
+ import { PLAIN, fields, headline, paragraph, render, steps, toneFor, workspaceShown } from "./style.js";
3
4
 
4
5
  // Fixed text for read command reasons and notices. Reasons shared with sign
5
6
  // in and doctor fall back to their wording. Nothing here contains a value from
@@ -7,18 +8,18 @@ import { PLAIN, fields, headline, paragraph, render, steps, toneFor } from "./st
7
8
 
8
9
  const READ_MESSAGES = {
9
10
  not_signed_in:
10
- 'This project is not signed in and no Metadata key was given. Run "metergraph login --runtime local" first, ' +
11
- "or, where browser sign in cannot finish, give the CLI a coding-agent key with Metadata access in " +
12
- "METERGRAPH_AGENT_TOKEN.",
11
+ 'This project is not signed in and no coding-agent key was given. Run "metergraph login --runtime local" first, ' +
12
+ "or, where browser sign in cannot finish, give the CLI a coding-agent key with the Metadata or Debug role " +
13
+ "in METERGRAPH_AGENT_TOKEN.",
13
14
  agent_key_rejected:
14
- "The service refused the Metadata key: it is unknown, expired or revoked. Nothing was read.",
15
+ "The service refused the coding-agent key: it is unknown, expired or revoked. Nothing was read.",
15
16
  agent_key_insufficient_scope:
16
17
  "The key is valid but lacks Metadata access to this workspace, for example because it is an ingest key. " +
17
18
  "No broader access was requested. Nothing was read.",
18
19
  agent_token_malformed:
19
20
  "METERGRAPH_AGENT_TOKEN does not hold exactly one key with no label, spaces or extra lines. Its value is not " +
20
21
  "shown. Nothing was sent.",
21
- agent_token_missing: "No Metadata key was found. Nothing was sent.",
22
+ agent_token_missing: "No coding-agent key was found. Nothing was sent.",
22
23
  metergraph_url_invalid:
23
24
  "METERGRAPH_URL is not a bare https origin, or an http origin on localhost. Its value is not shown. Nothing was sent.",
24
25
  bound_to_other_origin:
@@ -28,13 +29,14 @@ const READ_MESSAGES = {
28
29
  "This project's binding names a non-hosted origin, and a key is never sent to an origin taken only from a " +
29
30
  "project file. Set METERGRAPH_URL or pass --url with the same origin to confirm it. Nothing was sent.",
30
31
  scope_mismatch:
31
- "The credential is not limited to exactly the Metadata scope, so it was not used. The CLI never uses broader access.",
32
+ "The credential has Replay access or a scope this CLI does not use, so it was not used. Use a credential with " +
33
+ "the Metadata or Debug role.",
32
34
  content_access_granted:
33
- "The credential would allow content, replay or provider access, so it was not used. The CLI never uses broader access.",
35
+ "The service offered content, replay or provider access to a Metadata credential, so it was not used.",
34
36
  platform_credential_handoff:
35
37
  "--agent-token-file cannot be checked for privacy on this platform. Use METERGRAPH_AGENT_TOKEN instead.",
36
38
  capability_unavailable:
37
- "The service does not offer this read to the project's Metadata grant. No data was read and no broader access was requested.",
39
+ "The service does not offer this read to this credential. No data was read and no broader access was requested.",
38
40
  exceeds_service_bounds:
39
41
  "The requested --days or --limit is larger than the service allows. It was not reduced automatically. No data was read.",
40
42
  insufficient_scope: "The service refused this read because the grant lacks a required scope. No broader scope was requested.",
@@ -279,7 +281,7 @@ export function readText(result, message, style = PLAIN) {
279
281
  statusRows(data, rows);
280
282
  lines.push(...fields(style, rows));
281
283
  } else {
282
- rows.push(["Workspace", data.workspace?.id ?? null]);
284
+ rows.push(["Workspace", workspaceShown(data.workspace)]);
283
285
  if (result.ok) {
284
286
  const body = [];
285
287
  resultLines(result.command, data.result, body);
@@ -325,16 +327,30 @@ export function readText(result, message, style = PLAIN) {
325
327
  ["Connection guide", handoff ? next.url : null],
326
328
  ]));
327
329
  }
330
+ const access = result.command === "status" && result.ok ? data.access : null;
331
+ const step = access && !allPermissions(access) && access.permissions_offered !== false
332
+ ? permissionsStep(data.deployment_profile)
333
+ : null;
334
+ if (step !== null) lines.push("", ...steps(style, [["", `${PERMISSIONS_NEEDED} ${step}`]]));
328
335
  return render(lines);
329
336
  }
330
337
 
338
+ // The verified workspace by name and slug, and whether it is the bound one.
339
+ function statusWorkspace(workspace) {
340
+ const shown = workspaceShown(workspace);
341
+ if (workspace.actual === null) return workspace.intended === null ? "none" : "bound, not verified";
342
+ const name = shown ?? "verified";
343
+ if (workspace.match === false) return `${name}, which is not the workspace this project is bound to`;
344
+ return workspace.match === true ? `${name}, the bound workspace` : name;
345
+ }
346
+
331
347
  function statusRows(data, rows) {
332
348
  rows.push(
333
349
  ["Configured", data.configured ? "yes" : "no"],
334
- ["Credential", { oauth_grant: "saved sign in", agent_key: "Metadata key" }[data.credential] ?? "none"],
350
+ ["Credential", { oauth_grant: "saved sign in", agent_key: "coding-agent key" }[data.credential] ?? "none"],
335
351
  ["Reachable", yesNo(data.reachable)],
336
352
  ["Authenticated", data.authenticated ? "yes, verified by the service" : "no"],
337
- ["Workspace", `bound ${show(data.workspace.intended)}, verified ${show(data.workspace.actual)}`],
353
+ ["Workspace", statusWorkspace(data.workspace)],
338
354
  ["Deployment profile", `bound ${show(data.deployment_profile)}, reported by the service: ${
339
355
  data.deployment_profile_verified === true
340
356
  ? "same"
@@ -348,12 +364,49 @@ function statusRows(data, rows) {
348
364
  const flag = (name) => yesNo(data.capabilities[name]);
349
365
  rows.push(
350
366
  ["Metadata reads", `usage ${flag("usage")}, routes ${flag("routes")}, traces ${flag("trace_metadata")}`],
351
- ["Content and replay", "not available to this grant"],
367
+ ["Content", data.content_access
368
+ ? "available to this credential; the read commands never request it"
369
+ : "not available to this credential"],
352
370
  );
353
371
  }
372
+ if (data.access) rows.push(["Role", ROLE_LABELS[data.access.role] ?? "none"], ["Permissions", permissionsText(data.access)]);
354
373
  rows.push(["Application traffic verified", "no"]);
355
374
  }
356
375
 
376
+ // The role and permission labels the Keys and consent pages use.
377
+ const ROLE_LABELS = { metadata: "Metadata", debug: "Debug" };
378
+
379
+ const PERMISSIONS_NEEDED =
380
+ "Writing and testing evaluations and starting analyses need the Debug role with Allow evaluation writes, " +
381
+ "Allow evaluation previews and Allow analysis starts.";
382
+
383
+ const allPermissions = (access) =>
384
+ access.role === "debug" && WORKFLOW_PERMISSIONS.every(({ field }) => access.permissions[field] === true);
385
+
386
+ function permissionsText(access) {
387
+ if (access.permissions_offered === false) return "not offered by this deployment";
388
+ const granted = WORKFLOW_PERMISSIONS.filter(({ field }) => access.permissions[field] === true);
389
+ return granted.length > 0 ? granted.map(({ label }) => label).join(", ") : "none";
390
+ }
391
+
392
+ // How a person gives this agent Debug with all three permissions, by
393
+ // deployment. Fixed text: no value from the service is printed.
394
+ export function permissionsStep(profile) {
395
+ if (profile === "local") {
396
+ return "From the bundle directory, a workspace owner runs ./bin/agent-key create --role debug " +
397
+ "--allow-evaluation-writes --allow-evaluation-previews --allow-analysis-starts " +
398
+ "--write-to ~/.config/metergraph/agent.env, then loads that file in the shell that starts the agent.";
399
+ }
400
+ if (profile === "managed") {
401
+ return 'Run "metergraph login --runtime local --role debug --allow-evaluation-writes ' +
402
+ '--allow-evaluation-previews --allow-analysis-starts"; a workspace owner approves it in the browser. ' +
403
+ 'Or a workspace owner creates a coding-agent key on the Keys page, choosing "Debug with all three ' +
404
+ 'permissions" under Access, saved as METERGRAPH_AGENT_TOKEN in a private file outside the project. ' +
405
+ "Do not paste a key into chat.";
406
+ }
407
+ return null;
408
+ }
409
+
357
410
  function resultLines(command, report, lines) {
358
411
  if (command === "context") {
359
412
  const { workspace } = report;