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.
- package/README.md +121 -13
- package/assets/skill/SKILL.md +124 -31
- package/assets/skill/manifest.json +3 -3
- package/assets/skills/manifest.json +31 -31
- package/assets/skills/metergraph-analyze/SKILL.md +161 -58
- package/assets/skills/metergraph-candidates/SKILL.md +50 -8
- package/assets/skills/metergraph-evals/SKILL.md +83 -16
- package/assets/skills/metergraph-investigate/SKILL.md +90 -34
- package/assets/skills/metergraph-iterate/SKILL.md +82 -12
- package/assets/skills/metergraph-local-bundle/SKILL.md +146 -128
- package/assets/skills/metergraph-model-swap/SKILL.md +142 -20
- package/assets/skills/metergraph-onboarding/SKILL.md +93 -49
- package/assets/skills/metergraph-report/SKILL.md +60 -15
- package/assets/skills/metergraph-workloads/SKILL.md +97 -13
- package/bin/metergraph.js +1 -1
- package/native/README.md +85 -0
- package/native/anchored-fs.c +243 -0
- package/native/bin/anchored-fs-darwin-arm64 +0 -0
- package/native/bin/anchored-fs-darwin-x64 +0 -0
- package/native/bin/anchored-fs-linux-arm64 +0 -0
- package/native/bin/anchored-fs-linux-x64 +0 -0
- package/native/build.mjs +55 -0
- package/native/manifest.json +9 -0
- package/native/targets.mjs +24 -0
- package/package.json +10 -3
- package/src/action-help.js +130 -0
- package/src/agent-key.js +22 -12
- package/src/anchored-fs.js +163 -0
- package/src/args.js +335 -31
- package/src/auth-binding.js +12 -7
- package/src/auth-callback.js +10 -2
- package/src/auth-login.js +111 -28
- package/src/auth-oauth.js +133 -36
- package/src/auth-session.js +19 -12
- package/src/auth-store.js +28 -7
- package/src/cli.js +147 -15
- package/src/constants.js +70 -1
- package/src/experiments-contract.js +462 -0
- package/src/experiments-output.js +190 -0
- package/src/experiments.js +406 -0
- package/src/output.js +374 -68
- 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 +64 -10
- package/src/read-output.js +66 -13
- package/src/read.js +105 -16
- package/src/repository-identity.js +2 -2
- package/src/setup-deployment.js +7 -0
- package/src/setup-env-git.js +1 -1
- package/src/setup-env.js +16 -9
- package/src/setup-interactive.js +356 -0
- package/src/setup-refusal.js +2 -2
- package/src/setup-state.js +9 -4
- package/src/setup.js +15 -3
- package/src/skill-bundle.js +2 -2
- package/src/skill-trusted.js +56 -0
- package/src/skill.js +28 -9
- package/src/style.js +33 -7
- package/src/tools-output.js +73 -0
- package/src/tools.js +249 -0
- package/src/trace-contract.js +1 -1
- package/src/transport.js +26 -3
- package/src/verify-output.js +2 -2
- package/src/verify.js +5 -2
|
@@ -0,0 +1,356 @@
|
|
|
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 { parseArgs } from "./args.js";
|
|
8
|
+
import { resolveProject } from "./auth-binding.js";
|
|
9
|
+
import { runDoctor } from "./doctor.js";
|
|
10
|
+
import { classifyEnvironment, handoffPages, shellArg } from "./environment.js";
|
|
11
|
+
import { prerequisitesFor } from "./setup-deployment.js";
|
|
12
|
+
import { readSetupState } from "./setup-state.js";
|
|
13
|
+
|
|
14
|
+
// Setup asks for what it is missing only when a person is at a terminal: no
|
|
15
|
+
// --json, stdin and stdout both terminals, and not CI. An agent or a script
|
|
16
|
+
// never gets a question, so nothing waits on input that will never come; it
|
|
17
|
+
// keeps the flag errors and the next_action contract. The answers become the
|
|
18
|
+
// same flags a script would pass, and the full command is printed so it can
|
|
19
|
+
// be rerun without questions.
|
|
20
|
+
|
|
21
|
+
// The setup parse failures a question can answer. Any other failure, such as
|
|
22
|
+
// an invalid value or two conflicting flags, is reported as it is.
|
|
23
|
+
export const ASKABLE = new Set([
|
|
24
|
+
"missing_runtime", "non_hosted_origin_required", "non_hosted_workspace_required", "client_required",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
export const LOCAL_BUNDLE_ORIGIN = "http://localhost:8080";
|
|
28
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
29
|
+
const PROBE_TIMEOUT_MS = 1500;
|
|
30
|
+
|
|
31
|
+
const DEPLOYMENTS = [
|
|
32
|
+
["managed", "Metergraph cloud (app.metergraph.dev)"],
|
|
33
|
+
["customer-local", "The Metergraph bundle running on this machine"],
|
|
34
|
+
["byoc", "Your company's Metergraph installation on AWS"],
|
|
35
|
+
["oss", "The open source Metergraph server"],
|
|
36
|
+
];
|
|
37
|
+
const CLIENTS = [
|
|
38
|
+
["claude", "Claude Code"],
|
|
39
|
+
["codex", "Codex"],
|
|
40
|
+
["cursor", "Cursor"],
|
|
41
|
+
[null, "None (skip installing the skill)"],
|
|
42
|
+
];
|
|
43
|
+
const URL_QUESTIONS = {
|
|
44
|
+
"customer-local": "Address of the bundle",
|
|
45
|
+
byoc: "HTTPS address of the installation",
|
|
46
|
+
oss: "Address of the server",
|
|
47
|
+
};
|
|
48
|
+
const WORKSPACE_QUESTIONS = {
|
|
49
|
+
"customer-local": "Workspace ID (./bin/status in the bundle directory prints it)",
|
|
50
|
+
byoc: "Workspace ID (shown on the installation's Settings page)",
|
|
51
|
+
oss: "Workspace ID",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export function canAsk(argv, { stdin, stdout, stderr = stdout, env = process.env }) {
|
|
55
|
+
return !argv.includes("--json") && Boolean(stdin?.isTTY) && Boolean(stdout?.isTTY)
|
|
56
|
+
&& Boolean(stderr?.isTTY) && !classifyEnvironment(env).remote;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Login needs no client or deployment questions. Only supply the local runtime
|
|
60
|
+
// after the rest of the command validates; callers gate this on canAsk.
|
|
61
|
+
export function fillLoginArgv(argv) {
|
|
62
|
+
const parsed = parseArgs(argv);
|
|
63
|
+
if (parsed.command !== "login" || parsed.code !== "missing_runtime") return argv;
|
|
64
|
+
const candidate = [...argv, "--runtime", "local"];
|
|
65
|
+
return parseArgs(candidate).ok ? candidate : argv;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Questions on the terminal. ask resolves with the trimmed answer, or null
|
|
69
|
+
// once the person presses Ctrl-C or Ctrl-D, after which every ask is null.
|
|
70
|
+
export function terminalPrompter(stdin, stdout) {
|
|
71
|
+
const lines = createInterface({ input: stdin, output: stdout, terminal: true });
|
|
72
|
+
const stop = new AbortController();
|
|
73
|
+
let closed = false;
|
|
74
|
+
lines.on("SIGINT", () => stop.abort());
|
|
75
|
+
lines.on("close", () => {
|
|
76
|
+
closed = true;
|
|
77
|
+
stop.abort();
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
async ask(question) {
|
|
81
|
+
if (stop.signal.aborted) return null;
|
|
82
|
+
try {
|
|
83
|
+
return (await lines.question(question, { signal: stop.signal })).trim();
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
close() {
|
|
89
|
+
if (!closed) lines.close();
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Resolves with argv plus the flags the answers stand for, or null when the
|
|
95
|
+
// person cancelled. Flags already given are never asked for or changed.
|
|
96
|
+
export async function fillSetupArgv(argv, {
|
|
97
|
+
ask, say, probe = probeLocalBundle, savedProfile = savedSetupProfile, remote = classifyEnvironment().remote,
|
|
98
|
+
}) {
|
|
99
|
+
const has = (flag) => argv.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
|
|
100
|
+
const added = [];
|
|
101
|
+
// A person at a terminal runs setup for a coding agent on this machine.
|
|
102
|
+
// Over SSH or in a cloud workspace, setup itself still hands off.
|
|
103
|
+
if (!has("--runtime")) added.push("--runtime", "local");
|
|
104
|
+
|
|
105
|
+
let deployment = valueOf(argv, "--deployment");
|
|
106
|
+
// A rerun in a project set up before resumes that route without asking.
|
|
107
|
+
if (deployment === undefined && savedProfile(valueOf(argv, "--project")) === null) {
|
|
108
|
+
// A remote shell sends no request before setup hands it off to the
|
|
109
|
+
// person's own machine, and its localhost is not that machine anyway.
|
|
110
|
+
const found = remote ? false : await probe(LOCAL_BUNDLE_ORIGIN);
|
|
111
|
+
if (found) say(`Found a Metergraph bundle at ${LOCAL_BUNDLE_ORIGIN}.`);
|
|
112
|
+
deployment = await choose(ask, say, "Where is Metergraph running?", DEPLOYMENTS,
|
|
113
|
+
found ? "customer-local" : "managed");
|
|
114
|
+
if (deployment === undefined) return null;
|
|
115
|
+
if (deployment !== "managed") added.push("--deployment", deployment);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (deployment !== undefined && deployment !== "managed") {
|
|
119
|
+
if (!has("--url")) {
|
|
120
|
+
const url = await askFor(ask, say, URL_QUESTIONS[deployment], {
|
|
121
|
+
fallback: deployment === "customer-local" ? LOCAL_BUNDLE_ORIGIN : null,
|
|
122
|
+
valid: (value) => /^https?:\/\/\S+$/.test(value),
|
|
123
|
+
retry: "Enter an address that starts with http:// or https://.",
|
|
124
|
+
});
|
|
125
|
+
if (url === null) return null;
|
|
126
|
+
added.push("--url", url);
|
|
127
|
+
}
|
|
128
|
+
if (!has("--workspace")) {
|
|
129
|
+
const workspace = await askFor(ask, say, WORKSPACE_QUESTIONS[deployment], {
|
|
130
|
+
valid: (value) => UUID.test(value),
|
|
131
|
+
retry: "A workspace ID looks like 11111111-1111-4111-8111-111111111111.",
|
|
132
|
+
});
|
|
133
|
+
if (workspace === null) return null;
|
|
134
|
+
added.push("--workspace", workspace.toLowerCase());
|
|
135
|
+
}
|
|
136
|
+
if (deployment === "oss" && !has("--agent-token-file")) {
|
|
137
|
+
const file = await askFor(ask, say, "Absolute path to a private file holding one MG_AGENT_TOKENS value", {
|
|
138
|
+
valid: (value) => path.isAbsolute(value),
|
|
139
|
+
retry: "Enter an absolute path, for example /home/me/.config/metergraph/oss-agent-token.",
|
|
140
|
+
});
|
|
141
|
+
if (file === null) return null;
|
|
142
|
+
added.push("--agent-token-file", file);
|
|
143
|
+
}
|
|
144
|
+
if (!has("--confirm-prerequisites")) {
|
|
145
|
+
say("", "Setup connects to an installation that is already running. It needs these done first:");
|
|
146
|
+
// Except on the open source server, setup signs in for its own
|
|
147
|
+
// Metadata access, so that one is not the person's to confirm.
|
|
148
|
+
for (const { name, guide } of prerequisitesFor(deployment)) {
|
|
149
|
+
if (name !== "metadata_agent_credential" || deployment === "oss") say(` - ${guide}`);
|
|
150
|
+
}
|
|
151
|
+
const done = await yesNo(ask, "Are they all done? [y/N] ");
|
|
152
|
+
if (done === null) return null;
|
|
153
|
+
// Without it, setup reports the first pending prerequisite.
|
|
154
|
+
if (done) added.push("--confirm-prerequisites");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!has("--client") && !has("--skip-skill")) {
|
|
159
|
+
const client = await choose(ask, say, "Install the Metergraph skill for which coding agent?", CLIENTS, "claude", {
|
|
160
|
+
...Object.fromEntries(CLIENTS.map(([value, label]) => [label.toLowerCase(), value])),
|
|
161
|
+
"skip skill installation": null,
|
|
162
|
+
});
|
|
163
|
+
if (client === undefined) return null;
|
|
164
|
+
if (client === null) added.push("--skip-skill");
|
|
165
|
+
else added.push("--client", client);
|
|
166
|
+
}
|
|
167
|
+
return [...argv, ...added];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function commandLine(argv) {
|
|
171
|
+
return ["metergraph", ...argv.map(shellArg)].join(" ");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function valueOf(argv, flag) {
|
|
175
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
176
|
+
if (argv[index] === flag) return argv[index + 1];
|
|
177
|
+
if (argv[index].startsWith(`${flag}=`)) return argv[index].slice(flag.length + 1);
|
|
178
|
+
}
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// A numbered menu. Resolves with the chosen value, or undefined on cancel
|
|
183
|
+
// (null is a valid choice: no client).
|
|
184
|
+
async function choose(ask, say, question, options, fallback, aliases = {}) {
|
|
185
|
+
say("", question);
|
|
186
|
+
options.forEach(([, label], index) => say(` ${index + 1}) ${label}`));
|
|
187
|
+
const fallbackIndex = options.findIndex(([value]) => value === fallback) + 1;
|
|
188
|
+
for (;;) {
|
|
189
|
+
const answer = await ask(`Choose 1-${options.length} [${fallbackIndex}]: `);
|
|
190
|
+
if (answer === null) return undefined;
|
|
191
|
+
if (Object.hasOwn(aliases, answer.toLowerCase())) return aliases[answer.toLowerCase()];
|
|
192
|
+
const index = answer === "" ? fallbackIndex : Number(answer);
|
|
193
|
+
if (Number.isInteger(index) && index >= 1 && index <= options.length) return options[index - 1][0];
|
|
194
|
+
say(`Enter a number from 1 to ${options.length}.`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function askFor(ask, say, question, { fallback = null, valid, retry }) {
|
|
199
|
+
for (;;) {
|
|
200
|
+
const answer = await ask(fallback === null ? `${question}: ` : `${question} [${fallback}]: `);
|
|
201
|
+
if (answer === null) return null;
|
|
202
|
+
const value = answer === "" ? fallback : answer;
|
|
203
|
+
if (value !== null && valid(value)) return value;
|
|
204
|
+
say(retry);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function yesNo(ask, question) {
|
|
209
|
+
const answer = await ask(question);
|
|
210
|
+
if (answer === null) return null;
|
|
211
|
+
return ["y", "yes"].includes(answer.toLowerCase());
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function probeLocalBundle(origin) {
|
|
215
|
+
try {
|
|
216
|
+
const { report } = await runDoctor({ origin, timeoutMs: PROBE_TIMEOUT_MS });
|
|
217
|
+
return report?.deployment_profile === "local";
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function savedSetupProfile(project) {
|
|
224
|
+
try {
|
|
225
|
+
return readSetupState(resolveProject(project))?.value.deployment_profile ?? null;
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// --- Access for the coding agent on the customer-local bundle ---------------
|
|
232
|
+
|
|
233
|
+
// Setup signs the agent in with Metadata access only. On the customer-local
|
|
234
|
+
// bundle, bin/agent-key issues a broader key without a sign in and writes it
|
|
235
|
+
// to a private file, never printing it. The command text is fixed here, not
|
|
236
|
+
// taken from the service, so an origin cannot choose what a person is asked
|
|
237
|
+
// to run.
|
|
238
|
+
export const AGENT_KEY_FILE = "~/.config/metergraph/agent.env";
|
|
239
|
+
const AGENT_KEY_GRANTS = [
|
|
240
|
+
"--role", "debug", "--allow-evaluation-writes", "--allow-evaluation-previews", "--allow-analysis-starts",
|
|
241
|
+
];
|
|
242
|
+
export const AGENT_KEY_COMMAND = ["./bin/agent-key", "create", "--write-to", AGENT_KEY_FILE, ...AGENT_KEY_GRANTS].join(" ");
|
|
243
|
+
|
|
244
|
+
export function agentAccessOffer(origin) {
|
|
245
|
+
return {
|
|
246
|
+
role: "metadata",
|
|
247
|
+
offer: {
|
|
248
|
+
kind: "bundle_agent_key",
|
|
249
|
+
run_in: "bundle_directory",
|
|
250
|
+
command: AGENT_KEY_COMMAND,
|
|
251
|
+
requires_person_approval: true,
|
|
252
|
+
keys_page_url: handoffPages(origin).keys_url,
|
|
253
|
+
message: "The agent has Metadata access. To let it write evaluations and start analyses, " +
|
|
254
|
+
"ask the person which access they want, then they run this command in the bundle directory, " +
|
|
255
|
+
"or the agent runs it with --yes after they agree. The Debug role reads captured prompts and " +
|
|
256
|
+
"responses, and analysis starts send captured content to the person's model provider and spend " +
|
|
257
|
+
"money. The command writes the key to the named private file and never prints it. A bundle " +
|
|
258
|
+
"without bin/agent-key uses the Keys page instead.",
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// After a customer-local setup at a terminal: offer to run bin/agent-key
|
|
264
|
+
// now. It shows what it grants and asks for its own confirmation. Setup runs
|
|
265
|
+
// in the person's project, which may be a repository they did not write, so
|
|
266
|
+
// nothing there is ever a default: the person types the bundle directory, it
|
|
267
|
+
// must look like a started bundle, and setup names the exact program and asks
|
|
268
|
+
// before running it.
|
|
269
|
+
const BUNDLE_MARKERS = [["bin", "start"], ["docker-compose.yml"], ["metergraph-byoc-release.json"]];
|
|
270
|
+
|
|
271
|
+
export async function offerAgentAccess({ client, origin, prompter, say, run = runInherited, platform = process.platform }) {
|
|
272
|
+
say("", "Your coding agent has Metadata access. It can also write evaluations and start analyses.",
|
|
273
|
+
"That reads captured prompts and responses, and an analysis sends captured content to your model",
|
|
274
|
+
"provider and spends money on your account.");
|
|
275
|
+
if (platform === "win32") {
|
|
276
|
+
// The bundle's commands are shell scripts that run inside WSL, and
|
|
277
|
+
// Windows has no owner-only file mode to check, so say what to run there.
|
|
278
|
+
say(`To allow it, run this in the bundle directory inside WSL: ${AGENT_KEY_COMMAND}`);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const wanted = await yesNo(prompter.ask, "Give it that access now? [y/N] ");
|
|
282
|
+
if (!wanted) {
|
|
283
|
+
say(`Later, run this in the bundle directory: ${AGENT_KEY_COMMAND}`);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const answer = await askFor(prompter.ask, say, "Bundle directory (the one you ran bin/start in)", {
|
|
287
|
+
valid: (value) => BUNDLE_MARKERS.every((parts) => fs.existsSync(path.join(expandHome(value), ...parts))),
|
|
288
|
+
retry: "That is not a started bundle (it needs bin/start, docker-compose.yml and the release manifest).",
|
|
289
|
+
});
|
|
290
|
+
if (answer === null) return;
|
|
291
|
+
const bundle = fs.realpathSync(expandHome(answer));
|
|
292
|
+
const command = path.join(bundle, "bin", "agent-key");
|
|
293
|
+
if (!fs.existsSync(command)) {
|
|
294
|
+
say("This bundle has no bin/agent-key. Create the key on the Keys page instead:",
|
|
295
|
+
` ${handoffPages(origin).keys_url}`);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const problem = untrustedProgram(command);
|
|
299
|
+
if (problem !== null) {
|
|
300
|
+
say(`Not running ${command}: ${problem}.`);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const confirmed = await yesNo(prompter.ask, `Run ${command}? [y/N] `);
|
|
304
|
+
if (!confirmed) {
|
|
305
|
+
say(`Later, run this in the bundle directory: ${AGENT_KEY_COMMAND}`);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
prompter.close();
|
|
309
|
+
const file = path.join(os.homedir(), ".config", "metergraph", "agent.env");
|
|
310
|
+
const code = await run(command, ["create", "--write-to", file, ...AGENT_KEY_GRANTS], bundle);
|
|
311
|
+
if (code !== 0) {
|
|
312
|
+
say(`bin/agent-key did not finish. Run it again from ${bundle}: ${AGENT_KEY_COMMAND}`);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
say("", ...connectSteps(client, origin, file));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Only a regular file the person owns and nobody else can change is run.
|
|
319
|
+
function untrustedProgram(command) {
|
|
320
|
+
const info = fs.lstatSync(command);
|
|
321
|
+
if (!info.isFile()) return "it is not a regular file";
|
|
322
|
+
if (typeof process.getuid === "function" && info.uid !== process.getuid()) return "it is owned by another user";
|
|
323
|
+
if (info.mode & 0o022) return "other users can change it";
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// How the person connects their agent to the key: load the file where the
|
|
328
|
+
// agent starts, and name the variable (never the token) in its MCP settings.
|
|
329
|
+
export function connectSteps(client, origin, file) {
|
|
330
|
+
const mcp = `${origin}/v1/agent/mcp`;
|
|
331
|
+
const steps = [`Load the key in the shell that starts your agent: . ${shellArg(file)}`];
|
|
332
|
+
if (client === "claude") {
|
|
333
|
+
steps.push("Add the MCP server to Claude Code (it saves the variable name, not the token):",
|
|
334
|
+
` claude mcp add-json --scope user metergraph '${JSON.stringify({
|
|
335
|
+
type: "http", url: mcp, headers: { Authorization: "Bearer ${METERGRAPH_AGENT_TOKEN}" } })}'`);
|
|
336
|
+
} else if (client === "codex") {
|
|
337
|
+
steps.push("Add the MCP server to Codex (it saves the variable name, not the token):",
|
|
338
|
+
` codex mcp add metergraph --url ${mcp} --bearer-token-env-var METERGRAPH_AGENT_TOKEN`);
|
|
339
|
+
} else {
|
|
340
|
+
steps.push("Add the MCP server as the bundle README's 'Let your coding agent run analyses' section shows.");
|
|
341
|
+
}
|
|
342
|
+
steps.push("Then restart the agent.");
|
|
343
|
+
return steps;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function expandHome(value) {
|
|
347
|
+
return value === "~" || value.startsWith("~/") ? path.join(os.homedir(), value.slice(1)) : value;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function runInherited(command, args, cwd) {
|
|
351
|
+
return new Promise((resolve) => {
|
|
352
|
+
const child = spawn(command, args, { cwd, stdio: "inherit" });
|
|
353
|
+
child.on("error", () => resolve(1));
|
|
354
|
+
child.on("close", (code) => resolve(code ?? 1));
|
|
355
|
+
});
|
|
356
|
+
}
|
package/src/setup-refusal.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
-
import fs from "
|
|
2
|
+
import fs, { withRollback } from "./anchored-fs.js";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
5
|
// What the --json --no-browser background waiter is doing with an approval
|
|
@@ -80,7 +80,7 @@ function write(root, value) {
|
|
|
80
80
|
fs.renameSync(temp, fileFor(root));
|
|
81
81
|
return true;
|
|
82
82
|
} catch {
|
|
83
|
-
try { fs.unlinkSync(temp); } catch { /* no temp */ }
|
|
83
|
+
try { withRollback(() => fs.unlinkSync(temp)); } catch { /* no temp */ }
|
|
84
84
|
return false;
|
|
85
85
|
}
|
|
86
86
|
}
|
package/src/setup-state.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
-
import fs from "
|
|
2
|
+
import fs, { withRollback } from "./anchored-fs.js";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
|
+
import { parseOrigin } from "./origin.js";
|
|
6
|
+
|
|
5
7
|
import { Stop } from "./auth-store.js";
|
|
6
8
|
|
|
7
9
|
// Public, non-credential project state. It is persisted before browser
|
|
@@ -53,7 +55,7 @@ export function setupReceipt(value) {
|
|
|
53
55
|
function core(value) {
|
|
54
56
|
return value.schema_version === 1 && UUID.test(value.family_id) &&
|
|
55
57
|
(value.key_id === null || UUID.test(value.key_id)) && PHASES.has(value.phase) &&
|
|
56
|
-
typeof value.origin === "string" && typeof value.workspace_id === "string" && UUID.test(value.workspace_id);
|
|
58
|
+
typeof value.origin === "string" && parseOrigin(value.origin) === value.origin && typeof value.workspace_id === "string" && UUID.test(value.workspace_id);
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
function valid(value) {
|
|
@@ -78,6 +80,7 @@ export function readSetupState(root) {
|
|
|
78
80
|
let stat;
|
|
79
81
|
try { stat = fs.lstatSync(file); }
|
|
80
82
|
catch (error) {
|
|
83
|
+
if (error.code === "ENOTSUP") throw new Stop("unsupported", "secure_writes_unavailable");
|
|
81
84
|
if (error.code === "ENOENT") return null;
|
|
82
85
|
throw new Stop("filesystem_error", "setup_state_read_failed");
|
|
83
86
|
}
|
|
@@ -95,6 +98,7 @@ export function readSetupState(root) {
|
|
|
95
98
|
selected_client: null, skill_status: "pending" }) : value, content };
|
|
96
99
|
} catch (error) {
|
|
97
100
|
if (error instanceof Stop) throw error;
|
|
101
|
+
if (error.code === "ENOTSUP") throw new Stop("unsupported", "secure_writes_unavailable");
|
|
98
102
|
throw new Stop("conflict", "setup_state_invalid");
|
|
99
103
|
} finally {
|
|
100
104
|
if (fd !== undefined) fs.closeSync(fd);
|
|
@@ -134,10 +138,11 @@ export function writeSetupState(root, next, previous) {
|
|
|
134
138
|
fs.renameSync(temp, dest);
|
|
135
139
|
} catch (error) {
|
|
136
140
|
if (error instanceof Stop) throw error;
|
|
141
|
+
if (error.code === "ENOTSUP") throw new Stop("unsupported", "secure_writes_unavailable");
|
|
137
142
|
if (error.code === "EEXIST") throw new Stop("conflict", "setup_state_locked");
|
|
138
143
|
throw new Stop("filesystem_error", "setup_state_write_failed");
|
|
139
144
|
} finally {
|
|
140
|
-
try { fs.unlinkSync(temp); } catch { /* no temp */ }
|
|
141
|
-
if (locked) { try { fs.unlinkSync(lock); } catch { /* next run refuses */ } }
|
|
145
|
+
try { withRollback(() => fs.unlinkSync(temp)); } catch { /* no temp */ }
|
|
146
|
+
if (locked) { try { withRollback(() => fs.unlinkSync(lock)); } catch { /* next run refuses */ } }
|
|
142
147
|
}
|
|
143
148
|
}
|
package/src/setup.js
CHANGED
|
@@ -57,6 +57,9 @@ export async function runSetup(options, progress = () => {}) {
|
|
|
57
57
|
const where = options.runtime !== "local" ? { kind: "declared_cloud" } : environment;
|
|
58
58
|
return fail("unsupported", "run_on_local_machine", "not_ready", null, remoteSetupAction(where, options.origin, options));
|
|
59
59
|
}
|
|
60
|
+
if (new URL(options.origin).protocol === "http:") {
|
|
61
|
+
return fail("unsupported", "local_docker_transport_required", "not_ready", null);
|
|
62
|
+
}
|
|
60
63
|
const trap = trapSignals();
|
|
61
64
|
let receipt = null;
|
|
62
65
|
let loginVerified = false;
|
|
@@ -112,9 +115,14 @@ export async function runSetup(options, progress = () => {}) {
|
|
|
112
115
|
// An env key this setup does not own stops before any sign in, so the
|
|
113
116
|
// refusal costs no approval and leaves the binding untouched. The check
|
|
114
117
|
// after sign in stays, against a fresh read.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (
|
|
118
|
+
const early = currentEnvValues(preflightEnv({ project: root, envFile: options.envFile, signal: trap.signal }));
|
|
119
|
+
if (early.token !== null) {
|
|
120
|
+
if (!saved) return stop("conflict", "existing_ingest_key_unowned");
|
|
121
|
+
// The saved public route cannot choose the destination of a token.
|
|
122
|
+
// Require its colocated ingest destination before any sign-in request.
|
|
123
|
+
if (early.ingestUrl !== intendedOrigin && early.ingestUrl !== `${intendedOrigin}/v1/ingest`) {
|
|
124
|
+
return stop("conflict", "ingest_url_mismatch");
|
|
125
|
+
}
|
|
118
126
|
}
|
|
119
127
|
const bindingDiffers = bound !== null && (bound.origin !== intendedOrigin ||
|
|
120
128
|
(intendedWorkspace !== null && bound.workspace_id !== intendedWorkspace));
|
|
@@ -182,6 +190,10 @@ export async function runSetup(options, progress = () => {}) {
|
|
|
182
190
|
// being reused. Pending delivery is acknowledged, including after a
|
|
183
191
|
// previous process stopped after the env write.
|
|
184
192
|
if (previous && existing.token !== null) {
|
|
193
|
+
// Recheck the fresh env snapshot immediately before the bearer sink.
|
|
194
|
+
if (existing.ingestUrl !== origin && existing.ingestUrl !== `${origin}/v1/ingest`) {
|
|
195
|
+
return stop("conflict", "ingest_url_mismatch");
|
|
196
|
+
}
|
|
185
197
|
const checked = await checkCredential(origin, existing.token, previous.value, profile, trap.signal);
|
|
186
198
|
if (checked.ok) {
|
|
187
199
|
if (existing.ingestUrl !== origin && existing.ingestUrl !== `${origin}/v1/ingest`) {
|
package/src/skill-bundle.js
CHANGED
|
@@ -5,7 +5,7 @@ import { readFileSync } from "node:fs";
|
|
|
5
5
|
// public skill at the manifest's source_url. The source has no version of its
|
|
6
6
|
// own, so the revision is derived from the content hash. The hash is pinned
|
|
7
7
|
// here as well as in the manifest, so editing either file alone is detected.
|
|
8
|
-
const PINNED_SHA256 = "
|
|
8
|
+
const PINNED_SHA256 = "f31c24c4074707df0e34502bd9efbfde4fff5703e6fd367e0f60692f9f5878ce";
|
|
9
9
|
|
|
10
10
|
const ASSET_DIR = new URL("../assets/skill/", import.meta.url);
|
|
11
11
|
const MANIFEST_KEYS = ["file", "manifest_version", "name", "revision", "sha256", "size", "source_url"];
|
|
@@ -55,7 +55,7 @@ export function loadBundledSkill() {
|
|
|
55
55
|
// The workflow skills that "skills install" writes, vendored from the public
|
|
56
56
|
// skills repository at one commit. The manifest's own hash is pinned here, and
|
|
57
57
|
// the manifest pins each skill's hash, so changing any file alone is detected.
|
|
58
|
-
const PINNED_PACK_SHA256 = "
|
|
58
|
+
const PINNED_PACK_SHA256 = "0460133ebbe3bc4f1ea03e297f36a15b72b291ab9b9efb80a35df863ef974e0c";
|
|
59
59
|
|
|
60
60
|
const PACK_DIR = new URL("../assets/skills/", import.meta.url);
|
|
61
61
|
const PACK_KEYS = ["commit", "manifest_version", "repository", "skills"];
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Exact bytes of bundled skills from prior public releases and the previous bundle.
|
|
2
|
+
// A repository receipt cannot authorize replacing arbitrary user-authored bytes.
|
|
3
|
+
export const TRUSTED_SKILLS = {
|
|
4
|
+
"metergraph": [
|
|
5
|
+
"90f7d8d78a5b0b7a57436f194222f0c73310b0b04201c297c8fbf0b00ad6bb3f",
|
|
6
|
+
"d527a67efc42a55245b9c2c8fd2687c1f31f553e154a6242d0f31466993e314e",
|
|
7
|
+
"e7fc4eb7a22eeb16c357bede9d0a080ad3b415aaab1d9c72f164d5350d48c4b1",
|
|
8
|
+
"ebf784673eff0280d85974504f93c683a1dabfb54156c9d27e091131462e9d5f",
|
|
9
|
+
"482bdb6c32ad04766ba5159449a612419c90bfe78ea07038eb8d1ca7fb1edb76"
|
|
10
|
+
],
|
|
11
|
+
"metergraph-analyze": [
|
|
12
|
+
"c45756ad1d33a360c7c845ca299d8c1ab1e2b8162865f635bc25c78358c2a1e6",
|
|
13
|
+
"e8bf1debf62aa7d33d3dd363e5d95d8f781e8ca23cde4619b5bb4133dd47a7a5",
|
|
14
|
+
"4544f8183a1259ef805eb89a72346ecc87306c70007b235436d6425787b85474"
|
|
15
|
+
],
|
|
16
|
+
"metergraph-candidates": [
|
|
17
|
+
"acc0176e29ea5b867855c1f4691e01e4cb9295705b0ffc89ad0b348da9c47d8a",
|
|
18
|
+
"43de69d168091492310fc2f8851710e861a0b5cd3975c30a5c23d205abfcdfc6"
|
|
19
|
+
],
|
|
20
|
+
"metergraph-evals": [
|
|
21
|
+
"b115c53c5ff4040ac35ff240aee6cb4b56465db0916d9f104f8ab433bfc77c60",
|
|
22
|
+
"9baef426288455b2be2ea8af2774a19c344b5b83f8e9046916f24f8bf744f2de"
|
|
23
|
+
],
|
|
24
|
+
"metergraph-investigate": [
|
|
25
|
+
"470579faf9e8b5f1a562060d51ec5a71d1b9c47213403a2447dd40af56a74869",
|
|
26
|
+
"775dbe7ca1100b17638d0905302ef1324694a60b90372c6c3df7b598a8f70438",
|
|
27
|
+
"b06951926490aa0cd7a6ba0de5d8c1f21cac931ccab882552cea7ebea4ab7562"
|
|
28
|
+
],
|
|
29
|
+
"metergraph-iterate": [
|
|
30
|
+
"9ce5a2cf527e3f9a81fddcabc4bfc0a4e4894df7b502df8672e3d66470e5a5e8",
|
|
31
|
+
"1c2a2ad249b0fdf7f9f201d99ff76b91a7cdfbf57dd8f43d61612775b9982445"
|
|
32
|
+
],
|
|
33
|
+
"metergraph-local-bundle": [
|
|
34
|
+
"692cd8952a8a8896ec2c06091b329a7a01912e261e87d62a9458452172a7c9b0",
|
|
35
|
+
"c31286b7ee6dd8fc228e81565662ebaf4b0faf2ae92229602baf7c809202491b",
|
|
36
|
+
"3afd094368053023716c3c76893b51f6ae4b45e38211f69e2bbc5117c86d9a51"
|
|
37
|
+
],
|
|
38
|
+
"metergraph-model-swap": [
|
|
39
|
+
"d77ee37f882ab1e50dc4a093e64a2c3661f4cd9bb59f184da6dccbd76fd51a18",
|
|
40
|
+
"8fbf010d11b75bbe26fb13f776251038fab4688ccbfe58f3c582c469cf149433"
|
|
41
|
+
],
|
|
42
|
+
"metergraph-onboarding": [
|
|
43
|
+
"409d105f3b042514fd347a1fffc5489a94c01d1bc8aac55800e8c9f13a66a2c1",
|
|
44
|
+
"6e6f418933f37b34437035ab3171344d43f37dee3770815b19f97efb62e5f1d1",
|
|
45
|
+
"8ee73ecfe42894c74b16fbc3675a234452f152f248f8f574be3f04dd6e509805"
|
|
46
|
+
],
|
|
47
|
+
"metergraph-report": [
|
|
48
|
+
"0cdbeb684c4f7f87932784db832f376cff984fe0220a1167f1a73edd550450b7",
|
|
49
|
+
"58add8f6a32191e6fc01ca533a700e190a8e6da412e619bca38c65a481ea9fb5",
|
|
50
|
+
"90d52900067616330ce073d7d47b302120c4d0f4f8ffbef712eeec0a0bae4153"
|
|
51
|
+
],
|
|
52
|
+
"metergraph-workloads": [
|
|
53
|
+
"caf819780722f941b9d6b2f9564413a7deadda53439c973a7764be1b6eca98e5",
|
|
54
|
+
"bd28b1710fc5dfc082fa53bbcd32f8a3300338a37b82c14a87c8849d41a3fc24"
|
|
55
|
+
]
|
|
56
|
+
};
|
package/src/skill.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { TRUSTED_SKILLS } from "./skill-trusted.js";
|
|
2
|
+
|
|
1
3
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import fs from "
|
|
4
|
+
import fs, { validatePath, withRollback } from "./anchored-fs.js";
|
|
3
5
|
import path from "node:path";
|
|
4
6
|
|
|
5
7
|
import {
|
|
@@ -31,6 +33,7 @@ const ENTRY_KEYS = ["client", "path", "revision", "runtimes", "sha256", "skill"]
|
|
|
31
33
|
const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
|
32
34
|
|
|
33
35
|
const MESSAGES = {
|
|
36
|
+
secure_writes_unavailable: "Secure automatic file writes are unavailable on this platform or the bundled filesystem helper failed verification. Nothing was written.",
|
|
34
37
|
invalid_project: "--project must name an existing directory.",
|
|
35
38
|
client_not_supported:
|
|
36
39
|
"This client cannot load project skill files. Follow the connection guide instead. Nothing was written.",
|
|
@@ -114,7 +117,7 @@ function apply(root, context) {
|
|
|
114
117
|
return plan.status;
|
|
115
118
|
} finally {
|
|
116
119
|
try {
|
|
117
|
-
fs.unlinkSync(lockPath);
|
|
120
|
+
withRollback(() => fs.unlinkSync(lockPath));
|
|
118
121
|
} catch {
|
|
119
122
|
// A lock that cannot be removed is reported by the next run.
|
|
120
123
|
}
|
|
@@ -134,7 +137,8 @@ function resolveProject(project) {
|
|
|
134
137
|
try {
|
|
135
138
|
const real = fs.realpathSync(path.resolve(project ?? process.cwd()));
|
|
136
139
|
if (fs.statSync(real).isDirectory()) return real;
|
|
137
|
-
} catch {
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (error.code === "ENOTSUP") throw new Stop("unsupported", "secure_writes_unavailable");
|
|
138
142
|
// Fall through to the fixed error.
|
|
139
143
|
}
|
|
140
144
|
throw new Stop("invalid_input", "invalid_project");
|
|
@@ -180,6 +184,10 @@ function decide(action, runtime, state, bundle) {
|
|
|
180
184
|
if (file !== null && (file.content === null || sha256Hex(file.content) !== entry.sha256)) {
|
|
181
185
|
throw new Stop("conflict", "modified");
|
|
182
186
|
}
|
|
187
|
+
if (file !== null && entry.sha256 !== bundle.sha256 &&
|
|
188
|
+
!TRUSTED_SKILLS[bundle.name]?.includes(entry.sha256)) {
|
|
189
|
+
throw new Stop("conflict", "not_owned");
|
|
190
|
+
}
|
|
183
191
|
const current = entry.revision === bundle.revision;
|
|
184
192
|
if (!current && action === "install") throw new Stop("conflict", "update_required");
|
|
185
193
|
if (file !== null && current) {
|
|
@@ -200,6 +208,7 @@ function commit(root, context, state, plan) {
|
|
|
200
208
|
const receipt = nextReceipt(state.receipt, context, target.relative);
|
|
201
209
|
let createdDirs = [];
|
|
202
210
|
let skillWritten = false;
|
|
211
|
+
let receiptWritten = false;
|
|
203
212
|
try {
|
|
204
213
|
if (plan.writeSkill) {
|
|
205
214
|
createdDirs = ensureDirs(root, target.dirs);
|
|
@@ -208,13 +217,23 @@ function commit(root, context, state, plan) {
|
|
|
208
217
|
}
|
|
209
218
|
if (state.receiptFile === null || !state.receiptFile.content.equals(receipt)) {
|
|
210
219
|
replaceFile(receiptPath, receipt, state.receiptFile);
|
|
220
|
+
receiptWritten = true;
|
|
211
221
|
}
|
|
222
|
+
validatePath(root);
|
|
223
|
+
validatePath(path.dirname(skillPath));
|
|
224
|
+
validatePath(path.dirname(receiptPath));
|
|
212
225
|
} catch (error) {
|
|
213
226
|
try {
|
|
214
|
-
|
|
215
|
-
if (
|
|
216
|
-
|
|
217
|
-
|
|
227
|
+
withRollback(() => {
|
|
228
|
+
if (receiptWritten) {
|
|
229
|
+
if (state.receiptFile === null) fs.unlinkSync(receiptPath);
|
|
230
|
+
else replaceFile(receiptPath, state.receiptFile.content, { content: receipt, mode: state.receiptFile.mode });
|
|
231
|
+
}
|
|
232
|
+
if (skillWritten) {
|
|
233
|
+
if (state.file === null) fs.unlinkSync(skillPath);
|
|
234
|
+
else replaceFile(skillPath, state.file.content, { content: bundle.content, mode: state.file.mode });
|
|
235
|
+
}
|
|
236
|
+
});
|
|
218
237
|
} catch {
|
|
219
238
|
throw new Stop("filesystem_error", "rollback_failed");
|
|
220
239
|
}
|
|
@@ -325,7 +344,7 @@ function ensureDirs(root, dirs) {
|
|
|
325
344
|
function removeDirs(created) {
|
|
326
345
|
for (const dir of [...created].reverse()) {
|
|
327
346
|
try {
|
|
328
|
-
fs.rmdirSync(dir);
|
|
347
|
+
withRollback(() => fs.rmdirSync(dir));
|
|
329
348
|
} catch {
|
|
330
349
|
// Not empty or not removable. An empty directory is harmless.
|
|
331
350
|
}
|
|
@@ -382,7 +401,7 @@ function replaceFile(dest, content, previous) {
|
|
|
382
401
|
fs.renameSync(temp, dest);
|
|
383
402
|
} catch (error) {
|
|
384
403
|
try {
|
|
385
|
-
fs.unlinkSync(temp);
|
|
404
|
+
withRollback(() => fs.unlinkSync(temp));
|
|
386
405
|
} catch {
|
|
387
406
|
// Already renamed or never created.
|
|
388
407
|
}
|