api-spec-cli 0.1.2 → 0.2.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.
@@ -1,14 +1,13 @@
1
- import { getSpec } from "../store.js";
2
1
  import { out } from "../output.js";
3
2
  import { parseArgs } from "../args.js";
3
+ import { resolveActiveSpec } from "../resolve.js";
4
4
 
5
5
  export async function showOperation(args) {
6
- const { positional } = parseArgs(args);
6
+ const { flags, positional } = parseArgs(args);
7
7
  const target = positional[0];
8
- if (!target) throw new Error("Usage: spec show <operationId-or-path>");
8
+ if (!target) throw new Error("Usage: spec show <operationId-or-path> [--spec <name> | --openapi <url> | ...]");
9
9
 
10
- const spec = getSpec();
11
- if (!spec) throw new Error("No spec loaded. Run: spec load <file-or-url>");
10
+ const { spec } = await resolveActiveSpec(flags);
12
11
 
13
12
  if (spec.type === "openapi") {
14
13
  showOpenAPI(spec, target);
@@ -22,13 +21,11 @@ export async function showOperation(args) {
22
21
  function showOpenAPI(spec, target) {
23
22
  const lower = target.toLowerCase();
24
23
 
25
- const op = spec.operations.find((o) => {
26
- return (
27
- o.id.toLowerCase() === lower ||
28
- o.path.toLowerCase() === lower ||
29
- `${o.method.toLowerCase()} ${o.path.toLowerCase()}` === lower
30
- );
31
- });
24
+ const op = spec.operations.find((o) =>
25
+ o.id.toLowerCase() === lower ||
26
+ o.path.toLowerCase() === lower ||
27
+ `${o.method.toLowerCase()} ${o.path.toLowerCase()}` === lower
28
+ );
32
29
 
33
30
  if (!op) {
34
31
  throw new Error(`Operation not found: ${target}. Run 'spec list' to see available operations.`);
@@ -61,6 +58,18 @@ function showOpenAPI(spec, target) {
61
58
  });
62
59
  }
63
60
 
61
+ function showMCP(spec, target) {
62
+ const tool = spec.tools.find((t) => t.name.toLowerCase() === target.toLowerCase());
63
+ if (!tool) {
64
+ throw new Error(`Tool not found: ${target}. Run 'spec list' to see available tools.`);
65
+ }
66
+ out({
67
+ name: tool.name,
68
+ description: tool.description,
69
+ inputSchema: tool.inputSchema,
70
+ });
71
+ }
72
+
64
73
  function showGraphQL(spec, target) {
65
74
  const lower = target.toLowerCase();
66
75
 
@@ -89,18 +98,6 @@ function showGraphQL(spec, target) {
89
98
  });
90
99
  }
91
100
 
92
- function showMCP(spec, target) {
93
- const tool = spec.tools.find((t) => t.name.toLowerCase() === target.toLowerCase());
94
- if (!tool) {
95
- throw new Error(`Tool not found: ${target}. Run 'spec list' to see available tools.`);
96
- }
97
- out({
98
- name: tool.name,
99
- description: tool.description,
100
- inputSchema: tool.inputSchema,
101
- });
102
- }
103
-
104
101
  // --- Helpers ---
105
102
 
106
103
  function resolveRef(obj, root) {
@@ -108,9 +105,7 @@ function resolveRef(obj, root) {
108
105
  if (obj.$ref) {
109
106
  const path = obj.$ref.replace("#/", "").split("/");
110
107
  let resolved = root;
111
- for (const p of path) {
112
- resolved = resolved?.[p];
113
- }
108
+ for (const p of path) resolved = resolved?.[p];
114
109
  return resolved || obj;
115
110
  }
116
111
  return obj;
@@ -120,7 +115,6 @@ function resolveRequestBody(body, root) {
120
115
  if (!body) return null;
121
116
  const resolved = resolveRef(body, root);
122
117
  if (resolved?.content) {
123
- // Only show application/json if available (most useful for agents)
124
118
  const jsonContent = resolved.content["application/json"];
125
119
  if (jsonContent) {
126
120
  return {
@@ -129,7 +123,6 @@ function resolveRequestBody(body, root) {
129
123
  schema: resolveSchema(jsonContent.schema, root),
130
124
  };
131
125
  }
132
- // Fallback: show first content type
133
126
  const [mediaType, value] = Object.entries(resolved.content)[0];
134
127
  return {
135
128
  description: resolved.description || undefined,
@@ -141,7 +134,6 @@ function resolveRequestBody(body, root) {
141
134
  return resolved;
142
135
  }
143
136
 
144
- // Only show success response schema — agent doesn't need error schemas to make a call
145
137
  function resolveResponsesCompact(responses, root) {
146
138
  if (!responses) return null;
147
139
  const result = {};
@@ -149,14 +141,9 @@ function resolveResponsesCompact(responses, root) {
149
141
  const resolved = resolveRef(resp, root);
150
142
  if (resolved?.content) {
151
143
  const jsonContent = resolved.content["application/json"];
152
- if (jsonContent) {
153
- result[code] = {
154
- description: resolved.description,
155
- schema: resolveSchema(jsonContent.schema, root),
156
- };
157
- } else {
158
- result[code] = { description: resolved.description };
159
- }
144
+ result[code] = jsonContent
145
+ ? { description: resolved.description, schema: resolveSchema(jsonContent.schema, root) }
146
+ : { description: resolved.description };
160
147
  } else {
161
148
  result[code] = { description: resolved.description };
162
149
  }
@@ -166,18 +153,12 @@ function resolveResponsesCompact(responses, root) {
166
153
 
167
154
  function resolveSchema(schema, root, depth = 0) {
168
155
  if (!schema || depth > 3) return schema;
169
- if (schema.$ref) {
170
- const resolved = resolveRef(schema, root);
171
- // Resolve one more level for the top-level ref
172
- return resolveSchema(resolved, root, depth + 1);
173
- }
156
+ if (schema.$ref) return resolveSchema(resolveRef(schema, root), root, depth + 1);
174
157
  if (schema.properties) {
175
- const result = { type: schema.type, required: schema.required };
176
- result.properties = {};
158
+ const result = { type: schema.type, required: schema.required, properties: {} };
177
159
  for (const [key, val] of Object.entries(schema.properties)) {
178
160
  if (val.$ref) {
179
- const refName = val.$ref.split("/").pop();
180
- result.properties[key] = { $ref: refName };
161
+ result.properties[key] = { $ref: val.$ref.split("/").pop() };
181
162
  } else if (val.type === "array" && val.items?.$ref) {
182
163
  result.properties[key] = { type: "array", items: val.items.$ref.split("/").pop() };
183
164
  } else {
@@ -191,9 +172,7 @@ function resolveSchema(schema, root, depth = 0) {
191
172
  return result;
192
173
  }
193
174
  if (schema.items) {
194
- if (schema.items.$ref) {
195
- return { type: "array", items: schema.items.$ref.split("/").pop() };
196
- }
175
+ if (schema.items.$ref) return { type: "array", items: schema.items.$ref.split("/").pop() };
197
176
  return { type: "array", items: resolveSchema(schema.items, root, depth + 1) };
198
177
  }
199
178
  return schema;
@@ -209,30 +188,16 @@ function findRelatedTypes(op, types) {
209
188
  }
210
189
 
211
190
  extractTypeNames(op.returnType);
212
- for (const arg of op.args || []) {
213
- extractTypeNames(flattenType(arg.type));
214
- }
191
+ for (const arg of op.args || []) extractTypeNames(flattenType(arg.type));
215
192
 
216
193
  const scalars = new Set(["String", "Int", "Float", "Boolean", "ID"]);
217
194
  return types
218
195
  .filter((t) => names.has(t.name) && !scalars.has(t.name))
219
196
  .map((t) => {
220
197
  const result = { name: t.name, kind: t.kind };
221
- if (t.fields) {
222
- result.fields = t.fields.map((f) => ({
223
- name: f.name,
224
- type: flattenType(f.type),
225
- }));
226
- }
227
- if (t.inputFields) {
228
- result.inputFields = t.inputFields.map((f) => ({
229
- name: f.name,
230
- type: flattenType(f.type),
231
- }));
232
- }
233
- if (t.enumValues) {
234
- result.enumValues = t.enumValues.map((e) => e.name);
235
- }
198
+ if (t.fields) result.fields = t.fields.map((f) => ({ name: f.name, type: flattenType(f.type) }));
199
+ if (t.inputFields) result.inputFields = t.inputFields.map((f) => ({ name: f.name, type: flattenType(f.type) }));
200
+ if (t.enumValues) result.enumValues = t.enumValues.map((e) => e.name);
236
201
  return result;
237
202
  });
238
203
  }
@@ -0,0 +1,69 @@
1
+ import { parseArgs } from "../args.js";
2
+ import { getRegistry, saveRegistry, getEntry, removeCachedSpec, saveCachedSpec } from "../registry.js";
3
+ import { resolveSpec } from "./load.js";
4
+ import { out } from "../output.js";
5
+
6
+ export async function specsCmd(args) {
7
+ const { flags } = parseArgs(args);
8
+ const compact = flags.compact !== "false";
9
+ const registry = getRegistry();
10
+
11
+ const specs = registry.map((e) => {
12
+ if (compact) {
13
+ return {
14
+ name: e.name,
15
+ type: e.type,
16
+ transport: e.transport,
17
+ description: e.description || null,
18
+ enabled: e.enabled,
19
+ };
20
+ }
21
+ return e;
22
+ });
23
+
24
+ out({ specs });
25
+ }
26
+
27
+ export async function registryMutate(action, args) {
28
+ const { positional } = parseArgs(args);
29
+ const name = positional[0];
30
+ if (!name) throw new Error(`Usage: spec ${action} <name>`);
31
+
32
+ const registry = getRegistry();
33
+ const idx = registry.findIndex((e) => e.name === name);
34
+ if (idx === -1) throw new Error(`No spec named '${name}'. Run 'spec specs' to see available.`);
35
+
36
+ if (action === "remove") {
37
+ registry.splice(idx, 1);
38
+ saveRegistry(registry);
39
+ removeCachedSpec(name);
40
+ out({ ok: true, removed: name });
41
+ return;
42
+ }
43
+
44
+ if (action === "enable") {
45
+ registry[idx].enabled = true;
46
+ saveRegistry(registry);
47
+ out({ ok: true, enabled: name });
48
+ return;
49
+ }
50
+
51
+ if (action === "disable") {
52
+ registry[idx].enabled = false;
53
+ saveRegistry(registry);
54
+ out({ ok: true, disabled: name });
55
+ return;
56
+ }
57
+
58
+ if (action === "refresh") {
59
+ const entry = registry[idx];
60
+ if (!entry.enabled) throw new Error(`Spec '${name}' is disabled. Enable it first.`);
61
+ const spec = await resolveSpec(entry);
62
+ saveCachedSpec(name, spec);
63
+ const count = spec.tools?.length ?? spec.operations?.length ?? 0;
64
+ out({ ok: true, refreshed: name, type: spec.type, count });
65
+ return;
66
+ }
67
+
68
+ throw new Error(`Unknown registry action: ${action}`);
69
+ }
@@ -1,12 +1,10 @@
1
- import { getSpec } from "../store.js";
2
1
  import { out } from "../output.js";
3
2
  import { parseArgs } from "../args.js";
3
+ import { resolveActiveSpec } from "../resolve.js";
4
4
 
5
5
  export async function typesCmd(args) {
6
- const spec = getSpec();
7
- if (!spec) throw new Error("No spec loaded. Run: spec load <file-or-url>");
8
-
9
6
  const { positional, flags } = parseArgs(args);
7
+ const { spec } = await resolveActiveSpec(flags);
10
8
  const target = positional[0];
11
9
 
12
10
  if (spec.type === "openapi") {
package/src/mcp-client.js CHANGED
@@ -11,11 +11,18 @@ export async function createMcpClient(spec) {
11
11
  transport = new StdioClientTransport({
12
12
  command: spec.command,
13
13
  args: spec.args,
14
+ env: spec.config?.env ? { ...process.env, ...spec.config.env } : undefined,
14
15
  });
15
16
  } else if (spec.transport === "sse") {
16
- transport = new SSEClientTransport(new URL(spec.url));
17
+ const h = spec.config?.headers;
18
+ transport = new SSEClientTransport(new URL(spec.url), {
19
+ requestInit: h && Object.keys(h).length > 0 ? { headers: h } : undefined,
20
+ });
17
21
  } else if (spec.transport === "streamable-http") {
18
- transport = new StreamableHTTPClientTransport(new URL(spec.url));
22
+ const h = spec.config?.headers;
23
+ transport = new StreamableHTTPClientTransport(new URL(spec.url), {
24
+ requestInit: h && Object.keys(h).length > 0 ? { headers: h } : undefined,
25
+ });
19
26
  } else {
20
27
  throw new Error(`Unknown MCP transport: ${spec.transport}. Supported: stdio, sse, streamable-http`);
21
28
  }
@@ -0,0 +1,53 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "fs";
4
+
5
+ const REGISTRY_DIR = join(homedir(), "spec-cli-config");
6
+ const REGISTRY_FILE = join(REGISTRY_DIR, "registry.json");
7
+ const CACHE_DIR = join(REGISTRY_DIR, "cache");
8
+
9
+ function ensureDir(dir) {
10
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
11
+ }
12
+
13
+ export function getRegistry() {
14
+ if (!existsSync(REGISTRY_FILE)) return [];
15
+ try {
16
+ return JSON.parse(readFileSync(REGISTRY_FILE, "utf-8"));
17
+ } catch {
18
+ throw new Error(`Registry file is corrupt: ${REGISTRY_FILE}. Delete it to reset.`);
19
+ }
20
+ }
21
+
22
+ export function saveRegistry(entries) {
23
+ ensureDir(REGISTRY_DIR);
24
+ writeFileSync(REGISTRY_FILE, JSON.stringify(entries, null, 2));
25
+ }
26
+
27
+ export function getEntry(name) {
28
+ 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;
33
+ }
34
+
35
+ export function getCachedSpec(name) {
36
+ const file = join(CACHE_DIR, `${name}.json`);
37
+ if (!existsSync(file)) return null;
38
+ try {
39
+ return JSON.parse(readFileSync(file, "utf-8"));
40
+ } catch {
41
+ return null; // Corrupt cache is treated as a miss — will re-fetch
42
+ }
43
+ }
44
+
45
+ export function saveCachedSpec(name, spec) {
46
+ ensureDir(CACHE_DIR);
47
+ writeFileSync(join(CACHE_DIR, `${name}.json`), JSON.stringify(spec, null, 2));
48
+ }
49
+
50
+ export function removeCachedSpec(name) {
51
+ const file = join(CACHE_DIR, `${name}.json`);
52
+ if (existsSync(file)) rmSync(file);
53
+ }
package/src/resolve.js ADDED
@@ -0,0 +1,65 @@
1
+ import { getEntry, getCachedSpec, saveCachedSpec } from "./registry.js";
2
+ import { resolveSpec, inlineEntryFromFlags } from "./commands/load.js";
3
+ import { getConfig } from "./store.js";
4
+ import { parseKV } from "./args.js";
5
+
6
+ /**
7
+ * Resolve the active spec from flags.
8
+ * Priority:
9
+ * 1. --spec <name> → registry (auto-caches on first use)
10
+ * 2. Inline flags → ad-hoc, no caching
11
+ * 3. Error → no spec source given
12
+ */
13
+ export async function resolveActiveSpec(flags) {
14
+ if (flags.spec) {
15
+ const entry = getEntry(flags.spec); // throws if missing or disabled
16
+ let spec = getCachedSpec(flags.spec);
17
+ if (!spec) {
18
+ spec = await resolveSpec(entry);
19
+ saveCachedSpec(flags.spec, spec);
20
+ }
21
+ return { spec, entry };
22
+ }
23
+
24
+ const inlineEntry = inlineEntryFromFlags(flags);
25
+ if (inlineEntry) {
26
+ const spec = await resolveSpec(inlineEntry);
27
+ return { spec, entry: inlineEntry };
28
+ }
29
+
30
+ throw new Error(
31
+ "No spec source. Pass --spec <name> (registered) or an inline flag:\n" +
32
+ " --openapi <url-or-file>\n" +
33
+ " --graphql <url>\n" +
34
+ " --mcp-http <url>\n" +
35
+ " --mcp-sse <url>\n" +
36
+ ' --mcp-stdio "<cmd args>"'
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Build the effective config for a command.
42
+ * Precedence (highest → lowest):
43
+ * 1. Call-time flags: --auth, --base-url, --header k=v
44
+ * 2. Registry entry config
45
+ * 3. .spec-cli/config.json
46
+ */
47
+ export function resolveConfig(flags, entry) {
48
+ const global = getConfig();
49
+ const entryConfig = entry?.config || {};
50
+ const callHeaders = parseKV(flags.header);
51
+
52
+ const auth = flags.auth || entryConfig.auth || global.auth;
53
+ const baseUrl = flags["base-url"] || entryConfig.baseUrl || global.baseUrl;
54
+ const headers = { ...global.headers, ...(entryConfig.headers || {}), ...callHeaders };
55
+
56
+ // Apply auth as Authorization header if not already there (case-insensitive check)
57
+ const hasAuthHeader = Object.keys(headers).some((k) => k.toLowerCase() === "authorization");
58
+ if (auth && !hasAuthHeader) {
59
+ headers["Authorization"] = auth.startsWith("Bearer ") || auth.startsWith("Basic ")
60
+ ? auth
61
+ : `Bearer ${auth}`;
62
+ }
63
+
64
+ return { auth, baseUrl, headers };
65
+ }