Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,8 @@ dist/
.env
.DS_Store
.claude/
.codex/

# dummy package manager files
pnpm-lock.yaml
.pnpm-store/
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ spec2cli reads OpenAPI 3.x and Swagger 2.0 specs at runtime and dynamically gene
# Try it with any OpenAPI spec
npx spec2cli --spec https://petstore3.swagger.io/api/v3/openapi.json pets --help

# Or run it from a pnpm project without installing
pnpm dlx spec2cli --spec https://petstore3.swagger.io/api/v3/openapi.json pets --help

# Or install globally
npm install -g spec2cli
```
Expand Down Expand Up @@ -134,6 +137,28 @@ spec2cli --spec api.yaml pets --help # shows subcommands
spec2cli --spec api.yaml pets create --help # shows flags with types
```

### Large remote specs

spec2cli can read large YAML or JSON specs directly from URLs. Use the raw file URL for GitHub-hosted specs, not the `github.com/.../blob/...` page URL.

```bash
# Inspect Stripe's public OpenAPI spec without cloning this repo or installing globally
pnpm dlx spec2cli \
--spec https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.yaml \
--agent-help

# Explore generated commands from another project
pnpm dlx spec2cli \
--spec https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.yaml \
default --help

# Run an authenticated request
pnpm dlx spec2cli \
--spec https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.yaml \
--token "$STRIPE_SECRET_KEY" \
default getbalance
```

### Debug

