api-spec-cli 0.1.2 → 0.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "api-spec-cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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"]);
5
+ const REPEATABLE = new Set(["query", "header", "var", "env"]);
6
6
 
7
7
  export function parseArgs(args) {
8
8
  const flags = {};
package/src/cli.js CHANGED
@@ -1,77 +1,103 @@
1
- import { loadSpec } from "./commands/load.js";
2
1
  import { listOperations } from "./commands/list.js";
3
2
  import { showOperation } from "./commands/show.js";
4
3
  import { callOperation } from "./commands/call.js";
5
4
  import { configCmd } from "./commands/config.js";
6
5
  import { validateSpec } from "./commands/validate.js";
7
6
  import { typesCmd } from "./commands/types.js";
7
+ import { addCmd } from "./commands/add.js";
8
+ import { specsCmd, registryMutate } from "./commands/specs.js";
8
9
  import { out, err, setFormat } from "./output.js";
9
10
 
10
11
  const HELP = `spec-cli — Explore and call APIs from the command line.
11
12
  All output is JSON. Designed for AI agents but works for humans too.
12
13
 
13
- WORKFLOW (follow this order):
14
- 1. spec load <file-or-url> Load an OpenAPI or GraphQL spec
15
- spec load --mcp-stdio <cmd> Load an MCP server via stdio
16
- spec load --mcp-sse <url> Load an MCP server via SSE
17
- spec load --mcp-http <url> Load an MCP server via streamable HTTP
18
- 2. spec list Browse operations/tools (compact IDs)
19
- 3. spec show <operation> Get params, body, response for one op
20
- 4. spec call <operation> [options] Execute the request
14
+ Every command is stateless — specify the spec source on each call.
21
15
 
22
- Use spec types [name] to inspect a schema/type referenced by show.
23
- Use spec config to set baseUrl, auth, and headers before calling.
16
+ SPEC SOURCE (required on every list/show/call):
17
+ --spec <name> Use a registered spec (auto-fetches + caches)
18
+ --openapi <url-or-file> OpenAPI inline (no registration needed)
19
+ --graphql <url> GraphQL inline
20
+ --mcp-http <url> MCP streamable-HTTP inline
21
+ --mcp-sse <url> MCP SSE inline
22
+ --mcp-stdio "<cmd args>" MCP stdio inline
24
23
 
25
- DISCOVERY (narrowing down):
26
- spec list All operations (just IDs)
27
- spec list --filter user Search across all fields
28
- spec list --tag pets OpenAPI tag or GraphQL kind (query/mutation)
29
- spec list --limit 10 --offset 20 Paginate large APIs
24
+ REGISTRY (register once, use anywhere):
25
+ spec add <name> --openapi <url> Register an OpenAPI spec
26
+ spec add <name> --graphql <url> Register a GraphQL endpoint
27
+ spec add <name> --mcp-http <url> Register an MCP server (streamable-HTTP)
28
+ spec add <name> --mcp-sse <url> Register an MCP server (SSE)
29
+ spec add <name> --mcp-stdio "<cmd>" Register an MCP server (stdio)
30
+ Options: --description <text> --base-url <url> --auth <token>
31
+ --header k=v (repeatable) --env KEY=VAL (repeatable, stdio only)
32
+
33
+ spec specs List all registered specs
34
+ spec specs --compact false Show full entry config
35
+ spec remove <name> Delete from registry
36
+ spec enable <name> Enable a disabled spec
37
+ spec disable <name> Disable without removing
38
+ spec refresh <name> Force re-fetch and update cache
39
+
40
+ DISCOVER:
41
+ spec list --spec <name> All operations/tools (compact IDs)
42
+ spec list --spec <name> --filter user Search by keyword
43
+ spec list --spec <name> --tag pets OpenAPI tag or GraphQL kind
44
+ spec list --spec <name> --limit 10 Paginate
45
+ spec list --mcp-http <url> Inline: no registration needed
30
46
 
31
47
  INSPECT:
32
- spec show getPetById Match by operationId
33
- spec show /pet/{petId} Match by path
34
- spec show "GET /pet/{petId}" Match by method + path
35
- spec show publishPost GraphQL operation name
36
- spec show read_file MCP tool name
37
- spec types List all schema/type names
38
- spec types Pet Inspect one schema (compact, no $ref explosion)
39
-
40
- EXECUTE:
41
- spec call <op> --var petId=1 Path or GraphQL variables
42
- spec call <op> --query status=available Query string params
43
- spec call <op> --data '{"name":"Rex"}' JSON body / MCP tool arguments
44
- spec call <op> --data-file /tmp/query.json JSON body from file (avoids shell escaping)
45
- spec call <op> --header X-Custom=val Extra headers (OpenAPI/GraphQL)
46
- spec call <op> --method PUT Override HTTP method (OpenAPI)
47
-
48
- MCP EXAMPLES:
49
- spec load --mcp-stdio "npx -y @modelcontextprotocol/server-filesystem /tmp"
50
- spec load --mcp-sse http://localhost:3000/sse
51
- spec load --mcp-http https://example.com/mcp
52
- spec list
53
- spec show read_file
54
- spec call read_file --var path=/tmp/hello.txt
55
- spec call read_file --data '{"path":"/tmp/hello.txt"}'
56
-
57
- CONFIG (persisted in .spec-cli/config.json):
48
+ spec show --spec <name> <op> Operation details (params, body, responses)
49
+ spec show --spec <name> <tool> MCP tool input schema
50
+ spec types --spec <name> List all schema/type names (OpenAPI/GraphQL)
51
+ spec types --spec <name> <TypeName> Inspect one type
52
+
53
+ CALL:
54
+ spec call --spec <name> <op> --var petId=1 Path/GraphQL vars
55
+ spec call --spec <name> <op> --query status=available Query params
56
+ spec call --spec <name> <op> --data '{"name":"Rex"}' JSON body / MCP args
57
+ spec call --spec <name> <op> --data-file args.json Body from file
58
+ spec call --spec <name> <op> --header X-Custom=val Extra headers
59
+ spec call --spec <name> <op> --method PUT Override HTTP method
60
+
61
+ PER-CALL OVERRIDES (win over registry entry config):
62
+ --auth <token> Override auth for this call
63
+ --base-url <url> Override base URL for this call
64
+ --header k=v Merge/override headers for this call
65
+
66
+ CONFIG (persisted in .spec-cli/config.json — lowest priority):
58
67
  spec config set baseUrl https://api.example.com
59
- spec config set auth <token> Auto-adds Bearer prefix
60
- spec config set headers.X-API-Key <key> Dot notation for nested keys
61
- spec config get Show current config
62
- spec config unset auth Remove a key
68
+ spec config set auth <token>
69
+ spec config set headers.X-API-Key <key>
70
+ spec config get
71
+ spec config unset auth
63
72
 
64
73
  OTHER:
65
74
  spec validate <file-or-url> Check OpenAPI spec for errors
66
- --format json|text|yaml Output format (default: json)`;
75
+ --format json|text|yaml Output format (default: json)
76
+
77
+ EXAMPLES:
78
+ spec add agno --mcp-http https://docs.agno.com/mcp --description "Agno docs"
79
+ spec add petstore --openapi https://petstore3.swagger.io/api/v3/openapi.json \\
80
+ --base-url https://petstore3.swagger.io/api/v3
81
+ spec specs
82
+ spec list --spec agno
83
+ spec show --spec agno search_agno
84
+ spec call --spec agno search_agno --var query="agents"
85
+ spec call --spec agno search_agno --var query="foo" --header X-Tenant=acme
86
+ spec list --mcp-http https://docs.agno.com/mcp (inline, no registration)`;
67
87
 
68
88
  export async function run(args) {
69
- // Extract --format before routing
70
- const formatIdx = args.indexOf("--format");
71
- if (formatIdx !== -1) {
72
- setFormat(args[formatIdx + 1]);
73
- args = [...args.slice(0, formatIdx), ...args.slice(formatIdx + 2)];
89
+ // Extract --format before routing (supports both --format json and --format=json)
90
+ const newArgs = [];
91
+ for (let i = 0; i < args.length; i++) {
92
+ if (args[i] === "--format" && i + 1 < args.length) {
93
+ setFormat(args[++i]);
94
+ } else if (args[i].startsWith("--format=")) {
95
+ setFormat(args[i].slice(9));
96
+ } else {
97
+ newArgs.push(args[i]);
98
+ }
74
99
  }
100
+ args = newArgs;
75
101
 
76
102
  const cmd = args[0];
77
103
 
@@ -82,9 +108,6 @@ export async function run(args) {
82
108
 
83
109
  try {
84
110
  switch (cmd) {
85
- case "load":
86
- await loadSpec(args.slice(1));
87
- break;
88
111
  case "list":
89
112
  case "ls":
90
113
  await listOperations(args.slice(1));
@@ -106,6 +129,25 @@ export async function run(args) {
106
129
  case "cfg":
107
130
  await configCmd(args.slice(1));
108
131
  break;
132
+ case "add":
133
+ await addCmd(args.slice(1));
134
+ break;
135
+ case "specs":
136
+ case "registry":
137
+ await specsCmd(args.slice(1));
138
+ break;
139
+ case "remove":
140
+ await registryMutate("remove", args.slice(1));
141
+ break;
142
+ case "enable":
143
+ await registryMutate("enable", args.slice(1));
144
+ break;
145
+ case "disable":
146
+ await registryMutate("disable", args.slice(1));
147
+ break;
148
+ case "refresh":
149
+ await registryMutate("refresh", args.slice(1));
150
+ break;
109
151
  default:
110
152
  err(`Unknown command: ${cmd}. Run 'spec help' for usage.`);
111
153
  }
@@ -0,0 +1,67 @@
1
+ import { parseArgs, parseKV } from "../args.js";
2
+ import { getRegistry, saveRegistry } from "../registry.js";
3
+ import { out } from "../output.js";
4
+
5
+ export async function addCmd(args) {
6
+ const { flags, positional } = parseArgs(args);
7
+ const name = positional[0];
8
+ if (!name) throw new Error(
9
+ "Usage: spec add <name> --openapi <url> | --graphql <url> | --mcp-http <url> | --mcp-sse <url> | --mcp-stdio \"<cmd>\""
10
+ );
11
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
12
+ throw new Error("Spec name must contain only letters, numbers, hyphens, and underscores.");
13
+ }
14
+
15
+ const registry = getRegistry();
16
+ if (registry.find((e) => e.name === name)) {
17
+ throw new Error(`Spec '${name}' already exists. Run 'spec remove ${name}' first.`);
18
+ }
19
+
20
+ const entry = { name, enabled: true };
21
+
22
+ if (flags.description) entry.description = flags.description;
23
+
24
+ if (flags.openapi) {
25
+ entry.type = "openapi";
26
+ entry.source = flags.openapi;
27
+ entry.config = {
28
+ baseUrl: flags["base-url"] || null,
29
+ auth: flags.auth || null,
30
+ headers: parseKV(flags.header),
31
+ };
32
+ } else if (flags.graphql) {
33
+ entry.type = "graphql";
34
+ entry.source = flags.graphql;
35
+ entry.config = {
36
+ auth: flags.auth || null,
37
+ headers: parseKV(flags.header),
38
+ };
39
+ } else if (flags["mcp-stdio"]) {
40
+ const raw = flags["mcp-stdio"];
41
+ const parts = (raw.trim() ? raw.match(/(?:[^\s"]+|"[^"]*")+/g) : null)?.map((p) => p.replace(/^"|"$/g, ""));
42
+ if (!parts?.length) throw new Error("--mcp-stdio requires a non-empty command string");
43
+ entry.type = "mcp";
44
+ entry.transport = "stdio";
45
+ entry.command = parts[0];
46
+ entry.args = parts.slice(1);
47
+ entry.config = { env: parseKV(flags.env) };
48
+ } else if (flags["mcp-sse"]) {
49
+ entry.type = "mcp";
50
+ entry.transport = "sse";
51
+ entry.url = flags["mcp-sse"];
52
+ entry.config = { headers: parseKV(flags.header) };
53
+ } else if (flags["mcp-http"]) {
54
+ entry.type = "mcp";
55
+ entry.transport = "streamable-http";
56
+ entry.url = flags["mcp-http"];
57
+ entry.config = { headers: parseKV(flags.header) };
58
+ } else {
59
+ throw new Error(
60
+ "Specify a source: --openapi <url>, --graphql <url>, --mcp-http <url>, --mcp-sse <url>, or --mcp-stdio \"<cmd>\""
61
+ );
62
+ }
63
+
64
+ registry.push(entry);
65
+ saveRegistry(registry);
66
+ out({ ok: true, name, type: entry.type, transport: entry.transport });
67
+ }
@@ -1,38 +1,36 @@
1
1
  import { readFileSync } from "fs";
2
- import { getSpec, getConfig } from "../store.js";
3
2
  import { out } from "../output.js";
4
3
  import { parseArgs, parseKV } from "../args.js";
5
4
  import { createMcpClient } from "../mcp-client.js";
5
+ import { resolveActiveSpec, resolveConfig } from "../resolve.js";
6
6
 
7
7
  export async function callOperation(args) {
8
8
  const { flags, positional } = parseArgs(args);
9
9
  const target = positional[0];
10
- if (!target) throw new Error("Usage: spec call <operationId-or-path> [--data '{}'] [--data-file path.json] [--query k=v] [--header k=v] [--var k=v] [--method GET]");
10
+ if (!target) throw new Error(
11
+ "Usage: spec call <operation> [--spec <name> | --openapi <url> | ...] [--data '{}'] [--var k=v] [--header k=v]"
12
+ );
11
13
 
12
- // Support --data-file to avoid shell escaping issues
13
14
  if (flags["data-file"] && !flags.data) {
14
15
  flags.data = readFileSync(flags["data-file"], "utf-8").trim();
15
16
  }
16
17
 
17
- const spec = getSpec();
18
- if (!spec) throw new Error("No spec loaded. Run: spec load <file-or-url>");
19
-
20
- const config = getConfig();
18
+ const { spec, entry } = await resolveActiveSpec(flags);
19
+ const config = resolveConfig(flags, entry);
21
20
 
22
21
  if (spec.type === "openapi") {
23
22
  await callOpenAPI(spec, config, target, flags);
24
23
  } else if (spec.type === "mcp") {
25
- await callMCP(spec, target, flags);
24
+ await callMCP(spec, entry, target, flags);
26
25
  } else {
27
26
  await callGraphQL(spec, config, target, flags);
28
27
  }
29
28
  }
30
29
 
31
- async function callMCP(spec, target, flags) {
30
+ async function callMCP(spec, entry, target, flags) {
32
31
  const tool = spec.tools.find((t) => t.name.toLowerCase() === target.toLowerCase());
33
32
  if (!tool) throw new Error(`Tool not found: ${target}. Run 'spec list' to see available tools.`);
34
33
 
35
- // Build arguments: --data for full JSON object, --var for individual keys
36
34
  let toolArgs = {};
37
35
  if (flags.data) {
38
36
  try {
@@ -41,11 +39,11 @@ async function callMCP(spec, target, flags) {
41
39
  throw new Error("--data must be valid JSON when calling an MCP tool");
42
40
  }
43
41
  }
44
- // --var key=value overrides / extends --data args
45
42
  const varOverrides = parseKV(flags.var);
46
43
  toolArgs = { ...toolArgs, ...varOverrides };
47
44
 
48
- const client = await createMcpClient(spec);
45
+ // Re-connect using the original entry (which holds transport config + headers/env)
46
+ const client = await createMcpClient(entry);
49
47
  try {
50
48
  const result = await client.callTool({ name: tool.name, arguments: toolArgs });
51
49
  out({ tool: tool.name, arguments: toolArgs, result });
@@ -57,67 +55,38 @@ async function callMCP(spec, target, flags) {
57
55
  async function callOpenAPI(spec, config, target, flags) {
58
56
  const lower = target.toLowerCase();
59
57
 
60
- const op = spec.operations.find((o) => {
61
- return (
62
- o.id.toLowerCase() === lower ||
63
- o.path.toLowerCase() === lower ||
64
- `${o.method.toLowerCase()} ${o.path.toLowerCase()}` === lower
65
- );
66
- });
58
+ const op = spec.operations.find((o) =>
59
+ o.id.toLowerCase() === lower ||
60
+ o.path.toLowerCase() === lower ||
61
+ `${o.method.toLowerCase()} ${o.path.toLowerCase()}` === lower
62
+ );
67
63
 
68
64
  if (!op) throw new Error(`Operation not found: ${target}`);
69
65
 
70
- // Build URL
71
66
  const baseUrl = config.baseUrl || spec.servers?.[0]?.url || "";
72
67
  let path = op.path;
73
68
 
74
- // Substitute path variables
75
69
  const vars = parseKV(flags.var);
76
70
  for (const [key, val] of Object.entries(vars)) {
77
71
  path = path.replace(`{${key}}`, encodeURIComponent(val));
78
72
  }
79
73
 
80
- // Query params
81
74
  const queryParams = parseKV(flags.query);
82
75
  const qs = new URLSearchParams(queryParams).toString();
83
76
  const url = `${baseUrl}${path}${qs ? "?" + qs : ""}`;
84
77
 
85
- // Method
86
78
  const method = (flags.method || op.method).toUpperCase();
79
+ const headers = { ...config.headers };
87
80
 
88
- // Headers
89
- const headers = {
90
- ...config.headers,
91
- ...parseKV(flags.header),
92
- };
93
-
94
- // Auth
95
- if (config.auth) {
96
- if (config.auth.startsWith("Bearer ") || config.auth.startsWith("Basic ")) {
97
- headers["Authorization"] = config.auth;
98
- } else {
99
- headers["Authorization"] = `Bearer ${config.auth}`;
100
- }
101
- }
102
-
103
- // Body
104
81
  let body = undefined;
105
82
  if (flags.data) {
106
83
  body = flags.data;
107
- if (!headers["Content-Type"]) {
108
- headers["Content-Type"] = "application/json";
109
- }
84
+ if (!headers["Content-Type"]) headers["Content-Type"] = "application/json";
110
85
  }
111
86
 
112
87
  const res = await fetch(url, { method, headers, body });
113
88
  const contentType = res.headers.get("content-type") || "";
114
- let responseBody;
115
-
116
- if (contentType.includes("json")) {
117
- responseBody = await res.json();
118
- } else {
119
- responseBody = await res.text();
120
- }
89
+ const responseBody = contentType.includes("json") ? await res.json() : await res.text();
121
90
 
122
91
  out({
123
92
  status: res.status,
@@ -133,14 +102,12 @@ async function callGraphQL(spec, config, target, flags) {
133
102
  const op = spec.operations.find((o) => o.name.toLowerCase() === lower);
134
103
  if (!op) throw new Error(`Operation not found: ${target}`);
135
104
 
136
- const endpoint = spec.endpoint;
137
- if (!endpoint) throw new Error("No GraphQL endpoint set");
105
+ const endpoint = config.baseUrl || spec.endpoint;
106
+ if (!endpoint) throw new Error("No GraphQL endpoint. Set --base-url or register with --graphql <url>.");
138
107
 
139
- // Build query from operation
140
108
  let query;
141
109
  let dataVariables;
142
110
  if (flags.data) {
143
- // If --data is provided, treat as raw GraphQL query
144
111
  try {
145
112
  const parsed = JSON.parse(flags.data);
146
113
  query = parsed.query || flags.data;
@@ -149,34 +116,23 @@ async function callGraphQL(spec, config, target, flags) {
149
116
  query = flags.data;
150
117
  }
151
118
  } else {
152
- // Auto-build a simple query/mutation
153
119
  query = buildGraphQLQuery(op, spec.types);
154
120
  }
155
121
 
156
- // Variables: --data variables merged with --var overrides
157
122
  const varOverrides = parseKV(flags.var);
158
123
  const variables = { ...dataVariables, ...varOverrides };
159
124
 
160
125
  const headers = {
161
126
  "Content-Type": "application/json",
162
127
  ...config.headers,
163
- ...parseKV(flags.header),
164
128
  };
165
129
 
166
- if (config.auth) {
167
- if (config.auth.startsWith("Bearer ") || config.auth.startsWith("Basic ")) {
168
- headers["Authorization"] = config.auth;
169
- } else {
170
- headers["Authorization"] = `Bearer ${config.auth}`;
171
- }
172
- }
173
-
174
130
  const body = JSON.stringify({
175
131
  query,
176
132
  variables: Object.keys(variables).length > 0 ? variables : undefined,
177
133
  });
178
134
 
179
- const res = await fetch(config.baseUrl || endpoint, { method: "POST", headers, body });
135
+ const res = await fetch(endpoint, { method: "POST", headers, body });
180
136
  const responseBody = await res.json();
181
137
 
182
138
  out({
@@ -193,18 +149,15 @@ function buildGraphQLQuery(op, types) {
193
149
  const argsStr = args.length > 0
194
150
  ? `(${args.map((a) => `$${a.name}: ${flattenType(a.type)}`).join(", ")})`
195
151
  : "";
196
-
197
152
  const passArgs = args.length > 0
198
153
  ? `(${args.map((a) => `${a.name}: $${a.name}`).join(", ")})`
199
154
  : "";
200
155
 
201
- // Try to build a field selection from the return type
202
156
  const returnTypeName = op.returnType?.replace(/[[\]!]/g, "");
203
157
  const returnType = types?.find((t) => t.name === returnTypeName);
204
158
  let fields = "";
205
159
 
206
160
  if (returnType?.fields) {
207
- // Select scalar fields only (1 level deep)
208
161
  const scalarFields = returnType.fields
209
162
  .filter((f) => {
210
163
  const typeName = flattenType(f.type)?.replace(/[[\]!]/g, "");
@@ -213,9 +166,7 @@ function buildGraphQLQuery(op, types) {
213
166
  })
214
167
  .map((f) => f.name);
215
168
 
216
- if (scalarFields.length > 0) {
217
- fields = ` { ${scalarFields.join(" ")} }`;
218
- }
169
+ if (scalarFields.length > 0) fields = ` { ${scalarFields.join(" ")} }`;
219
170
  }
220
171
 
221
172
  const keyword = op.kind === "mutation" ? "mutation" : "query";
@@ -1,22 +1,27 @@
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 listOperations(args) {
6
- const spec = getSpec();
7
- if (!spec) throw new Error("No spec loaded. Run: spec load <file-or-url>");
8
-
9
6
  const opts = parseArgs(args);
10
- const filter = opts.flags.filter?.toLowerCase();
11
- const compact = opts.flags.compact !== "false"; // compact by default
12
- const limit = parseInt(opts.flags.limit) || 0;
13
- const offset = parseInt(opts.flags.offset) || 0;
14
- const tag = opts.flags.tag?.toLowerCase();
7
+ const { flags } = opts;
8
+
9
+ const { spec } = await resolveActiveSpec(flags);
10
+
11
+ const filter = flags.filter?.toLowerCase();
12
+ const compact = flags.compact !== "false";
13
+ const limit = parseInt(flags.limit) || 0;
14
+ const offset = parseInt(flags.offset) || 0;
15
+ const tag = flags.tag?.toLowerCase();
15
16
 
16
17
  let operations;
17
18
 
18
19
  if (spec.type === "openapi") {
19
- operations = spec.operations.map((op) =>
20
+ let source = spec.operations;
21
+ if (tag) {
22
+ source = source.filter((op) => op.tags?.some((t) => t.toLowerCase().includes(tag)));
23
+ }
24
+ operations = source.map((op) =>
20
25
  compact
21
26
  ? { id: op.id, method: op.method, path: op.path }
22
27
  : {
@@ -28,14 +33,6 @@ export async function listOperations(args) {
28
33
  deprecated: op.deprecated,
29
34
  }
30
35
  );
31
-
32
- // Filter by tag
33
- if (tag) {
34
- const fullOps = spec.operations;
35
- operations = operations.filter((_, i) =>
36
- fullOps[i].tags?.some((t) => t.toLowerCase().includes(tag))
37
- );
38
- }
39
36
  } else if (spec.type === "mcp") {
40
37
  operations = spec.tools.map((t) =>
41
38
  compact
@@ -57,28 +54,21 @@ export async function listOperations(args) {
57
54
  }
58
55
  );
59
56
 
60
- // Filter by kind (query/mutation/subscription)
61
57
  if (tag) {
62
58
  operations = operations.filter((op) => op.kind === tag);
63
59
  }
64
60
  }
65
61
 
66
62
  if (filter) {
67
- operations = operations.filter((op) => {
68
- const text = JSON.stringify(op).toLowerCase();
69
- return text.includes(filter);
70
- });
63
+ operations = operations.filter((op) =>
64
+ JSON.stringify(op).toLowerCase().includes(filter)
65
+ );
71
66
  }
72
67
 
73
68
  const total = operations.length;
74
69
 
75
- // Pagination
76
- if (offset > 0) {
77
- operations = operations.slice(offset);
78
- }
79
- if (limit > 0) {
80
- operations = operations.slice(0, limit);
81
- }
70
+ if (offset > 0) operations = operations.slice(offset);
71
+ if (limit > 0) operations = operations.slice(0, limit);
82
72
 
83
73
  out({
84
74
  type: spec.type,
@@ -1,9 +1,7 @@
1
1
  import { readFileSync, existsSync } from "fs";
2
2
  import { resolve } from "path";
3
3
  import YAML from "yaml";
4
- import { saveSpec } from "../store.js";
5
- import { out, err } from "../output.js";
6
- import { parseArgs } from "../args.js";
4
+ import { parseKV } from "../args.js";
7
5
  import { createMcpClient } from "../mcp-client.js";
8
6
 
9
7
  const INTROSPECTION_QUERY = `{
@@ -61,78 +59,77 @@ fragment TypeRef on __Type {
61
59
  }
62
60
  }`;
63
61
 
64
- export async function loadSpec(args) {
65
- const { flags, positional } = parseArgs(args);
66
-
67
- // MCP transport flags
68
- if (flags["mcp-stdio"] || flags["mcp-sse"] || flags["mcp-http"]) {
69
- const spec = await loadMCP(flags);
70
- saveSpec(spec);
71
- out({
72
- ok: true,
73
- type: "mcp",
74
- title: spec.title,
75
- transport: spec.transport,
76
- toolCount: spec.tools.length,
77
- });
78
- return;
79
- }
80
-
81
- const source = positional[0];
82
- if (!source) throw new Error("Usage: spec load <file-or-url> | spec load --mcp-stdio <cmd> | spec load --mcp-sse <url> | spec load --mcp-http <url>");
83
-
84
- // Detect if it's a URL or file
85
- const isUrl = source.startsWith("http://") || source.startsWith("https://");
86
-
87
- let spec;
88
-
89
- if (isUrl) {
90
- spec = await loadFromUrl(source);
91
- } else {
92
- spec = loadFromFile(source);
93
- }
94
-
95
- saveSpec(spec);
96
- out({
97
- ok: true,
98
- type: spec.type,
99
- title: spec.title || null,
100
- operationCount: countOperations(spec),
101
- source: source,
102
- });
62
+ /**
63
+ * Resolve a spec from a registry entry or inline flags entry.
64
+ * Entry shape:
65
+ * { type: "openapi", source: "<url-or-file>", config: { headers, auth } }
66
+ * { type: "graphql", source: "<url>", config: { headers, auth } }
67
+ * { type: "mcp", transport: "stdio|sse|streamable-http", url?, command?, args?, config: { headers, env } }
68
+ */
69
+ export async function resolveSpec(entry) {
70
+ if (entry.type === "mcp") return await loadMCPFromEntry(entry);
71
+ if (entry.type === "graphql") return await loadGraphQL(entry.source, entry.config?.headers);
72
+ // openapi — url or file; skip GraphQL probe since type is explicitly declared
73
+ const isUrl = entry.source?.startsWith("http://") || entry.source?.startsWith("https://");
74
+ return isUrl ? await loadFromUrl(entry.source, true) : loadFromFile(entry.source);
103
75
  }
104
76
 
105
- async function loadMCP(flags) {
106
- let transportConfig;
107
-
77
+ /**
78
+ * Build an inline entry from flags (for ad-hoc commands like --mcp-http <url>).
79
+ * Returns null if no inline source flags present.
80
+ */
81
+ export function inlineEntryFromFlags(flags) {
108
82
  if (flags["mcp-stdio"]) {
109
83
  const raw = flags["mcp-stdio"];
110
- // Split on whitespace, respecting that the value is already a single flag string
111
- const parts = raw.match(/(?:[^\s"]+|"[^"]*")+/g).map((p) => p.replace(/^"|"$/g, ""));
112
- transportConfig = {
84
+ const parts = (raw.trim() ? raw.match(/(?:[^\s"]+|"[^"]*")+/g) : null)?.map((p) => p.replace(/^"|"$/g, ""));
85
+ if (!parts?.length) throw new Error("--mcp-stdio requires a non-empty command string");
86
+ return {
87
+ type: "mcp",
113
88
  transport: "stdio",
114
89
  command: parts[0],
115
90
  args: parts.slice(1),
91
+ config: { env: parseKV(flags.env) },
116
92
  };
117
- } else if (flags["mcp-sse"]) {
118
- transportConfig = {
93
+ }
94
+ if (flags["mcp-sse"]) {
95
+ return {
96
+ type: "mcp",
119
97
  transport: "sse",
120
98
  url: flags["mcp-sse"],
99
+ config: { headers: parseKV(flags.header) },
121
100
  };
122
- } else {
123
- transportConfig = {
101
+ }
102
+ if (flags["mcp-http"]) {
103
+ return {
104
+ type: "mcp",
124
105
  transport: "streamable-http",
125
106
  url: flags["mcp-http"],
107
+ config: { headers: parseKV(flags.header) },
126
108
  };
127
109
  }
110
+ if (flags.graphql) {
111
+ return { type: "graphql", source: flags.graphql, config: { headers: parseKV(flags.header) } };
112
+ }
113
+ if (flags.openapi) {
114
+ return { type: "openapi", source: flags.openapi, config: { headers: parseKV(flags.header), baseUrl: flags["base-url"] || null } };
115
+ }
116
+ return null;
117
+ }
118
+
119
+ // --- Internal loaders ---
128
120
 
129
- const client = await createMcpClient(transportConfig);
121
+ async function loadMCPFromEntry(entry) {
122
+ const client = await createMcpClient(entry);
130
123
  try {
131
124
  const { tools } = await client.listTools();
132
125
  return {
133
126
  type: "mcp",
134
- title: "MCP Server",
135
- ...transportConfig,
127
+ title: entry.name || "MCP Server",
128
+ transport: entry.transport,
129
+ url: entry.url,
130
+ command: entry.command,
131
+ args: entry.args,
132
+ config: entry.config,
136
133
  tools: tools.map((t) => ({
137
134
  name: t.name,
138
135
  description: t.description || null,
@@ -144,24 +141,21 @@ async function loadMCP(flags) {
144
141
  }
145
142
  }
146
143
 
147
- async function loadFromUrl(url) {
148
- // Try GraphQL introspection first if URL doesn't end with known extensions
144
+ async function loadFromUrl(url, skipGraphQLProbe = false) {
149
145
  const lowerUrl = url.toLowerCase();
150
146
  const isLikelyFile =
151
147
  lowerUrl.endsWith(".json") ||
152
148
  lowerUrl.endsWith(".yaml") ||
153
149
  lowerUrl.endsWith(".yml");
154
150
 
155
- if (!isLikelyFile) {
156
- // Try GraphQL introspection
151
+ if (!isLikelyFile && !skipGraphQLProbe) {
157
152
  try {
158
153
  return await loadGraphQL(url);
159
- } catch (e) {
154
+ } catch {
160
155
  // Fall through to OpenAPI
161
156
  }
162
157
  }
163
158
 
164
- // Fetch as OpenAPI
165
159
  const res = await fetch(url);
166
160
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
167
161
  const text = await res.text();
@@ -175,10 +169,10 @@ function loadFromFile(path) {
175
169
  return parseOpenAPI(text, path);
176
170
  }
177
171
 
178
- async function loadGraphQL(url) {
172
+ async function loadGraphQL(url, extraHeaders = {}) {
179
173
  const res = await fetch(url, {
180
174
  method: "POST",
181
- headers: { "Content-Type": "application/json" },
175
+ headers: { "Content-Type": "application/json", ...extraHeaders },
182
176
  body: JSON.stringify({ query: INTROSPECTION_QUERY }),
183
177
  });
184
178
 
@@ -190,8 +184,6 @@ async function loadGraphQL(url) {
190
184
  }
191
185
 
192
186
  const schema = json.data.__schema;
193
-
194
- // Extract operations from query/mutation/subscription types
195
187
  const operations = [];
196
188
  const typeMap = {};
197
189
  for (const t of schema.types) {
@@ -237,7 +229,6 @@ function parseOpenAPI(text, source) {
237
229
  doc = YAML.parse(text);
238
230
  }
239
231
 
240
- // Detect OpenAPI vs Swagger
241
232
  const version = doc.openapi || doc.swagger;
242
233
  if (!version) throw new Error("Not a valid OpenAPI/Swagger spec");
243
234
 
@@ -284,7 +275,3 @@ function flattenType(t) {
284
275
  }
285
276
  return t.kind;
286
277
  }
287
-
288
- function countOperations(spec) {
289
- return spec.operations?.length || 0;
290
- }
@@ -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
+ }