api-spec-cli 0.2.0 → 0.2.1
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/package.json +1 -1
- package/src/args.js +1 -1
- package/src/cli.js +14 -0
- package/src/commands/add.js +9 -0
- package/src/commands/call.js +14 -0
- package/src/commands/grep.js +63 -0
- package/src/commands/load.js +28 -5
- package/src/mcp-client.js +33 -2
package/package.json
CHANGED
package/src/args.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Supports: --flag value, --flag=value, and positional args
|
|
3
3
|
// Repeatable flags (--query, --header, --var) are collected into arrays.
|
|
4
4
|
|
|
5
|
-
const REPEATABLE = new Set(["query", "header", "var", "env"]);
|
|
5
|
+
const REPEATABLE = new Set(["query", "header", "var", "env", "allow-tool", "disable-tool"]);
|
|
6
6
|
|
|
7
7
|
export function parseArgs(args) {
|
|
8
8
|
const flags = {};
|
package/src/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ import { validateSpec } from "./commands/validate.js";
|
|
|
6
6
|
import { typesCmd } from "./commands/types.js";
|
|
7
7
|
import { addCmd } from "./commands/add.js";
|
|
8
8
|
import { specsCmd, registryMutate } from "./commands/specs.js";
|
|
9
|
+
import { grepCmd } from "./commands/grep.js";
|
|
9
10
|
import { out, err, setFormat } from "./output.js";
|
|
10
11
|
|
|
11
12
|
const HELP = `spec-cli — Explore and call APIs from the command line.
|
|
@@ -29,6 +30,9 @@ REGISTRY (register once, use anywhere):
|
|
|
29
30
|
spec add <name> --mcp-stdio "<cmd>" Register an MCP server (stdio)
|
|
30
31
|
Options: --description <text> --base-url <url> --auth <token>
|
|
31
32
|
--header k=v (repeatable) --env KEY=VAL (repeatable, stdio only)
|
|
33
|
+
--cwd <path> (stdio only)
|
|
34
|
+
--allow-tool <glob> (repeatable, MCP only)
|
|
35
|
+
--disable-tool <glob> (repeatable, MCP only)
|
|
32
36
|
|
|
33
37
|
spec specs List all registered specs
|
|
34
38
|
spec specs --compact false Show full entry config
|
|
@@ -43,6 +47,8 @@ DISCOVER:
|
|
|
43
47
|
spec list --spec <name> --tag pets OpenAPI tag or GraphQL kind
|
|
44
48
|
spec list --spec <name> --limit 10 Paginate
|
|
45
49
|
spec list --mcp-http <url> Inline: no registration needed
|
|
50
|
+
spec grep <pattern> Search across all registered specs
|
|
51
|
+
spec grep <pattern> --spec <name> Search within one spec
|
|
46
52
|
|
|
47
53
|
INSPECT:
|
|
48
54
|
spec show --spec <name> <op> Operation details (params, body, responses)
|
|
@@ -55,6 +61,7 @@ CALL:
|
|
|
55
61
|
spec call --spec <name> <op> --query status=available Query params
|
|
56
62
|
spec call --spec <name> <op> --data '{"name":"Rex"}' JSON body / MCP args
|
|
57
63
|
spec call --spec <name> <op> --data-file args.json Body from file
|
|
64
|
+
echo '{"query":"foo"}' | spec call --spec <name> <op> Pipe JSON from stdin
|
|
58
65
|
spec call --spec <name> <op> --header X-Custom=val Extra headers
|
|
59
66
|
spec call --spec <name> <op> --method PUT Override HTTP method
|
|
60
67
|
|
|
@@ -74,6 +81,10 @@ OTHER:
|
|
|
74
81
|
spec validate <file-or-url> Check OpenAPI spec for errors
|
|
75
82
|
--format json|text|yaml Output format (default: json)
|
|
76
83
|
|
|
84
|
+
ENV VARS (MCP):
|
|
85
|
+
MCP_MAX_RETRIES=3 Retry attempts on connection failure (default: 3)
|
|
86
|
+
MCP_RETRY_DELAY=1000 Base retry delay in ms, doubles each attempt (default: 1000)
|
|
87
|
+
|
|
77
88
|
EXAMPLES:
|
|
78
89
|
spec add agno --mcp-http https://docs.agno.com/mcp --description "Agno docs"
|
|
79
90
|
spec add petstore --openapi https://petstore3.swagger.io/api/v3/openapi.json \\
|
|
@@ -148,6 +159,9 @@ export async function run(args) {
|
|
|
148
159
|
case "refresh":
|
|
149
160
|
await registryMutate("refresh", args.slice(1));
|
|
150
161
|
break;
|
|
162
|
+
case "grep":
|
|
163
|
+
await grepCmd(args.slice(1));
|
|
164
|
+
break;
|
|
151
165
|
default:
|
|
152
166
|
err(`Unknown command: ${cmd}. Run 'spec help' for usage.`);
|
|
153
167
|
}
|
package/src/commands/add.js
CHANGED
|
@@ -44,6 +44,7 @@ export async function addCmd(args) {
|
|
|
44
44
|
entry.transport = "stdio";
|
|
45
45
|
entry.command = parts[0];
|
|
46
46
|
entry.args = parts.slice(1);
|
|
47
|
+
if (flags.cwd) entry.cwd = flags.cwd;
|
|
47
48
|
entry.config = { env: parseKV(flags.env) };
|
|
48
49
|
} else if (flags["mcp-sse"]) {
|
|
49
50
|
entry.type = "mcp";
|
|
@@ -61,6 +62,14 @@ export async function addCmd(args) {
|
|
|
61
62
|
);
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
// Tool filtering (MCP only)
|
|
66
|
+
if (entry.type === "mcp") {
|
|
67
|
+
const allowed = flags["allow-tool"];
|
|
68
|
+
const disabled = flags["disable-tool"];
|
|
69
|
+
if (allowed?.length) entry.config.allowedTools = allowed;
|
|
70
|
+
if (disabled?.length) entry.config.disabledTools = disabled;
|
|
71
|
+
}
|
|
72
|
+
|
|
64
73
|
registry.push(entry);
|
|
65
74
|
saveRegistry(registry);
|
|
66
75
|
out({ ok: true, name, type: entry.type, transport: entry.transport });
|
package/src/commands/call.js
CHANGED
|
@@ -15,6 +15,20 @@ export async function callOperation(args) {
|
|
|
15
15
|
flags.data = readFileSync(flags["data-file"], "utf-8").trim();
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// Read from stdin when piped and no --data/--data-file provided.
|
|
19
|
+
// isTTY is true in a terminal, undefined when piped — so !isTTY catches piped input.
|
|
20
|
+
// Wrapped in try-catch so test runners with closed stdin don't crash.
|
|
21
|
+
if (!flags.data && !process.stdin.isTTY) {
|
|
22
|
+
try {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
25
|
+
const piped = Buffer.concat(chunks).toString("utf-8").trim();
|
|
26
|
+
if (piped) flags.data = piped;
|
|
27
|
+
} catch {
|
|
28
|
+
// stdin unavailable (test runner, closed pipe) — ignore
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
18
32
|
const { spec, entry } = await resolveActiveSpec(flags);
|
|
19
33
|
const config = resolveConfig(flags, entry);
|
|
20
34
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { out } from "../output.js";
|
|
2
|
+
import { parseArgs } from "../args.js";
|
|
3
|
+
import { getRegistry, getEntry, getCachedSpec, saveCachedSpec } from "../registry.js";
|
|
4
|
+
import { resolveSpec, matchGlob } from "./load.js";
|
|
5
|
+
|
|
6
|
+
export async function grepCmd(args) {
|
|
7
|
+
const { flags, positional } = parseArgs(args);
|
|
8
|
+
const pattern = positional[0];
|
|
9
|
+
if (!pattern) throw new Error(
|
|
10
|
+
"Usage: spec grep <pattern> [--spec <name>]\n" +
|
|
11
|
+
" Glob patterns: * matches anything, ? matches one char\n" +
|
|
12
|
+
" Plain text: substring match across name and description"
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const entries = flags.spec
|
|
16
|
+
? [getEntry(flags.spec)]
|
|
17
|
+
: getRegistry().filter((e) => e.enabled);
|
|
18
|
+
|
|
19
|
+
if (entries.length === 0) throw new Error("No registered specs. Run 'spec add' first.");
|
|
20
|
+
|
|
21
|
+
const results = [];
|
|
22
|
+
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
let spec = getCachedSpec(entry.name);
|
|
25
|
+
if (!spec) {
|
|
26
|
+
spec = await resolveSpec(entry);
|
|
27
|
+
saveCachedSpec(entry.name, spec);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const matches = [];
|
|
31
|
+
|
|
32
|
+
if (spec.type === "mcp") {
|
|
33
|
+
for (const tool of spec.tools) {
|
|
34
|
+
const nameMatch = matchGlob(pattern, tool.name);
|
|
35
|
+
const descMatch = tool.description && matchGlob(pattern, tool.description);
|
|
36
|
+
if (nameMatch || descMatch) {
|
|
37
|
+
matches.push({ id: tool.name, description: tool.description });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
} else if (spec.type === "openapi") {
|
|
41
|
+
for (const op of spec.operations) {
|
|
42
|
+
if (matchGlob(pattern, op.id) || matchGlob(pattern, op.path) ||
|
|
43
|
+
(op.summary && matchGlob(pattern, op.summary))) {
|
|
44
|
+
matches.push({ id: op.id, method: op.method, path: op.path });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} else if (spec.type === "graphql") {
|
|
48
|
+
for (const op of spec.operations) {
|
|
49
|
+
if (matchGlob(pattern, op.name) ||
|
|
50
|
+
(op.description && matchGlob(pattern, op.description))) {
|
|
51
|
+
matches.push({ id: op.name, kind: op.kind });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (matches.length > 0) {
|
|
57
|
+
results.push({ spec: entry.name, type: spec.type, matches });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const total = results.reduce((s, r) => s + r.matches.length, 0);
|
|
62
|
+
out({ pattern, total, results });
|
|
63
|
+
}
|
package/src/commands/load.js
CHANGED
|
@@ -88,6 +88,7 @@ export function inlineEntryFromFlags(flags) {
|
|
|
88
88
|
transport: "stdio",
|
|
89
89
|
command: parts[0],
|
|
90
90
|
args: parts.slice(1),
|
|
91
|
+
cwd: flags.cwd,
|
|
91
92
|
config: { env: parseKV(flags.env) },
|
|
92
93
|
};
|
|
93
94
|
}
|
|
@@ -118,10 +119,35 @@ export function inlineEntryFromFlags(flags) {
|
|
|
118
119
|
|
|
119
120
|
// --- Internal loaders ---
|
|
120
121
|
|
|
122
|
+
function matchGlob(pattern, str) {
|
|
123
|
+
// If no glob chars, use case-insensitive substring match
|
|
124
|
+
if (!pattern.includes("*") && !pattern.includes("?")) {
|
|
125
|
+
return str.toLowerCase().includes(pattern.toLowerCase());
|
|
126
|
+
}
|
|
127
|
+
const re = new RegExp(
|
|
128
|
+
"^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$",
|
|
129
|
+
"i"
|
|
130
|
+
);
|
|
131
|
+
return re.test(str);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export { matchGlob };
|
|
135
|
+
|
|
121
136
|
async function loadMCPFromEntry(entry) {
|
|
122
137
|
const client = await createMcpClient(entry);
|
|
123
138
|
try {
|
|
124
139
|
const { tools } = await client.listTools();
|
|
140
|
+
let mapped = tools.map((t) => ({
|
|
141
|
+
name: t.name,
|
|
142
|
+
description: t.description || null,
|
|
143
|
+
inputSchema: t.inputSchema || null,
|
|
144
|
+
}));
|
|
145
|
+
|
|
146
|
+
const allowed = entry.config?.allowedTools;
|
|
147
|
+
const disabled = entry.config?.disabledTools;
|
|
148
|
+
if (allowed?.length) mapped = mapped.filter((t) => allowed.some((p) => matchGlob(p, t.name)));
|
|
149
|
+
if (disabled?.length) mapped = mapped.filter((t) => !disabled.some((p) => matchGlob(p, t.name)));
|
|
150
|
+
|
|
125
151
|
return {
|
|
126
152
|
type: "mcp",
|
|
127
153
|
title: entry.name || "MCP Server",
|
|
@@ -129,12 +155,9 @@ async function loadMCPFromEntry(entry) {
|
|
|
129
155
|
url: entry.url,
|
|
130
156
|
command: entry.command,
|
|
131
157
|
args: entry.args,
|
|
158
|
+
cwd: entry.cwd,
|
|
132
159
|
config: entry.config,
|
|
133
|
-
tools:
|
|
134
|
-
name: t.name,
|
|
135
|
-
description: t.description || null,
|
|
136
|
-
inputSchema: t.inputSchema || null,
|
|
137
|
-
})),
|
|
160
|
+
tools: mapped,
|
|
138
161
|
};
|
|
139
162
|
} finally {
|
|
140
163
|
await client.close();
|
package/src/mcp-client.js
CHANGED
|
@@ -3,15 +3,31 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
|
3
3
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
4
4
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
5
5
|
|
|
6
|
-
|
|
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) {
|
|
7
18
|
const client = new Client({ name: "spec-cli", version: "1.0.0" });
|
|
8
19
|
|
|
9
20
|
let transport;
|
|
10
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
|
+
);
|
|
11
26
|
transport = new StdioClientTransport({
|
|
12
27
|
command: spec.command,
|
|
13
28
|
args: spec.args,
|
|
14
|
-
env:
|
|
29
|
+
env: Object.keys(expandedEnv).length > 0 ? { ...process.env, ...expandedEnv } : undefined,
|
|
30
|
+
cwd: spec.cwd,
|
|
15
31
|
});
|
|
16
32
|
} else if (spec.transport === "sse") {
|
|
17
33
|
const h = spec.config?.headers;
|
|
@@ -30,3 +46,18 @@ export async function createMcpClient(spec) {
|
|
|
30
46
|
await client.connect(transport);
|
|
31
47
|
return client;
|
|
32
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, RETRY_DELAY * Math.pow(2, attempt)));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
throw lastError;
|
|
63
|
+
}
|