api-spec-cli 0.2.1 → 0.2.3
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 +63 -4
- package/package.json +1 -1
- package/src/cli.js +3 -3
- package/src/commands/add.js +11 -9
- package/src/commands/call.js +28 -22
- package/src/commands/{load.js → fetch.js} +34 -26
- package/src/commands/grep.js +3 -2
- package/src/commands/list.js +2 -2
- package/src/commands/show.js +2 -2
- package/src/commands/specs.js +2 -2
- package/src/commands/types.js +2 -2
- package/src/glob.js +29 -0
- package/src/mcp-client.js +1 -1
- package/src/resolve.js +4 -4
package/README.md
CHANGED
|
@@ -56,6 +56,19 @@ Inline fetches every call, nothing cached.
|
|
|
56
56
|
|
|
57
57
|
## Discovery
|
|
58
58
|
|
|
59
|
+
### Search across specs
|
|
60
|
+
|
|
61
|
+
`grep` searches operation/tool names and descriptions across all registered specs.
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
spec grep search # Substring match across all specs
|
|
65
|
+
spec grep "get*" # Glob: anything starting with "get"
|
|
66
|
+
spec grep "*list*" # Glob: anything containing "list"
|
|
67
|
+
spec grep search --spec agno # Limit to one spec
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Matches on name and description. Case-insensitive. Plain text = substring, `*`/`?` = glob.
|
|
71
|
+
|
|
59
72
|
### List all specs in the registry
|
|
60
73
|
|
|
61
74
|
```bash
|
|
@@ -131,6 +144,10 @@ spec call --spec hashnode publication --var host=blog.hashnode.dev
|
|
|
131
144
|
spec call --spec agno search_agno --var query="how to create an agent"
|
|
132
145
|
spec call --spec agno search_agno --data '{"query":"agents"}'
|
|
133
146
|
|
|
147
|
+
# Read body from stdin (explicit --data -)
|
|
148
|
+
echo '{"query":"agents"}' | spec call --spec agno search_agno --data -
|
|
149
|
+
cat body.json | spec call --spec petstore addPet --data -
|
|
150
|
+
|
|
134
151
|
# Inline (no registration)
|
|
135
152
|
spec call --openapi https://petstore3.swagger.io/api/v3/openapi.json \
|
|
136
153
|
getPetById --var petId=1 --base-url https://petstore3.swagger.io/api/v3
|
|
@@ -164,13 +181,38 @@ spec refresh <name> # Force re-fetch and update cache
|
|
|
164
181
|
```bash
|
|
165
182
|
spec add <name> --openapi <url-or-file> [--base-url <url>] [--auth <token>] [--header k=v]
|
|
166
183
|
spec add <name> --graphql <url> [--auth <token>] [--header k=v]
|
|
167
|
-
spec add <name> --mcp-http <url> [--header k=v]
|
|
168
|
-
spec add <name> --mcp-sse <url> [--header k=v]
|
|
169
|
-
spec add <name> --mcp-stdio "<cmd args>" [--env KEY=VAL]
|
|
184
|
+
spec add <name> --mcp-http <url> [--auth <token>] [--header k=v]
|
|
185
|
+
spec add <name> --mcp-sse <url> [--auth <token>] [--header k=v]
|
|
186
|
+
spec add <name> --mcp-stdio "<cmd args>" [--env KEY=VAL] [--cwd <path>]
|
|
170
187
|
[--description <text>] (all types)
|
|
171
188
|
```
|
|
172
189
|
|
|
173
|
-
|
|
190
|
+
All options are repeatable where it makes sense (`--header`, `--env`). `--auth` adds `Authorization: Bearer <token>` unless the header is already set.
|
|
191
|
+
|
|
192
|
+
Operation filtering works for all spec types — MCP, OpenAPI, and GraphQL:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
# MCP: allow only read/list tools
|
|
196
|
+
spec add <name> --mcp-http <url> \
|
|
197
|
+
--allow-tool "read_*" --allow-tool "list_*" \
|
|
198
|
+
--disable-tool "delete_*"
|
|
199
|
+
|
|
200
|
+
# OpenAPI: allow only GET operations (by operationId)
|
|
201
|
+
spec add <name> --openapi <url> \
|
|
202
|
+
--allow-tool "get*" --allow-tool "find*"
|
|
203
|
+
|
|
204
|
+
# GraphQL: allow specific operations by exact name
|
|
205
|
+
spec add <name> --graphql <url> \
|
|
206
|
+
--allow-tool "me" --allow-tool "publication"
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
`--allow-tool` keeps only matching operations. `--disable-tool` removes matching operations (applied after allow). Both are repeatable.
|
|
210
|
+
|
|
211
|
+
**Matching rules:**
|
|
212
|
+
- Plain text → **exact match** (case-insensitive): `"me"` matches only `me`
|
|
213
|
+
- Glob patterns → anchored match: `"get*"` matches `getPetById`, `"*post*"` matches `createPost`
|
|
214
|
+
|
|
215
|
+
Use `grep` for search (substring) — `--allow-tool` / `--disable-tool` for precise whitelists (exact or glob).
|
|
174
216
|
|
|
175
217
|
---
|
|
176
218
|
|
|
@@ -214,6 +256,23 @@ spec list --spec petstore --format=json # equals syntax also works
|
|
|
214
256
|
- `--limit` / `--offset` paginate large APIs
|
|
215
257
|
- `--filter` and `--tag` narrow results before output
|
|
216
258
|
|
|
259
|
+
## MCP Options
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
# Retry on connection failure (useful for stdio servers that take time to start)
|
|
263
|
+
MCP_MAX_RETRIES=3 # Attempts (default: 3)
|
|
264
|
+
MCP_RETRY_DELAY=1000 # Base delay in ms, doubles each attempt, capped at 5s (default: 1000)
|
|
265
|
+
|
|
266
|
+
# HTTP timeout for OpenAPI/GraphQL calls
|
|
267
|
+
SPEC_HTTP_TIMEOUT=30000 # ms (default: 30000)
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Stdio env vars support `${VAR}` expansion from the host environment:
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
spec add fs --mcp-stdio "npx -y server /tmp" --env "TOKEN=${MY_SECRET}"
|
|
274
|
+
```
|
|
275
|
+
|
|
217
276
|
## Storage
|
|
218
277
|
|
|
219
278
|
| Path | Purpose |
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -31,8 +31,8 @@ REGISTRY (register once, use anywhere):
|
|
|
31
31
|
Options: --description <text> --base-url <url> --auth <token>
|
|
32
32
|
--header k=v (repeatable) --env KEY=VAL (repeatable, stdio only)
|
|
33
33
|
--cwd <path> (stdio only)
|
|
34
|
-
--allow-tool <glob> (repeatable
|
|
35
|
-
--disable-tool <glob> (repeatable
|
|
34
|
+
--allow-tool <glob> (repeatable)
|
|
35
|
+
--disable-tool <glob> (repeatable)
|
|
36
36
|
|
|
37
37
|
spec specs List all registered specs
|
|
38
38
|
spec specs --compact false Show full entry config
|
|
@@ -61,7 +61,7 @@ CALL:
|
|
|
61
61
|
spec call --spec <name> <op> --query status=available Query params
|
|
62
62
|
spec call --spec <name> <op> --data '{"name":"Rex"}' JSON body / MCP args
|
|
63
63
|
spec call --spec <name> <op> --data-file args.json Body from file
|
|
64
|
-
|
|
64
|
+
spec call --spec <name> <op> --data - Read JSON body from stdin (pipe)
|
|
65
65
|
spec call --spec <name> <op> --header X-Custom=val Extra headers
|
|
66
66
|
spec call --spec <name> <op> --method PUT Override HTTP method
|
|
67
67
|
|
package/src/commands/add.js
CHANGED
|
@@ -50,25 +50,27 @@ export async function addCmd(args) {
|
|
|
50
50
|
entry.type = "mcp";
|
|
51
51
|
entry.transport = "sse";
|
|
52
52
|
entry.url = flags["mcp-sse"];
|
|
53
|
-
|
|
53
|
+
const headers = parseKV(flags.header);
|
|
54
|
+
if (flags.auth && !headers["Authorization"]) headers["Authorization"] = `Bearer ${flags.auth}`;
|
|
55
|
+
entry.config = { headers };
|
|
54
56
|
} else if (flags["mcp-http"]) {
|
|
55
57
|
entry.type = "mcp";
|
|
56
58
|
entry.transport = "streamable-http";
|
|
57
59
|
entry.url = flags["mcp-http"];
|
|
58
|
-
|
|
60
|
+
const headers = parseKV(flags.header);
|
|
61
|
+
if (flags.auth && !headers["Authorization"]) headers["Authorization"] = `Bearer ${flags.auth}`;
|
|
62
|
+
entry.config = { headers };
|
|
59
63
|
} else {
|
|
60
64
|
throw new Error(
|
|
61
65
|
"Specify a source: --openapi <url>, --graphql <url>, --mcp-http <url>, --mcp-sse <url>, or --mcp-stdio \"<cmd>\""
|
|
62
66
|
);
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (disabled?.length) entry.config.disabledTools = disabled;
|
|
71
|
-
}
|
|
69
|
+
// Operation filtering (all types)
|
|
70
|
+
const allowed = flags["allow-tool"];
|
|
71
|
+
const disabled = flags["disable-tool"];
|
|
72
|
+
if (allowed?.length) entry.config.allowedTools = allowed;
|
|
73
|
+
if (disabled?.length) entry.config.disabledTools = disabled;
|
|
72
74
|
|
|
73
75
|
registry.push(entry);
|
|
74
76
|
saveRegistry(registry);
|
package/src/commands/call.js
CHANGED
|
@@ -2,34 +2,30 @@ import { readFileSync } from "fs";
|
|
|
2
2
|
import { out } from "../output.js";
|
|
3
3
|
import { parseArgs, parseKV } from "../args.js";
|
|
4
4
|
import { createMcpClient } from "../mcp-client.js";
|
|
5
|
-
import {
|
|
5
|
+
import { resolveSpec, resolveConfig } from "../resolve.js";
|
|
6
|
+
|
|
7
|
+
const HTTP_TIMEOUT = parseInt(process.env.SPEC_HTTP_TIMEOUT ?? "30000");
|
|
6
8
|
|
|
7
9
|
export async function callOperation(args) {
|
|
8
10
|
const { flags, positional } = parseArgs(args);
|
|
9
11
|
const target = positional[0];
|
|
10
12
|
if (!target) throw new Error(
|
|
11
|
-
"Usage: spec call <operation> [--spec <name> | --openapi <url> | ...] [--data '{}'] [--var k=v] [--header k=v]"
|
|
13
|
+
"Usage: spec call <operation> [--spec <name> | --openapi <url> | ...] [--data '{}' | --data -] [--var k=v] [--header k=v]"
|
|
12
14
|
);
|
|
13
15
|
|
|
14
16
|
if (flags["data-file"] && !flags.data) {
|
|
15
17
|
flags.data = readFileSync(flags["data-file"], "utf-8").trim();
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
// Read from stdin when
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
}
|
|
20
|
+
// Read from stdin only when --data - is explicitly passed.
|
|
21
|
+
// Agents run in non-TTY environments — auto-detecting stdin would add latency on every call.
|
|
22
|
+
if (flags.data === "-") {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
25
|
+
flags.data = Buffer.concat(chunks).toString("utf-8").trim();
|
|
30
26
|
}
|
|
31
27
|
|
|
32
|
-
const { spec, entry } = await
|
|
28
|
+
const { spec, entry } = await resolveSpec(flags);
|
|
33
29
|
const config = resolveConfig(flags, entry);
|
|
34
30
|
|
|
35
31
|
if (spec.type === "openapi") {
|
|
@@ -60,7 +56,10 @@ async function callMCP(spec, entry, target, flags) {
|
|
|
60
56
|
const client = await createMcpClient(entry);
|
|
61
57
|
try {
|
|
62
58
|
const result = await client.callTool({ name: tool.name, arguments: toolArgs });
|
|
63
|
-
|
|
59
|
+
// Normalize MCP result: expose isError and content at the top level
|
|
60
|
+
const isError = result.isError === true;
|
|
61
|
+
out({ tool: tool.name, arguments: toolArgs, isError, content: result.content, result });
|
|
62
|
+
if (isError) process.exit(1);
|
|
64
63
|
} finally {
|
|
65
64
|
await client.close();
|
|
66
65
|
}
|
|
@@ -82,7 +81,13 @@ async function callOpenAPI(spec, config, target, flags) {
|
|
|
82
81
|
|
|
83
82
|
const vars = parseKV(flags.var);
|
|
84
83
|
for (const [key, val] of Object.entries(vars)) {
|
|
85
|
-
path = path.
|
|
84
|
+
path = path.replaceAll(`{${key}}`, encodeURIComponent(val));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Detect unreplaced path parameters and error clearly
|
|
88
|
+
const missing = [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
|
|
89
|
+
if (missing.length > 0) {
|
|
90
|
+
throw new Error(`Missing required path parameters: ${missing.join(", ")}. Pass --var ${missing[0]}=<value>`);
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
const queryParams = parseKV(flags.query);
|
|
@@ -98,7 +103,7 @@ async function callOpenAPI(spec, config, target, flags) {
|
|
|
98
103
|
if (!headers["Content-Type"]) headers["Content-Type"] = "application/json";
|
|
99
104
|
}
|
|
100
105
|
|
|
101
|
-
const res = await fetch(url, { method, headers, body });
|
|
106
|
+
const res = await fetch(url, { method, headers, body, signal: AbortSignal.timeout(HTTP_TIMEOUT) });
|
|
102
107
|
const contentType = res.headers.get("content-type") || "";
|
|
103
108
|
const responseBody = contentType.includes("json") ? await res.json() : await res.text();
|
|
104
109
|
|
|
@@ -146,15 +151,16 @@ async function callGraphQL(spec, config, target, flags) {
|
|
|
146
151
|
variables: Object.keys(variables).length > 0 ? variables : undefined,
|
|
147
152
|
});
|
|
148
153
|
|
|
149
|
-
const res = await fetch(endpoint, { method: "POST", headers, body });
|
|
150
|
-
const
|
|
154
|
+
const res = await fetch(endpoint, { method: "POST", headers, body, signal: AbortSignal.timeout(HTTP_TIMEOUT) });
|
|
155
|
+
const contentType = res.headers.get("content-type") || "";
|
|
156
|
+
const responseBody = contentType.includes("json") ? await res.json() : await res.text();
|
|
151
157
|
|
|
152
158
|
out({
|
|
153
159
|
status: res.status,
|
|
154
160
|
query,
|
|
155
161
|
variables: Object.keys(variables).length > 0 ? variables : undefined,
|
|
156
|
-
data: responseBody
|
|
157
|
-
errors: responseBody
|
|
162
|
+
data: responseBody?.data || null,
|
|
163
|
+
errors: responseBody?.errors || null,
|
|
158
164
|
});
|
|
159
165
|
}
|
|
160
166
|
|
|
@@ -3,6 +3,7 @@ import { resolve } from "path";
|
|
|
3
3
|
import YAML from "yaml";
|
|
4
4
|
import { parseKV } from "../args.js";
|
|
5
5
|
import { createMcpClient } from "../mcp-client.js";
|
|
6
|
+
import { matchFilter } from "../glob.js";
|
|
6
7
|
|
|
7
8
|
const INTROSPECTION_QUERY = `{
|
|
8
9
|
__schema {
|
|
@@ -59,6 +60,13 @@ fragment TypeRef on __Type {
|
|
|
59
60
|
}
|
|
60
61
|
}`;
|
|
61
62
|
|
|
63
|
+
function applyFilter(items, nameFn, allowed, disabled) {
|
|
64
|
+
let result = items;
|
|
65
|
+
if (allowed?.length) result = result.filter((item) => allowed.some((p) => matchFilter(p, nameFn(item))));
|
|
66
|
+
if (disabled?.length) result = result.filter((item) => !disabled.some((p) => matchFilter(p, nameFn(item))));
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
62
70
|
/**
|
|
63
71
|
* Resolve a spec from a registry entry or inline flags entry.
|
|
64
72
|
* Entry shape:
|
|
@@ -66,12 +74,22 @@ fragment TypeRef on __Type {
|
|
|
66
74
|
* { type: "graphql", source: "<url>", config: { headers, auth } }
|
|
67
75
|
* { type: "mcp", transport: "stdio|sse|streamable-http", url?, command?, args?, config: { headers, env } }
|
|
68
76
|
*/
|
|
69
|
-
export async function
|
|
77
|
+
export async function fetchSpec(entry) {
|
|
70
78
|
if (entry.type === "mcp") return await loadMCPFromEntry(entry);
|
|
71
|
-
if (entry.type === "graphql")
|
|
79
|
+
if (entry.type === "graphql") {
|
|
80
|
+
const spec = await loadGraphQL(entry.source, entry.config?.headers);
|
|
81
|
+
return {
|
|
82
|
+
...spec,
|
|
83
|
+
operations: applyFilter(spec.operations, (op) => op.name, entry.config?.allowedTools, entry.config?.disabledTools),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
72
86
|
// openapi — url or file; skip GraphQL probe since type is explicitly declared
|
|
73
87
|
const isUrl = entry.source?.startsWith("http://") || entry.source?.startsWith("https://");
|
|
74
|
-
|
|
88
|
+
const spec = isUrl ? await loadFromUrl(entry.source, true) : loadFromFile(entry.source);
|
|
89
|
+
return {
|
|
90
|
+
...spec,
|
|
91
|
+
operations: applyFilter(spec.operations, (op) => op.id, entry.config?.allowedTools, entry.config?.disabledTools),
|
|
92
|
+
};
|
|
75
93
|
}
|
|
76
94
|
|
|
77
95
|
/**
|
|
@@ -79,6 +97,13 @@ export async function resolveSpec(entry) {
|
|
|
79
97
|
* Returns null if no inline source flags present.
|
|
80
98
|
*/
|
|
81
99
|
export function inlineEntryFromFlags(flags) {
|
|
100
|
+
const allowed = flags["allow-tool"];
|
|
101
|
+
const disabled = flags["disable-tool"];
|
|
102
|
+
const filterConfig = {
|
|
103
|
+
...(allowed?.length ? { allowedTools: allowed } : {}),
|
|
104
|
+
...(disabled?.length ? { disabledTools: disabled } : {}),
|
|
105
|
+
};
|
|
106
|
+
|
|
82
107
|
if (flags["mcp-stdio"]) {
|
|
83
108
|
const raw = flags["mcp-stdio"];
|
|
84
109
|
const parts = (raw.trim() ? raw.match(/(?:[^\s"]+|"[^"]*")+/g) : null)?.map((p) => p.replace(/^"|"$/g, ""));
|
|
@@ -89,7 +114,7 @@ export function inlineEntryFromFlags(flags) {
|
|
|
89
114
|
command: parts[0],
|
|
90
115
|
args: parts.slice(1),
|
|
91
116
|
cwd: flags.cwd,
|
|
92
|
-
config: { env: parseKV(flags.env) },
|
|
117
|
+
config: { env: parseKV(flags.env), ...filterConfig },
|
|
93
118
|
};
|
|
94
119
|
}
|
|
95
120
|
if (flags["mcp-sse"]) {
|
|
@@ -97,7 +122,7 @@ export function inlineEntryFromFlags(flags) {
|
|
|
97
122
|
type: "mcp",
|
|
98
123
|
transport: "sse",
|
|
99
124
|
url: flags["mcp-sse"],
|
|
100
|
-
config: { headers: parseKV(flags.header) },
|
|
125
|
+
config: { headers: parseKV(flags.header), ...filterConfig },
|
|
101
126
|
};
|
|
102
127
|
}
|
|
103
128
|
if (flags["mcp-http"]) {
|
|
@@ -105,34 +130,20 @@ export function inlineEntryFromFlags(flags) {
|
|
|
105
130
|
type: "mcp",
|
|
106
131
|
transport: "streamable-http",
|
|
107
132
|
url: flags["mcp-http"],
|
|
108
|
-
config: { headers: parseKV(flags.header) },
|
|
133
|
+
config: { headers: parseKV(flags.header), ...filterConfig },
|
|
109
134
|
};
|
|
110
135
|
}
|
|
111
136
|
if (flags.graphql) {
|
|
112
|
-
return { type: "graphql", source: flags.graphql, config: { headers: parseKV(flags.header) } };
|
|
137
|
+
return { type: "graphql", source: flags.graphql, config: { headers: parseKV(flags.header), ...filterConfig } };
|
|
113
138
|
}
|
|
114
139
|
if (flags.openapi) {
|
|
115
|
-
return { type: "openapi", source: flags.openapi, config: { headers: parseKV(flags.header), baseUrl: flags["base-url"] || null } };
|
|
140
|
+
return { type: "openapi", source: flags.openapi, config: { headers: parseKV(flags.header), baseUrl: flags["base-url"] || null, ...filterConfig } };
|
|
116
141
|
}
|
|
117
142
|
return null;
|
|
118
143
|
}
|
|
119
144
|
|
|
120
145
|
// --- Internal loaders ---
|
|
121
146
|
|
|
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
|
-
|
|
136
147
|
async function loadMCPFromEntry(entry) {
|
|
137
148
|
const client = await createMcpClient(entry);
|
|
138
149
|
try {
|
|
@@ -143,10 +154,7 @@ async function loadMCPFromEntry(entry) {
|
|
|
143
154
|
inputSchema: t.inputSchema || null,
|
|
144
155
|
}));
|
|
145
156
|
|
|
146
|
-
|
|
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)));
|
|
157
|
+
mapped = applyFilter(mapped, (t) => t.name, entry.config?.allowedTools, entry.config?.disabledTools);
|
|
150
158
|
|
|
151
159
|
return {
|
|
152
160
|
type: "mcp",
|
package/src/commands/grep.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { out } from "../output.js";
|
|
2
2
|
import { parseArgs } from "../args.js";
|
|
3
3
|
import { getRegistry, getEntry, getCachedSpec, saveCachedSpec } from "../registry.js";
|
|
4
|
-
import {
|
|
4
|
+
import { fetchSpec } from "./fetch.js";
|
|
5
|
+
import { matchGlob } from "../glob.js";
|
|
5
6
|
|
|
6
7
|
export async function grepCmd(args) {
|
|
7
8
|
const { flags, positional } = parseArgs(args);
|
|
@@ -23,7 +24,7 @@ export async function grepCmd(args) {
|
|
|
23
24
|
for (const entry of entries) {
|
|
24
25
|
let spec = getCachedSpec(entry.name);
|
|
25
26
|
if (!spec) {
|
|
26
|
-
spec = await
|
|
27
|
+
spec = await fetchSpec(entry);
|
|
27
28
|
saveCachedSpec(entry.name, spec);
|
|
28
29
|
}
|
|
29
30
|
|
package/src/commands/list.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { out } from "../output.js";
|
|
2
2
|
import { parseArgs } from "../args.js";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveSpec } from "../resolve.js";
|
|
4
4
|
|
|
5
5
|
export async function listOperations(args) {
|
|
6
6
|
const opts = parseArgs(args);
|
|
7
7
|
const { flags } = opts;
|
|
8
8
|
|
|
9
|
-
const { spec } = await
|
|
9
|
+
const { spec } = await resolveSpec(flags);
|
|
10
10
|
|
|
11
11
|
const filter = flags.filter?.toLowerCase();
|
|
12
12
|
const compact = flags.compact !== "false";
|
package/src/commands/show.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { out } from "../output.js";
|
|
2
2
|
import { parseArgs } from "../args.js";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveSpec } from "../resolve.js";
|
|
4
4
|
|
|
5
5
|
export async function showOperation(args) {
|
|
6
6
|
const { flags, positional } = parseArgs(args);
|
|
7
7
|
const target = positional[0];
|
|
8
8
|
if (!target) throw new Error("Usage: spec show <operationId-or-path> [--spec <name> | --openapi <url> | ...]");
|
|
9
9
|
|
|
10
|
-
const { spec } = await
|
|
10
|
+
const { spec } = await resolveSpec(flags);
|
|
11
11
|
|
|
12
12
|
if (spec.type === "openapi") {
|
|
13
13
|
showOpenAPI(spec, target);
|
package/src/commands/specs.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parseArgs } from "../args.js";
|
|
2
2
|
import { getRegistry, saveRegistry, getEntry, removeCachedSpec, saveCachedSpec } from "../registry.js";
|
|
3
|
-
import {
|
|
3
|
+
import { fetchSpec } from "./fetch.js";
|
|
4
4
|
import { out } from "../output.js";
|
|
5
5
|
|
|
6
6
|
export async function specsCmd(args) {
|
|
@@ -58,7 +58,7 @@ export async function registryMutate(action, args) {
|
|
|
58
58
|
if (action === "refresh") {
|
|
59
59
|
const entry = registry[idx];
|
|
60
60
|
if (!entry.enabled) throw new Error(`Spec '${name}' is disabled. Enable it first.`);
|
|
61
|
-
const spec = await
|
|
61
|
+
const spec = await fetchSpec(entry);
|
|
62
62
|
saveCachedSpec(name, spec);
|
|
63
63
|
const count = spec.tools?.length ?? spec.operations?.length ?? 0;
|
|
64
64
|
out({ ok: true, refreshed: name, type: spec.type, count });
|
package/src/commands/types.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { out } from "../output.js";
|
|
2
2
|
import { parseArgs } from "../args.js";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveSpec } from "../resolve.js";
|
|
4
4
|
|
|
5
5
|
export async function typesCmd(args) {
|
|
6
6
|
const { positional, flags } = parseArgs(args);
|
|
7
|
-
const { spec } = await
|
|
7
|
+
const { spec } = await resolveSpec(flags);
|
|
8
8
|
const target = positional[0];
|
|
9
9
|
|
|
10
10
|
if (spec.type === "openapi") {
|
package/src/glob.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
function globToRegex(pattern) {
|
|
2
|
+
return new RegExp(
|
|
3
|
+
"^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$",
|
|
4
|
+
"i"
|
|
5
|
+
);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Search match: plain text = substring, glob chars (* ?) = anchored glob.
|
|
10
|
+
* Used by grep — broad matching is desirable for search.
|
|
11
|
+
*/
|
|
12
|
+
export function matchGlob(pattern, str) {
|
|
13
|
+
if (!pattern.includes("*") && !pattern.includes("?")) {
|
|
14
|
+
return str.toLowerCase().includes(pattern.toLowerCase());
|
|
15
|
+
}
|
|
16
|
+
return globToRegex(pattern).test(str);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Filter match: plain text = exact (case-insensitive), glob chars (* ?) = anchored glob.
|
|
21
|
+
* Used by --allow-tool / --disable-tool — precision is required for whitelists.
|
|
22
|
+
* Use *pattern* for explicit substring matching.
|
|
23
|
+
*/
|
|
24
|
+
export function matchFilter(pattern, str) {
|
|
25
|
+
if (!pattern.includes("*") && !pattern.includes("?")) {
|
|
26
|
+
return str.toLowerCase() === pattern.toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
return globToRegex(pattern).test(str);
|
|
29
|
+
}
|
package/src/mcp-client.js
CHANGED
|
@@ -55,7 +55,7 @@ export async function createMcpClient(spec) {
|
|
|
55
55
|
} catch (e) {
|
|
56
56
|
lastError = e;
|
|
57
57
|
if (attempt < MAX_RETRIES) {
|
|
58
|
-
await new Promise((r) => setTimeout(r, RETRY_DELAY * Math.pow(2, attempt)));
|
|
58
|
+
await new Promise((r) => setTimeout(r, Math.min(RETRY_DELAY * Math.pow(2, attempt), 5000)));
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
}
|
package/src/resolve.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getEntry, getCachedSpec, saveCachedSpec } from "./registry.js";
|
|
2
|
-
import {
|
|
2
|
+
import { fetchSpec, inlineEntryFromFlags } from "./commands/fetch.js";
|
|
3
3
|
import { getConfig } from "./store.js";
|
|
4
4
|
import { parseKV } from "./args.js";
|
|
5
5
|
|
|
@@ -10,12 +10,12 @@ import { parseKV } from "./args.js";
|
|
|
10
10
|
* 2. Inline flags → ad-hoc, no caching
|
|
11
11
|
* 3. Error → no spec source given
|
|
12
12
|
*/
|
|
13
|
-
export async function
|
|
13
|
+
export async function resolveSpec(flags) {
|
|
14
14
|
if (flags.spec) {
|
|
15
15
|
const entry = getEntry(flags.spec); // throws if missing or disabled
|
|
16
16
|
let spec = getCachedSpec(flags.spec);
|
|
17
17
|
if (!spec) {
|
|
18
|
-
spec = await
|
|
18
|
+
spec = await fetchSpec(entry);
|
|
19
19
|
saveCachedSpec(flags.spec, spec);
|
|
20
20
|
}
|
|
21
21
|
return { spec, entry };
|
|
@@ -23,7 +23,7 @@ export async function resolveActiveSpec(flags) {
|
|
|
23
23
|
|
|
24
24
|
const inlineEntry = inlineEntryFromFlags(flags);
|
|
25
25
|
if (inlineEntry) {
|
|
26
|
-
const spec = await
|
|
26
|
+
const spec = await fetchSpec(inlineEntry);
|
|
27
27
|
return { spec, entry: inlineEntry };
|
|
28
28
|
}
|
|
29
29
|
|