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 +1 -1
- package/src/args.js +1 -1
- package/src/cli.js +97 -55
- package/src/commands/add.js +67 -0
- package/src/commands/call.js +22 -71
- package/src/commands/list.js +20 -30
- package/src/commands/load.js +57 -70
- package/src/commands/show.js +33 -68
- package/src/commands/specs.js +69 -0
- package/src/commands/types.js +2 -4
- package/src/mcp-client.js +9 -2
- package/src/registry.js +53 -0
- package/src/resolve.js +65 -0
package/package.json
CHANGED
package/src/args.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Supports: --flag value, --flag=value, and positional args
|
|
3
3
|
// Repeatable flags (--query, --header, --var) are collected into arrays.
|
|
4
4
|
|
|
5
|
-
const REPEATABLE = new Set(["query", "header", "var"]);
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
spec
|
|
27
|
-
spec
|
|
28
|
-
spec
|
|
29
|
-
spec
|
|
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
|
|
33
|
-
spec show
|
|
34
|
-
spec
|
|
35
|
-
spec
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
spec
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
spec call <op> --
|
|
42
|
-
spec call <op> --
|
|
43
|
-
spec call <op> --
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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>
|
|
60
|
-
spec config set headers.X-API-Key <key>
|
|
61
|
-
spec config get
|
|
62
|
-
spec config unset auth
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
+
}
|
package/src/commands/call.js
CHANGED
|
@@ -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(
|
|
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 =
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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";
|
package/src/commands/list.js
CHANGED
|
@@ -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
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
76
|
-
if (
|
|
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,
|
package/src/commands/load.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
}
|
|
118
|
-
|
|
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
|
-
}
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|
package/src/commands/show.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
+
}
|
package/src/commands/types.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/registry.js
ADDED
|
@@ -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
|
+
}
|