metergraph-cli 0.2.0 → 0.2.2
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 +21 -16
- package/assets/skill/SKILL.md +53 -4
- package/assets/skill/manifest.json +3 -3
- package/assets/skills/manifest.json +16 -16
- package/assets/skills/metergraph-analyze/SKILL.md +62 -4
- package/assets/skills/metergraph-investigate/SKILL.md +222 -35
- package/assets/skills/metergraph-local-bundle/SKILL.md +4 -4
- package/assets/skills/metergraph-onboarding/SKILL.md +7 -0
- package/assets/skills/metergraph-report/SKILL.md +19 -2
- package/package.json +1 -1
- package/src/agent-key.js +157 -0
- package/src/approval-handoff.js +5 -0
- package/src/args.js +373 -14
- package/src/auth-browser.js +5 -19
- package/src/auth-callback.js +24 -15
- package/src/auth-login.js +55 -12
- package/src/auth-oauth.js +20 -3
- package/src/callback-page.js +58 -0
- package/src/cli.js +94 -37
- package/src/constants.js +32 -3
- package/src/environment.js +169 -0
- package/src/origin.js +6 -0
- package/src/output.js +325 -130
- package/src/read-contract.js +953 -13
- package/src/read-output.js +449 -30
- package/src/read.js +297 -59
- package/src/setup-deployment.js +4 -3
- package/src/setup-refusal.js +172 -0
- package/src/setup-state.js +4 -2
- package/src/setup.js +227 -53
- package/src/skill-bundle.js +2 -2
- package/src/style.js +101 -0
- package/src/transport.js +8 -4
- package/src/usage-aggregates.js +197 -0
- package/src/verify-output.js +21 -11
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { CONNECTION_GUIDE_URL, DEFAULT_ORIGIN } from "./constants.js";
|
|
4
|
+
|
|
5
|
+
// Where this CLI runs, for the sign in and setup path matrix. Only the
|
|
6
|
+
// presence of a variable or file is checked; no value is ever read into
|
|
7
|
+
// output.
|
|
8
|
+
//
|
|
9
|
+
// local_shell A terminal on the person's own machine. Browser sign in
|
|
10
|
+
// and setup work here.
|
|
11
|
+
// local_container A container on the person's machine (Docker, Podman, a
|
|
12
|
+
// local dev container) with no remote marker. It is not
|
|
13
|
+
// treated as remote: browser sign in works when the
|
|
14
|
+
// container's loopback port is forwarded, and --no-browser
|
|
15
|
+
// prints the URL to open.
|
|
16
|
+
// ssh_session A shell reached over SSH. The person's browser is on
|
|
17
|
+
// another machine.
|
|
18
|
+
// cloud_workspace A hosted development environment (Codespaces, Gitpod,
|
|
19
|
+
// Cloud Shell, or a remote dev container). The person's
|
|
20
|
+
// browser is on another machine.
|
|
21
|
+
// ci A CI job. No person is present.
|
|
22
|
+
//
|
|
23
|
+
// Remote markers win over container markers: a cloud workspace usually runs
|
|
24
|
+
// in a container, and it is still remote.
|
|
25
|
+
|
|
26
|
+
const REMOTE_MARKERS = [
|
|
27
|
+
["ssh_session", ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"]],
|
|
28
|
+
["cloud_workspace", ["CODESPACES", "GITPOD_WORKSPACE_ID", "CLOUD_SHELL"]],
|
|
29
|
+
["ci", ["CI"]],
|
|
30
|
+
];
|
|
31
|
+
const CONTAINER_VARIABLES = ["REMOTE_CONTAINERS", "DEVCONTAINER", "container"];
|
|
32
|
+
const CONTAINER_FILES = ["/.dockerenv", "/run/.containerenv"];
|
|
33
|
+
|
|
34
|
+
// The fixed reason each remote kind reports. These are the reasons login and
|
|
35
|
+
// setup have always used, so scripts that match them keep working.
|
|
36
|
+
const REMOTE_REASONS = Object.freeze({
|
|
37
|
+
ssh_session: "ssh_session",
|
|
38
|
+
cloud_workspace: "cloud_workspace",
|
|
39
|
+
ci: "ci_environment",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// The secret name an agent reads its Metadata key from. It matches the name
|
|
43
|
+
// the hosted Keys page and the Agent Access MCP setup already use.
|
|
44
|
+
export const AGENT_TOKEN_ENV = "METERGRAPH_AGENT_TOKEN";
|
|
45
|
+
// The origin an agent key is sent to, when no project binding and no --url
|
|
46
|
+
// names one. It matches the MCP setup's variable.
|
|
47
|
+
export const AGENT_URL_ENV = "METERGRAPH_URL";
|
|
48
|
+
|
|
49
|
+
function present(value) {
|
|
50
|
+
return typeof value === "string" && value !== "" && value !== "0" && value.toLowerCase() !== "false";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function defaultFileExists(file) {
|
|
54
|
+
try {
|
|
55
|
+
return fs.existsSync(file);
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Returns { kind, remote, reason } where reason is null for a local kind.
|
|
62
|
+
export function classifyEnvironment(env = process.env, { fileExists = defaultFileExists } = {}) {
|
|
63
|
+
for (const [kind, names] of REMOTE_MARKERS) {
|
|
64
|
+
if (names.some((name) => present(env[name]))) return { kind, remote: true, reason: REMOTE_REASONS[kind] };
|
|
65
|
+
}
|
|
66
|
+
const container =
|
|
67
|
+
CONTAINER_VARIABLES.some((name) => present(env[name])) || CONTAINER_FILES.some((file) => fileExists(file));
|
|
68
|
+
return { kind: container ? "local_container" : "local_shell", remote: false, reason: null };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Hosted pages a person opens on their own device. The Keys page creates a
|
|
72
|
+
// coding-agent key in the workspace the person is signed in to, which is how
|
|
73
|
+
// the person chooses the workspace. Sign up exists only on the hosted service.
|
|
74
|
+
export function handoffPages(origin) {
|
|
75
|
+
const hosted = origin === DEFAULT_ORIGIN;
|
|
76
|
+
return {
|
|
77
|
+
sign_up_url: hosted ? `${origin}/v1/auth/signup?return_to=${encodeURIComponent("/#api-keys")}` : null,
|
|
78
|
+
keys_url: `${origin}/#api-keys`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The structured next action for an agent that cannot finish browser sign in
|
|
83
|
+
// where it runs, or has no credential yet. It names what a person does on
|
|
84
|
+
// their own device, where the key goes, and the exact commands the agent runs
|
|
85
|
+
// afterwards. The key itself never passes through the agent's chat, a command
|
|
86
|
+
// line or a file in the project.
|
|
87
|
+
export function agentKeyAction(environment, origin) {
|
|
88
|
+
const pages = handoffPages(origin);
|
|
89
|
+
const where = environment.kind === "ci"
|
|
90
|
+
? `a CI secret exposed to this job as ${AGENT_TOKEN_ENV}`
|
|
91
|
+
: `this environment's secret settings as ${AGENT_TOKEN_ENV}`;
|
|
92
|
+
const steps = [];
|
|
93
|
+
if (pages.sign_up_url !== null) {
|
|
94
|
+
steps.push({ action: "sign_up_or_sign_in", url: pages.sign_up_url });
|
|
95
|
+
} else {
|
|
96
|
+
steps.push({ action: "request_invitation", url: null });
|
|
97
|
+
}
|
|
98
|
+
steps.push({ action: "create_metadata_key", url: pages.keys_url });
|
|
99
|
+
steps.push({ action: "store_secret", name: AGENT_TOKEN_ENV });
|
|
100
|
+
// A key for a deployment other than the hosted service must only ever be
|
|
101
|
+
// sent there, so the commands name its origin; without it they would use
|
|
102
|
+
// the hosted default.
|
|
103
|
+
const hosted = origin === DEFAULT_ORIGIN;
|
|
104
|
+
const url = hosted ? "" : ` --url ${shellArg(origin)}`;
|
|
105
|
+
return {
|
|
106
|
+
kind: "use_agent_key",
|
|
107
|
+
environment: environment.kind,
|
|
108
|
+
credential_env: AGENT_TOKEN_ENV,
|
|
109
|
+
origin,
|
|
110
|
+
person_steps: steps,
|
|
111
|
+
then_run: [`metergraph status${url} --json`, `metergraph usage${url} --json`],
|
|
112
|
+
message:
|
|
113
|
+
(environment.remote === false
|
|
114
|
+
? "If browser sign in cannot finish here, use a Metadata key instead. "
|
|
115
|
+
: "Browser sign in cannot finish here. ") +
|
|
116
|
+
"Ask the person to sign in on their own device, open the Keys page " +
|
|
117
|
+
"in the workspace this agent should read, create a coding-agent key with Metadata access, and save it in " +
|
|
118
|
+
`${where}. Do not ask them to paste the key into chat or a command. Then run the listed commands; ` +
|
|
119
|
+
"they verify the origin, workspace, scope and capabilities before reading." +
|
|
120
|
+
(hosted
|
|
121
|
+
? ""
|
|
122
|
+
: " On the open-source self-hosted server there is no Keys page: the operator provides one of its " +
|
|
123
|
+
"MG_AGENT_TOKENS values instead."),
|
|
124
|
+
alternative: { kind: "run_on_local_machine", command: "metergraph login --runtime local" },
|
|
125
|
+
url: CONNECTION_GUIDE_URL,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The next action for setup in a remote session. Ingest setup writes the
|
|
130
|
+
// application's own key and needs a local browser approval, so it runs on the
|
|
131
|
+
// person's machine. Metadata reads can still use an agent key here; that key
|
|
132
|
+
// is separate from the ingest key and never replaces it.
|
|
133
|
+
// options are the parsed setup options; the command repeats the caller's
|
|
134
|
+
// deployment, origin, workspace and skill choice so it runs as given.
|
|
135
|
+
export function remoteSetupAction(environment, origin, options = {}) {
|
|
136
|
+
return {
|
|
137
|
+
kind: "run_on_local_machine",
|
|
138
|
+
environment: environment.kind,
|
|
139
|
+
command: setupCommand(origin, options),
|
|
140
|
+
message:
|
|
141
|
+
"Setup approves an ingest key in a browser on the same machine as the CLI, so it cannot finish here. " +
|
|
142
|
+
"Run it on the person's own machine, or follow the connection guide. For Metadata reads from this " +
|
|
143
|
+
"environment, follow metadata_reads.",
|
|
144
|
+
metadata_reads: agentKeyAction(environment, origin),
|
|
145
|
+
url: CONNECTION_GUIDE_URL,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Agents run these commands in a shell, and an origin may come from a
|
|
150
|
+
// committed project file, so every interpolated value is quoted even though
|
|
151
|
+
// the parsers already restrict it. Without a client choice the command skips
|
|
152
|
+
// the skill rather than show a placeholder a shell would misread.
|
|
153
|
+
function setupCommand(origin, options) {
|
|
154
|
+
const parts = ["metergraph setup --runtime local"];
|
|
155
|
+
if (options.deployment && options.deployment !== "managed") parts.push(`--deployment ${shellArg(options.deployment)}`);
|
|
156
|
+
if (origin !== DEFAULT_ORIGIN) parts.push(`--url ${shellArg(origin)}`);
|
|
157
|
+
if (options.workspace) parts.push(`--workspace ${shellArg(options.workspace)}`);
|
|
158
|
+
if (options.skipSkill || !options.client) parts.push("--skip-skill");
|
|
159
|
+
else parts.push(`--client ${shellArg(options.client)}`);
|
|
160
|
+
return parts.join(" ");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A value as one POSIX shell word: as is when it holds only characters no
|
|
164
|
+
// shell treats specially, otherwise single-quoted.
|
|
165
|
+
export function shellArg(value) {
|
|
166
|
+
const text = String(value);
|
|
167
|
+
if (/^[A-Za-z0-9._:/=-]+$/.test(text)) return text;
|
|
168
|
+
return `'${text.replace(/'/g, "'\\''")}'`;
|
|
169
|
+
}
|
package/src/origin.js
CHANGED
|
@@ -9,6 +9,7 @@ const HTTPS_ORIGIN = /^https:\/\/[^/?#@\\]+\/?$/i;
|
|
|
9
9
|
// such as 127.1 or 0x7f000001 are rejected even though they resolve locally.
|
|
10
10
|
const HTTP_LOOPBACK_ORIGIN = /^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]{1,5})?\/?$/i;
|
|
11
11
|
const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
12
|
+
const SAFE_HOSTNAME = /^(?:[a-z0-9-]+(?:\.[a-z0-9-]+)*\.?|\[[0-9a-f:.]+\])$/;
|
|
12
13
|
|
|
13
14
|
// Returns the normalized origin (for example "https://example.com") or null.
|
|
14
15
|
// The caller must not echo the raw input when this returns null.
|
|
@@ -32,6 +33,11 @@ export function parseOrigin(raw) {
|
|
|
32
33
|
if (url.pathname !== "/" || url.search !== "" || url.hash !== "") return null;
|
|
33
34
|
if (url.hostname === "" || url.port === "0") return null;
|
|
34
35
|
if (url.protocol === "http:" && !LOOPBACK_HOSTNAMES.has(url.hostname)) return null;
|
|
36
|
+
// A DNS name (letters, digits, hyphens and dots) or a bracketed IPv6
|
|
37
|
+
// literal. The URL parser accepts characters such as ; $ ( ) ` & ' in a
|
|
38
|
+
// host, which no real deployment uses and which would be unsafe wherever
|
|
39
|
+
// the origin is shown inside a command.
|
|
40
|
+
if (!SAFE_HOSTNAME.test(url.hostname)) return null;
|
|
35
41
|
|
|
36
42
|
return url.origin;
|
|
37
43
|
}
|