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.
- package/README.md +60 -11
- package/assets/skill/SKILL.md +52 -3
- package/assets/skill/manifest.json +3 -3
- package/assets/skills/manifest.json +13 -13
- package/assets/skills/metergraph-analyze/SKILL.md +62 -4
- package/assets/skills/metergraph-investigate/SKILL.md +222 -35
- package/assets/skills/metergraph-onboarding/SKILL.md +7 -0
- package/assets/skills/metergraph-report/SKILL.md +19 -2
- package/bin/metergraph.js +1 -1
- package/package.json +1 -1
- package/src/agent-key.js +160 -0
- package/src/approval-handoff.js +5 -0
- package/src/args.js +572 -17
- package/src/auth-browser.js +5 -19
- package/src/auth-callback.js +24 -15
- package/src/auth-login.js +55 -12
- package/src/auth-oauth.js +77 -7
- package/src/callback-page.js +58 -0
- package/src/cli.js +190 -45
- package/src/constants.js +73 -3
- package/src/environment.js +169 -0
- package/src/experiments-contract.js +462 -0
- package/src/experiments-output.js +189 -0
- package/src/experiments.js +397 -0
- package/src/origin.js +6 -0
- package/src/output.js +437 -131
- package/src/pricing-args.js +163 -0
- package/src/pricing-output.js +206 -0
- package/src/pricing-reference.js +195 -0
- package/src/pricing.js +307 -0
- package/src/read-contract.js +976 -16
- package/src/read-output.js +449 -30
- package/src/read.js +312 -60
- package/src/setup-deployment.js +11 -3
- package/src/setup-interactive.js +341 -0
- package/src/setup-refusal.js +172 -0
- package/src/setup-state.js +4 -2
- package/src/setup.js +227 -53
- package/src/skill-bundle.js +2 -2
- package/src/style.js +101 -0
- package/src/transport.js +13 -7
- package/src/usage-aggregates.js +197 -0
- 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.
|
|
29
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
341
|
+
lines.push("Confirm each prerequisite, then rerun with --confirm-prerequisites:");
|
|
220
342
|
for (const name of data.pending_prerequisites) {
|
|
221
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
//
|
|
233
|
-
//
|
|
234
|
-
function
|
|
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
|
|
383
|
+
case "written": return `${repository.repository} (recorded${where}; commit it)`;
|
|
239
384
|
case "existing": return repository.repository === null
|
|
240
|
-
? `
|
|
241
|
-
case "mismatch": return
|
|
242
|
-
case "invalid_config": return `
|
|
243
|
-
case "skipped": return "
|
|
244
|
-
case "write_failed": return `
|
|
245
|
-
default: return "
|
|
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/
|
|
103
|
-
"/v1/
|
|
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.
|
|
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
|
+
}
|