metergraph-cli 0.0.0-stage → 0.2.0-preview.0

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.
@@ -0,0 +1,73 @@
1
+ ---
2
+ name: metergraph
3
+ description: Route Metergraph setup by client, execution runtime and deployment, then perform bounded evidence-backed investigation when already authenticated.
4
+ ---
5
+
6
+ # Use Metergraph with your agent
7
+
8
+ Public setup: https://www.metergraph.dev/docs/guides/agent-access/
9
+ Connection and troubleshooting: https://www.metergraph.dev/docs/guides/agent-access/
10
+
11
+ ## Confirm setup before producing instructions
12
+
13
+ Ask for or confirm these non-secret choices. Do not guess from the agent brand:
14
+
15
+ 1. Client: Claude Desktop, Claude Code, ChatGPT, Codex or Cursor.
16
+ 2. Execution runtime: customer machine or cloud. Codex local app/CLI/IDE is distinct from cloud execution. Claude Desktop remote connectors execute in Anthropic's cloud; Desktop local MCP is a separate mechanism. ChatGPT MCP apps execute in the cloud.
17
+ 3. Deployment: Metergraph hosted cloud, the signed commercial customer-local bundle, a customer-owned AWS installation (`byoc-core`), or the open-source self-hosted server. These have different addresses, accounts and credentials. Staging is not a customer setup path.
18
+ 4. Fresh workspace/installation or connecting an existing workspace. Ask for the intended workspace name and deployment origin, never a token.
19
+
20
+ Use the routing below with those four choices. Follow the matching credential and client configuration instructions in the connection guide.
21
+
22
+ For a first application trace, check the installed CLI's version and help before
23
+ using it. `metergraph-cli@0.1.0` supports `doctor` and project skill
24
+ installation; later versions may also offer `login`, `setup` and `verify`.
25
+ Use those commands only if the installed package lists them. Use the [first
26
+ trace guide](https://www.metergraph.dev/docs/start/first-trace/) for steps the
27
+ package does not support. A CLI login or health probe alone does not prove
28
+ application traffic. Verification needs an exact trace or request ID from an
29
+ application invocation, its time window and the intended workspace.
30
+
31
+ ## Route honestly
32
+
33
+ | Client and runtime | Hosted | Customer-local bundle | Customer AWS | Open source self-hosted |
34
+ | --- | --- | --- | --- | --- |
35
+ | Claude Code or Codex on the customer machine | Keyed HTTP at `https://app.metergraph.dev/v1/agent/mcp` | Keyed HTTP at the installed local address, normally `http://127.0.0.1:8080/v1/agent/mcp` | Keyed HTTP at the customer's reachable installation address | Static `MG_AGENT_TOKENS` bearer at the OSS server address, normally `http://localhost:8787/v1/agent/mcp` |
36
+ | Cursor on the customer machine | Project skill supported; MCP connection not validated in this guide | Project skill supported; MCP connection not validated in this guide | Project skill supported; MCP connection not validated in this guide | Project skill supported; MCP connection not validated in this guide |
37
+ | Claude Desktop remote connector on an individual account | Custom connector with a scoped bearer key if Request headers are available for the account; verify tools before claiming success | Cannot reach localhost from Anthropic's cloud | Default private installation is unreachable from Anthropic's cloud; no validated public route in this guide | Cannot reach localhost from Anthropic's cloud |
38
+ | Claude Desktop local MCP | Separate local extension required; not supported by this guide | Docker does not install a host extension | Separate local extension required; not supported by this guide | Docker does not install a host extension |
39
+ | ChatGPT MCP app or cloud execution | Separate cloud client configuration; not supported by this guide | Cannot reach localhost | Default private installation is unreachable | Cannot reach localhost |
40
+
41
+ For Cursor, the published CLI can install a project skill with
42
+ `metergraph skill install --client cursor --runtime local` when installed.
43
+ Confirm Cursor discovers the skill. This does not configure MCP or sign in.
44
+ Use the first trace guide for instrumentation, and report the read connection
45
+ as unverified until a supported Cursor MCP route is tested.
46
+
47
+ For hosted Claude Desktop on an individual account, guide the human through Customize → Connectors → Add custom connector with `https://app.metergraph.dev/v1/agent/mcp`. They create a coding-agent key in the intended hosted workspace. If Request headers are available, choose No sign-in and enter a required header named `Authorization` with value `Bearer <key>` in the connector's private settings, never in chat. Request headers are in beta and may be unavailable for the account; do not claim the route works without them. They must enable the connector in a conversation. Team and Enterprise connectors may share fixed credentials across users, so do not put one person's workspace key in a shared connector. Do not present OAuth server code, a tool listing, local CLI installation or a saved configuration as connection success. Do not invent a released plugin, extension, tunnel or signup path. If a route is blocked, name the blocker and offer the customer-machine Claude Code/Codex keyed HTTP route or hosted deployment as appropriate. Do not expose localhost publicly or disable authentication as a workaround.
48
+
49
+ For a fresh hosted workspace, first ask whether the human already has a Metergraph account and workspace. Direct them to https://app.metergraph.dev/ and the signup, invitation or sign-in flow actually offered. The hosted UI can offer "Get a free API key" for self-service signup, but do not assume that button or a workspace invitation is available to everyone. If access is unavailable, direct them to their workspace owner or Metergraph. Confirm the workspace before creating a coding-agent key.
50
+
51
+ For the commercial customer-local bundle, direct them to https://www.metergraph.dev/docs/self-host/local/#the-customer-local-bundle and the signed public release. They need a separate pull-only registry credential from Metergraph to pull the images; the public bundle download does not grant registry access. Verify the release before installation. Local admin credentials from the bundle's private configuration provide dashboard sign-in; create a coding-agent key in that installation's Keys page. Do not substitute hosted signup or a hosted key. Do not clone the private implementation or run development seed commands.
52
+
53
+ For customer-owned AWS, direct them to https://www.metergraph.dev/docs/self-host/aws/. An operator provisions the installation, identity and membership. There is no public signup. Ask for the installation's actual reachable address and use a coding-agent key from its Keys page. Its default internal load balancer is private, and its Agent Access surface excludes trace content and replay.
54
+
55
+ For the open-source self-hosted server, direct them to https://www.metergraph.dev/docs/self-host/local/#the-open-source-server. No Metergraph account, commercial release, registry credential or Keys page is needed. The operator configures static `MG_AGENT_TOKENS` separately from ingestion `MG_TOKENS`. Agent Access is content-blind and does not provide replay, incidents, ingestion health or pipeline reports. Use the actual local server address; do not ask them to mint a hosted coding-agent key.
56
+
57
+ ## Keep credentials outside chat
58
+
59
+ Direct the human to the correct workspace's Keys page and private client configuration. Leave replay disabled unless separately authorized. Never ask for, quote, log or include raw keys in a prompt. Registry pull, ingestion, Agent Access and model/provider credentials are different credentials. Use the client's supported secure environment/settings input. Keep token-bearing configuration out of shell history and committed project files. Rotation/revocation happens in Keys; a revoked token must be rejected on the next request.
60
+
61
+ ## Answer capability and documentation questions
62
+
63
+ Use https://www.metergraph.dev/docs/ for public product documentation and https://www.metergraph.dev/docs/guides/mcp-server/ for Agent Access tool details. Explain what the docs say about an edition, then use `metergraph_get_capabilities` to confirm what the connected workspace actually supports. Cite the public page you used and distinguish a missing capability from an empty workspace. Do not read retained content or run replay just to answer a documentation question.
64
+
65
+ ## When already connected: bounded investigation
66
+
67
+ 1. Call `metergraph_get_workspace_context` and `metergraph_get_capabilities` first. Verify the intended workspace, deployment profile, available tools, required scopes and privacy classes. Stop on wrong workspace, unavailable capabilities or failed authorization.
68
+ 2. Confirm the question, route and explicit time window. Choose the smallest available tool: bounded usage/route metadata for aggregate questions, incidents for known anomalies, or `metergraph_query_traces` with a small limit (start with 1) for a failed/anomalous trace. Follow cursors only within the agreed bound. Never request generic SQL or unbounded data.
69
+ 3. Start with metadata. Retained-content debugging and report evidence are separate content-bearing tools; confirm the user's content intent and capability before calling them. Replay is a separate scope and can send data to a provider and spend money. Obtain explicit bounded execution/data-egress authority before any replay. Read access grants no eval-write, analysis-run or production-mutation authority.
70
+ 4. Explain the failure or anomaly using only returned evidence. Cite workspace, time window, provenance, trace/report IDs, completeness/coverage, warnings and working app links when returned. Do not fabricate links. Distinguish no data, incomplete evidence, unavailable capability, classification pending, stale/mismatched identity, failed analysis and missing report. An empty result alone does not prove absence of traffic.
71
+ 5. An optional approved replay must be eligible, bounded and non-persistent. Do not claim that it changed production telemetry or fixed the application. Refuse autonomous dashboard mutations or production configuration changes.
72
+
73
+ For an empty workspace, use https://www.metergraph.dev/docs/agents/instrument-a-repo/ after confirming the ingestion destination and obtaining a separate ingest credential securely. Do not initiate provider calls or paid work merely to validate setup. Ingestion accepted, processed, retained content, classification readiness and analysis readiness are distinct states. Label synthetic/demo fixtures; they do not prove a first real application trace.
@@ -0,0 +1,9 @@
1
+ {
2
+ "manifest_version": 1,
3
+ "name": "metergraph",
4
+ "file": "SKILL.md",
5
+ "source_url": "https://www.metergraph.dev/SKILL.md",
6
+ "sha256": "57b920677adf62759c7221629327192a2d16b7e6034f7948ffd96cee402d4891",
7
+ "size": 10426,
8
+ "revision": "sha256-57b920677adf"
9
+ }
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../src/cli.js";
3
+
4
+ // A closed pipe on stdout (for example "metergraph --help | head -1") is not
5
+ // a CLI failure.
6
+ process.stdout.on("error", () => {});
7
+
8
+ process.exitCode = await main(process.argv.slice(2), {
9
+ stdout: process.stdout,
10
+ stderr: process.stderr,
11
+ });
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "metergraph-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0-preview.0",
4
+ "description": "Preview Metergraph command line tool with a read-only connection probe, a project skill installer, Metadata-only sign in and bounded workspace Metadata reads",
5
+ "type": "module",
6
+ "bin": {
7
+ "metergraph": "bin/metergraph.js"
8
+ },
9
+ "exports": {
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "bin/metergraph.js",
14
+ "src/*.js",
15
+ "assets/skill/SKILL.md",
16
+ "assets/skill/manifest.json",
17
+ "LICENSE",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "test": "node --test test/*.test.js",
22
+ "test:package": "node --test test/package/*.test.js"
23
+ },
24
+ "engines": {
25
+ "node": ">=22"
26
+ },
27
+ "license": "Apache-2.0",
28
+ "author": "Metergraph",
29
+ "homepage": "https://www.metergraph.dev/",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/metergraph/cli.git"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/metergraph/cli/issues"
36
+ },
37
+ "keywords": [
38
+ "metergraph",
39
+ "cli",
40
+ "llm",
41
+ "cost-tracking"
42
+ ],
43
+ "publishConfig": {
44
+ "access": "public"
45
+ }
46
+ }
package/src/args.js ADDED
@@ -0,0 +1,486 @@
1
+ import {
2
+ DEFAULT_ORIGIN,
3
+ DEFAULT_TIMEOUT_MS,
4
+ HANDOFF_SKILL_CLIENTS,
5
+ HANDOFF_SKILL_RUNTIMES,
6
+ HANDOFF_LOGIN_RUNTIMES,
7
+ LOGIN_DEFAULT_TIMEOUT_MS,
8
+ LOGIN_MAX_TIMEOUT_MS,
9
+ LOGIN_MIN_TIMEOUT_MS,
10
+ LOGIN_RUNTIMES,
11
+ MAX_TIMEOUT_MS,
12
+ MIN_TIMEOUT_MS,
13
+ READ_DEFAULT_DAYS,
14
+ READ_DEFAULT_LIMIT,
15
+ READ_DEFAULT_TIMEOUT_MS,
16
+ READ_MAX_DAYS,
17
+ READ_MAX_LIMIT,
18
+ READ_MAX_TIMEOUT_MS,
19
+ READ_MIN_TIMEOUT_MS,
20
+ SKILL_CLIENTS,
21
+ SKILL_RUNTIMES,
22
+ TRACES_DEFAULT_LIMIT,
23
+ } from "./constants.js";
24
+ import { parseOrigin } from "./origin.js";
25
+ import { isCursor, isSafeFilter } from "./read-contract.js";
26
+
27
+ export const READ_COMMANDS = Object.freeze(["status", "context", "capabilities", "usage", "routes", "traces"]);
28
+ const COMMANDS = new Set(["doctor", "help", "skill", "login", "logout", "setup", "verify", ...READ_COMMANDS]);
29
+ const HELP_TOPICS = new Set(["doctor", "skill", "login", "logout", "setup", "verify", ...READ_COMMANDS]);
30
+ const SKILL_ACTIONS = new Set(["install", "update"]);
31
+ const READ_BASE = ["--project", "--config-dir", "--timeout-ms"];
32
+ const OPTIONS = {
33
+ doctor: new Set(["--url", "--timeout-ms"]),
34
+ skill: new Set(["--client", "--runtime", "--project"]),
35
+ login: new Set(["--runtime", "--url", "--workspace", "--project", "--config-dir", "--timeout-ms"]),
36
+ setup: new Set(["--runtime", "--url", "--workspace", "--project", "--config-dir", "--env-file", "--client", "--timeout-ms", "--deployment", "--agent-token-file"]),
37
+ logout: new Set(["--project", "--config-dir"]),
38
+ status: new Set(READ_BASE),
39
+ context: new Set(READ_BASE),
40
+ capabilities: new Set(READ_BASE),
41
+ usage: new Set([...READ_BASE, "--days", "--limit"]),
42
+ routes: new Set([...READ_BASE, "--limit"]),
43
+ traces: new Set([...READ_BASE, "--days", "--limit", "--route", "--status", "--cursor"]),
44
+ verify: new Set([...READ_BASE, "--trace-id", "--request-id", "--since", "--until", "--source", "--days", "--poll-ms", "--max-attempts"]),
45
+ };
46
+ // Requests the read commands recognize and refuse, so a script gets an
47
+ // explicit unsupported result instead of an unknown argument or, worse, a
48
+ // broader query than it asked for. Nothing is sent for them.
49
+ const REFUSED = {
50
+ "--environment": { value: true, reason: "environment_selector_unsupported" },
51
+ "--workload": { value: true, reason: "workload_filter_unsupported" },
52
+ "--since": { value: true, reason: "time_range_unsupported" },
53
+ "--until": { value: true, reason: "time_range_unsupported" },
54
+ "--sql": { value: true, reason: "query_unsupported" },
55
+ "--query": { value: true, reason: "query_unsupported" },
56
+ "--content": { value: false, reason: "content_access_unsupported" },
57
+ "--include-content": { value: false, reason: "content_access_unsupported" },
58
+ "--debug": { value: false, reason: "content_access_unsupported" },
59
+ "--replay": { value: false, reason: "content_access_unsupported" },
60
+ };
61
+ const REFUSED_MESSAGES = {
62
+ environment_selector_unsupported:
63
+ "--environment is not supported: the service's agent access contract has no environment selector, " +
64
+ "so the CLI never sends one. Results cover the bound workspace. No request was made.",
65
+ workload_filter_unsupported:
66
+ "--workload is not supported by this CLI version: it cannot verify from the returned traces that a " +
67
+ "workload filter was applied, so it never sends one. No request was made.",
68
+ time_range_unsupported:
69
+ "--since and --until are not supported. Use --days N (1 to 90) for a window relative to now. No request was made.",
70
+ query_unsupported:
71
+ "Free-form queries are not supported. Use the fixed options of this command. No request was made.",
72
+ content_access_unsupported:
73
+ "Read commands return Metadata only. They never read retained content, debug data or replays. No request was made.",
74
+ };
75
+ // Options that take no value.
76
+ const FLAGS = {
77
+ login: new Set(["--signup", "--no-browser", "--reconnect"]),
78
+ setup: new Set(["--no-browser", "--repair", "--signup", "--reconnect", "--skip-skill", "--confirm-prerequisites"]),
79
+ verify: new Set(["--open", "--no-browser"]),
80
+ };
81
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
82
+
83
+ // Parses argv into one of:
84
+ // { ok: true, command: "help", topic, json }
85
+ // { ok: true, command: "version", json }
86
+ // { ok: true, command: "doctor", origin, timeoutMs, json }
87
+ // { ok: true, command: "skill", action, client, runtime, project, json }
88
+ // { ok: true, command: "login", runtime, origin, workspace, project,
89
+ // configDir, timeoutMs, signup, noBrowser, reconnect, json }
90
+ // { ok: true, command: "logout", project, configDir, json }
91
+ // { ok: true, command: one of READ_COMMANDS, project, configDir, timeoutMs,
92
+ // days, limit, route, status, cursor, json }
93
+ // { ok: false, command, json, code, message, outcome }
94
+ // outcome is invalid_input, or unsupported for a recognized request the read
95
+ // commands refuse (such as --environment).
96
+ // Error messages are fixed strings. They never contain an argument value,
97
+ // because a mistyped argument can be a credential.
98
+ export function parseArgs(argv) {
99
+ const json = argv.includes("--json");
100
+
101
+ if (argv.includes("--help") || argv.includes("-h")) {
102
+ const topic = argv.find((arg) => HELP_TOPICS.has(arg)) ?? null;
103
+ return { ok: true, command: "help", topic, json };
104
+ }
105
+
106
+ let command = null;
107
+ let action = null;
108
+ let topic = null;
109
+ let version = false;
110
+ const values = {};
111
+ const flags = new Set();
112
+
113
+ const fail = (code, message, outcome = "invalid_input") => ({
114
+ ok: false,
115
+ command: command === "skill" && action !== null ? `skill ${action}` : command ?? (version ? "version" : null),
116
+ json,
117
+ code,
118
+ message,
119
+ outcome,
120
+ });
121
+
122
+ for (let index = 0; index < argv.length; index += 1) {
123
+ const arg = argv[index];
124
+ const position = index + 1;
125
+
126
+ if (arg === "--json") continue;
127
+
128
+ if (command === null && !version && COMMANDS.has(arg)) {
129
+ command = arg;
130
+ continue;
131
+ }
132
+ if (command === null && arg === "--version") {
133
+ version = true;
134
+ continue;
135
+ }
136
+ if (command === "help" && topic === null && HELP_TOPICS.has(arg)) {
137
+ topic = arg;
138
+ continue;
139
+ }
140
+ if (command === "skill" && action === null && SKILL_ACTIONS.has(arg)) {
141
+ action = arg;
142
+ continue;
143
+ }
144
+
145
+ if (READ_COMMANDS.includes(command)) {
146
+ const equals = arg.indexOf("=");
147
+ const name = equals === -1 ? arg : arg.slice(0, equals);
148
+ if (Object.hasOwn(REFUSED, name)) {
149
+ const { reason } = REFUSED[name];
150
+ return fail(reason, REFUSED_MESSAGES[reason], "unsupported");
151
+ }
152
+ }
153
+
154
+ if (Object.hasOwn(OPTIONS, command ?? "")) {
155
+ const equals = arg.indexOf("=");
156
+ const name = equals === -1 ? arg : arg.slice(0, equals);
157
+ if (FLAGS[command]?.has(name)) {
158
+ if (equals !== -1) return fail("unexpected_value", `${name} does not take a value.`);
159
+ if (flags.has(name)) return fail("duplicate_option", `${name} may be given only once.`);
160
+ flags.add(name);
161
+ continue;
162
+ }
163
+ if (OPTIONS[command].has(name)) {
164
+ let value;
165
+ if (equals === -1) {
166
+ index += 1;
167
+ value = argv[index];
168
+ } else {
169
+ value = arg.slice(equals + 1);
170
+ }
171
+ if (value === undefined) {
172
+ return fail("missing_value", `${name} requires a value.`);
173
+ }
174
+ if (values[name] !== undefined) {
175
+ return fail("duplicate_option", `${name} may be given only once.`);
176
+ }
177
+ values[name] = value;
178
+ continue;
179
+ }
180
+ }
181
+
182
+ if (command === null && !version && !arg.startsWith("-")) {
183
+ return fail(
184
+ "unknown_command",
185
+ `Unknown command at argument ${position}. Run "metergraph --help" for usage.`,
186
+ );
187
+ }
188
+ if (command === "skill" && action === null && !arg.startsWith("-")) {
189
+ return fail(
190
+ "unknown_subcommand",
191
+ `Unknown skill subcommand at argument ${position}. Use install or update.`,
192
+ );
193
+ }
194
+ return fail(
195
+ "unknown_argument",
196
+ `Unrecognized argument at position ${position}. Run "metergraph --help" for usage.`,
197
+ );
198
+ }
199
+
200
+ if (version) return { ok: true, command: "version", json };
201
+ if (command === null || command === "help") {
202
+ return { ok: true, command: "help", topic, json };
203
+ }
204
+ if (command === "skill") return parseSkill(action, values, json, fail);
205
+ if (command === "login") return parseLogin(values, flags, json, fail);
206
+ if (command === "setup") return parseSetup(values, flags, json, fail);
207
+ if (command === "verify") return parseVerify(values, flags, json, fail);
208
+ if (READ_COMMANDS.includes(command)) return parseRead(command, values, json, fail);
209
+ if (command === "logout") {
210
+ const paths = parsePaths(values, fail);
211
+ if (!paths.ok) return paths;
212
+ return { ok: true, command: "logout", project: paths.project, configDir: paths.configDir, json };
213
+ }
214
+
215
+ const origin = parseUrlOption(values);
216
+ if (origin === null) return fail("invalid_url", INVALID_URL);
217
+
218
+ const rawTimeout = values["--timeout-ms"];
219
+ const timeoutMs =
220
+ rawTimeout === undefined ? DEFAULT_TIMEOUT_MS : parseTimeout(rawTimeout);
221
+ if (timeoutMs === null) {
222
+ return fail(
223
+ "invalid_timeout",
224
+ `--timeout-ms must be a whole number from ${MIN_TIMEOUT_MS} to ${MAX_TIMEOUT_MS}.`,
225
+ );
226
+ }
227
+
228
+ return { ok: true, command: "doctor", origin, timeoutMs, json };
229
+ }
230
+
231
+ function parseVerify(values, flags, json, fail) {
232
+ const paths = parsePaths(values, fail);
233
+ if (!paths.ok) return paths;
234
+ const traceId = values["--trace-id"] ?? null;
235
+ const requestId = values["--request-id"] ?? null;
236
+ if ((traceId === null) === (requestId === null) ||
237
+ !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(traceId ?? requestId)) {
238
+ return fail("invalid_trace_identity", "Provide exactly one --trace-id or --request-id (1 to 200 safe characters).");
239
+ }
240
+ const since = values["--since"];
241
+ const until = values["--until"];
242
+ const validTime = (value) => typeof value === "string" && value.length <= 40 &&
243
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/.test(value) && Number.isFinite(Date.parse(value));
244
+ if (!validTime(since) || !validTime(until) || Date.parse(since) > Date.parse(until) || Date.parse(until) > Date.now()) {
245
+ return fail("invalid_invocation_window", "--since and --until must bound a past invocation using ISO 8601 timestamps.");
246
+ }
247
+ const source = values["--source"] ?? "unspecified";
248
+ if (!["application", "synthetic", "demo", "import", "unspecified"].includes(source)) {
249
+ return fail("invalid_source", "--source must be application, synthetic, demo, import or unspecified.");
250
+ }
251
+ const days = values["--days"] === undefined ? undefined : parseBounded(values["--days"], 1, 90);
252
+ if (days === null) return fail("invalid_days", "--days must be a whole number from 1 to 90.");
253
+ const timeoutMs = values["--timeout-ms"] === undefined ? 30000 : parseTimeout(values["--timeout-ms"], 100, 60000);
254
+ if (timeoutMs === null) return fail("invalid_timeout", "--timeout-ms must be a whole number from 100 to 60000.");
255
+ const pollIntervalMs = values["--poll-ms"] === undefined ? 1000 : parseTimeout(values["--poll-ms"], 100, 10000);
256
+ if (pollIntervalMs === null) return fail("invalid_poll_interval", "--poll-ms must be a whole number from 100 to 10000.");
257
+ const maxAttempts = values["--max-attempts"] === undefined ? 30 : parseBounded(values["--max-attempts"], 1, 60);
258
+ if (maxAttempts === null) return fail("invalid_max_attempts", "--max-attempts must be a whole number from 1 to 60.");
259
+ return { ok: true, command: "verify", project: paths.project, configDir: paths.configDir,
260
+ traceId, requestId, since, until, source, days, timeoutMs, pollIntervalMs, maxAttempts,
261
+ open: flags.has("--open"), noBrowser: flags.has("--no-browser"), json };
262
+ }
263
+
264
+ // Client and runtime are required, so a script states where the skill is
265
+ // used instead of the CLI guessing. Values for clients and runtimes that
266
+ // cannot use project skill files are accepted here and handed off later.
267
+ function parseSkill(action, values, json, fail) {
268
+ if (action === null) {
269
+ return fail(
270
+ "missing_subcommand",
271
+ 'skill requires a subcommand: install or update. Run "metergraph help skill" for usage.',
272
+ );
273
+ }
274
+ const client = values["--client"];
275
+ if (client === undefined) {
276
+ return fail("missing_client", "--client is required. Use codex, claude or cursor.");
277
+ }
278
+ if (!Object.hasOwn(SKILL_CLIENTS, client) && !Object.hasOwn(HANDOFF_SKILL_CLIENTS, client)) {
279
+ return fail("invalid_client", "--client must be codex, claude or cursor.");
280
+ }
281
+ const runtime = values["--runtime"];
282
+ if (runtime === undefined) {
283
+ return fail("missing_runtime", "--runtime is required. Use local or cloud.");
284
+ }
285
+ if (!SKILL_RUNTIMES.includes(runtime) && !HANDOFF_SKILL_RUNTIMES.includes(runtime)) {
286
+ return fail("invalid_runtime", "--runtime must be local or cloud.");
287
+ }
288
+ const project = values["--project"];
289
+ if (project !== undefined && (project === "" || project.includes("\0"))) {
290
+ return fail("invalid_project", "--project must name an existing directory.");
291
+ }
292
+ return { ok: true, command: "skill", action, client, runtime, project: project ?? null, json };
293
+ }
294
+
295
+ const INVALID_URL =
296
+ "--url must be a bare https origin such as https://metergraph.example.com, " +
297
+ "or an http origin on localhost, 127.0.0.1 or [::1]. " +
298
+ "Credentials, paths, queries and fragments are not accepted.";
299
+
300
+ function parseUrlOption(values) {
301
+ const raw = values["--url"];
302
+ return raw === undefined ? DEFAULT_ORIGIN : parseOrigin(raw);
303
+ }
304
+
305
+ // The runtime is required so a script states where the browser is. Cloud
306
+ // runtimes are accepted here and handed off later, before any request.
307
+ function parseLogin(values, flags, json, fail) {
308
+ const runtime = values["--runtime"];
309
+ if (runtime === undefined) return fail("missing_runtime", "--runtime is required. Use local.");
310
+ if (!LOGIN_RUNTIMES.includes(runtime) && !HANDOFF_LOGIN_RUNTIMES.includes(runtime)) {
311
+ return fail("invalid_runtime", "--runtime must be local, cloud or cloud-no-shell.");
312
+ }
313
+ const origin = parseUrlOption(values);
314
+ if (origin === null) return fail("invalid_url", INVALID_URL);
315
+ const rawWorkspace = values["--workspace"];
316
+ if (rawWorkspace !== undefined && !UUID.test(rawWorkspace)) {
317
+ return fail("invalid_workspace", "--workspace must be a workspace ID in UUID form.");
318
+ }
319
+ const paths = parsePaths(values, fail);
320
+ if (!paths.ok) return paths;
321
+ const rawTimeout = values["--timeout-ms"];
322
+ const timeoutMs =
323
+ rawTimeout === undefined
324
+ ? LOGIN_DEFAULT_TIMEOUT_MS
325
+ : parseTimeout(rawTimeout, LOGIN_MIN_TIMEOUT_MS, LOGIN_MAX_TIMEOUT_MS);
326
+ if (timeoutMs === null) {
327
+ return fail(
328
+ "invalid_timeout",
329
+ `--timeout-ms must be a whole number from ${LOGIN_MIN_TIMEOUT_MS} to ${LOGIN_MAX_TIMEOUT_MS}.`,
330
+ );
331
+ }
332
+ return {
333
+ ok: true,
334
+ command: "login",
335
+ runtime,
336
+ origin,
337
+ workspace: rawWorkspace === undefined ? null : rawWorkspace.toLowerCase(),
338
+ project: paths.project,
339
+ configDir: paths.configDir,
340
+ timeoutMs,
341
+ signup: flags.has("--signup"),
342
+ noBrowser: flags.has("--no-browser"),
343
+ reconnect: flags.has("--reconnect"),
344
+ json,
345
+ };
346
+ }
347
+
348
+ function parseSetup(values, flags, json, fail) {
349
+ const runtime = values["--runtime"];
350
+ if (runtime === undefined) return fail("missing_runtime", "--runtime is required. Use local.");
351
+ if (!LOGIN_RUNTIMES.includes(runtime) && !HANDOFF_LOGIN_RUNTIMES.includes(runtime)) {
352
+ return fail("invalid_runtime", "--runtime must be local, cloud or cloud-no-shell.");
353
+ }
354
+ const paths = parsePaths(values, fail);
355
+ if (!paths.ok) return paths;
356
+ const origin = parseUrlOption(values);
357
+ if (origin === null) return fail("invalid_url", INVALID_URL);
358
+ const deployment = values["--deployment"] ?? "managed";
359
+ if (!["managed", "customer-local", "byoc", "oss"].includes(deployment)) {
360
+ return fail("invalid_deployment", "--deployment must be managed, customer-local, byoc or oss.");
361
+ }
362
+ if (deployment === "managed" && (flags.has("--confirm-prerequisites") || values["--agent-token-file"] !== undefined)) {
363
+ return fail("managed_route_conflict", "Operator prerequisites and agent token files are only for non-hosted deployments.");
364
+ }
365
+ if (deployment !== "managed" && values["--url"] === undefined) {
366
+ return fail("non_hosted_origin_required", "Non-hosted setup requires an explicit --url for the installed service.");
367
+ }
368
+ const rawWorkspace = values["--workspace"];
369
+ if (rawWorkspace !== undefined && !UUID.test(rawWorkspace)) {
370
+ return fail("invalid_workspace", "--workspace must be a workspace ID in UUID form.");
371
+ }
372
+ if (deployment !== "managed" && rawWorkspace === undefined) {
373
+ return fail("non_hosted_workspace_required", "Non-hosted setup requires an explicit --workspace UUID.");
374
+ }
375
+ const agentTokenFile = values["--agent-token-file"] ?? null;
376
+ if (agentTokenFile !== null && (agentTokenFile === "" || agentTokenFile.includes("\0"))) {
377
+ return fail("invalid_agent_token_file", "--agent-token-file must name a private absolute file.");
378
+ }
379
+ const client = values["--client"] ?? null;
380
+ if (client === null && !flags.has("--skip-skill")) {
381
+ return fail("client_required", "Choose --client codex, claude or cursor, or explicitly use --skip-skill.");
382
+ }
383
+ if (client !== null && !Object.hasOwn(SKILL_CLIENTS, client)) {
384
+ return fail("invalid_client", "--client must be codex, claude or cursor.");
385
+ }
386
+ if (client !== null && flags.has("--skip-skill")) {
387
+ return fail("client_conflict", "Use either --client or --skip-skill, not both.");
388
+ }
389
+ const envFile = values["--env-file"] ?? ".env";
390
+ if (envFile === "" || envFile.includes("\0")) return fail("invalid_env_file", "--env-file must name a project-relative env file.");
391
+ const rawTimeout = values["--timeout-ms"];
392
+ const timeoutMs = rawTimeout === undefined ? LOGIN_DEFAULT_TIMEOUT_MS :
393
+ parseTimeout(rawTimeout, LOGIN_MIN_TIMEOUT_MS, LOGIN_MAX_TIMEOUT_MS);
394
+ if (timeoutMs === null) return fail("invalid_timeout", `--timeout-ms must be a whole number from ${LOGIN_MIN_TIMEOUT_MS} to ${LOGIN_MAX_TIMEOUT_MS}.`);
395
+ return { ok: true, command: "setup", runtime, origin, originExplicit: values["--url"] !== undefined,
396
+ deployment, confirmPrerequisites: flags.has("--confirm-prerequisites"), agentTokenFile,
397
+ workspace: rawWorkspace === undefined ? null : rawWorkspace.toLowerCase(),
398
+ project: paths.project, configDir: paths.configDir, envFile, client, skipSkill: flags.has("--skip-skill"),
399
+ timeoutMs, noBrowser: flags.has("--no-browser"), repair: flags.has("--repair"),
400
+ signup: flags.has("--signup"), reconnect: flags.has("--reconnect"), json };
401
+ }
402
+
403
+ // Read commands take the project and config directory, one total timeout and,
404
+ // where the endpoint supports them, bounded days, limit and trace filters.
405
+ // Out of range values fail here; nothing is clamped.
406
+ function parseRead(command, values, json, fail) {
407
+ const paths = parsePaths(values, fail);
408
+ if (!paths.ok) return paths;
409
+ const rawTimeout = values["--timeout-ms"];
410
+ const timeoutMs =
411
+ rawTimeout === undefined
412
+ ? READ_DEFAULT_TIMEOUT_MS
413
+ : parseTimeout(rawTimeout, READ_MIN_TIMEOUT_MS, READ_MAX_TIMEOUT_MS);
414
+ if (timeoutMs === null) {
415
+ return fail(
416
+ "invalid_timeout",
417
+ `--timeout-ms must be a whole number from ${READ_MIN_TIMEOUT_MS} to ${READ_MAX_TIMEOUT_MS}.`,
418
+ );
419
+ }
420
+ const takesDays = command === "usage" || command === "traces";
421
+ const takesLimit = takesDays || command === "routes";
422
+ let days = null;
423
+ if (takesDays) {
424
+ days = values["--days"] === undefined ? READ_DEFAULT_DAYS : parseBounded(values["--days"], 1, READ_MAX_DAYS);
425
+ if (days === null) return fail("invalid_days", `--days must be a whole number from 1 to ${READ_MAX_DAYS}.`);
426
+ }
427
+ let limit = null;
428
+ if (takesLimit) {
429
+ const fallback = command === "traces" ? TRACES_DEFAULT_LIMIT : READ_DEFAULT_LIMIT;
430
+ limit = values["--limit"] === undefined ? fallback : parseBounded(values["--limit"], 1, READ_MAX_LIMIT);
431
+ if (limit === null) return fail("invalid_limit", `--limit must be a whole number from 1 to ${READ_MAX_LIMIT}.`);
432
+ }
433
+ const route = values["--route"] ?? null;
434
+ if (route !== null && !isSafeFilter(route)) {
435
+ return fail("invalid_route", "--route must be 1 to 256 characters without control characters.");
436
+ }
437
+ const status = values["--status"] ?? null;
438
+ if (status !== null && status !== "success" && status !== "error") {
439
+ return fail("invalid_status", "--status must be success or error.");
440
+ }
441
+ const cursor = values["--cursor"] ?? null;
442
+ if (cursor !== null && !isCursor(cursor)) {
443
+ return fail(
444
+ "invalid_cursor",
445
+ "--cursor must be the next_cursor value from an earlier traces result, at most 512 printable characters.",
446
+ );
447
+ }
448
+ return {
449
+ ok: true,
450
+ command,
451
+ project: paths.project,
452
+ configDir: paths.configDir,
453
+ timeoutMs,
454
+ days,
455
+ limit,
456
+ route,
457
+ status,
458
+ cursor,
459
+ json,
460
+ };
461
+ }
462
+
463
+ function parseBounded(raw, min, max) {
464
+ if (!/^[0-9]{1,3}$/.test(raw)) return null;
465
+ const value = Number(raw);
466
+ return value < min || value > max ? null : value;
467
+ }
468
+
469
+ function parsePaths(values, fail) {
470
+ const project = values["--project"];
471
+ if (project !== undefined && (project === "" || project.includes("\0"))) {
472
+ return fail("invalid_project", "--project must name an existing directory.");
473
+ }
474
+ const configDir = values["--config-dir"];
475
+ if (configDir !== undefined && (configDir === "" || configDir.includes("\0"))) {
476
+ return fail("invalid_config_dir", "--config-dir must name a directory path.");
477
+ }
478
+ return { ok: true, project: project ?? null, configDir: configDir ?? null };
479
+ }
480
+
481
+ function parseTimeout(raw, min = MIN_TIMEOUT_MS, max = MAX_TIMEOUT_MS) {
482
+ if (!/^[0-9]{1,6}$/.test(raw)) return null;
483
+ const value = Number(raw);
484
+ if (value < min || value > max) return null;
485
+ return value;
486
+ }