api-spec-cli 0.2.3 → 0.2.5

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/mcp-client.js CHANGED
@@ -1,63 +1,91 @@
1
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
- import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
- import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
4
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5
-
6
- const MAX_RETRIES = parseInt(process.env.MCP_MAX_RETRIES ?? "3");
7
- const RETRY_DELAY = parseInt(process.env.MCP_RETRY_DELAY ?? "1000");
8
-
9
- // Expand ${VAR} placeholders from process.env at call time
10
- function expandEnv(val) {
11
- return val.replace(/\$\{([^}]+)\}/g, (_, name) => {
12
- if (!(name in process.env)) throw new Error(`Environment variable not set: ${name}`);
13
- return process.env[name];
14
- });
15
- }
16
-
17
- async function connect(spec) {
18
- const client = new Client({ name: "spec-cli", version: "1.0.0" });
19
-
20
- let transport;
21
- if (spec.transport === "stdio") {
22
- const rawEnv = spec.config?.env || {};
23
- const expandedEnv = Object.fromEntries(
24
- Object.entries(rawEnv).map(([k, v]) => [k, expandEnv(v)])
25
- );
26
- transport = new StdioClientTransport({
27
- command: spec.command,
28
- args: spec.args,
29
- env: Object.keys(expandedEnv).length > 0 ? { ...process.env, ...expandedEnv } : undefined,
30
- cwd: spec.cwd,
31
- });
32
- } else if (spec.transport === "sse") {
33
- const h = spec.config?.headers;
34
- transport = new SSEClientTransport(new URL(spec.url), {
35
- requestInit: h && Object.keys(h).length > 0 ? { headers: h } : undefined,
36
- });
37
- } else if (spec.transport === "streamable-http") {
38
- const h = spec.config?.headers;
39
- transport = new StreamableHTTPClientTransport(new URL(spec.url), {
40
- requestInit: h && Object.keys(h).length > 0 ? { headers: h } : undefined,
41
- });
42
- } else {
43
- throw new Error(`Unknown MCP transport: ${spec.transport}. Supported: stdio, sse, streamable-http`);
44
- }
45
-
46
- await client.connect(transport);
47
- return client;
48
- }
49
-
50
- export async function createMcpClient(spec) {
51
- let lastError;
52
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
53
- try {
54
- return await connect(spec);
55
- } catch (e) {
56
- lastError = e;
57
- if (attempt < MAX_RETRIES) {
58
- await new Promise((r) => setTimeout(r, Math.min(RETRY_DELAY * Math.pow(2, attempt), 5000)));
59
- }
60
- }
61
- }
62
- throw lastError;
63
- }
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
4
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5
+ import { SpecCliOAuthProvider } from "./oauth/provider.js";
6
+ import { ClientCredentialsProvider } from "@modelcontextprotocol/sdk/client/auth-extensions.js";
7
+ import { getClientSecret } from "./oauth/tokens.js";
8
+ import {
9
+ expandSecrets,
10
+ expandSecretsMap,
11
+ envHeaderOverrides,
12
+ envUrlOverride,
13
+ mergeHeaders,
14
+ } from "./secrets.js";
15
+
16
+ const MAX_RETRIES = parseInt(process.env.MCP_MAX_RETRIES ?? "3");
17
+ const RETRY_DELAY = parseInt(process.env.MCP_RETRY_DELAY ?? "1000");
18
+
19
+ function expandEnv(val) {
20
+ return expandSecrets(val);
21
+ }
22
+
23
+ async function connect(spec) {
24
+ const client = new Client({ name: "spec-cli", version: "1.0.0" });
25
+
26
+ let transport;
27
+ if (spec.type === "stdio") {
28
+ const rawEnv = spec.env || {};
29
+ const expandedEnv = Object.fromEntries(
30
+ Object.entries(rawEnv).map(([k, v]) => [k, expandEnv(v)])
31
+ );
32
+ transport = new StdioClientTransport({
33
+ command: spec.command,
34
+ args: spec.args,
35
+ env: Object.keys(expandedEnv).length > 0 ? { ...process.env, ...expandedEnv } : undefined,
36
+ cwd: spec.cwd,
37
+ });
38
+ } else if (spec.type === "sse") {
39
+ const h = expandSecretsMap(mergeHeaders(spec.headers, envHeaderOverrides()));
40
+ const url = envUrlOverride() ?? spec.url;
41
+ let authProvider;
42
+ const hasAuthSse = Object.keys(h).some((k) => k.toLowerCase() === "authorization");
43
+ if (spec.name && !hasAuthSse) {
44
+ const clientSecret = getClientSecret(spec.name);
45
+ authProvider =
46
+ spec.oauthFlow === "client_credentials" && spec.oauthClientId && clientSecret
47
+ ? new ClientCredentialsProvider({ clientId: spec.oauthClientId, clientSecret })
48
+ : new SpecCliOAuthProvider(spec.name, spec);
49
+ }
50
+ transport = new SSEClientTransport(new URL(url), {
51
+ authProvider,
52
+ requestInit: Object.keys(h).length > 0 ? { headers: h } : undefined,
53
+ });
54
+ } else if (spec.type === "http") {
55
+ const h = expandSecretsMap(mergeHeaders(spec.headers, envHeaderOverrides()));
56
+ const url = envUrlOverride() ?? spec.url;
57
+ let authProvider;
58
+ const hasAuthHttp = Object.keys(h).some((k) => k.toLowerCase() === "authorization");
59
+ if (spec.name && !hasAuthHttp) {
60
+ const clientSecret = getClientSecret(spec.name);
61
+ authProvider =
62
+ spec.oauthFlow === "client_credentials" && spec.oauthClientId && clientSecret
63
+ ? new ClientCredentialsProvider({ clientId: spec.oauthClientId, clientSecret })
64
+ : new SpecCliOAuthProvider(spec.name, spec);
65
+ }
66
+ transport = new StreamableHTTPClientTransport(new URL(url), {
67
+ authProvider,
68
+ requestInit: Object.keys(h).length > 0 ? { headers: h } : undefined,
69
+ });
70
+ } else {
71
+ throw new Error(`Unknown MCP type: ${spec.type}. Supported: stdio, sse, http`);
72
+ }
73
+
74
+ await client.connect(transport);
75
+ return client;
76
+ }
77
+
78
+ export async function createMcpClient(spec) {
79
+ let lastError;
80
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
81
+ try {
82
+ return await connect(spec);
83
+ } catch (e) {
84
+ lastError = e;
85
+ if (attempt < MAX_RETRIES) {
86
+ await new Promise((r) => setTimeout(r, Math.min(RETRY_DELAY * Math.pow(2, attempt), 5000)));
87
+ }
88
+ }
89
+ }
90
+ throw lastError;
91
+ }
@@ -0,0 +1,59 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
4
+ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
5
+ import { ClientCredentialsProvider } from "@modelcontextprotocol/sdk/client/auth-extensions.js";
6
+ import { SpecCliOAuthProvider } from "./provider.js";
7
+ import { getClientSecret } from "./tokens.js";
8
+
9
+ /**
10
+ * Run the full OAuth flow for a named MCP HTTP/SSE entry.
11
+ * Client secret is loaded from the token file (not the registry entry).
12
+ * Returns { flow: "client_credentials" | "browser" | "device" | "none_required" }.
13
+ * Throws on connection errors and unsupported flows.
14
+ */
15
+ export async function runOAuthFlow(name, entry) {
16
+ const TransportClass = entry.type === "sse" ? SSEClientTransport : StreamableHTTPClientTransport;
17
+ const clientSecret = getClientSecret(name);
18
+
19
+ // Only use client credentials grant when explicitly requested.
20
+ // Having a clientSecret does NOT imply client_credentials — for most OAuth apps
21
+ // (e.g. GitHub) the secret is used during the authorization code token exchange.
22
+ if (entry.oauthFlow === "client_credentials" && entry.oauthClientId && clientSecret) {
23
+ process.stderr.write(`Using client credentials flow for '${name}'...\n`);
24
+ const provider = new ClientCredentialsProvider({ clientId: entry.oauthClientId, clientSecret });
25
+ const transport = new TransportClass(new URL(entry.url), { authProvider: provider });
26
+ const client = new Client({ name: "spec-cli", version: "1.0.0" });
27
+ await client.connect(transport);
28
+ await client.close();
29
+ process.stderr.write(`Connected with client credentials.\n`);
30
+ return { flow: "client_credentials" };
31
+ }
32
+
33
+ const provider = new SpecCliOAuthProvider(name, entry);
34
+ await provider.prepareRedirect();
35
+ const transport = new TransportClass(new URL(entry.url), { authProvider: provider });
36
+ const client = new Client({ name: "spec-cli", version: "1.0.0" });
37
+
38
+ try {
39
+ await client.connect(transport);
40
+ await client.close();
41
+ return { flow: "none_required" };
42
+ } catch (e) {
43
+ if (!(e instanceof UnauthorizedError)) throw e;
44
+ if ((entry.oauthFlow || "browser") !== "browser") {
45
+ throw new Error(
46
+ `Device flow: open the URL above, complete authorization, then run:\n spec auth ${name}`
47
+ );
48
+ }
49
+ process.stderr.write(`Waiting for browser authorization...\n`);
50
+ const code = await provider.waitForAuthCode();
51
+ await transport.finishAuth(code);
52
+ // Transport was already started by the first connect() — must use a fresh one
53
+ const transport2 = new TransportClass(new URL(entry.url), { authProvider: provider });
54
+ const client2 = new Client({ name: "spec-cli", version: "1.0.0" });
55
+ await client2.connect(transport2);
56
+ await client2.close();
57
+ return { flow: "browser" };
58
+ }
59
+ }
@@ -0,0 +1,191 @@
1
+ import { createServer } from "http";
2
+ import { exec } from "child_process";
3
+ import { randomUUID } from "crypto";
4
+ import { loadTokenFile, saveTokenFile, getClientSecret } from "./tokens.js";
5
+
6
+ function openBrowser(url) {
7
+ const cmd =
8
+ process.platform === "win32"
9
+ ? `start "" "${url}"`
10
+ : process.platform === "darwin"
11
+ ? `open "${url}"`
12
+ : `xdg-open "${url}"`;
13
+ exec(cmd);
14
+ }
15
+
16
+ function getAvailablePort() {
17
+ return new Promise((resolve, reject) => {
18
+ const server = createServer();
19
+ server.listen(0, "127.0.0.1", () => {
20
+ const { port } = server.address();
21
+ server.close(() => resolve(port));
22
+ });
23
+ server.on("error", reject);
24
+ });
25
+ }
26
+
27
+ /**
28
+ * OAuthClientProvider for spec-cli.
29
+ * Persists tokens, client registration, and discovery state to
30
+ * ~/spec-cli-config/tokens/<name>.json.
31
+ *
32
+ * Flows:
33
+ * "browser" (default) — opens browser + local PKCE callback server
34
+ * "device" — prints device code URL to stderr
35
+ *
36
+ * For client_credentials, use SDK's ClientCredentialsProvider directly.
37
+ */
38
+ export class SpecCliOAuthProvider {
39
+ #name;
40
+ #flow;
41
+ #redirectPort;
42
+ #fixedPort;
43
+ #codeVerifier;
44
+ #pendingCode = null;
45
+ #callbackServer = null;
46
+ #oauthState = null;
47
+ #clientId;
48
+
49
+ constructor(name, entry = {}) {
50
+ this.#name = name;
51
+ this.#flow = entry.oauthFlow || "browser";
52
+ this.#clientId = entry.oauthClientId || undefined;
53
+ const envPort = process.env.SPEC_OAUTH_CALLBACK_PORT
54
+ ? parseInt(process.env.SPEC_OAUTH_CALLBACK_PORT, 10)
55
+ : undefined;
56
+ this.#fixedPort = entry.oauthCallbackPort ? parseInt(entry.oauthCallbackPort, 10) : envPort;
57
+ }
58
+
59
+ get redirectUrl() {
60
+ if (this.#flow === "device") return undefined;
61
+ return `http://127.0.0.1:${this.#redirectPort || 0}/callback`;
62
+ }
63
+
64
+ get clientMetadata() {
65
+ const clientSecret = loadTokenFile(this.#name).clientSecret;
66
+ return {
67
+ client_name: "spec-cli",
68
+ redirect_uris: this.#redirectPort ? [`http://127.0.0.1:${this.#redirectPort}/callback`] : [],
69
+ grant_types: ["authorization_code", "refresh_token"],
70
+ response_types: ["code"],
71
+ token_endpoint_auth_method: clientSecret ? "client_secret_post" : "none",
72
+ };
73
+ }
74
+
75
+ /** Called by the SDK to generate a CSRF state parameter for the authorization URL. */
76
+ state() {
77
+ if (!this.#oauthState) this.#oauthState = randomUUID();
78
+ return this.#oauthState;
79
+ }
80
+
81
+ tokens() {
82
+ return loadTokenFile(this.#name).tokens ?? undefined;
83
+ }
84
+
85
+ saveTokens(tokens) {
86
+ saveTokenFile(this.#name, { tokens });
87
+ }
88
+
89
+ clientInformation() {
90
+ const stored = loadTokenFile(this.#name).clientInfo;
91
+ if (stored) return stored;
92
+ if (this.#clientId) {
93
+ const clientSecret = getClientSecret(this.#name);
94
+ return clientSecret
95
+ ? { client_id: this.#clientId, client_secret: clientSecret }
96
+ : { client_id: this.#clientId };
97
+ }
98
+ return undefined;
99
+ }
100
+
101
+ saveClientInformation(info) {
102
+ saveTokenFile(this.#name, { clientInfo: info });
103
+ }
104
+
105
+ discoveryState() {
106
+ return loadTokenFile(this.#name).discovery ?? undefined;
107
+ }
108
+
109
+ saveDiscoveryState(state) {
110
+ saveTokenFile(this.#name, { discovery: state });
111
+ }
112
+
113
+ saveCodeVerifier(codeVerifier) {
114
+ this.#codeVerifier = codeVerifier;
115
+ }
116
+
117
+ codeVerifier() {
118
+ if (!this.#codeVerifier) throw new Error("No code verifier saved");
119
+ return this.#codeVerifier;
120
+ }
121
+
122
+ /** Reserve a local port for the OAuth callback. Call before connecting. */
123
+ async prepareRedirect() {
124
+ if (this.#flow === "device") return;
125
+ this.#redirectPort = this.#fixedPort ?? (await getAvailablePort());
126
+ }
127
+
128
+ redirectToAuthorization(authorizationUrl) {
129
+ if (this.#flow === "device") {
130
+ process.stderr.write(
131
+ `\nOpen this URL to authorize spec-cli:\n ${authorizationUrl.toString()}\n\n`
132
+ );
133
+ return;
134
+ }
135
+
136
+ let resolveCode, rejectCode;
137
+ this.#pendingCode = new Promise((resolve, reject) => {
138
+ resolveCode = resolve;
139
+ rejectCode = reject;
140
+ });
141
+
142
+ this.#callbackServer = createServer((req, res) => {
143
+ const url = new URL(req.url, `http://127.0.0.1:${this.#redirectPort}`);
144
+ const code = url.searchParams.get("code");
145
+ const state = url.searchParams.get("state");
146
+ res.writeHead(200, { "Content-Type": "text/html" });
147
+ res.end("<html><body><h2>Authorization complete. You can close this tab.</h2></body></html>");
148
+ this.#callbackServer.close();
149
+ if (this.#oauthState !== null && state !== this.#oauthState) {
150
+ rejectCode(new Error("OAuth state mismatch — possible CSRF attack"));
151
+ } else {
152
+ resolveCode(code);
153
+ }
154
+ });
155
+
156
+ this.#callbackServer.listen(this.#redirectPort, "127.0.0.1", () => {
157
+ process.stderr.write(`\nOpening browser for authorization...\n`);
158
+ openBrowser(authorizationUrl.toString());
159
+ process.stderr.write(
160
+ `Waiting for callback on http://127.0.0.1:${this.#redirectPort}/callback\n`
161
+ );
162
+ });
163
+ }
164
+
165
+ /** Resolves with the authorization code once the browser callback arrives. */
166
+ async waitForAuthCode() {
167
+ if (this.#flow === "device") throw new Error("Device flow does not use a local callback");
168
+ if (!this.#pendingCode) throw new Error("redirectToAuthorization() was not called");
169
+
170
+ let timeoutId;
171
+ const timeoutPromise = new Promise((_, reject) => {
172
+ timeoutId = setTimeout(
173
+ () => {
174
+ this.#callbackServer?.close();
175
+ reject(
176
+ new Error(
177
+ "Authorization timed out after 5 minutes. Run 'spec auth <name>' to try again."
178
+ )
179
+ );
180
+ },
181
+ 5 * 60 * 1000
182
+ );
183
+ });
184
+
185
+ try {
186
+ return await Promise.race([this.#pendingCode, timeoutPromise]);
187
+ } finally {
188
+ clearTimeout(timeoutId);
189
+ }
190
+ }
191
+ }
@@ -0,0 +1,59 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "fs";
4
+ import { expandSecrets } from "../secrets.js";
5
+
6
+ let TOKEN_DIR = join(homedir(), "spec-cli-config", "tokens");
7
+
8
+ export function setTokenDir(dir) {
9
+ TOKEN_DIR = dir;
10
+ }
11
+
12
+ function tokenPath(name) {
13
+ return join(TOKEN_DIR, `${name}.json`);
14
+ }
15
+
16
+ export function getClientSecret(name) {
17
+ const secret = loadTokenFile(name).clientSecret;
18
+ return secret ? expandSecrets(secret) : secret;
19
+ }
20
+
21
+ export function loadTokenFile(name) {
22
+ const file = tokenPath(name);
23
+ if (!existsSync(file)) return {};
24
+ try {
25
+ return JSON.parse(readFileSync(file, "utf-8"));
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
31
+ export function saveTokenFile(name, data) {
32
+ mkdirSync(TOKEN_DIR, { recursive: true });
33
+ const existing = loadTokenFile(name);
34
+ writeFileSync(tokenPath(name), JSON.stringify({ ...existing, ...data }, null, 2));
35
+ }
36
+
37
+ /**
38
+ * Clear session tokens for re-auth.
39
+ * Preserves clientSecret (a permanent credential) unless revokeAll is true.
40
+ * Pass { revokeAll: true } for `spec auth <name> --revoke` to wipe everything.
41
+ */
42
+ export function clearTokenFile(name, { revokeAll = false } = {}) {
43
+ const file = tokenPath(name);
44
+ if (!existsSync(file)) return;
45
+ if (revokeAll) {
46
+ rmSync(file);
47
+ return;
48
+ }
49
+ // Keep permanent credentials; wipe session tokens, discovery, and clientInfo
50
+ const existing = loadTokenFile(name);
51
+ if (existing.clientSecret) {
52
+ writeFileSync(
53
+ tokenPath(name),
54
+ JSON.stringify({ clientSecret: existing.clientSecret }, null, 2)
55
+ );
56
+ } else {
57
+ rmSync(file);
58
+ }
59
+ }
package/src/output.js CHANGED
@@ -1,61 +1,65 @@
1
- import YAML from "yaml";
2
-
3
- let outputFormat = "json";
4
-
5
- export function setFormat(format) {
6
- if (format && ["json", "text", "yaml"].includes(format)) {
7
- outputFormat = format;
8
- }
9
- }
10
-
11
- export function out(data) {
12
- switch (outputFormat) {
13
- case "yaml":
14
- console.log(YAML.stringify(data).trimEnd());
15
- break;
16
- case "text":
17
- console.log(formatText(data));
18
- break;
19
- case "json":
20
- default:
21
- console.log(JSON.stringify(data, null, 2));
22
- break;
23
- }
24
- }
25
-
26
- export function err(message) {
27
- // Errors are always JSON for reliable agent parsing
28
- console.error(JSON.stringify({ error: message }));
29
- }
30
-
31
- function formatText(data, indent = 0) {
32
- if (data === null || data === undefined) return "null";
33
- if (typeof data === "string") return data;
34
- if (typeof data === "number" || typeof data === "boolean") return String(data);
35
-
36
- if (Array.isArray(data)) {
37
- if (data.length === 0) return "(empty)";
38
- return data
39
- .map((item, i) => {
40
- if (typeof item === "object" && item !== null) {
41
- return `${" ".repeat(indent)}[${i}]\n${formatText(item, indent + 1)}`;
42
- }
43
- return `${" ".repeat(indent)}- ${item}`;
44
- })
45
- .join("\n");
46
- }
47
-
48
- if (typeof data === "object") {
49
- return Object.entries(data)
50
- .map(([key, val]) => {
51
- if (val === null || val === undefined) return `${" ".repeat(indent)}${key}: null`;
52
- if (typeof val === "object") {
53
- return `${" ".repeat(indent)}${key}:\n${formatText(val, indent + 1)}`;
54
- }
55
- return `${" ".repeat(indent)}${key}: ${val}`;
56
- })
57
- .join("\n");
58
- }
59
-
60
- return String(data);
61
- }
1
+ import YAML from "yaml";
2
+ import { encode } from "@toon-format/toon";
3
+
4
+ let outputFormat = "json";
5
+
6
+ export function setFormat(format) {
7
+ if (format && ["json", "text", "yaml", "toon"].includes(format)) {
8
+ outputFormat = format;
9
+ }
10
+ }
11
+
12
+ export function out(data) {
13
+ switch (outputFormat) {
14
+ case "yaml":
15
+ console.log(YAML.stringify(data).trimEnd());
16
+ break;
17
+ case "toon":
18
+ console.log(encode(data).trimEnd());
19
+ break;
20
+ case "text":
21
+ console.log(formatText(data));
22
+ break;
23
+ case "json":
24
+ default:
25
+ console.log(JSON.stringify(data, null, 2));
26
+ break;
27
+ }
28
+ }
29
+
30
+ export function err(message) {
31
+ // Errors are always JSON for reliable agent parsing
32
+ console.error(JSON.stringify({ error: message }));
33
+ }
34
+
35
+ function formatText(data, indent = 0) {
36
+ if (data === null || data === undefined) return "null";
37
+ if (typeof data === "string") return data;
38
+ if (typeof data === "number" || typeof data === "boolean") return String(data);
39
+
40
+ if (Array.isArray(data)) {
41
+ if (data.length === 0) return "(empty)";
42
+ return data
43
+ .map((item, i) => {
44
+ if (typeof item === "object" && item !== null) {
45
+ return `${" ".repeat(indent)}[${i}]\n${formatText(item, indent + 1)}`;
46
+ }
47
+ return `${" ".repeat(indent)}- ${item}`;
48
+ })
49
+ .join("\n");
50
+ }
51
+
52
+ if (typeof data === "object") {
53
+ return Object.entries(data)
54
+ .map(([key, val]) => {
55
+ if (val === null || val === undefined) return `${" ".repeat(indent)}${key}: null`;
56
+ if (typeof val === "object") {
57
+ return `${" ".repeat(indent)}${key}:\n${formatText(val, indent + 1)}`;
58
+ }
59
+ return `${" ".repeat(indent)}${key}: ${val}`;
60
+ })
61
+ .join("\n");
62
+ }
63
+
64
+ return String(data);
65
+ }
package/src/registry.js CHANGED
@@ -10,26 +10,52 @@ function ensureDir(dir) {
10
10
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
11
11
  }
12
12
 
13
+ const EMPTY = { mcp: {}, openapi: {}, graphql: {} };
14
+
13
15
  export function getRegistry() {
14
- if (!existsSync(REGISTRY_FILE)) return [];
16
+ if (!existsSync(REGISTRY_FILE)) return { ...EMPTY };
15
17
  try {
16
- return JSON.parse(readFileSync(REGISTRY_FILE, "utf-8"));
17
- } catch {
18
+ const data = JSON.parse(readFileSync(REGISTRY_FILE, "utf-8"));
19
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
20
+ throw new Error(`Registry file has old format: ${REGISTRY_FILE}. Delete it to reset.`);
21
+ }
22
+ return data;
23
+ } catch (e) {
24
+ if (e.message.includes("old format")) throw e;
18
25
  throw new Error(`Registry file is corrupt: ${REGISTRY_FILE}. Delete it to reset.`);
19
26
  }
20
27
  }
21
28
 
22
- export function saveRegistry(entries) {
29
+ export function saveRegistry(registry) {
23
30
  ensureDir(REGISTRY_DIR);
24
- writeFileSync(REGISTRY_FILE, JSON.stringify(entries, null, 2));
31
+ writeFileSync(REGISTRY_FILE, JSON.stringify(registry, null, 2));
32
+ }
33
+
34
+ /**
35
+ * Find an entry by name across all sections.
36
+ * Returns the entry with `name` and `_section` injected.
37
+ */
38
+ export function allEntries(registry) {
39
+ const entries = [];
40
+ for (const section of ["mcp", "openapi", "graphql"]) {
41
+ for (const [name, entry] of Object.entries(registry[section] || {})) {
42
+ entries.push({ ...entry, name, _section: section });
43
+ }
44
+ }
45
+ return entries;
25
46
  }
26
47
 
27
48
  export function getEntry(name) {
28
49
  const registry = getRegistry();
29
- const entry = registry.find((e) => e.name === name);
30
- if (!entry) throw new Error(`No spec named '${name}'. Run 'spec specs' to see available.`);
31
- if (!entry.enabled) throw new Error(`Spec '${name}' is disabled. Run 'spec enable ${name}' first.`);
32
- return entry;
50
+ for (const section of ["mcp", "openapi", "graphql"]) {
51
+ const entry = registry[section]?.[name];
52
+ if (entry) {
53
+ if (!entry.enabled)
54
+ throw new Error(`Spec '${name}' is disabled. Run 'spec enable ${name}' first.`);
55
+ return { ...entry, name, _section: section };
56
+ }
57
+ }
58
+ throw new Error(`No spec named '${name}'. Run 'spec specs' to see available.`);
33
59
  }
34
60
 
35
61
  export function getCachedSpec(name) {
@@ -38,7 +64,7 @@ export function getCachedSpec(name) {
38
64
  try {
39
65
  return JSON.parse(readFileSync(file, "utf-8"));
40
66
  } catch {
41
- return null; // Corrupt cache is treated as a miss — will re-fetch
67
+ return null;
42
68
  }
43
69
  }
44
70