api-spec-cli 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +343 -267
- package/package.json +53 -41
- package/src/cli.js +183 -172
- package/src/commands/add.js +161 -80
- package/src/commands/auth.js +32 -0
- package/src/commands/call.js +220 -207
- package/src/commands/fetch.js +344 -287
- package/src/commands/grep.js +67 -64
- package/src/commands/list.js +78 -80
- package/src/commands/show.js +224 -215
- package/src/commands/specs.js +82 -69
- package/src/commands/types.js +167 -163
- package/src/commands/validate.js +295 -269
- package/src/glob.js +34 -15
- package/src/mcp-client.js +88 -63
- package/src/oauth/auth-flow.js +59 -0
- package/src/oauth/provider.js +192 -0
- package/src/oauth/tokens.js +53 -0
- package/src/registry.js +79 -53
- package/src/resolve.js +64 -65
package/src/commands/add.js
CHANGED
|
@@ -1,80 +1,161 @@
|
|
|
1
|
-
import { parseArgs, parseKV } from "../args.js";
|
|
2
|
-
import { getRegistry, saveRegistry } from "../registry.js";
|
|
3
|
-
import { out } from "../output.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
1
|
+
import { parseArgs, parseKV } from "../args.js";
|
|
2
|
+
import { getRegistry, saveRegistry } from "../registry.js";
|
|
3
|
+
import { out } from "../output.js";
|
|
4
|
+
import { saveTokenFile } from "../oauth/tokens.js";
|
|
5
|
+
import { runOAuthFlow } from "../oauth/auth-flow.js";
|
|
6
|
+
|
|
7
|
+
export async function addCmd(args) {
|
|
8
|
+
const { flags, positional } = parseArgs(args);
|
|
9
|
+
const name = positional[0];
|
|
10
|
+
if (!name)
|
|
11
|
+
throw new Error(
|
|
12
|
+
'Usage: spec add <name> --openapi <url> | --graphql <url> | --mcp-http <url> | --mcp-sse <url> | --mcp-stdio "<cmd>"'
|
|
13
|
+
);
|
|
14
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
15
|
+
throw new Error("Spec name must contain only letters, numbers, hyphens, and underscores.");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const registry = getRegistry();
|
|
19
|
+
|
|
20
|
+
// Check for name collision across all sections
|
|
21
|
+
for (const section of ["mcp", "openapi", "graphql"]) {
|
|
22
|
+
if (registry[section]?.[name]) {
|
|
23
|
+
throw new Error(`Spec '${name}' already exists. Run 'spec remove ${name}' first.`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const allowed = flags["allow-tool"];
|
|
28
|
+
const disabled = flags["disable-tool"];
|
|
29
|
+
const filterConfig = {
|
|
30
|
+
...(allowed?.length ? { allowedTools: allowed } : {}),
|
|
31
|
+
...(disabled?.length ? { disabledTools: disabled } : {}),
|
|
32
|
+
};
|
|
33
|
+
const base = {
|
|
34
|
+
enabled: true,
|
|
35
|
+
...(flags.description ? { description: flags.description } : {}),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Validate callback port once, shared by both mcp-http and mcp-sse
|
|
39
|
+
let callbackPort;
|
|
40
|
+
if (flags["oauth-callback-port"]) {
|
|
41
|
+
callbackPort = parseInt(flags["oauth-callback-port"], 10);
|
|
42
|
+
if (isNaN(callbackPort) || callbackPort < 1 || callbackPort > 65535) {
|
|
43
|
+
throw new Error("--oauth-callback-port must be an integer between 1 and 65535");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Client secret is never stored in the registry — saved to token file below
|
|
48
|
+
const oauthClientSecret = flags["oauth-client-secret"];
|
|
49
|
+
|
|
50
|
+
let section, entry;
|
|
51
|
+
|
|
52
|
+
if (flags.openapi) {
|
|
53
|
+
section = "openapi";
|
|
54
|
+
entry = {
|
|
55
|
+
...base,
|
|
56
|
+
type: "openapi",
|
|
57
|
+
source: flags.openapi,
|
|
58
|
+
config: {
|
|
59
|
+
baseUrl: flags["base-url"] || null,
|
|
60
|
+
auth: flags.auth || null,
|
|
61
|
+
headers: parseKV(flags.header),
|
|
62
|
+
...filterConfig,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
} else if (flags.graphql) {
|
|
66
|
+
section = "graphql";
|
|
67
|
+
entry = {
|
|
68
|
+
...base,
|
|
69
|
+
type: "graphql",
|
|
70
|
+
source: flags.graphql,
|
|
71
|
+
config: {
|
|
72
|
+
auth: flags.auth || null,
|
|
73
|
+
headers: parseKV(flags.header),
|
|
74
|
+
...filterConfig,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
} else if (flags["mcp-stdio"]) {
|
|
78
|
+
const raw = flags["mcp-stdio"];
|
|
79
|
+
const parts = (raw.trim() ? raw.match(/(?:[^\s"]+|"[^"]*")+/g) : null)?.map((p) =>
|
|
80
|
+
p.replace(/^"|"$/g, "")
|
|
81
|
+
);
|
|
82
|
+
if (!parts?.length) throw new Error("--mcp-stdio requires a non-empty command string");
|
|
83
|
+
section = "mcp";
|
|
84
|
+
const env = parseKV(flags.env);
|
|
85
|
+
entry = {
|
|
86
|
+
...base,
|
|
87
|
+
...filterConfig,
|
|
88
|
+
type: "stdio",
|
|
89
|
+
command: parts[0],
|
|
90
|
+
args: parts.slice(1),
|
|
91
|
+
...(flags.cwd ? { cwd: flags.cwd } : {}),
|
|
92
|
+
...(Object.keys(env).length ? { env } : {}),
|
|
93
|
+
};
|
|
94
|
+
} else if (flags["mcp-sse"]) {
|
|
95
|
+
section = "mcp";
|
|
96
|
+
const headers = parseKV(flags.header);
|
|
97
|
+
if (flags.auth && !headers["Authorization"]) headers["Authorization"] = `Bearer ${flags.auth}`;
|
|
98
|
+
entry = {
|
|
99
|
+
...base,
|
|
100
|
+
...filterConfig,
|
|
101
|
+
type: "sse",
|
|
102
|
+
url: flags["mcp-sse"],
|
|
103
|
+
...(Object.keys(headers).length ? { headers } : {}),
|
|
104
|
+
...(flags["oauth-flow"] ? { oauthFlow: flags["oauth-flow"] } : {}),
|
|
105
|
+
...(flags["oauth-client-id"] ? { oauthClientId: flags["oauth-client-id"] } : {}),
|
|
106
|
+
...(callbackPort ? { oauthCallbackPort: callbackPort } : {}),
|
|
107
|
+
};
|
|
108
|
+
} else if (flags["mcp-http"]) {
|
|
109
|
+
section = "mcp";
|
|
110
|
+
const headers = parseKV(flags.header);
|
|
111
|
+
if (flags.auth && !headers["Authorization"]) headers["Authorization"] = `Bearer ${flags.auth}`;
|
|
112
|
+
entry = {
|
|
113
|
+
...base,
|
|
114
|
+
...filterConfig,
|
|
115
|
+
type: "http",
|
|
116
|
+
url: flags["mcp-http"],
|
|
117
|
+
...(Object.keys(headers).length ? { headers } : {}),
|
|
118
|
+
...(flags["oauth-flow"] ? { oauthFlow: flags["oauth-flow"] } : {}),
|
|
119
|
+
...(flags["oauth-client-id"] ? { oauthClientId: flags["oauth-client-id"] } : {}),
|
|
120
|
+
...(callbackPort ? { oauthCallbackPort: callbackPort } : {}),
|
|
121
|
+
};
|
|
122
|
+
} else {
|
|
123
|
+
throw new Error(
|
|
124
|
+
'Specify a source: --openapi <url>, --graphql <url>, --mcp-http <url>, --mcp-sse <url>, or --mcp-stdio "<cmd>"'
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
registry[section][name] = entry;
|
|
129
|
+
saveRegistry(registry);
|
|
130
|
+
|
|
131
|
+
// Store client secret in token file (not registry) — it's sensitive
|
|
132
|
+
if (oauthClientSecret) {
|
|
133
|
+
saveTokenFile(name, { clientSecret: oauthClientSecret });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Probe for OAuth on HTTP/SSE MCP entries (skip if static Authorization header already set)
|
|
137
|
+
if (
|
|
138
|
+
section === "mcp" &&
|
|
139
|
+
(entry.type === "http" || entry.type === "sse") &&
|
|
140
|
+
!entry.headers?.Authorization
|
|
141
|
+
) {
|
|
142
|
+
await probeAndAuth({ ...entry, name, _section: "mcp" });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
out({ ok: true, name, section, type: entry.type });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function probeAndAuth(entry) {
|
|
149
|
+
try {
|
|
150
|
+
const { flow } = await runOAuthFlow(entry.name, entry);
|
|
151
|
+
if (flow === "none_required") process.stderr.write(`Connected (no auth required).\n`);
|
|
152
|
+
else if (flow === "browser")
|
|
153
|
+
process.stderr.write(`Authorization complete for '${entry.name}'.\n`);
|
|
154
|
+
} catch (e) {
|
|
155
|
+
process.stderr.write(
|
|
156
|
+
`Could not reach server: ${e.message}\nRun 'spec auth ${entry.name}' after the server is available.\n`
|
|
157
|
+
);
|
|
158
|
+
// Signal partial failure: entry was saved but connection failed
|
|
159
|
+
process.exitCode = 1;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { parseArgs } from "../args.js";
|
|
2
|
+
import { getEntry } from "../registry.js";
|
|
3
|
+
import { clearTokenFile } from "../oauth/tokens.js";
|
|
4
|
+
import { runOAuthFlow } from "../oauth/auth-flow.js";
|
|
5
|
+
import { out } from "../output.js";
|
|
6
|
+
|
|
7
|
+
export async function authCmd(args) {
|
|
8
|
+
const { positional, flags } = parseArgs(args);
|
|
9
|
+
const name = positional[0];
|
|
10
|
+
if (!name) throw new Error("Usage: spec auth <name> [--revoke]");
|
|
11
|
+
|
|
12
|
+
const entry = getEntry(name);
|
|
13
|
+
|
|
14
|
+
if (entry._section !== "mcp" || (entry.type !== "http" && entry.type !== "sse")) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
`'${name}' is not an HTTP/SSE MCP spec — OAuth only applies to mcp http and sse entries`
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if ("revoke" in flags) {
|
|
21
|
+
// revokeAll wipes everything including clientSecret
|
|
22
|
+
clearTokenFile(name, { revokeAll: true });
|
|
23
|
+
out({ ok: true, name, revoked: true });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Clear session tokens but preserve clientSecret so client credentials flow still works
|
|
28
|
+
clearTokenFile(name);
|
|
29
|
+
|
|
30
|
+
const { flow } = await runOAuthFlow(name, entry);
|
|
31
|
+
out({ ok: true, name, flow });
|
|
32
|
+
}
|