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.
@@ -1,21 +1,22 @@
1
1
  import { out } from "../output.js";
2
2
  import { parseArgs } from "../args.js";
3
- import { getRegistry, getEntry, getCachedSpec, saveCachedSpec } from "../registry.js";
3
+ import { getRegistry, getEntry, getCachedSpec, saveCachedSpec, allEntries } from "../registry.js";
4
4
  import { fetchSpec } from "./fetch.js";
5
5
  import { matchGlob } from "../glob.js";
6
6
 
7
7
  export async function grepCmd(args) {
8
8
  const { flags, positional } = parseArgs(args);
9
9
  const pattern = positional[0];
10
- if (!pattern) throw new Error(
11
- "Usage: spec grep <pattern> [--spec <name>]\n" +
12
- " Glob patterns: * matches anything, ? matches one char\n" +
13
- " Plain text: substring match across name and description"
14
- );
10
+ if (!pattern)
11
+ throw new Error(
12
+ "Usage: spec grep <pattern> [--spec <name>]\n" +
13
+ " Glob patterns: * matches anything, ? matches one char\n" +
14
+ " Plain text: substring match across name and description"
15
+ );
15
16
 
16
17
  const entries = flags.spec
17
18
  ? [getEntry(flags.spec)]
18
- : getRegistry().filter((e) => e.enabled);
19
+ : allEntries(getRegistry()).filter((e) => e.enabled);
19
20
 
20
21
  if (entries.length === 0) throw new Error("No registered specs. Run 'spec add' first.");
21
22
 
@@ -40,15 +41,17 @@ export async function grepCmd(args) {
40
41
  }
41
42
  } else if (spec.type === "openapi") {
42
43
  for (const op of spec.operations) {
43
- if (matchGlob(pattern, op.id) || matchGlob(pattern, op.path) ||
44
- (op.summary && matchGlob(pattern, op.summary))) {
44
+ if (
45
+ matchGlob(pattern, op.id) ||
46
+ matchGlob(pattern, op.path) ||
47
+ (op.summary && matchGlob(pattern, op.summary))
48
+ ) {
45
49
  matches.push({ id: op.id, method: op.method, path: op.path });
46
50
  }
47
51
  }
48
52
  } else if (spec.type === "graphql") {
49
53
  for (const op of spec.operations) {
50
- if (matchGlob(pattern, op.name) ||
51
- (op.description && matchGlob(pattern, op.description))) {
54
+ if (matchGlob(pattern, op.name) || (op.description && matchGlob(pattern, op.description))) {
52
55
  matches.push({ id: op.name, kind: op.kind });
53
56
  }
54
57
  }
@@ -1,80 +1,89 @@
1
- import { out } from "../output.js";
2
- import { parseArgs } from "../args.js";
3
- import { resolveSpec } from "../resolve.js";
4
-
5
- export async function listOperations(args) {
6
- const opts = parseArgs(args);
7
- const { flags } = opts;
8
-
9
- const { spec } = await resolveSpec(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();
16
-
17
- let operations;
18
-
19
- if (spec.type === "openapi") {
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) =>
25
- compact
26
- ? { id: op.id, method: op.method, path: op.path }
27
- : {
28
- id: op.id,
29
- method: op.method,
30
- path: op.path,
31
- summary: op.summary,
32
- tags: op.tags,
33
- deprecated: op.deprecated,
34
- }
35
- );
36
- } else if (spec.type === "mcp") {
37
- operations = spec.tools.map((t) =>
38
- compact
39
- ? { id: t.name, description: t.description }
40
- : { id: t.name, description: t.description, inputSchema: t.inputSchema }
41
- );
42
- } else {
43
- // graphql
44
- operations = spec.operations.map((op) =>
45
- compact
46
- ? { id: op.name, kind: op.kind }
47
- : {
48
- id: op.name,
49
- kind: op.kind,
50
- description: op.description,
51
- args: op.args.map((a) => a.name),
52
- returnType: op.returnType,
53
- isDeprecated: op.isDeprecated,
54
- }
55
- );
56
-
57
- if (tag) {
58
- operations = operations.filter((op) => op.kind === tag);
59
- }
60
- }
61
-
62
- if (filter) {
63
- operations = operations.filter((op) =>
64
- JSON.stringify(op).toLowerCase().includes(filter)
65
- );
66
- }
67
-
68
- const total = operations.length;
69
-
70
- if (offset > 0) operations = operations.slice(offset);
71
- if (limit > 0) operations = operations.slice(0, limit);
72
-
73
- out({
74
- type: spec.type,
75
- total,
76
- showing: operations.length,
77
- offset: offset || 0,
78
- operations,
79
- });
80
- }
1
+ import { out } from "../output.js";
2
+ import { parseArgs } from "../args.js";
3
+ import { resolveSpec } from "../resolve.js";
4
+ import { getUsage } from "../usage.js";
5
+
6
+ export async function listOperations(args) {
7
+ const opts = parseArgs(args);
8
+ const { flags } = opts;
9
+
10
+ const { spec } = await resolveSpec(flags);
11
+
12
+ const filter = flags.filter?.toLowerCase();
13
+ const compact = flags.compact !== "false";
14
+ const limit = parseInt(flags.limit) || 0;
15
+ const offset = parseInt(flags.offset) || 0;
16
+ const tag = flags.tag?.toLowerCase();
17
+ const top = parseInt(flags.top) || 0;
18
+
19
+ let operations;
20
+
21
+ if (spec.type === "openapi") {
22
+ let source = spec.operations;
23
+ if (tag) {
24
+ source = source.filter((op) => op.tags?.some((t) => t.toLowerCase().includes(tag)));
25
+ }
26
+ operations = source.map((op) =>
27
+ compact
28
+ ? { id: op.id, method: op.method, path: op.path }
29
+ : {
30
+ id: op.id,
31
+ method: op.method,
32
+ path: op.path,
33
+ summary: op.summary,
34
+ tags: op.tags,
35
+ deprecated: op.deprecated,
36
+ }
37
+ );
38
+ } else if (spec.type === "mcp") {
39
+ operations = spec.tools.map((t) =>
40
+ compact
41
+ ? { id: t.name, description: t.description }
42
+ : { id: t.name, description: t.description, inputSchema: t.inputSchema }
43
+ );
44
+ } else {
45
+ // graphql
46
+ operations = spec.operations.map((op) =>
47
+ compact
48
+ ? { id: op.name, kind: op.kind }
49
+ : {
50
+ id: op.name,
51
+ kind: op.kind,
52
+ description: op.description,
53
+ args: op.args.map((a) => a.name),
54
+ returnType: op.returnType,
55
+ isDeprecated: op.isDeprecated,
56
+ }
57
+ );
58
+
59
+ if (tag) {
60
+ operations = operations.filter((op) => op.kind === tag);
61
+ }
62
+ }
63
+
64
+ if (filter) {
65
+ operations = operations.filter((op) => JSON.stringify(op).toLowerCase().includes(filter));
66
+ }
67
+
68
+ const total = operations.length;
69
+
70
+ if (top > 0) {
71
+ const usageMap = flags.spec ? getUsage(flags.spec) : {};
72
+ operations = operations
73
+ .map((op) => ({ op, count: usageMap[op.id]?.count ?? 0 }))
74
+ .sort((a, b) => b.count - a.count)
75
+ .map(({ op }) => op)
76
+ .slice(0, top);
77
+ } else {
78
+ if (offset > 0) operations = operations.slice(offset);
79
+ if (limit > 0) operations = operations.slice(0, limit);
80
+ }
81
+
82
+ out({
83
+ type: spec.type,
84
+ total,
85
+ showing: operations.length,
86
+ offset: offset || 0,
87
+ operations,
88
+ });
89
+ }
@@ -5,7 +5,10 @@ import { resolveSpec } from "../resolve.js";
5
5
  export async function showOperation(args) {
6
6
  const { flags, positional } = parseArgs(args);
7
7
  const target = positional[0];
8
- if (!target) throw new Error("Usage: spec show <operationId-or-path> [--spec <name> | --openapi <url> | ...]");
8
+ if (!target)
9
+ throw new Error(
10
+ "Usage: spec show <operationId-or-path> [--spec <name> | --openapi <url> | ...]"
11
+ );
9
12
 
10
13
  const { spec } = await resolveSpec(flags);
11
14
 
@@ -21,10 +24,11 @@ export async function showOperation(args) {
21
24
  function showOpenAPI(spec, target) {
22
25
  const lower = target.toLowerCase();
23
26
 
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
27
+ const op = spec.operations.find(
28
+ (o) =>
29
+ o.id.toLowerCase() === lower ||
30
+ o.path.toLowerCase() === lower ||
31
+ `${o.method.toLowerCase()} ${o.path.toLowerCase()}` === lower
28
32
  );
29
33
 
30
34
  if (!op) {
@@ -195,8 +199,13 @@ function findRelatedTypes(op, types) {
195
199
  .filter((t) => names.has(t.name) && !scalars.has(t.name))
196
200
  .map((t) => {
197
201
  const result = { name: t.name, kind: t.kind };
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) }));
202
+ if (t.fields)
203
+ result.fields = t.fields.map((f) => ({ name: f.name, type: flattenType(f.type) }));
204
+ if (t.inputFields)
205
+ result.inputFields = t.inputFields.map((f) => ({
206
+ name: f.name,
207
+ type: flattenType(f.type),
208
+ }));
200
209
  if (t.enumValues) result.enumValues = t.enumValues.map((e) => e.name);
201
210
  return result;
202
211
  });
@@ -0,0 +1,40 @@
1
+ import { homedir } from "os";
2
+ import { join, dirname } from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
5
+ import { parseArgs } from "../args.js";
6
+ import { out } from "../output.js";
7
+
8
+ const SKILL_NAME = "api-spec-cli";
9
+ const BUNDLED = join(dirname(fileURLToPath(import.meta.url)), "..", "skill", "SKILL.md");
10
+
11
+ function installDir() {
12
+ return join(homedir(), ".claude", "skills", SKILL_NAME);
13
+ }
14
+
15
+ export async function skillCmd(args) {
16
+ const { flags, positional } = parseArgs(args);
17
+
18
+ if (positional[0] === "path" || "path" in flags) {
19
+ out({ skill: SKILL_NAME, path: BUNDLED });
20
+ return;
21
+ }
22
+
23
+ if (positional[0] === "install" || "install" in flags) {
24
+ if (!existsSync(BUNDLED)) throw new Error(`Bundled skill not found at ${BUNDLED}`);
25
+ const dest = join(installDir(), "SKILL.md");
26
+ mkdirSync(installDir(), { recursive: true });
27
+ writeFileSync(dest, readFileSync(BUNDLED, "utf-8"));
28
+ out({ installed: true, skill: SKILL_NAME, path: dest });
29
+ return;
30
+ }
31
+
32
+ out({
33
+ skill: SKILL_NAME,
34
+ usage: {
35
+ install: "spec skill install Copy the skill into ~/.claude/skills/",
36
+ path: "spec skill path Print the bundled SKILL.md location",
37
+ },
38
+ bundled: BUNDLED,
39
+ });
40
+ }
@@ -1,19 +1,32 @@
1
1
  import { parseArgs } from "../args.js";
2
- import { getRegistry, saveRegistry, getEntry, removeCachedSpec, saveCachedSpec } from "../registry.js";
2
+ import {
3
+ getRegistry,
4
+ saveRegistry,
5
+ getEntry,
6
+ removeCachedSpec,
7
+ saveCachedSpec,
8
+ allEntries,
9
+ } from "../registry.js";
3
10
  import { fetchSpec } from "./fetch.js";
4
11
  import { out } from "../output.js";
5
12
 
13
+ function findSection(registry, name) {
14
+ for (const section of ["mcp", "openapi", "graphql"]) {
15
+ if (registry[section]?.[name]) return section;
16
+ }
17
+ return null;
18
+ }
19
+
6
20
  export async function specsCmd(args) {
7
21
  const { flags } = parseArgs(args);
8
22
  const compact = flags.compact !== "false";
9
23
  const registry = getRegistry();
10
24
 
11
- const specs = registry.map((e) => {
25
+ const specs = allEntries(registry).map((e) => {
12
26
  if (compact) {
13
27
  return {
14
28
  name: e.name,
15
29
  type: e.type,
16
- transport: e.transport,
17
30
  description: e.description || null,
18
31
  enabled: e.enabled,
19
32
  };
@@ -30,11 +43,11 @@ export async function registryMutate(action, args) {
30
43
  if (!name) throw new Error(`Usage: spec ${action} <name>`);
31
44
 
32
45
  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.`);
46
+ const section = findSection(registry, name);
47
+ if (!section) throw new Error(`No spec named '${name}'. Run 'spec specs' to see available.`);
35
48
 
36
49
  if (action === "remove") {
37
- registry.splice(idx, 1);
50
+ delete registry[section][name];
38
51
  saveRegistry(registry);
39
52
  removeCachedSpec(name);
40
53
  out({ ok: true, removed: name });
@@ -42,21 +55,21 @@ export async function registryMutate(action, args) {
42
55
  }
43
56
 
44
57
  if (action === "enable") {
45
- registry[idx].enabled = true;
58
+ registry[section][name].enabled = true;
46
59
  saveRegistry(registry);
47
60
  out({ ok: true, enabled: name });
48
61
  return;
49
62
  }
50
63
 
51
64
  if (action === "disable") {
52
- registry[idx].enabled = false;
65
+ registry[section][name].enabled = false;
53
66
  saveRegistry(registry);
54
67
  out({ ok: true, disabled: name });
55
68
  return;
56
69
  }
57
70
 
58
71
  if (action === "refresh") {
59
- const entry = registry[idx];
72
+ const entry = { ...registry[section][name], name, _section: section };
60
73
  if (!entry.enabled) throw new Error(`Spec '${name}' is disabled. Enable it first.`);
61
74
  const spec = await fetchSpec(entry);
62
75
  saveCachedSpec(name, spec);
@@ -50,7 +50,8 @@ function showOpenAPISchema(spec, target, flags) {
50
50
 
51
51
  function showGraphQLType(spec, target, flags) {
52
52
  const scalars = new Set(["String", "Int", "Float", "Boolean", "ID"]);
53
- const userTypes = spec.types?.filter((t) => !t.name.startsWith("__") && !scalars.has(t.name)) || [];
53
+ const userTypes =
54
+ spec.types?.filter((t) => !t.name.startsWith("__") && !scalars.has(t.name)) || [];
54
55
 
55
56
  if (!target) {
56
57
  // List type names grouped by kind — compact
@@ -85,7 +86,10 @@ function showGraphQLType(spec, target, flags) {
85
86
  result.fields = type.fields.map((f) => ({
86
87
  name: f.name,
87
88
  type: flattenType(f.type),
88
- args: f.args?.length > 0 ? f.args.map((a) => ({ name: a.name, type: flattenType(a.type) })) : undefined,
89
+ args:
90
+ f.args?.length > 0
91
+ ? f.args.map((a) => ({ name: a.name, type: flattenType(a.type) }))
92
+ : undefined,
89
93
  }));
90
94
  }
91
95
 
@@ -0,0 +1,15 @@
1
+ import { out } from "../output.js";
2
+ import { parseArgs } from "../args.js";
3
+ import { allUsage, topOperations } from "../usage.js";
4
+
5
+ export async function usageCmd(args) {
6
+ const { positional } = parseArgs(args);
7
+ const specName = positional[0];
8
+
9
+ if (specName) {
10
+ const operations = topOperations(specName, Infinity);
11
+ out({ spec: specName, operations });
12
+ } else {
13
+ out({ usage: allUsage() });
14
+ }
15
+ }
@@ -88,7 +88,10 @@ function validateInfo(doc, errors, warnings) {
88
88
  errors.push({ path: "info.version", message: "Missing required 'info.version'" });
89
89
  }
90
90
  if (!doc.info.description) {
91
- warnings.push({ path: "info.description", message: "Missing 'info.description' (recommended)" });
91
+ warnings.push({
92
+ path: "info.description",
93
+ message: "Missing 'info.description' (recommended)",
94
+ });
92
95
  }
93
96
  }
94
97
 
@@ -121,7 +124,10 @@ function validatePaths(doc, errors, warnings, isV3) {
121
124
  if (method.startsWith("x-") || method === "parameters" || method === "$ref") continue;
122
125
 
123
126
  if (!METHODS.has(method)) {
124
- warnings.push({ path: `paths.${path}.${method}`, message: `Unknown HTTP method '${method}'` });
127
+ warnings.push({
128
+ path: `paths.${path}.${method}`,
129
+ message: `Unknown HTTP method '${method}'`,
130
+ });
125
131
  continue;
126
132
  }
127
133
 
@@ -161,13 +167,19 @@ function validatePaths(doc, errors, warnings, isV3) {
161
167
  for (const param of pathParams) {
162
168
  if (!declaredParams.has(param)) {
163
169
  // Only warn — the param might be declared via $ref
164
- warnings.push({ path: opPath, message: `Path parameter '{${param}}' may not be declared in parameters` });
170
+ warnings.push({
171
+ path: opPath,
172
+ message: `Path parameter '{${param}}' may not be declared in parameters`,
173
+ });
165
174
  }
166
175
  }
167
176
 
168
177
  // Request body on GET/DELETE/HEAD
169
178
  if (isV3 && op.requestBody && ["get", "delete", "head"].includes(method)) {
170
- warnings.push({ path: opPath, message: `requestBody on ${method.toUpperCase()} is unusual` });
179
+ warnings.push({
180
+ path: opPath,
181
+ message: `requestBody on ${method.toUpperCase()} is unusual`,
182
+ });
171
183
  }
172
184
  }
173
185
  }
@@ -196,7 +208,15 @@ function validateSchema(schema, path, errors, warnings) {
196
208
  if (!schema || typeof schema !== "object") return;
197
209
  if (schema.$ref) return; // reference, skip
198
210
 
199
- const VALID_TYPES = new Set(["string", "number", "integer", "boolean", "array", "object", "null"]);
211
+ const VALID_TYPES = new Set([
212
+ "string",
213
+ "number",
214
+ "integer",
215
+ "boolean",
216
+ "array",
217
+ "object",
218
+ "null",
219
+ ]);
200
220
 
201
221
  if (schema.type && !VALID_TYPES.has(schema.type)) {
202
222
  errors.push({ path, message: `Invalid type '${schema.type}'` });
@@ -220,11 +240,17 @@ function validateSchema(schema, path, errors, warnings) {
220
240
  function validateServers(doc, errors, warnings, isV3) {
221
241
  if (isV3) {
222
242
  if (!doc.servers || doc.servers.length === 0) {
223
- warnings.push({ path: "servers", message: "No servers defined — agent will need baseUrl configured" });
243
+ warnings.push({
244
+ path: "servers",
245
+ message: "No servers defined — agent will need baseUrl configured",
246
+ });
224
247
  }
225
248
  } else {
226
249
  if (!doc.host) {
227
- warnings.push({ path: "host", message: "No host defined — agent will need baseUrl configured" });
250
+ warnings.push({
251
+ path: "host",
252
+ message: "No host defined — agent will need baseUrl configured",
253
+ });
228
254
  }
229
255
  }
230
256
  }
package/src/dotenv.js ADDED
@@ -0,0 +1,38 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { join } from "path";
3
+
4
+ function parseEnv(text) {
5
+ const out = {};
6
+ for (const rawLine of text.split(/\r?\n/)) {
7
+ const line = rawLine.trim();
8
+ if (!line || line.startsWith("#")) continue;
9
+ const eq = line.indexOf("=");
10
+ if (eq === -1) continue;
11
+ const key = line.slice(0, eq).trim();
12
+ if (!key) continue;
13
+ let value = line.slice(eq + 1).trim();
14
+ if (
15
+ (value.startsWith('"') && value.endsWith('"')) ||
16
+ (value.startsWith("'") && value.endsWith("'"))
17
+ ) {
18
+ value = value.slice(1, -1);
19
+ }
20
+ out[key] = value;
21
+ }
22
+ return out;
23
+ }
24
+
25
+ export function loadDotenv(dir = process.cwd()) {
26
+ if (process.env.SPEC_NO_DOTENV) return;
27
+ const file = join(dir, ".env");
28
+ if (!existsSync(file)) return;
29
+ let parsed;
30
+ try {
31
+ parsed = parseEnv(readFileSync(file, "utf-8"));
32
+ } catch {
33
+ return;
34
+ }
35
+ for (const [key, value] of Object.entries(parsed)) {
36
+ if (!(key in process.env)) process.env[key] = value;
37
+ }
38
+ }
package/src/glob.js CHANGED
@@ -1,6 +1,11 @@
1
1
  function globToRegex(pattern) {
2
2
  return new RegExp(
3
- "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$",
3
+ "^" +
4
+ pattern
5
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
6
+ .replace(/\*/g, ".*")
7
+ .replace(/\?/g, ".") +
8
+ "$",
4
9
  "i"
5
10
  );
6
11
  }