```bash
Expand All @@ -151,7 +176,7 @@ spec2cli --spec api.yaml --verbose pets get --petId 1
- Auth: Bearer token, API key, with persistent profiles
- Project config (`.toclirc`) with multiple environments
- Verbose mode for debugging requests
- Works with `npx` (zero install)
- Works with `npx` and `pnpm dlx` (zero install)

## Development

Expand Down
19 changes: 4 additions & 15 deletions src/cli/agent-help.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { stringify as toYaml } from "yaml";
import { commandNamesForGroup } from "./command-names.js";
import type { OperationGroup, OpenAPISpec } from "../parser/types.js";

export function printAgentHelp(groups: OperationGroup[], spec: OpenAPISpec): void {
Expand All @@ -21,9 +22,10 @@ export function printAgentHelp(groups: OperationGroup[], spec: OpenAPISpec): voi

for (const group of groups) {
const groupCmds: Record<string, unknown> = {};
const commandNames = commandNamesForGroup(group);

for (const op of group.operations) {
const cmdName = simplifyName(op.id, group.tag);
for (const [index, op] of group.operations.entries()) {
const cmdName = commandNames[index];
const cmd: Record<string, unknown> = {
method: op.method,
desc: op.summary || op.description,
Expand Down Expand Up @@ -82,19 +84,6 @@ export function resolveAuthHint(spec: OpenAPISpec): string {
return "none";
}

export function simplifyName(operationId: string, tag: string): string {
const tagLower = tag.toLowerCase();
const idLower = operationId.toLowerCase();
const singular = tagLower.endsWith("s") ? tagLower.slice(0, -1) : tagLower;

for (const suffix of [tagLower, singular]) {
if (idLower.endsWith(suffix) && idLower.length > suffix.length) {
return operationId.slice(0, operationId.length - suffix.length).toLowerCase();
}
}
return operationId.toLowerCase();
}

export function resolveBaseUrl(spec: OpenAPISpec, specSource: string): string {
const serverUrl = spec.servers?.[0]?.url;

Expand Down
62 changes: 62 additions & 0 deletions src/cli/command-names.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { commandNamesForGroup } from "./command-names.js";
import type { OperationGroup } from "../parser/types.js";

function makeGroup(overrides: Partial<OperationGroup> = {}): OperationGroup {
return {
tag: "jobs",
description: "Manage jobs",
operations: [
{
id: "syncJob",
method: "GET",
path: "/jobs/sync",
summary: "",
description: "",
params: [],
bodyRequired: false,
security: [],
},
{
id: "syncJob",
method: "POST",
path: "/jobs/sync",
summary: "",
description: "",
params: [],
bodyRequired: false,
security: [],
},
],
...overrides,
};
}

describe("commandNamesForGroup", () => {
it("keeps unique simplified command names unchanged", () => {
const names = commandNamesForGroup(makeGroup({
operations: [
{
id: "listPets",
method: "GET",
path: "/pets",
summary: "",
description: "",
params: [],
bodyRequired: false,
security: [],
},
],
tag: "pets",
}));

expect(names).toEqual(["list"]);
});

it("adds HTTP method suffixes for duplicate simplified names", () => {
expect(commandNamesForGroup(makeGroup())).toEqual([
"sync-get",
"sync-post",
]);
});
});
32 changes: 32 additions & 0 deletions src/cli/command-names.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { OperationGroup } from "../parser/types.js";

export function commandNamesForGroup(group: OperationGroup): string[] {
const baseNames = group.operations.map((op) => simplifyName(op.id, group.tag));
const baseCounts = new Map<string, number>();
for (const name of baseNames) {
baseCounts.set(name, (baseCounts.get(name) ?? 0) + 1);
}

const used = new Map<string, number>();
return group.operations.map((op, index) => {
const baseName = baseNames[index];
const hasCollision = (baseCounts.get(baseName) ?? 0) > 1;
const preferred = hasCollision ? `${baseName}-${op.method.toLowerCase()}` : baseName;
const seen = used.get(preferred) ?? 0;
used.set(preferred, seen + 1);
return seen === 0 ? preferred : `${preferred}-${seen + 1}`;
});
}

export function simplifyName(operationId: string, tag: string): string {
const tagLower = tag.toLowerCase();
const idLower = operationId.toLowerCase();
const singular = tagLower.endsWith("s") ? tagLower.slice(0, -1) : tagLower;

for (const suffix of [tagLower, singular]) {
if (idLower.endsWith(suffix) && idLower.length > suffix.length) {
return operationId.slice(0, operationId.length - suffix.length).toLowerCase();
}
}
return operationId.toLowerCase();
}
43 changes: 43 additions & 0 deletions src/cli/dry-run.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from "vitest";
import { printDryRun } from "./dry-run.js";
import type { Operation } from "../parser/types.js";
import type { RuntimeConfig } from "../executor/types.js";

const op: Operation = {
id: "startImport",
method: "GET",
path: "/#mode=preview",
summary: "",
description: "",
params: [
{ name: "mode", in: "query", type: "enum", required: true, description: "", enum: ["preview"] },
{ name: "fileId", in: "query", type: "string", required: true, description: "" },
],
bodyRequired: false,
security: [],
};

const config: RuntimeConfig = {
specPath: "openapi.json",
baseUrl: "http://localhost:3000",
auth: { type: "none", value: "" },
output: "json",
verbose: false,
quiet: false,
dryRun: true,
validate: false,
};

describe("printDryRun", () => {
it("prints query-like path fragments as search params", () => {
const stdout = vi.spyOn(console, "log").mockImplementation(() => {});

printDryRun(op, { mode: "preview", fileId: "file-123" }, config);

const output = stdout.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("GET http://localhost:3000/?mode=preview&fileId=file-123");
expect(output).not.toContain("#mode=preview");

stdout.mockRestore();
});
});
19 changes: 4 additions & 15 deletions src/cli/dry-run.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,9 @@
import type { Operation } from "../parser/types.js";
import type { RuntimeConfig } from "../executor/types.js";
import { buildOperationUrl } from "../executor/url.js";

export function printDryRun(op: Operation, params: Record<string, unknown>, config: RuntimeConfig): void {
let path = op.path;
for (const p of op.params) {
if (p.in === "path" && params[p.name] !== undefined) {
path = path.replace(`{${p.name}}`, String(params[p.name]));
}
}
const base = config.baseUrl.endsWith("/") ? config.baseUrl.slice(0, -1) : config.baseUrl;
const url = new URL(base + path);
for (const p of op.params) {
if (p.in === "query" && params[p.name] !== undefined) {
url.searchParams.set(p.name, String(params[p.name]));
}
}
const url = buildOperationUrl(op, params, config.baseUrl);

const headers: string[] = [];
if (["POST", "PUT", "PATCH"].includes(op.method)) {
Expand All @@ -37,15 +26,15 @@ export function printDryRun(op: Operation, params: Record<string, unknown>, conf
if (params[p.name] !== undefined) body[p.name] = params[p.name];
}

console.log(`${op.method} ${url.toString()}`);
console.log(`${op.method} ${url}`);
for (const h of headers) console.log(h);
if (Object.keys(body).length > 0) {
console.log("");
console.log(JSON.stringify(body, null, 2));
}

console.log("");
let curl = `curl -X ${op.method} '${url.toString()}'`;
let curl = `curl -X ${op.method} '${url}'`;
for (const h of headers) curl += ` \\\n -H '${h}'`;
if (Object.keys(body).length > 0) curl += ` \\\n -d '${JSON.stringify(body)}'`;
console.log(curl);
Expand Down
30 changes: 19 additions & 11 deletions src/cli/dynamic-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { formatOutput } from "../output/formatters.js";
import { validateResponse } from "../validator/schema.js";
import { filterPii } from "@lucianfialho/pii-filter";
import { printDryRun } from "./dry-run.js";
import { simplifyName } from "./agent-help.js";
import { commandNamesForGroup } from "./command-names.js";
import { flagNameForParam, optionKeyForParam, optionValueForParam } from "./options.js";
import type { RuntimeConfig } from "../executor/types.js";
import type { OperationGroup, OpenAPISpec } from "../parser/types.js";

Expand All @@ -16,13 +17,19 @@ export function buildDynamicCommands(
): void {
for (const group of groups) {
const groupCmd = prog.command(group.tag).description(group.description);
const commandNames = commandNamesForGroup(group);

for (const op of group.operations) {
const cmdName = simplifyName(op.id, group.tag);
for (const [index, op] of group.operations.entries()) {
const cmdName = commandNames[index];
const cmd = groupCmd.command(cmdName).description(op.summary || op.description);
const optionKeys = new Set<string>();

for (const p of op.params) {
const flag = `--${p.name} <${p.name}>`;
const optionKey = optionKeyForParam(p.name);
if (optionKeys.has(optionKey)) continue;
optionKeys.add(optionKey);
const flagName = flagNameForParam(p.name);
const flag = `--${flagName} <${flagName}>`;
const desc = p.description || p.name;
if (p.required) {
cmd.requiredOption(flag, desc);
Expand All @@ -36,19 +43,20 @@ export function buildDynamicCommands(
cmd.action(async (opts: Record<string, unknown>) => {
const params: Record<string, unknown> = {};
for (const p of op.params) {
if (opts[p.name] === undefined) continue;
const value = optionValueForParam(opts, p.name);
if (value === undefined) continue;
if (p.type === "integer" || p.type === "number") {
params[p.name] = Number(opts[p.name]);
params[p.name] = Number(value);
} else if (p.type === "boolean") {
params[p.name] = opts[p.name] === true || opts[p.name] === "true";
} else if ((p.type === "object" || p.type === "array") && typeof opts[p.name] === "string") {
params[p.name] = value === true || value === "true";
} else if ((p.type === "object" || p.type === "array") && typeof value === "string") {
try {
params[p.name] = JSON.parse(opts[p.name] as string);
params[p.name] = JSON.parse(value);
} catch {
params[p.name] = opts[p.name];
params[p.name] = value;
}
} else {
params[p.name] = opts[p.name];
params[p.name] = value;
}
}

Expand Down
13 changes: 13 additions & 0 deletions src/cli/options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { flagNameForParam, optionKeyForParam, optionValueForParam } from "./options.js";

describe("CLI option helpers", () => {
it("trims invalid leading and trailing hyphens from flag names", () => {
expect(flagNameForParam("custom-meta-")).toBe("custom-meta");
});

it("maps hyphenated flags back to original OpenAPI parameter names", () => {
expect(optionKeyForParam("request-id")).toBe("requestId");
expect(optionValueForParam({ requestId: "req-123" }, "request-id")).toBe("req-123");
});
});
24 changes: 24 additions & 0 deletions src/cli/options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export function flagNameForParam(paramName: string): string {
const flagName = paramName
.replace(/[^A-Za-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "");
return flagName || "value";
}

export function optionKeyForParam(paramName: string): string {
return camelcase(flagNameForParam(paramName));
}

export function optionValueForParam(
opts: Record<string, unknown>,
paramName: string
): unknown {
return opts[paramName] ?? opts[optionKeyForParam(paramName)];
}

function camelcase(value: string): string {
return value.split("-").reduce((result, word) => {
if (!word) return result;
return result + word[0].toUpperCase() + word.slice(1);
});
}
Loading
Loading