metergraph-cli 0.0.0-stage → 0.1.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.
package/src/doctor.js ADDED
@@ -0,0 +1,198 @@
1
+ import { CONNECTION_GUIDE_URL, SUPPORTED_PROFILES } from "./constants.js";
2
+ import { get, parseJsonObject } from "./http.js";
3
+
4
+ const CHECKS = Object.freeze([
5
+ { name: "health", path: "/healthz", readBody: true },
6
+ { name: "deployment", path: "/v1/deployment", readBody: true },
7
+ { name: "capabilities", path: "/v1/agent/capabilities", readBody: false },
8
+ ]);
9
+
10
+ // Runs the read-only connection probe against an already validated origin.
11
+ // Every request shares one deadline of timeoutMs. The result contains only
12
+ // fixed tokens, numeric HTTP statuses, the validated origin and a profile from
13
+ // SUPPORTED_PROFILES. Nothing from a response body or header is copied.
14
+ export async function runDoctor({ origin, timeoutMs }) {
15
+ const controller = new AbortController();
16
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
17
+ try {
18
+ return await probe(origin, controller.signal);
19
+ } finally {
20
+ clearTimeout(timer);
21
+ }
22
+ }
23
+
24
+ async function probe(origin, signal) {
25
+ const report = {
26
+ origin,
27
+ reachable: null,
28
+ healthy: null,
29
+ deployment_profile: null,
30
+ profile_status: "unknown",
31
+ authentication_required: null,
32
+ authenticated: false,
33
+ workspace: null,
34
+ checks: CHECKS.map(({ name, path }) => ({
35
+ name,
36
+ path,
37
+ result: "skipped",
38
+ http_status: null,
39
+ reason: null,
40
+ })),
41
+ next_action: null,
42
+ };
43
+
44
+ for (const [index, check] of CHECKS.entries()) {
45
+ const response = await get(new URL(check.path, origin), {
46
+ signal,
47
+ readBody: check.readBody,
48
+ });
49
+ const entry = report.checks[index];
50
+ const verdict = evaluate(check.name, response, report);
51
+ entry.result = verdict.outcome === null || verdict.pass ? "pass" : "fail";
52
+ entry.http_status = response.status ?? null;
53
+ entry.reason = verdict.reason;
54
+ if (verdict.outcome !== null) {
55
+ return { outcome: verdict.outcome, reason: verdict.reason, report };
56
+ }
57
+ }
58
+
59
+ // Unreachable in this preview: the capabilities check always ends the probe.
60
+ return { outcome: "internal_error", reason: "probe_incomplete", report };
61
+ }
62
+
63
+ // Returns { outcome, reason } where outcome is null when the probe should
64
+ // continue to the next check.
65
+ function evaluate(name, response, report) {
66
+ if (response.kind === "error") {
67
+ // Headers that arrived before a body failure still prove the server
68
+ // answered over HTTP. They prove nothing about health or authentication.
69
+ if (name === "health") report.reachable = response.status !== null;
70
+ return { outcome: "connection_failed", reason: response.reason };
71
+ }
72
+
73
+ report.reachable = true;
74
+ const { status } = response;
75
+
76
+ if (status >= 300 && status < 400) {
77
+ return { outcome: "redirect_rejected", reason: "redirect" };
78
+ }
79
+ if (status === 503) {
80
+ report.healthy = false;
81
+ return { outcome: "unhealthy", reason: "service_unavailable" };
82
+ }
83
+ if (status >= 500) {
84
+ report.healthy = false;
85
+ return { outcome: "unhealthy", reason: "server_error" };
86
+ }
87
+
88
+ if (name === "health") return evaluateHealth(response, report);
89
+ if (name === "deployment") return evaluateDeployment(response, report);
90
+ return evaluateCapabilities(response, report);
91
+ }
92
+
93
+ function evaluateHealth(response, report) {
94
+ if (response.status !== 200) {
95
+ return { outcome: "unsupported", reason: "unexpected_status" };
96
+ }
97
+ if (response.tooLarge) {
98
+ return { outcome: "unsupported", reason: "response_too_large" };
99
+ }
100
+ const body = parseJsonObject(response);
101
+ if (body === null || typeof body.ok !== "boolean") {
102
+ return { outcome: "unsupported", reason: "invalid_response" };
103
+ }
104
+ if (body.ok !== true) {
105
+ report.healthy = false;
106
+ return { outcome: "unhealthy", reason: "reported_unhealthy" };
107
+ }
108
+ report.healthy = true;
109
+ return { outcome: null, reason: null };
110
+ }
111
+
112
+ function evaluateDeployment(response, report) {
113
+ if (response.status === 404) {
114
+ // Servers without this endpoint, such as a self-hosted open source
115
+ // server, have no profile adapter yet. Never assume they are hosted.
116
+ report.profile_status = "unavailable";
117
+ return { outcome: "unsupported", reason: "deployment_endpoint_missing" };
118
+ }
119
+ if (response.status !== 200) {
120
+ return { outcome: "unsupported", reason: "unexpected_status" };
121
+ }
122
+ if (response.tooLarge) {
123
+ return { outcome: "unsupported", reason: "response_too_large" };
124
+ }
125
+ const body = parseJsonObject(response);
126
+ if (body === null || typeof body.deployment_profile !== "string") {
127
+ return { outcome: "unsupported", reason: "invalid_response" };
128
+ }
129
+ if (!SUPPORTED_PROFILES.includes(body.deployment_profile)) {
130
+ report.profile_status = "unrecognized";
131
+ return { outcome: "unsupported", reason: "unrecognized_profile" };
132
+ }
133
+ report.deployment_profile = body.deployment_profile;
134
+ report.profile_status = "supported";
135
+ return { outcome: null, reason: null };
136
+ }
137
+
138
+ function evaluateCapabilities(response, report) {
139
+ if (response.status === 401) {
140
+ if (!hasBearerChallenge(response.headers["www-authenticate"])) {
141
+ return { outcome: "unsupported", reason: "unexpected_auth_challenge" };
142
+ }
143
+ report.authentication_required = true;
144
+ report.next_action = { kind: "connection_guide", url: CONNECTION_GUIDE_URL };
145
+ // The check passed: the server behaved as expected. The outcome is still
146
+ // not a connection, because this CLI holds no credentials.
147
+ return { outcome: "authentication_required", reason: "bearer_token_required", pass: true };
148
+ }
149
+ if (response.status === 200) {
150
+ report.authentication_required = false;
151
+ return { outcome: "unsupported", reason: "unexpected_unauthenticated_access" };
152
+ }
153
+ return { outcome: "unsupported", reason: "unexpected_status" };
154
+ }
155
+
156
+ // RFC 9110 challenge grammar, applied to one comma-separated list element:
157
+ // auth-scheme [ 1*SP ( token68 / auth-param ) ] or a further auth-param.
158
+ // Only the first form names a scheme, so "Bearer=x" or "Bearer = x" is a
159
+ // parameter, never a challenge.
160
+ const TOKEN = "[!#$%&'*+.^_`|~0-9A-Za-z-]+";
161
+ const QUOTED = '"(?:[^"\\\\]|\\\\.)*"';
162
+ const PARAM = `${TOKEN}[ \\t]*=[ \\t]*(?:${TOKEN}|${QUOTED})`;
163
+ const TOKEN68 = "[A-Za-z0-9._~+/-]+=*";
164
+ const ELEMENT = new RegExp(`^(?:(${TOKEN})(?:[ \\t]+(?:${TOKEN68}|${PARAM}))?|${PARAM})$`);
165
+
166
+ // True when the header holds a Bearer challenge. Commas inside quoted strings
167
+ // (with backslash escapes) do not split elements. Any malformed element,
168
+ // including unbalanced quoting, makes the whole header unacceptable.
169
+ function hasBearerChallenge(value) {
170
+ if (typeof value !== "string") return false;
171
+ const elements = [];
172
+ let start = 0;
173
+ let quoted = false;
174
+ for (let i = 0; i < value.length; i += 1) {
175
+ const char = value[i];
176
+ if (quoted) {
177
+ if (char === "\\") i += 1;
178
+ else if (char === '"') quoted = false;
179
+ } else if (char === '"') {
180
+ quoted = true;
181
+ } else if (char === ",") {
182
+ elements.push(value.slice(start, i));
183
+ start = i + 1;
184
+ }
185
+ }
186
+ if (quoted) return false;
187
+ elements.push(value.slice(start));
188
+
189
+ let bearer = false;
190
+ for (const element of elements) {
191
+ const trimmed = element.trim();
192
+ if (trimmed === "") continue;
193
+ const match = ELEMENT.exec(trimmed);
194
+ if (match === null) return false;
195
+ if (match[1] !== undefined && match[1].toLowerCase() === "bearer") bearer = true;
196
+ }
197
+ return bearer;
198
+ }
package/src/http.js ADDED
@@ -0,0 +1,129 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+
4
+ import { MAX_BODY_BYTES, PACKAGE_NAME, VERSION } from "./constants.js";
5
+
6
+ const USER_AGENT = `${PACKAGE_NAME}/${VERSION}`;
7
+
8
+ const DNS_ERRORS = new Set(["ENOTFOUND", "EAI_AGAIN", "EAI_FAIL", "EAI_NONAME"]);
9
+ const REFUSED_ERRORS = new Set(["ECONNREFUSED"]);
10
+ const RESET_ERRORS = new Set(["ECONNRESET", "EPIPE", "ECONNABORTED"]);
11
+ const UNREACHABLE_ERRORS = new Set(["EHOSTUNREACH", "ENETUNREACH", "ETIMEDOUT"]);
12
+
13
+ // Sends one unauthenticated GET and resolves (never rejects) with either
14
+ // { kind: "response", status, headers, body, tooLarge }
15
+ // { kind: "error", reason, status }
16
+ // where reason is a fixed token from classifyError, and status is the HTTP
17
+ // status when the headers arrived before the failure (a body timeout, reset or
18
+ // truncation), otherwise null. Redirects are returned as ordinary responses
19
+ // and never followed. Only fixed headers are sent: no cookies, no
20
+ // authorization and nothing taken from the environment.
21
+ // The body is read only when readBody is true and the status is 200. Any
22
+ // other response is settled as soon as its headers arrive and its body is
23
+ // discarded, so a slow body cannot hide a status that is already known.
24
+ export function get(url, { signal, readBody }) {
25
+ return new Promise((resolve) => {
26
+ let settled = false;
27
+ let status = null;
28
+ const finish = (value) => {
29
+ if (!settled) {
30
+ settled = true;
31
+ resolve(value);
32
+ }
33
+ };
34
+ const fail = (error) =>
35
+ finish({ kind: "error", reason: classifyError(error, signal), status });
36
+
37
+ const transport = url.protocol === "https:" ? https : http;
38
+ let request;
39
+ try {
40
+ request = transport.request(url, {
41
+ method: "GET",
42
+ agent: false,
43
+ signal,
44
+ headers: {
45
+ accept: "application/json",
46
+ "accept-encoding": "identity",
47
+ "user-agent": USER_AGENT,
48
+ },
49
+ });
50
+ } catch (error) {
51
+ fail(error);
52
+ return;
53
+ }
54
+
55
+ request.on("error", fail);
56
+ request.on("response", (response) => {
57
+ response.on("error", fail);
58
+ status = response.statusCode;
59
+ const headers = response.headers;
60
+ const respond = (body, tooLarge) =>
61
+ finish({ kind: "response", status, headers, body, tooLarge });
62
+
63
+ if (!readBody || status !== 200) {
64
+ respond(null, false);
65
+ response.destroy();
66
+ return;
67
+ }
68
+
69
+ const declared = headers["content-length"];
70
+ if (declared !== undefined && Number(declared) > MAX_BODY_BYTES) {
71
+ respond(null, true);
72
+ response.destroy();
73
+ return;
74
+ }
75
+
76
+ const chunks = [];
77
+ let size = 0;
78
+ response.on("data", (chunk) => {
79
+ size += chunk.length;
80
+ if (size > MAX_BODY_BYTES) {
81
+ respond(null, true);
82
+ response.destroy();
83
+ return;
84
+ }
85
+ chunks.push(chunk);
86
+ });
87
+ response.on("end", () => respond(Buffer.concat(chunks), false));
88
+ // A close without end means the body was cut off.
89
+ response.on("close", () => fail(null));
90
+ });
91
+ request.end();
92
+ });
93
+ }
94
+
95
+ function classifyError(error, signal) {
96
+ if (signal?.aborted) return "timeout";
97
+ const code = typeof error?.code === "string" ? error.code : "";
98
+ if (DNS_ERRORS.has(code)) return "dns_lookup_failed";
99
+ if (REFUSED_ERRORS.has(code)) return "connection_refused";
100
+ if (RESET_ERRORS.has(code)) return "connection_reset";
101
+ if (UNREACHABLE_ERRORS.has(code)) return "host_unreachable";
102
+ if (code.startsWith("HPE_")) return "invalid_http_response";
103
+ if (code.startsWith("ERR_TLS") || code.startsWith("ERR_SSL") || code.includes("CERT")) {
104
+ return "tls_error";
105
+ }
106
+ return "network_error";
107
+ }
108
+
109
+ // Decodes a bounded body as a JSON object. Returns the object, or null when
110
+ // the content type, encoding, text or top-level shape is not acceptable.
111
+ export function parseJsonObject(response) {
112
+ if (response.body === null || response.tooLarge) return null;
113
+ const encoding = response.headers["content-encoding"];
114
+ if (encoding !== undefined && encoding.trim().toLowerCase() !== "identity") return null;
115
+ const contentType = response.headers["content-type"];
116
+ if (typeof contentType !== "string") return null;
117
+ const mediaType = contentType.split(";")[0].trim().toLowerCase();
118
+ if (mediaType !== "application/json") return null;
119
+
120
+ let value;
121
+ try {
122
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(response.body);
123
+ value = JSON.parse(text);
124
+ } catch {
125
+ return null;
126
+ }
127
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
128
+ return value;
129
+ }
package/src/origin.js ADDED
@@ -0,0 +1,37 @@
1
+ const MAX_INPUT_LENGTH = 2048;
2
+
3
+ // A bare authority with an optional trailing slash: no userinfo, path, query,
4
+ // fragment or backslash. Checked on the raw text because the URL parser
5
+ // silently normalizes some of these away.
6
+ const HTTPS_ORIGIN = /^https:\/\/[^/?#@\\]+\/?$/i;
7
+
8
+ // Plaintext HTTP is only for these exact loopback spellings. Alternate forms
9
+ // such as 127.1 or 0x7f000001 are rejected even though they resolve locally.
10
+ const HTTP_LOOPBACK_ORIGIN = /^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]{1,5})?\/?$/i;
11
+ const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
12
+
13
+ // Returns the normalized origin (for example "https://example.com") or null.
14
+ // The caller must not echo the raw input when this returns null.
15
+ export function parseOrigin(raw) {
16
+ if (typeof raw !== "string" || raw.length === 0 || raw.length > MAX_INPUT_LENGTH) {
17
+ return null;
18
+ }
19
+ // Printable ASCII only. Hosts with non-ASCII labels use their xn-- form.
20
+ if (/[^\x21-\x7e]/.test(raw)) return null;
21
+ if (!HTTPS_ORIGIN.test(raw) && !HTTP_LOOPBACK_ORIGIN.test(raw)) return null;
22
+
23
+ let url;
24
+ try {
25
+ url = new URL(raw);
26
+ } catch {
27
+ return null;
28
+ }
29
+
30
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
31
+ if (url.username !== "" || url.password !== "") return null;
32
+ if (url.pathname !== "/" || url.search !== "" || url.hash !== "") return null;
33
+ if (url.hostname === "" || url.port === "0") return null;
34
+ if (url.protocol === "http:" && !LOOPBACK_HOSTNAMES.has(url.hostname)) return null;
35
+
36
+ return url.origin;
37
+ }
package/src/output.js ADDED
@@ -0,0 +1,274 @@
1
+ import {
2
+ CONNECTION_GUIDE_URL,
3
+ DEFAULT_ORIGIN,
4
+ DEFAULT_TIMEOUT_MS,
5
+ EXIT_CODE_MEANINGS,
6
+ EXIT_CODES,
7
+ HANDOFF_SKILL_CLIENTS,
8
+ MAX_TIMEOUT_MS,
9
+ MIN_TIMEOUT_MS,
10
+ PACKAGE_NAME,
11
+ SCHEMA_VERSION,
12
+ SKILL_CLIENTS,
13
+ SKILL_RUNTIMES,
14
+ SUPPORTED_PROFILES,
15
+ VERSION,
16
+ } from "./constants.js";
17
+
18
+ // Every JSON result, success or failure, has the same top-level keys:
19
+ // schema_version, command, ok, outcome, exit_code, data, error
20
+ // error is null when ok is true, otherwise { code, reason, message } where
21
+ // code equals outcome. All strings come from this package, never from input
22
+ // or from a server.
23
+ export function envelope({ command, outcome, data = null, reason = null, message = null }) {
24
+ const ok = outcome === "ok";
25
+ return {
26
+ schema_version: SCHEMA_VERSION,
27
+ command,
28
+ ok,
29
+ outcome,
30
+ exit_code: EXIT_CODES[outcome],
31
+ data,
32
+ error: ok ? null : { code: outcome, reason, message },
33
+ };
34
+ }
35
+
36
+ export function toJsonLine(result) {
37
+ return `${JSON.stringify(result)}\n`;
38
+ }
39
+
40
+ const DOCTOR_OPTIONS = [
41
+ {
42
+ name: "--url",
43
+ value: "ORIGIN",
44
+ summary: `Origin to probe. Default ${DEFAULT_ORIGIN}.`,
45
+ },
46
+ {
47
+ name: "--timeout-ms",
48
+ value: "N",
49
+ summary: `Total time allowed for the whole probe, ${MIN_TIMEOUT_MS} to ${MAX_TIMEOUT_MS}. Default ${DEFAULT_TIMEOUT_MS}.`,
50
+ },
51
+ { name: "--json", value: null, summary: "Print one JSON line on stdout." },
52
+ ];
53
+
54
+ const SKILL_OPTIONS = [
55
+ {
56
+ name: "--client",
57
+ value: "CLIENT",
58
+ summary: "Required. codex (.agents/skills), claude (.claude/skills) or cursor (.cursor/skills).",
59
+ },
60
+ {
61
+ name: "--runtime",
62
+ value: "RUNTIME",
63
+ summary: "Required. local or cloud: where the client runs. Recorded, not detected.",
64
+ },
65
+ { name: "--project", value: "DIR", summary: "Existing project directory. Default: the current directory." },
66
+ { name: "--json", value: null, summary: "Print one JSON line on stdout." },
67
+ ];
68
+
69
+ const SKILL_USAGE = [
70
+ "metergraph skill install --client CLIENT --runtime RUNTIME [--project DIR] [--json]",
71
+ "metergraph skill update --client CLIENT --runtime RUNTIME [--project DIR] [--json]",
72
+ ];
73
+
74
+ export function helpData(topic) {
75
+ return {
76
+ topic,
77
+ usage: [
78
+ "metergraph --help [--json]",
79
+ "metergraph --version [--json]",
80
+ "metergraph doctor [--url ORIGIN] [--timeout-ms N] [--json]",
81
+ ...SKILL_USAGE,
82
+ ],
83
+ commands: [
84
+ {
85
+ name: "doctor",
86
+ summary:
87
+ "Check that a Metergraph service is reachable, healthy and supported. Read only, sends no credentials.",
88
+ options: DOCTOR_OPTIONS,
89
+ },
90
+ {
91
+ name: "skill install",
92
+ summary:
93
+ "Copy the Metergraph skill bundled with this CLI into one client's project skill directory. " +
94
+ "Never replaces a skill it did not install. No network requests, no sign in.",
95
+ options: SKILL_OPTIONS,
96
+ },
97
+ {
98
+ name: "skill update",
99
+ summary:
100
+ "Replace a skill this CLI installed, and that is unchanged since, with the bundled revision.",
101
+ options: SKILL_OPTIONS,
102
+ },
103
+ ],
104
+ skill_clients: Object.keys(SKILL_CLIENTS),
105
+ skill_runtimes: [...SKILL_RUNTIMES],
106
+ supported_profiles: [...SUPPORTED_PROFILES],
107
+ exit_codes: Object.entries(EXIT_CODES).map(([outcome, code]) => ({
108
+ code,
109
+ outcome,
110
+ meaning: EXIT_CODE_MEANINGS[outcome],
111
+ })),
112
+ };
113
+ }
114
+
115
+ export function helpText(topic) {
116
+ const lines = [];
117
+ if (topic === "doctor") {
118
+ lines.push(
119
+ "Usage: metergraph doctor [--url ORIGIN] [--timeout-ms N] [--json]",
120
+ "",
121
+ "Makes unauthenticated, read-only GET requests to /healthz, /v1/deployment and",
122
+ "/v1/agent/capabilities on one origin. Sends no credentials and follows no redirects.",
123
+ "",
124
+ "Options:",
125
+ );
126
+ for (const option of DOCTOR_OPTIONS) {
127
+ const flag = option.value ? `${option.name} ${option.value}` : option.name;
128
+ lines.push(` ${flag.padEnd(18)}${option.summary}`);
129
+ }
130
+ lines.push(
131
+ "",
132
+ "ORIGIN must be a bare https origin such as https://metergraph.example.com.",
133
+ "Plain http is accepted only for localhost, 127.0.0.1 and [::1].",
134
+ "",
135
+ "A healthy, supported service that requires sign in exits with code 3.",
136
+ "This preview cannot sign in, so it never reports a connected workspace.",
137
+ );
138
+ } else if (topic === "skill") {
139
+ lines.push(
140
+ "Usage:",
141
+ ...SKILL_USAGE.map((usage) => ` ${usage}`),
142
+ "",
143
+ "Copies the Metergraph skill bundled with this CLI into one client's project skill",
144
+ "directory and records ownership in .metergraph/skill-installations.json. Writes",
145
+ "nothing else. Makes no network requests, does not sign in and does not configure",
146
+ "MCP, client settings, AGENTS.md or CLAUDE.md.",
147
+ "",
148
+ "Options:",
149
+ );
150
+ for (const option of SKILL_OPTIONS) {
151
+ const flag = option.value ? `${option.name} ${option.value}` : option.name;
152
+ lines.push(` ${flag.padEnd(18)}${option.summary}`);
153
+ }
154
+ lines.push(
155
+ "",
156
+ "install never replaces an existing skill. update replaces only a skill this CLI",
157
+ "installed and that is unchanged since. There is no force option.",
158
+ "",
159
+ "Claude Desktop (--client claude-desktop), ChatGPT (--client chatgpt) and cloud",
160
+ "runtimes without a shell (--runtime cloud-no-shell) cannot load project skill files.",
161
+ "They exit with code 6, point to the connection guide and write nothing.",
162
+ "",
163
+ "Discovery stays pending until the client itself loads the skill.",
164
+ );
165
+ } else {
166
+ lines.push(
167
+ `metergraph ${VERSION} (preview)`,
168
+ "",
169
+ "Usage:",
170
+ " metergraph --help [--json] Show this help",
171
+ " metergraph --version [--json] Show the CLI version",
172
+ " metergraph doctor [options] Check a Metergraph service, read only",
173
+ " metergraph skill install|update Install or update the agent skill in a project",
174
+ "",
175
+ 'Run "metergraph help doctor" for doctor options.',
176
+ 'Run "metergraph help skill" for skill options.',
177
+ );
178
+ }
179
+ lines.push("", "Exit codes:");
180
+ for (const [outcome, code] of Object.entries(EXIT_CODES)) {
181
+ lines.push(` ${String(code).padEnd(3)}${outcome.padEnd(25)}${EXIT_CODE_MEANINGS[outcome]}`);
182
+ }
183
+ return `${lines.join("\n")}\n`;
184
+ }
185
+
186
+ export function skillText(result, message) {
187
+ const report = result.data;
188
+ const label = SKILL_CLIENTS[report.client]?.label ?? HANDOFF_SKILL_CLIENTS[report.client];
189
+ const lines = [`Metergraph ${result.command}: ${label}, ${report.runtime} runtime`];
190
+ if (report.path !== null) lines.push(`Path: ${report.path}`);
191
+ if (result.ok) {
192
+ lines.push(
193
+ `Status: ${report.status}`,
194
+ `Source revision: ${report.source.revision} (sha256 ${report.source.sha256})`,
195
+ `Discovery: pending until ${label} loads the skill`,
196
+ "Authenticated: no",
197
+ "",
198
+ message,
199
+ `Next: ${report.next_action.message}`,
200
+ );
201
+ } else {
202
+ lines.push("", `Result: ${result.outcome} (exit ${result.exit_code})`, message);
203
+ if (report.next_action?.kind === "connection_guide") {
204
+ lines.push(`Connection guide: ${report.next_action.url}`);
205
+ }
206
+ }
207
+ return `${lines.join("\n")}\n`;
208
+ }
209
+
210
+ export function versionData() {
211
+ return { name: PACKAGE_NAME, version: VERSION };
212
+ }
213
+
214
+ const CHECK_LABELS = {
215
+ health: "Health",
216
+ deployment: "Deployment profile",
217
+ capabilities: "Agent capabilities",
218
+ };
219
+
220
+ const REASON_TEXT = {
221
+ timeout: "the probe did not finish within the time limit",
222
+ dns_lookup_failed: "the host name could not be resolved",
223
+ connection_refused: "the connection was refused",
224
+ connection_reset: "the connection was closed unexpectedly",
225
+ host_unreachable: "the host could not be reached",
226
+ invalid_http_response: "the server did not send a valid HTTP response",
227
+ tls_error: "the TLS connection could not be verified",
228
+ network_error: "a network error occurred",
229
+ redirect: "the server answered with a redirect, which is never followed",
230
+ service_unavailable: "the service reported that it is unavailable",
231
+ server_error: "the service answered with a server error",
232
+ reported_unhealthy: "the service reported that it is not healthy",
233
+ unexpected_status: "the service answered with an unexpected status",
234
+ response_too_large: "the response was larger than the allowed limit",
235
+ invalid_response: "the response was not the expected JSON",
236
+ deployment_endpoint_missing:
237
+ "the service does not report a deployment profile; this preview supports only services that do",
238
+ unrecognized_profile: "the service reported a deployment profile this CLI does not support",
239
+ unexpected_auth_challenge: "the service did not ask for a bearer token",
240
+ unexpected_unauthenticated_access: "the service answered without asking for authentication",
241
+ bearer_token_required: "authentication required",
242
+ probe_incomplete: "the probe did not complete",
243
+ };
244
+
245
+ export function doctorMessage(outcome, reason) {
246
+ if (outcome === "authentication_required") {
247
+ return "The service is reachable and supported, and it requires authentication. No workspace is connected.";
248
+ }
249
+ const detail = REASON_TEXT[reason] ?? "the probe failed";
250
+ return `${detail.charAt(0).toUpperCase()}${detail.slice(1)}.`;
251
+ }
252
+
253
+ export function doctorText(result) {
254
+ const report = result.data;
255
+ const lines = ["Metergraph doctor (read only, no credentials sent)", `Origin: ${report.origin}`, ""];
256
+ for (const check of report.checks) {
257
+ let line = ` ${check.result.padEnd(8)}${CHECK_LABELS[check.name]} (${check.path})`;
258
+ if (check.name === "deployment" && report.deployment_profile !== null) {
259
+ line += `: ${report.deployment_profile}`;
260
+ } else if (check.reason !== null) {
261
+ line += `: ${REASON_TEXT[check.reason] ?? check.reason}`;
262
+ }
263
+ lines.push(line);
264
+ }
265
+ lines.push("", `Result: ${result.outcome} (exit ${result.exit_code})`);
266
+ if (result.error !== null) lines.push(result.error.message);
267
+ if (result.outcome === "authentication_required") {
268
+ lines.push(
269
+ "This preview cannot sign in. To connect an application, follow the connection guide:",
270
+ CONNECTION_GUIDE_URL,
271
+ );
272
+ }
273
+ return `${lines.join("\n")}\n`;
274
+ }
@@ -0,0 +1,53 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ // The skill file shipped in assets/skill/ is a byte-for-byte copy of the
5
+ // public skill at the manifest's source_url. The source has no version of its
6
+ // own, so the revision is derived from the content hash. The hash is pinned
7
+ // here as well as in the manifest, so editing either file alone is detected.
8
+ const PINNED_SHA256 = "90f7d8d78a5b0b7a57436f194222f0c73310b0b04201c297c8fbf0b00ad6bb3f";
9
+
10
+ const ASSET_DIR = new URL("../assets/skill/", import.meta.url);
11
+ const MANIFEST_KEYS = ["file", "manifest_version", "name", "revision", "sha256", "size", "source_url"];
12
+
13
+ export function sha256Hex(content) {
14
+ return createHash("sha256").update(content).digest("hex");
15
+ }
16
+
17
+ export function revisionFor(sha256) {
18
+ return `sha256-${sha256.slice(0, 12)}`;
19
+ }
20
+
21
+ // Returns { name, revision, sha256, content } for the bundled skill, or null
22
+ // when the manifest or the skill file is missing, malformed or does not match
23
+ // the pinned hash. Never downloads anything.
24
+ export function loadBundledSkill() {
25
+ let manifest;
26
+ let content;
27
+ try {
28
+ manifest = JSON.parse(readFileSync(new URL("manifest.json", ASSET_DIR), "utf8"));
29
+ content = readFileSync(new URL("SKILL.md", ASSET_DIR));
30
+ } catch {
31
+ return null;
32
+ }
33
+ if (
34
+ manifest === null ||
35
+ typeof manifest !== "object" ||
36
+ Array.isArray(manifest) ||
37
+ Object.keys(manifest).sort().join() !== MANIFEST_KEYS.join() ||
38
+ manifest.manifest_version !== 1 ||
39
+ manifest.file !== "SKILL.md" ||
40
+ manifest.source_url !== "https://www.metergraph.dev/SKILL.md" ||
41
+ typeof manifest.name !== "string" ||
42
+ !/^[a-z0-9-]{1,64}$/.test(manifest.name) ||
43
+ manifest.sha256 !== PINNED_SHA256 ||
44
+ manifest.revision !== revisionFor(PINNED_SHA256) ||
45
+ manifest.size !== content.length
46
+ ) {
47
+ return null;
48
+ }
49
+ const sha256 = sha256Hex(content);
50
+ if (sha256 !== PINNED_SHA256) return null;
51
+ if (!content.toString("utf8").startsWith(`---\nname: ${manifest.name}\ndescription: `)) return null;
52
+ return { name: manifest.name, revision: manifest.revision, sha256, content };
53
+ }