api-spec-cli 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- }