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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "api-spec-cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Agent-friendly CLI for exploring and calling OpenAPI and GraphQL APIs",
5
5
  "type": "module",
6
6
  "bin": {
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
  }
@@ -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 });
@@ -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
+ }
@@ -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: tools.map((t) => ({
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
- export async function createMcpClient(spec) {
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: spec.config?.env ? { ...process.env, ...spec.config.env } : undefined,
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
+ }