api-spec-cli 0.0.1 → 0.0.2

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 ADDED
@@ -0,0 +1,191 @@
1
+ # spec-cli
2
+
3
+ Agent-friendly CLI for exploring and calling OpenAPI and GraphQL APIs. Designed for AI coding agents — all output is structured JSON by default, with optional text and YAML formats.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ bun install
9
+ bun link
10
+ ```
11
+
12
+ Or run directly:
13
+
14
+ ```bash
15
+ bun bin/spec.js <command>
16
+ ```
17
+
18
+ ## Quick Start
19
+
20
+ ```bash
21
+ # Load an OpenAPI spec
22
+ spec load https://petstore3.swagger.io/api/v3/openapi.json
23
+
24
+ # Load a GraphQL endpoint (introspection)
25
+ spec load https://gql.hashnode.com
26
+
27
+ # List operations
28
+ spec list
29
+ spec list --filter pets
30
+
31
+ # Show operation details
32
+ spec show getPetById
33
+ spec show publishPost
34
+
35
+ # Call an endpoint
36
+ spec config set baseUrl https://petstore3.swagger.io/api/v3
37
+ spec call findPetsByStatus --query status=available
38
+
39
+ # GraphQL with auth
40
+ spec config set auth YOUR_TOKEN
41
+ spec call me
42
+ ```
43
+
44
+ ## Commands
45
+
46
+ ### `spec load <file-or-url>`
47
+
48
+ Load an API spec from a local file (JSON/YAML) or URL.
49
+
50
+ - **OpenAPI/Swagger**: Detects JSON or YAML, supports v2 and v3
51
+ - **GraphQL**: Runs introspection query on the endpoint
52
+
53
+ ```bash
54
+ spec load ./openapi.yaml
55
+ spec load https://api.example.com/openapi.json
56
+ spec load https://gql.example.com/graphql
57
+ ```
58
+
59
+ ### `spec list [--filter <text>]`
60
+
61
+ List all operations in the loaded spec.
62
+
63
+ ```bash
64
+ spec list # all operations
65
+ spec list --filter user # filter by keyword
66
+ ```
67
+
68
+ **OpenAPI output**: `id`, `method`, `path`, `summary`, `tags`, `deprecated`
69
+ **GraphQL output**: `id`, `kind` (query/mutation/subscription), `description`, `args`, `returnType`
70
+
71
+ ### `spec show <operation>`
72
+
73
+ Show full details of an operation.
74
+
75
+ ```bash
76
+ spec show getPetById # by operationId
77
+ spec show /pet/{petId} # by path
78
+ spec show "GET /pet/{petId}" # by method + path
79
+ spec show publishPost # GraphQL operation name
80
+ ```
81
+
82
+ For OpenAPI: resolves `$ref` references in parameters, request body, and responses.
83
+ For GraphQL: includes related types with field definitions.
84
+
85
+ ### `spec call <operation> [options]`
86
+
87
+ Execute an API request.
88
+
89
+ ```bash
90
+ # OpenAPI
91
+ spec call addPet --data '{"name":"Rex","photoUrls":[]}'
92
+ spec call getPetById --var petId=1
93
+ spec call findPetsByStatus --query status=available
94
+ spec call updatePet --method PUT --data '{"id":1,"name":"Rex"}'
95
+
96
+ # GraphQL
97
+ spec call me
98
+ spec call publication --var host=blog.example.com
99
+ spec call publishPost --data '{"query":"mutation { ... }"}'
100
+ ```
101
+
102
+ **Options:**
103
+ | Flag | Description |
104
+ |------|-------------|
105
+ | `--data '{"key":"val"}'` | Request body (JSON) |
106
+ | `--query key=val` | Query parameter (repeatable) |
107
+ | `--header key=val` | Per-request header (repeatable) |
108
+ | `--var key=val` | Path variable or GraphQL variable (repeatable) |
109
+ | `--method GET\|POST\|...` | Override HTTP method |
110
+
111
+ For GraphQL calls without `--data`, the CLI auto-generates a query from the operation's schema, selecting scalar fields from the return type.
112
+
113
+ ### `spec validate <file-or-url>`
114
+
115
+ Validate an OpenAPI spec and report errors and warnings.
116
+
117
+ ```bash
118
+ spec validate ./openapi.yaml
119
+ spec validate https://api.example.com/openapi.json
120
+ ```
121
+
122
+ Checks:
123
+ - Required fields (`info`, `info.title`, `info.version`, `paths`)
124
+ - Valid HTTP methods and schema types
125
+ - Unique `operationId` values
126
+ - Broken `$ref` references
127
+ - Array schemas have `items`
128
+ - Path parameters are declared
129
+ - Server/host definitions
130
+ - Unusual patterns (request body on GET)
131
+
132
+ ### `spec config`
133
+
134
+ Manage persistent configuration stored in `.spec-cli/config.json`.
135
+
136
+ ```bash
137
+ spec config get # show all config
138
+ spec config get baseUrl # show single key
139
+ spec config set baseUrl https://api.example.com
140
+ spec config set auth my-api-token # adds Bearer prefix
141
+ spec config set auth "Bearer my-token" # explicit Bearer
142
+ spec config set auth "Basic dXNlcjpwYXNz" # Basic auth
143
+ spec config set headers.X-API-Key abc123 # custom header (dot notation)
144
+ spec config unset headers.X-API-Key # remove a key
145
+ ```
146
+
147
+ ## Output Formats
148
+
149
+ All commands support `--format`:
150
+
151
+ ```bash
152
+ spec list --format json # JSON (default)
153
+ spec list --format text # human-readable
154
+ spec list --format yaml # YAML
155
+ ```
156
+
157
+ Errors always output as JSON to stderr for reliable agent parsing.
158
+
159
+ ## GraphQL Coverage
160
+
161
+ Full GraphQL schema support via introspection:
162
+ - **Queries** — all root query fields
163
+ - **Mutations** — all root mutation fields
164
+ - **Subscriptions** — all root subscription fields
165
+ - **Types** — input objects, enums, scalars, object types with fields
166
+ - **Args** — full argument definitions with types and defaults
167
+
168
+ ## For AI Agents
169
+
170
+ This CLI is designed to be used by AI coding agents (Claude, GPT, etc.) as an MCP tool or shell command:
171
+
172
+ 1. **Structured output** — JSON by default, every field is predictable
173
+ 2. **Error format** — `{"error": "message"}` on stderr, non-zero exit code
174
+ 3. **Discoverable** — `list` and `show` let agents explore APIs without docs
175
+ 4. **Auto-query building** — GraphQL calls auto-generate queries from the schema
176
+ 5. **Persistent config** — set auth once, use across calls
177
+ 6. **No interactive prompts** — everything is flags and args
178
+
179
+ ## Storage
180
+
181
+ All state is stored in `.spec-cli/` in the current directory:
182
+ - `spec.json` — cached loaded spec
183
+ - `config.json` — base URL, headers, auth
184
+
185
+ Add `.spec-cli/` to your `.gitignore`.
186
+
187
+ ## Dependencies
188
+
189
+ - [yaml](https://www.npmjs.com/package/yaml) — YAML parsing (for OpenAPI YAML specs and YAML output)
190
+
191
+ No other runtime dependencies. Uses Bun's built-in `fetch` for HTTP.
package/bin/spec.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { run } from "../src/cli.js";
4
+
5
+ run(process.argv.slice(2));
package/package.json CHANGED
@@ -1,13 +1,37 @@
1
1
  {
2
2
  "name": "api-spec-cli",
3
- "version": "0.0.1",
4
- "description": "",
5
- "main": "index.js",
3
+ "version": "0.0.2",
4
+ "description": "Agent-friendly CLI for exploring and calling OpenAPI and GraphQL APIs",
5
+ "type": "module",
6
+ "bin": {
7
+ "spec": "./bin/spec.js"
8
+ },
6
9
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
10
+ "test": "bun test"
11
+ },
12
+ "files": [
13
+ "bin/",
14
+ "src/",
15
+ "README.md"
16
+ ],
17
+ "keywords": [
18
+ "openapi",
19
+ "graphql",
20
+ "cli",
21
+ "agent",
22
+ "api",
23
+ "swagger",
24
+ "ai-agent",
25
+ "mcp",
26
+ "introspection"
27
+ ],
28
+ "author": "niradler55",
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/niradler/api-spec-cli.git"
8
33
  },
9
- "keywords": [],
10
- "author": "",
11
- "license": "ISC",
12
- "type": "commonjs"
34
+ "dependencies": {
35
+ "yaml": "^2.7.0"
36
+ }
13
37
  }
package/src/args.js ADDED
@@ -0,0 +1,46 @@
1
+ // Simple arg parser — no deps needed.
2
+ // Supports: --flag value, --flag=value, and positional args
3
+ // Repeatable flags (--query, --header, --var) are collected into arrays.
4
+
5
+ const REPEATABLE = new Set(["query", "header", "var"]);
6
+
7
+ export function parseArgs(args) {
8
+ const flags = {};
9
+ const positional = [];
10
+
11
+ for (let i = 0; i < args.length; i++) {
12
+ const arg = args[i];
13
+ if (arg.startsWith("--")) {
14
+ let key, value;
15
+ if (arg.includes("=")) {
16
+ [key, ...value] = arg.slice(2).split("=");
17
+ value = value.join("=");
18
+ } else {
19
+ key = arg.slice(2);
20
+ value = args[++i];
21
+ }
22
+
23
+ if (REPEATABLE.has(key)) {
24
+ if (!flags[key]) flags[key] = [];
25
+ flags[key].push(value);
26
+ } else {
27
+ flags[key] = value;
28
+ }
29
+ } else {
30
+ positional.push(arg);
31
+ }
32
+ }
33
+
34
+ return { flags, positional };
35
+ }
36
+
37
+ // Parse key=value pairs from an array of strings
38
+ export function parseKV(pairs) {
39
+ const result = {};
40
+ for (const pair of pairs || []) {
41
+ const idx = pair.indexOf("=");
42
+ if (idx === -1) throw new Error(`Invalid key=value: ${pair}`);
43
+ result[pair.slice(0, idx)] = pair.slice(idx + 1);
44
+ }
45
+ return result;
46
+ }
package/src/cli.js ADDED
@@ -0,0 +1,79 @@
1
+ import { loadSpec } from "./commands/load.js";
2
+ import { listOperations } from "./commands/list.js";
3
+ import { showOperation } from "./commands/show.js";
4
+ import { callOperation } from "./commands/call.js";
5
+ import { configCmd } from "./commands/config.js";
6
+ import { validateSpec } from "./commands/validate.js";
7
+ import { out, err, setFormat } from "./output.js";
8
+
9
+ const HELP = `spec-cli — Agent-friendly API spec explorer
10
+
11
+ Commands:
12
+ spec load <file-or-url> Load an OpenAPI (JSON/YAML) or GraphQL endpoint
13
+ spec list [--filter <text>] List all operations
14
+ spec show <operation> Show operation details (params, body, response)
15
+ spec call <operation> [options] Execute a request
16
+ spec validate <file-or-url> Validate an OpenAPI spec (errors + warnings)
17
+ spec config set <key> <value> Set config (baseUrl, headers.X-Key, auth)
18
+ spec config get [key] Show config
19
+ spec config unset <key> Remove config key
20
+
21
+ Call options:
22
+ --data '{"key":"val"}' Request body (JSON)
23
+ --query key=val Query parameters (repeatable)
24
+ --header key=val Per-request headers (repeatable)
25
+ --var key=val Path variables (repeatable)
26
+ --method GET|POST|... Override HTTP method
27
+
28
+ Output format (all commands):
29
+ --format json JSON (default, best for agents)
30
+ --format text Human-readable text
31
+ --format yaml YAML output
32
+
33
+ All output defaults to JSON for agent consumption.`;
34
+
35
+ export async function run(args) {
36
+ // Extract --format before routing
37
+ const formatIdx = args.indexOf("--format");
38
+ if (formatIdx !== -1) {
39
+ setFormat(args[formatIdx + 1]);
40
+ args = [...args.slice(0, formatIdx), ...args.slice(formatIdx + 2)];
41
+ }
42
+
43
+ const cmd = args[0];
44
+
45
+ if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
46
+ out({ help: HELP });
47
+ return;
48
+ }
49
+
50
+ try {
51
+ switch (cmd) {
52
+ case "load":
53
+ await loadSpec(args.slice(1));
54
+ break;
55
+ case "list":
56
+ case "ls":
57
+ await listOperations(args.slice(1));
58
+ break;
59
+ case "show":
60
+ await showOperation(args.slice(1));
61
+ break;
62
+ case "call":
63
+ await callOperation(args.slice(1));
64
+ break;
65
+ case "validate":
66
+ await validateSpec(args.slice(1));
67
+ break;
68
+ case "config":
69
+ case "cfg":
70
+ await configCmd(args.slice(1));
71
+ break;
72
+ default:
73
+ err(`Unknown command: ${cmd}. Run 'spec help' for usage.`);
74
+ }
75
+ } catch (e) {
76
+ err(e.message);
77
+ process.exit(1);
78
+ }
79
+ }
@@ -0,0 +1,198 @@
1
+ import { getSpec, getConfig } from "../store.js";
2
+ import { out } from "../output.js";
3
+ import { parseArgs, parseKV } from "../args.js";
4
+
5
+ export async function callOperation(args) {
6
+ const { flags, positional } = parseArgs(args);
7
+ const target = positional[0];
8
+ if (!target) throw new Error("Usage: spec call <operationId-or-path> [--data '{}'] [--query k=v] [--header k=v] [--var k=v] [--method GET]");
9
+
10
+ const spec = getSpec();
11
+ if (!spec) throw new Error("No spec loaded. Run: spec load <file-or-url>");
12
+
13
+ const config = getConfig();
14
+
15
+ if (spec.type === "openapi") {
16
+ await callOpenAPI(spec, config, target, flags);
17
+ } else {
18
+ await callGraphQL(spec, config, target, flags);
19
+ }
20
+ }
21
+
22
+ async function callOpenAPI(spec, config, target, flags) {
23
+ const lower = target.toLowerCase();
24
+
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
+ });
32
+
33
+ if (!op) throw new Error(`Operation not found: ${target}`);
34
+
35
+ // Build URL
36
+ const baseUrl = config.baseUrl || spec.servers?.[0]?.url || "";
37
+ let path = op.path;
38
+
39
+ // Substitute path variables
40
+ const vars = parseKV(flags.var);
41
+ for (const [key, val] of Object.entries(vars)) {
42
+ path = path.replace(`{${key}}`, encodeURIComponent(val));
43
+ }
44
+
45
+ // Query params
46
+ const queryParams = parseKV(flags.query);
47
+ const qs = new URLSearchParams(queryParams).toString();
48
+ const url = `${baseUrl}${path}${qs ? "?" + qs : ""}`;
49
+
50
+ // Method
51
+ const method = (flags.method || op.method).toUpperCase();
52
+
53
+ // Headers
54
+ const headers = {
55
+ ...config.headers,
56
+ ...parseKV(flags.header),
57
+ };
58
+
59
+ // Auth
60
+ if (config.auth) {
61
+ if (config.auth.startsWith("Bearer ") || config.auth.startsWith("Basic ")) {
62
+ headers["Authorization"] = config.auth;
63
+ } else {
64
+ headers["Authorization"] = `Bearer ${config.auth}`;
65
+ }
66
+ }
67
+
68
+ // Body
69
+ let body = undefined;
70
+ if (flags.data) {
71
+ body = flags.data;
72
+ if (!headers["Content-Type"]) {
73
+ headers["Content-Type"] = "application/json";
74
+ }
75
+ }
76
+
77
+ const res = await fetch(url, { method, headers, body });
78
+ const contentType = res.headers.get("content-type") || "";
79
+ let responseBody;
80
+
81
+ if (contentType.includes("json")) {
82
+ responseBody = await res.json();
83
+ } else {
84
+ responseBody = await res.text();
85
+ }
86
+
87
+ out({
88
+ status: res.status,
89
+ statusText: res.statusText,
90
+ headers: Object.fromEntries(res.headers.entries()),
91
+ body: responseBody,
92
+ });
93
+ }
94
+
95
+ async function callGraphQL(spec, config, target, flags) {
96
+ const lower = target.toLowerCase();
97
+
98
+ const op = spec.operations.find((o) => o.name.toLowerCase() === lower);
99
+ if (!op) throw new Error(`Operation not found: ${target}`);
100
+
101
+ const endpoint = spec.endpoint;
102
+ if (!endpoint) throw new Error("No GraphQL endpoint set");
103
+
104
+ // Build query from operation
105
+ let query;
106
+ if (flags.data) {
107
+ // If --data is provided, treat as raw GraphQL query
108
+ try {
109
+ const parsed = JSON.parse(flags.data);
110
+ query = parsed.query || flags.data;
111
+ } catch {
112
+ query = flags.data;
113
+ }
114
+ } else {
115
+ // Auto-build a simple query/mutation
116
+ query = buildGraphQLQuery(op, spec.types);
117
+ }
118
+
119
+ // Variables from --var flags
120
+ const variables = parseKV(flags.var);
121
+
122
+ const headers = {
123
+ "Content-Type": "application/json",
124
+ ...config.headers,
125
+ ...parseKV(flags.header),
126
+ };
127
+
128
+ if (config.auth) {
129
+ if (config.auth.startsWith("Bearer ") || config.auth.startsWith("Basic ")) {
130
+ headers["Authorization"] = config.auth;
131
+ } else {
132
+ headers["Authorization"] = `Bearer ${config.auth}`;
133
+ }
134
+ }
135
+
136
+ const body = JSON.stringify({
137
+ query,
138
+ variables: Object.keys(variables).length > 0 ? variables : undefined,
139
+ });
140
+
141
+ const res = await fetch(config.baseUrl || endpoint, { method: "POST", headers, body });
142
+ const responseBody = await res.json();
143
+
144
+ out({
145
+ status: res.status,
146
+ query,
147
+ variables: Object.keys(variables).length > 0 ? variables : undefined,
148
+ data: responseBody.data || null,
149
+ errors: responseBody.errors || null,
150
+ });
151
+ }
152
+
153
+ function buildGraphQLQuery(op, types) {
154
+ const args = op.args || [];
155
+ const argsStr = args.length > 0
156
+ ? `(${args.map((a) => `$${a.name}: ${flattenType(a.type)}`).join(", ")})`
157
+ : "";
158
+
159
+ const passArgs = args.length > 0
160
+ ? `(${args.map((a) => `${a.name}: $${a.name}`).join(", ")})`
161
+ : "";
162
+
163
+ // Try to build a field selection from the return type
164
+ const returnTypeName = op.returnType?.replace(/[[\]!]/g, "");
165
+ const returnType = types?.find((t) => t.name === returnTypeName);
166
+ let fields = "";
167
+
168
+ if (returnType?.fields) {
169
+ // Select scalar fields only (1 level deep)
170
+ const scalarFields = returnType.fields
171
+ .filter((f) => {
172
+ const typeName = flattenType(f.type)?.replace(/[[\]!]/g, "");
173
+ const t = types?.find((tt) => tt.name === typeName);
174
+ return !t || t.kind === "SCALAR" || t.kind === "ENUM";
175
+ })
176
+ .map((f) => f.name);
177
+
178
+ if (scalarFields.length > 0) {
179
+ fields = ` { ${scalarFields.join(" ")} }`;
180
+ }
181
+ }
182
+
183
+ const keyword = op.kind === "mutation" ? "mutation" : "query";
184
+ return `${keyword}${argsStr} { ${op.name}${passArgs}${fields} }`;
185
+ }
186
+
187
+ function flattenType(t) {
188
+ if (!t) return null;
189
+ if (typeof t === "string") return t;
190
+ if (t.name) return t.kind === "NON_NULL" ? `${t.name}!` : t.name;
191
+ if (t.ofType) {
192
+ const inner = flattenType(t.ofType);
193
+ if (t.kind === "LIST") return `[${inner}]`;
194
+ if (t.kind === "NON_NULL") return `${inner}!`;
195
+ return inner;
196
+ }
197
+ return t.kind;
198
+ }
@@ -0,0 +1,75 @@
1
+ import { getConfig, setConfig } from "../store.js";
2
+ import { out } from "../output.js";
3
+
4
+ export async function configCmd(args) {
5
+ const sub = args[0];
6
+
7
+ if (!sub || sub === "get" || sub === "show") {
8
+ const key = args[1];
9
+ const config = getConfig();
10
+ if (key) {
11
+ out({ [key]: getNestedValue(config, key) });
12
+ } else {
13
+ out(config);
14
+ }
15
+ return;
16
+ }
17
+
18
+ if (sub === "set") {
19
+ const key = args[1];
20
+ const value = args[2];
21
+ if (!key || value === undefined) throw new Error("Usage: spec config set <key> <value>");
22
+
23
+ const config = getConfig();
24
+ setNestedValue(config, key, value);
25
+ setConfig(config);
26
+ out({ ok: true, [key]: value });
27
+ return;
28
+ }
29
+
30
+ if (sub === "unset") {
31
+ const key = args[1];
32
+ if (!key) throw new Error("Usage: spec config unset <key>");
33
+
34
+ const config = getConfig();
35
+ deleteNestedValue(config, key);
36
+ setConfig(config);
37
+ out({ ok: true, deleted: key });
38
+ return;
39
+ }
40
+
41
+ throw new Error(`Unknown config subcommand: ${sub}. Use: get, set, unset`);
42
+ }
43
+
44
+ // Support dotted keys: "headers.Authorization", "headers.X-API-Key"
45
+ function setNestedValue(obj, key, value) {
46
+ const parts = key.split(".");
47
+ let current = obj;
48
+ for (let i = 0; i < parts.length - 1; i++) {
49
+ if (!(parts[i] in current) || typeof current[parts[i]] !== "object") {
50
+ current[parts[i]] = {};
51
+ }
52
+ current = current[parts[i]];
53
+ }
54
+ current[parts[parts.length - 1]] = value;
55
+ }
56
+
57
+ function getNestedValue(obj, key) {
58
+ const parts = key.split(".");
59
+ let current = obj;
60
+ for (const part of parts) {
61
+ if (current == null) return undefined;
62
+ current = current[part];
63
+ }
64
+ return current;
65
+ }
66
+
67
+ function deleteNestedValue(obj, key) {
68
+ const parts = key.split(".");
69
+ let current = obj;
70
+ for (let i = 0; i < parts.length - 1; i++) {
71
+ if (current == null) return;
72
+ current = current[parts[i]];
73
+ }
74
+ if (current != null) delete current[parts[parts.length - 1]];
75
+ }
@@ -0,0 +1,47 @@
1
+ import { getSpec } from "../store.js";
2
+ import { out } from "../output.js";
3
+ import { parseArgs } from "../args.js";
4
+
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
+ const opts = parseArgs(args);
10
+ const filter = opts.flags.filter?.toLowerCase();
11
+
12
+ let operations;
13
+
14
+ if (spec.type === "openapi") {
15
+ operations = spec.operations.map((op) => ({
16
+ id: op.id,
17
+ method: op.method,
18
+ path: op.path,
19
+ summary: op.summary,
20
+ tags: op.tags,
21
+ deprecated: op.deprecated,
22
+ }));
23
+ } else {
24
+ // graphql
25
+ operations = spec.operations.map((op) => ({
26
+ id: op.name,
27
+ kind: op.kind,
28
+ description: op.description,
29
+ args: op.args.map((a) => a.name),
30
+ returnType: op.returnType,
31
+ isDeprecated: op.isDeprecated,
32
+ }));
33
+ }
34
+
35
+ if (filter) {
36
+ operations = operations.filter((op) => {
37
+ const text = JSON.stringify(op).toLowerCase();
38
+ return text.includes(filter);
39
+ });
40
+ }
41
+
42
+ out({
43
+ type: spec.type,
44
+ count: operations.length,
45
+ operations,
46
+ });
47
+ }