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.
- package/README.md +129 -73
- package/package.json +1 -1
- package/src/args.js +1 -1
- package/src/cli.js +97 -55
- package/src/commands/add.js +67 -0
- package/src/commands/call.js +22 -71
- package/src/commands/list.js +20 -30
- package/src/commands/load.js +57 -70
- package/src/commands/show.js +33 -68
- package/src/commands/specs.js +69 -0
- package/src/commands/types.js +2 -4
- package/src/mcp-client.js +9 -2
- package/src/registry.js +53 -0
- package/src/resolve.js +65 -0
package/src/commands/show.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
+
}
|
package/src/commands/types.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/registry.js
ADDED
|
@@ -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
|
+
}
|