metergraph-cli 0.2.2 → 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 +49 -5
- package/bin/metergraph.js +1 -1
- package/package.json +1 -1
- package/src/agent-key.js +6 -3
- package/src/args.js +218 -22
- package/src/auth-oauth.js +58 -5
- package/src/cli.js +98 -10
- package/src/constants.js +41 -0
- package/src/experiments-contract.js +462 -0
- package/src/experiments-output.js +189 -0
- package/src/experiments.js +397 -0
- package/src/output.js +112 -1
- 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 +23 -3
- package/src/read.js +15 -1
- package/src/setup-deployment.js +7 -0
- package/src/setup-interactive.js +341 -0
- package/src/transport.js +5 -3
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
|
|
7
|
+
import { resolveProject } from "./auth-binding.js";
|
|
8
|
+
import { runDoctor } from "./doctor.js";
|
|
9
|
+
import { classifyEnvironment, handoffPages, shellArg } from "./environment.js";
|
|
10
|
+
import { prerequisitesFor } from "./setup-deployment.js";
|
|
11
|
+
import { readSetupState } from "./setup-state.js";
|
|
12
|
+
|
|
13
|
+
// Setup asks for what it is missing only when a person is at a terminal: no
|
|
14
|
+
// --json, stdin and stdout both terminals, and not CI. An agent or a script
|
|
15
|
+
// never gets a question, so nothing waits on input that will never come; it
|
|
16
|
+
// keeps the flag errors and the next_action contract. The answers become the
|
|
17
|
+
// same flags a script would pass, and the full command is printed so it can
|
|
18
|
+
// be rerun without questions.
|
|
19
|
+
|
|
20
|
+
// The setup parse failures a question can answer. Any other failure, such as
|
|
21
|
+
// an invalid value or two conflicting flags, is reported as it is.
|
|
22
|
+
export const ASKABLE = new Set([
|
|
23
|
+
"missing_runtime", "non_hosted_origin_required", "non_hosted_workspace_required", "client_required",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
export const LOCAL_BUNDLE_ORIGIN = "http://localhost:8080";
|
|
27
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
28
|
+
const PROBE_TIMEOUT_MS = 1500;
|
|
29
|
+
|
|
30
|
+
const DEPLOYMENTS = [
|
|
31
|
+
["managed", "Metergraph cloud (app.metergraph.dev)"],
|
|
32
|
+
["customer-local", "The Metergraph bundle running on this machine"],
|
|
33
|
+
["byoc", "Your company's Metergraph installation on AWS"],
|
|
34
|
+
["oss", "The open source Metergraph server"],
|
|
35
|
+
];
|
|
36
|
+
const CLIENTS = [
|
|
37
|
+
["claude", "Claude Code"],
|
|
38
|
+
["codex", "Codex"],
|
|
39
|
+
["cursor", "Cursor"],
|
|
40
|
+
[null, "None (skip installing the skill)"],
|
|
41
|
+
];
|
|
42
|
+
const URL_QUESTIONS = {
|
|
43
|
+
"customer-local": "Address of the bundle",
|
|
44
|
+
byoc: "HTTPS address of the installation",
|
|
45
|
+
oss: "Address of the server",
|
|
46
|
+
};
|
|
47
|
+
const WORKSPACE_QUESTIONS = {
|
|
48
|
+
"customer-local": "Workspace ID (./bin/status in the bundle directory prints it)",
|
|
49
|
+
byoc: "Workspace ID (shown on the installation's Settings page)",
|
|
50
|
+
oss: "Workspace ID",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function canAsk(argv, { stdin, stdout, env = process.env }) {
|
|
54
|
+
return !argv.includes("--json") && Boolean(stdin?.isTTY) && Boolean(stdout?.isTTY) && !env.CI;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Questions on the terminal. ask resolves with the trimmed answer, or null
|
|
58
|
+
// once the person presses Ctrl-C or Ctrl-D, after which every ask is null.
|
|
59
|
+
export function terminalPrompter(stdin, stdout) {
|
|
60
|
+
const lines = createInterface({ input: stdin, output: stdout, terminal: true });
|
|
61
|
+
const stop = new AbortController();
|
|
62
|
+
let closed = false;
|
|
63
|
+
lines.on("SIGINT", () => stop.abort());
|
|
64
|
+
lines.on("close", () => {
|
|
65
|
+
closed = true;
|
|
66
|
+
stop.abort();
|
|
67
|
+
});
|
|
68
|
+
return {
|
|
69
|
+
async ask(question) {
|
|
70
|
+
if (stop.signal.aborted) return null;
|
|
71
|
+
try {
|
|
72
|
+
return (await lines.question(question, { signal: stop.signal })).trim();
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
close() {
|
|
78
|
+
if (!closed) lines.close();
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Resolves with argv plus the flags the answers stand for, or null when the
|
|
84
|
+
// person cancelled. Flags already given are never asked for or changed.
|
|
85
|
+
export async function fillSetupArgv(argv, {
|
|
86
|
+
ask, say, probe = probeLocalBundle, savedProfile = savedSetupProfile, remote = classifyEnvironment().remote,
|
|
87
|
+
}) {
|
|
88
|
+
const has = (flag) => argv.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
|
|
89
|
+
const added = [];
|
|
90
|
+
// A person at a terminal runs setup for a coding agent on this machine.
|
|
91
|
+
// Over SSH or in a cloud workspace, setup itself still hands off.
|
|
92
|
+
if (!has("--runtime")) added.push("--runtime", "local");
|
|
93
|
+
|
|
94
|
+
let deployment = valueOf(argv, "--deployment");
|
|
95
|
+
// A rerun in a project set up before resumes that route without asking.
|
|
96
|
+
if (deployment === undefined && savedProfile(valueOf(argv, "--project")) === null) {
|
|
97
|
+
// A remote shell sends no request before setup hands it off to the
|
|
98
|
+
// person's own machine, and its localhost is not that machine anyway.
|
|
99
|
+
const found = remote ? false : await probe(LOCAL_BUNDLE_ORIGIN);
|
|
100
|
+
if (found) say(`Found a Metergraph bundle at ${LOCAL_BUNDLE_ORIGIN}.`);
|
|
101
|
+
deployment = await choose(ask, say, "Where is Metergraph running?", DEPLOYMENTS,
|
|
102
|
+
found ? "customer-local" : "managed");
|
|
103
|
+
if (deployment === undefined) return null;
|
|
104
|
+
if (deployment !== "managed") added.push("--deployment", deployment);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (deployment !== undefined && deployment !== "managed") {
|
|
108
|
+
if (!has("--url")) {
|
|
109
|
+
const url = await askFor(ask, say, URL_QUESTIONS[deployment], {
|
|
110
|
+
fallback: deployment === "customer-local" ? LOCAL_BUNDLE_ORIGIN : null,
|
|
111
|
+
valid: (value) => /^https?:\/\/\S+$/.test(value),
|
|
112
|
+
retry: "Enter an address that starts with http:// or https://.",
|
|
113
|
+
});
|
|
114
|
+
if (url === null) return null;
|
|
115
|
+
added.push("--url", url);
|
|
116
|
+
}
|
|
117
|
+
if (!has("--workspace")) {
|
|
118
|
+
const workspace = await askFor(ask, say, WORKSPACE_QUESTIONS[deployment], {
|
|
119
|
+
valid: (value) => UUID.test(value),
|
|
120
|
+
retry: "A workspace ID looks like 11111111-1111-4111-8111-111111111111.",
|
|
121
|
+
});
|
|
122
|
+
if (workspace === null) return null;
|
|
123
|
+
added.push("--workspace", workspace.toLowerCase());
|
|
124
|
+
}
|
|
125
|
+
if (deployment === "oss" && !has("--agent-token-file")) {
|
|
126
|
+
const file = await askFor(ask, say, "Absolute path to a private file holding one MG_AGENT_TOKENS value", {
|
|
127
|
+
valid: (value) => path.isAbsolute(value),
|
|
128
|
+
retry: "Enter an absolute path, for example /home/me/.config/metergraph/oss-agent-token.",
|
|
129
|
+
});
|
|
130
|
+
if (file === null) return null;
|
|
131
|
+
added.push("--agent-token-file", file);
|
|
132
|
+
}
|
|
133
|
+
if (!has("--confirm-prerequisites")) {
|
|
134
|
+
say("", "Setup connects to an installation that is already running. It needs these done first:");
|
|
135
|
+
// Except on the open source server, setup signs in for its own
|
|
136
|
+
// Metadata access, so that one is not the person's to confirm.
|
|
137
|
+
for (const { name, guide } of prerequisitesFor(deployment)) {
|
|
138
|
+
if (name !== "metadata_agent_credential" || deployment === "oss") say(` - ${guide}`);
|
|
139
|
+
}
|
|
140
|
+
const done = await yesNo(ask, "Are they all done? [y/N] ");
|
|
141
|
+
if (done === null) return null;
|
|
142
|
+
// Without it, setup reports the first pending prerequisite.
|
|
143
|
+
if (done) added.push("--confirm-prerequisites");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!has("--client") && !has("--skip-skill")) {
|
|
148
|
+
const client = await choose(ask, say, "Install the Metergraph skill for which coding agent?", CLIENTS, "claude");
|
|
149
|
+
if (client === undefined) return null;
|
|
150
|
+
if (client === null) added.push("--skip-skill");
|
|
151
|
+
else added.push("--client", client);
|
|
152
|
+
}
|
|
153
|
+
return [...argv, ...added];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function commandLine(argv) {
|
|
157
|
+
return ["metergraph", ...argv.map(shellArg)].join(" ");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function valueOf(argv, flag) {
|
|
161
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
162
|
+
if (argv[index] === flag) return argv[index + 1];
|
|
163
|
+
if (argv[index].startsWith(`${flag}=`)) return argv[index].slice(flag.length + 1);
|
|
164
|
+
}
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// A numbered menu. Resolves with the chosen value, or undefined on cancel
|
|
169
|
+
// (null is a valid choice: no client).
|
|
170
|
+
async function choose(ask, say, question, options, fallback) {
|
|
171
|
+
say("", question);
|
|
172
|
+
options.forEach(([, label], index) => say(` ${index + 1}) ${label}`));
|
|
173
|
+
const fallbackIndex = options.findIndex(([value]) => value === fallback) + 1;
|
|
174
|
+
for (;;) {
|
|
175
|
+
const answer = await ask(`Choose 1-${options.length} [${fallbackIndex}]: `);
|
|
176
|
+
if (answer === null) return undefined;
|
|
177
|
+
const index = answer === "" ? fallbackIndex : Number(answer);
|
|
178
|
+
if (Number.isInteger(index) && index >= 1 && index <= options.length) return options[index - 1][0];
|
|
179
|
+
say(`Enter a number from 1 to ${options.length}.`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function askFor(ask, say, question, { fallback = null, valid, retry }) {
|
|
184
|
+
for (;;) {
|
|
185
|
+
const answer = await ask(fallback === null ? `${question}: ` : `${question} [${fallback}]: `);
|
|
186
|
+
if (answer === null) return null;
|
|
187
|
+
const value = answer === "" ? fallback : answer;
|
|
188
|
+
if (value !== null && valid(value)) return value;
|
|
189
|
+
say(retry);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function yesNo(ask, question) {
|
|
194
|
+
const answer = await ask(question);
|
|
195
|
+
if (answer === null) return null;
|
|
196
|
+
return ["y", "yes"].includes(answer.toLowerCase());
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function probeLocalBundle(origin) {
|
|
200
|
+
try {
|
|
201
|
+
const { report } = await runDoctor({ origin, timeoutMs: PROBE_TIMEOUT_MS });
|
|
202
|
+
return report?.deployment_profile === "local";
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function savedSetupProfile(project) {
|
|
209
|
+
try {
|
|
210
|
+
return readSetupState(resolveProject(project))?.value.deployment_profile ?? null;
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// --- Access for the coding agent on the customer-local bundle ---------------
|
|
217
|
+
|
|
218
|
+
// Setup signs the agent in with Metadata access only. On the customer-local
|
|
219
|
+
// bundle, bin/agent-key issues a broader key without a sign in and writes it
|
|
220
|
+
// to a private file, never printing it. The command text is fixed here, not
|
|
221
|
+
// taken from the service, so an origin cannot choose what a person is asked
|
|
222
|
+
// to run.
|
|
223
|
+
export const AGENT_KEY_FILE = "~/.config/metergraph/agent.env";
|
|
224
|
+
const AGENT_KEY_GRANTS = [
|
|
225
|
+
"--role", "debug", "--allow-evaluation-writes", "--allow-evaluation-previews", "--allow-analysis-starts",
|
|
226
|
+
];
|
|
227
|
+
export const AGENT_KEY_COMMAND = ["./bin/agent-key", "create", "--write-to", AGENT_KEY_FILE, ...AGENT_KEY_GRANTS].join(" ");
|
|
228
|
+
|
|
229
|
+
export function agentAccessOffer(origin) {
|
|
230
|
+
return {
|
|
231
|
+
role: "metadata",
|
|
232
|
+
offer: {
|
|
233
|
+
kind: "bundle_agent_key",
|
|
234
|
+
run_in: "bundle_directory",
|
|
235
|
+
command: AGENT_KEY_COMMAND,
|
|
236
|
+
requires_person_approval: true,
|
|
237
|
+
keys_page_url: handoffPages(origin).keys_url,
|
|
238
|
+
message: "The agent has Metadata access. To let it write evaluations and start analyses, " +
|
|
239
|
+
"ask the person which access they want, then they run this command in the bundle directory, " +
|
|
240
|
+
"or the agent runs it with --yes after they agree. The Debug role reads captured prompts and " +
|
|
241
|
+
"responses, and analysis starts send captured content to the person's model provider and spend " +
|
|
242
|
+
"money. The command writes the key to the named private file and never prints it. A bundle " +
|
|
243
|
+
"without bin/agent-key uses the Keys page instead.",
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// After a customer-local setup at a terminal: offer to run bin/agent-key
|
|
249
|
+
// now. It shows what it grants and asks for its own confirmation. Setup runs
|
|
250
|
+
// in the person's project, which may be a repository they did not write, so
|
|
251
|
+
// nothing there is ever a default: the person types the bundle directory, it
|
|
252
|
+
// must look like a started bundle, and setup names the exact program and asks
|
|
253
|
+
// before running it.
|
|
254
|
+
const BUNDLE_MARKERS = [["bin", "start"], ["docker-compose.yml"], ["metergraph-byoc-release.json"]];
|
|
255
|
+
|
|
256
|
+
export async function offerAgentAccess({ client, origin, prompter, say, run = runInherited, platform = process.platform }) {
|
|
257
|
+
say("", "Your coding agent has Metadata access. It can also write evaluations and start analyses.",
|
|
258
|
+
"That reads captured prompts and responses, and an analysis sends captured content to your model",
|
|
259
|
+
"provider and spends money on your account.");
|
|
260
|
+
if (platform === "win32") {
|
|
261
|
+
// The bundle's commands are shell scripts that run inside WSL, and
|
|
262
|
+
// Windows has no owner-only file mode to check, so say what to run there.
|
|
263
|
+
say(`To allow it, run this in the bundle directory inside WSL: ${AGENT_KEY_COMMAND}`);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const wanted = await yesNo(prompter.ask, "Give it that access now? [y/N] ");
|
|
267
|
+
if (!wanted) {
|
|
268
|
+
say(`Later, run this in the bundle directory: ${AGENT_KEY_COMMAND}`);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const answer = await askFor(prompter.ask, say, "Bundle directory (the one you ran bin/start in)", {
|
|
272
|
+
valid: (value) => BUNDLE_MARKERS.every((parts) => fs.existsSync(path.join(expandHome(value), ...parts))),
|
|
273
|
+
retry: "That is not a started bundle (it needs bin/start, docker-compose.yml and the release manifest).",
|
|
274
|
+
});
|
|
275
|
+
if (answer === null) return;
|
|
276
|
+
const bundle = fs.realpathSync(expandHome(answer));
|
|
277
|
+
const command = path.join(bundle, "bin", "agent-key");
|
|
278
|
+
if (!fs.existsSync(command)) {
|
|
279
|
+
say("This bundle has no bin/agent-key. Create the key on the Keys page instead:",
|
|
280
|
+
` ${handoffPages(origin).keys_url}`);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const problem = untrustedProgram(command);
|
|
284
|
+
if (problem !== null) {
|
|
285
|
+
say(`Not running ${command}: ${problem}.`);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const confirmed = await yesNo(prompter.ask, `Run ${command}? [y/N] `);
|
|
289
|
+
if (!confirmed) {
|
|
290
|
+
say(`Later, run this in the bundle directory: ${AGENT_KEY_COMMAND}`);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
prompter.close();
|
|
294
|
+
const file = path.join(os.homedir(), ".config", "metergraph", "agent.env");
|
|
295
|
+
const code = await run(command, ["create", "--write-to", file, ...AGENT_KEY_GRANTS], bundle);
|
|
296
|
+
if (code !== 0) {
|
|
297
|
+
say(`bin/agent-key did not finish. Run it again from ${bundle}: ${AGENT_KEY_COMMAND}`);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
say("", ...connectSteps(client, origin, file));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Only a regular file the person owns and nobody else can change is run.
|
|
304
|
+
function untrustedProgram(command) {
|
|
305
|
+
const info = fs.lstatSync(command);
|
|
306
|
+
if (!info.isFile()) return "it is not a regular file";
|
|
307
|
+
if (typeof process.getuid === "function" && info.uid !== process.getuid()) return "it is owned by another user";
|
|
308
|
+
if (info.mode & 0o022) return "other users can change it";
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// How the person connects their agent to the key: load the file where the
|
|
313
|
+
// agent starts, and name the variable (never the token) in its MCP settings.
|
|
314
|
+
export function connectSteps(client, origin, file) {
|
|
315
|
+
const mcp = `${origin}/v1/agent/mcp`;
|
|
316
|
+
const steps = [`Load the key in the shell that starts your agent: . ${shellArg(file)}`];
|
|
317
|
+
if (client === "claude") {
|
|
318
|
+
steps.push("Add the MCP server to Claude Code (it saves the variable name, not the token):",
|
|
319
|
+
` claude mcp add-json --scope user metergraph '${JSON.stringify({
|
|
320
|
+
type: "http", url: mcp, headers: { Authorization: "Bearer ${METERGRAPH_AGENT_TOKEN}" } })}'`);
|
|
321
|
+
} else if (client === "codex") {
|
|
322
|
+
steps.push("Add the MCP server to Codex (it saves the variable name, not the token):",
|
|
323
|
+
` codex mcp add metergraph --url ${mcp} --bearer-token-env-var METERGRAPH_AGENT_TOKEN`);
|
|
324
|
+
} else {
|
|
325
|
+
steps.push("Add the MCP server as the bundle README's 'Let your coding agent run analyses' section shows.");
|
|
326
|
+
}
|
|
327
|
+
steps.push("Then restart the agent.");
|
|
328
|
+
return steps;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function expandHome(value) {
|
|
332
|
+
return value === "~" || value.startsWith("~/") ? path.join(os.homedir(), value.slice(1)) : value;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function runInherited(command, args, cwd) {
|
|
336
|
+
return new Promise((resolve) => {
|
|
337
|
+
const child = spawn(command, args, { cwd, stdio: "inherit" });
|
|
338
|
+
child.on("error", () => resolve(1));
|
|
339
|
+
child.on("close", (code) => resolve(code ?? 1));
|
|
340
|
+
});
|
|
341
|
+
}
|
package/src/transport.js
CHANGED
|
@@ -52,11 +52,13 @@ function requestUrl(origin, pathname, query) {
|
|
|
52
52
|
// other status are read up to maxBytes; a larger body sets tooLarge and is
|
|
53
53
|
// discarded. The only credential ever sent is the bearer argument, and only
|
|
54
54
|
// to this origin. Nothing is taken from the environment, and no cookies are
|
|
55
|
-
// sent or kept.
|
|
55
|
+
// sent or kept. useGlobalAgent is opt-in for public pricing so Node can use its
|
|
56
|
+
// configured environment proxy (Node 24+ with --use-env-proxy). Other callers
|
|
57
|
+
// retain their existing isolated-agent behavior. query is null or a URLSearchParams accepted by requestUrl.
|
|
56
58
|
export function send(
|
|
57
59
|
origin,
|
|
58
60
|
pathname,
|
|
59
|
-
{ method = "GET", signal, maxBytes, bearer = null, form = null, json = null, query = null },
|
|
61
|
+
{ method = "GET", signal, maxBytes, bearer = null, form = null, json = null, query = null, useGlobalAgent = false },
|
|
60
62
|
) {
|
|
61
63
|
const url = requestUrl(origin, pathname, query);
|
|
62
64
|
|
|
@@ -91,7 +93,7 @@ export function send(
|
|
|
91
93
|
const transport = url.protocol === "https:" ? https : http;
|
|
92
94
|
let request;
|
|
93
95
|
try {
|
|
94
|
-
request = transport.request(url, { method, agent: false, signal, headers });
|
|
96
|
+
request = transport.request(url, { method, agent: useGlobalAgent ? undefined : false, signal, headers });
|
|
95
97
|
} catch (error) {
|
|
96
98
|
fail(error);
|
|
97
99
|
return;
|