api-spec-cli 0.2.3 → 0.2.5

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/src/resolve.js CHANGED
@@ -1,65 +1,66 @@
1
- import { getEntry, getCachedSpec, saveCachedSpec } from "./registry.js";
2
- import { fetchSpec, inlineEntryFromFlags } from "./commands/fetch.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 resolveSpec(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 fetchSpec(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 fetchSpec(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
- }
1
+ import { getEntry, getCachedSpec, saveCachedSpec } from "./registry.js";
2
+ import { fetchSpec, inlineEntryFromFlags } from "./commands/fetch.js";
3
+ import { getConfig } from "./store.js";
4
+ import { parseKV } from "./args.js";
5
+ import {
6
+ expandSecrets,
7
+ expandSecretsMap,
8
+ envHeaderOverrides,
9
+ envUrlOverride,
10
+ mergeHeaders,
11
+ } from "./secrets.js";
12
+
13
+ export async function resolveSpec(flags) {
14
+ if (flags.spec) {
15
+ const entry = getEntry(flags.spec);
16
+ let spec = getCachedSpec(flags.spec);
17
+ if (!spec) {
18
+ spec = await fetchSpec(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 fetchSpec(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
+ export function resolveConfig(flags, entry) {
41
+ const global = getConfig();
42
+ const entryConfig = entry?.config || {};
43
+ const callHeaders = parseKV(flags.header);
44
+
45
+ const rawAuth = flags.auth || entryConfig.auth || global.auth;
46
+ const auth = rawAuth ? expandSecrets(rawAuth) : rawAuth;
47
+ const isGraphql = entry?.type === "graphql" || entry?._section === "graphql";
48
+ const envUrl = isGraphql ? envUrlOverride() : undefined;
49
+ const baseUrl = flags["base-url"] || envUrl || entryConfig.baseUrl || global.baseUrl;
50
+
51
+ const mergedHeaders = mergeHeaders(
52
+ global.headers,
53
+ entryConfig.headers,
54
+ envHeaderOverrides(),
55
+ callHeaders
56
+ );
57
+ const headers = expandSecretsMap(mergedHeaders);
58
+
59
+ const hasAuthHeader = Object.keys(headers).some((k) => k.toLowerCase() === "authorization");
60
+ if (auth && !hasAuthHeader) {
61
+ headers["Authorization"] =
62
+ auth.startsWith("Bearer ") || auth.startsWith("Basic ") ? auth : `Bearer ${auth}`;
63
+ }
64
+
65
+ return { auth, baseUrl, headers };
66
+ }
package/src/secrets.js ADDED
@@ -0,0 +1,46 @@
1
+ const ENV_VAR_RE = /\$\{([^}]+)\}/g;
2
+
3
+ export function expandSecrets(str) {
4
+ if (typeof str !== "string") return str;
5
+ if (!str.includes("${")) return str;
6
+ return str.replace(ENV_VAR_RE, (_, name) => {
7
+ if (!(name in process.env)) throw new Error(`Environment variable not set: ${name}`);
8
+ return process.env[name];
9
+ });
10
+ }
11
+
12
+ export function expandSecretsMap(obj) {
13
+ if (!obj || typeof obj !== "object") return obj;
14
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, expandSecrets(v)]));
15
+ }
16
+
17
+ export function envHeaderOverrides() {
18
+ const headers = {};
19
+ for (const [key, value] of Object.entries(process.env)) {
20
+ if (!key.startsWith("SPEC_HEADER_")) continue;
21
+ const headerName = key.slice("SPEC_HEADER_".length).replace(/_/g, "-").toLowerCase();
22
+ headers[headerName] = value;
23
+ }
24
+ return headers;
25
+ }
26
+
27
+ export function mergeHeaders(...maps) {
28
+ const canonical = {};
29
+ const result = {};
30
+ for (const map of maps) {
31
+ if (!map) continue;
32
+ for (const [key, value] of Object.entries(map)) {
33
+ const lower = key.toLowerCase();
34
+ if (lower in canonical) {
35
+ delete result[canonical[lower]];
36
+ }
37
+ canonical[lower] = key;
38
+ result[key] = value;
39
+ }
40
+ }
41
+ return result;
42
+ }
43
+
44
+ export function envUrlOverride() {
45
+ return process.env.SPEC_URL;
46
+ }
@@ -0,0 +1,112 @@
1
+ ---
2
+ name: api-spec-cli
3
+ description: Use when you need to explore or call an OpenAPI, GraphQL, or MCP API from the shell — register a spec once, search/list/inspect operations token-efficiently, then call them. Prefer this over loading whole specs or wiring an MCP client into context.
4
+ ---
5
+
6
+ # Exploring and calling APIs with `spec`
7
+
8
+ `spec` (the `api-spec-cli` package) turns any OpenAPI, GraphQL, or MCP server into a small set of shell commands. It is built for agents: output is compact JSON by default, you load only the operation you need, and you can search across many specs at once instead of holding full schemas in context.
9
+
10
+ Install: `npm install -g api-spec-cli` (or `npx api-spec-cli <command>`).
11
+
12
+ ## The workflow
13
+
14
+ Work in four steps. Each step narrows what you load into context — never fetch a whole spec when you can grep then show one operation.
15
+
16
+ 1. **Register once** — `spec add <name> ...`. Fetches and caches; later commands hit the cache.
17
+ 2. **Search** — `spec grep <pattern>` across all specs, or `spec list --spec <name>` for one. Both return compact IDs only.
18
+ 3. **Inspect one** — `spec show --spec <name> <op>` returns everything needed to call it (params, body, response, related types) in a single result.
19
+ 4. **Call** — `spec call --spec <name> <op> ...`.
20
+
21
+ ## Register a spec
22
+
23
+ ```bash
24
+ spec add petstore --openapi https://petstore3.swagger.io/api/v3/openapi.json --base-url https://petstore3.swagger.io/api/v3
25
+ spec add hashnode --graphql https://gql.hashnode.com
26
+ spec add agno --mcp-http https://docs.agno.com/mcp
27
+ spec add fs --mcp-stdio "npx -y @modelcontextprotocol/server-filesystem /tmp"
28
+ ```
29
+
30
+ You can skip registration and pass an inline source on any command: `--openapi <url>`, `--graphql <url>`, `--mcp-http <url>`, `--mcp-sse <url>`, `--mcp-stdio "<cmd>"`.
31
+
32
+ ## Discover without burning context
33
+
34
+ ```bash
35
+ spec grep search # substring match across every registered spec
36
+ spec grep "get*" # glob
37
+ spec list --spec petstore # compact IDs, no schemas
38
+ spec list --spec petstore --filter pet --limit 10
39
+ spec list --spec petstore --top 10 # the 10 most-called operations first
40
+ spec show --spec petstore getPetById # full detail for ONE operation
41
+ spec types --spec petstore Pet # one schema at a time (OpenAPI/GraphQL)
42
+ ```
43
+
44
+ `list` is compact by default — add `--compact false` only when you actually need schemas.
45
+
46
+ ## Call
47
+
48
+ ```bash
49
+ spec call --spec petstore getPetById --var petId=1
50
+ spec call --spec petstore findPetsByStatus --query status=available
51
+ spec call --spec petstore addPet --data '{"name":"Rex","photoUrls":[]}'
52
+ spec call --spec hashnode publication --var host=blog.hashnode.dev
53
+ spec call --spec agno search_agno --var query="how to create an agent"
54
+ echo '{"query":"agents"}' | spec call --spec agno search_agno --data -
55
+ ```
56
+
57
+ ## Output formats — pick the cheapest that works
58
+
59
+ ```bash
60
+ spec list --spec petstore --format toon # most token-efficient for uniform arrays
61
+ spec list --spec petstore --format json # default
62
+ spec show --spec petstore getPetById --format yaml
63
+ ```
64
+
65
+ `toon` (Token-Oriented Object Notation) is the densest for tabular/list data and is the best default when feeding results back into a model. Errors are always JSON on stderr.
66
+
67
+ ## Usage ranking
68
+
69
+ `spec` records which operations you call. Use it to surface the operations that matter:
70
+
71
+ ```bash
72
+ spec list --spec petstore --top 5 # rank by call count
73
+ spec usage # all recorded usage
74
+ spec usage petstore # ranked operations for one spec
75
+ ```
76
+
77
+ Set `SPEC_NO_USAGE=1` to disable tracking.
78
+
79
+ ## Secrets and per-call overrides
80
+
81
+ Never paste raw secrets into the registry. Store a placeholder and let it expand from the environment at call time:
82
+
83
+ ```bash
84
+ spec add gh --mcp-http https://api.example.com/mcp --header "Authorization=Bearer ${GH_TOKEN}"
85
+ spec config set auth '${API_TOKEN}'
86
+ ```
87
+
88
+ Override a registered spec's connection at call time without editing it:
89
+
90
+ ```bash
91
+ SPEC_URL=https://staging.example.com/mcp spec call --spec gh some_tool
92
+ SPEC_HEADER_X_TENANT=acme spec list --spec gh
93
+ ```
94
+
95
+ A `.env` file next to where you run `spec` is auto-loaded (real environment variables win over it).
96
+
97
+ ## Auth (MCP OAuth)
98
+
99
+ OAuth 2.1 MCP servers are handled automatically on `spec add` (browser flow opens; DCR servers need no flags). For pre-registered apps like GitHub, pass `--oauth-client-id` and `--oauth-callback-port`. Re-authenticate with `spec auth <name>`; tokens refresh automatically and persist across invocations.
100
+
101
+ ## Quick reference
102
+
103
+ ```bash
104
+ spec specs # list registered specs
105
+ spec grep <pattern> # search across all specs
106
+ spec list --spec <name> # compact operation list
107
+ spec show --spec <name> <op> # full detail for one operation
108
+ spec call --spec <name> <op> # call it
109
+ spec usage [<name>] # usage ranking
110
+ spec validate <file-or-url> # check an OpenAPI spec for errors
111
+ spec help # full flag reference
112
+ ```
package/src/usage.js ADDED
@@ -0,0 +1,62 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
4
+
5
+ let USAGE_DIR = join(homedir(), "spec-cli-config");
6
+
7
+ export function setUsageDir(dir) {
8
+ USAGE_DIR = dir;
9
+ }
10
+
11
+ function usagePath() {
12
+ return join(USAGE_DIR, "usage.json");
13
+ }
14
+
15
+ function loadStore() {
16
+ const file = usagePath();
17
+ if (!existsSync(file)) return {};
18
+ try {
19
+ return JSON.parse(readFileSync(file, "utf-8"));
20
+ } catch {
21
+ return {};
22
+ }
23
+ }
24
+
25
+ function saveStore(store) {
26
+ mkdirSync(USAGE_DIR, { recursive: true });
27
+ writeFileSync(usagePath(), JSON.stringify(store, null, 2));
28
+ }
29
+
30
+ export function recordUsage(specName, operationId) {
31
+ if (process.env.SPEC_NO_USAGE) return;
32
+ if (!specName || !operationId) return;
33
+ try {
34
+ const store = loadStore();
35
+ if (!store[specName]) store[specName] = {};
36
+ const entry = store[specName][operationId] || { count: 0, lastUsed: null };
37
+ entry.count += 1;
38
+ entry.lastUsed = new Date().toISOString();
39
+ store[specName][operationId] = entry;
40
+ saveStore(store);
41
+ } catch {}
42
+ }
43
+
44
+ export function getUsage(specName) {
45
+ const store = loadStore();
46
+ return store[specName] || {};
47
+ }
48
+
49
+ export function topOperations(specName, n) {
50
+ const perSpec = getUsage(specName);
51
+ return Object.entries(perSpec)
52
+ .map(([id, { count, lastUsed }]) => ({ id, count, lastUsed }))
53
+ .sort((a, b) => {
54
+ if (b.count !== a.count) return b.count - a.count;
55
+ return (b.lastUsed || "") < (a.lastUsed || "") ? -1 : 1;
56
+ })
57
+ .slice(0, n);
58
+ }
59
+
60
+ export function allUsage() {
61
+ return loadStore();
62
+ }