diff --git a/README.md b/README.md index 0fc3d11..abc746a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ## OpenAPI to CLI (ocli) -`openapi-to-cli` (short `ocli`) is a TypeScript CLI that turns any HTTP API described by an OpenAPI/Swagger spec into a set of CLI commands — at runtime, without code generation. +`openapi-to-cli` (short `ocli`) is a TypeScript CLI that turns any HTTP API described by an OpenAPI, Swagger, or OpenRPC spec into a set of CLI commands — at runtime, without code generation. ```bash npm install -g openapi-to-cli @@ -63,6 +63,35 @@ ocli commands -p other --query "send message" `--profile` (short `-p`) overrides the profile selected by `ocli use` for this invocation only. It works for both dynamic API commands and `ocli commands`. Place it anywhere after the command name. When omitted, the profile set via `ocli use` is used (falling back to `default`). +### OpenRPC and JSON-RPC APIs + +`ocli` also accepts OpenRPC JSON or YAML documents through the existing `--openapi-spec` option. It creates one command for each documented RPC method and exposes named RPC parameters as command flags. + +```bash +ocli profiles add widgets \ + --api-base-url https://api.example.com/rpc \ + --openapi-spec ./openrpc.json \ + --api-bearer-token "$TOKEN" + +ocli use widgets +ocli getWidget --widgetId widget-7 +``` + +OpenRPC commands always send an HTTP `POST` request with `Content-Type: application/json`. `ocli` builds the JSON-RPC 2.0 envelope automatically: + +```json +{ + "jsonrpc": "2.0", + "method": "getWidget", + "params": { + "widgetId": "widget-7" + }, + "id": 1 +} +``` + +The profile's `--api-base-url` remains the request target. If it is empty, `ocli` falls back to the first server URL in the OpenRPC document. + ### Authentication and custom headers A profile stores up to three credentials, set with `ocli profiles add` (or `ocli onboard`): @@ -107,13 +136,14 @@ npx openapi-to-cli onboard \ ### Broader spec support -`ocli` now handles a wider range of real-world OpenAPI and Swagger documents: +`ocli` now handles a wider range of real-world API descriptions: - OAS 3 `requestBody` for JSON payloads - Swagger 2 `body` and `formData` parameters - path-level parameters inherited by operations - local `$ref` references for parameters and request bodies - header and cookie parameters in generated commands +- OpenRPC methods with named parameters, including local schema references In practice this improves compatibility with APIs that define inputs outside simple path/query parameters, especially for `POST`, `PUT`, and `PATCH` operations. @@ -222,6 +252,7 @@ Note: MCP+Search Compact (search → get_schema → call) is the fairest compari | Regex command search | ✅ | ❌ | ❌ | ❌ | | Per-profile endpoint filtering | ✅ | ✅ | ❌ | ❌ | | OpenAPI/Swagger (JSON + YAML) | ✅ | ✅ | ✅ | ❌ | +| OpenRPC (JSON + YAML) | ✅ | ? | ? | ? | | MCP server support | ❌ | ✅ (HTTP/SSE/stdio) | ❌ | ❌ | | GraphQL support | ❌ | ✅ (introspection) | ❌ | ❌ | | Spec caching | ✅ | ✅ (1h TTL) | ❌ | ❌ | diff --git a/examples/skill-ocli-api.md b/examples/skill-ocli-api.md index 16bf840..044600d 100644 --- a/examples/skill-ocli-api.md +++ b/examples/skill-ocli-api.md @@ -72,6 +72,18 @@ ocli repos_get --profile github --owner octocat --repo Hello-World ocli commands -p github --query "list pull requests" ``` +## OpenRPC APIs + +`ocli` accepts OpenRPC JSON or YAML documents through `--openapi-spec` too. It creates one command per RPC method and automatically sends a JSON-RPC 2.0 envelope. Pass documented named RPC parameters as regular flags: + +```bash +ocli profiles add widgets \ + --api-base-url https://api.example.com/rpc \ + --openapi-spec ./openrpc.json + +ocli getWidget --widgetId widget-7 +``` + ## Tips - All responses are JSON — pipe through `jq` for filtering @@ -80,4 +92,5 @@ ocli commands -p github --query "list pull requests" - Undeclared flags are rejected with `Unknown option: --x`, so copy names exactly from `--help` (including a leading `$`) - Use `ocli commands` to list all available commands - Use `--profile ` (or `-p `) to switch profile for a single call without running `ocli use` +- For OpenRPC, pass documented RPC parameters as flags. `ocli` adds `jsonrpc`, `method`, and `id` to the request body. ```` diff --git a/skills/ocli-api/SKILL.md b/skills/ocli-api/SKILL.md index f9c3553..def682a 100644 --- a/skills/ocli-api/SKILL.md +++ b/skills/ocli-api/SKILL.md @@ -1,6 +1,6 @@ --- name: ocli-api -description: Turn any OpenAPI/Swagger API into CLI commands and call them. Search endpoints with BM25, check parameters, execute — no MCP server needed. +description: Turn any OpenAPI, Swagger, or OpenRPC API into CLI commands and call them. Search endpoints with BM25, check parameters, execute — no MCP server needed. version: 1.0.0 user-invocable: true disable-model-invocation: false @@ -8,15 +8,16 @@ metadata: {"openclaw":{"emoji":"🔌","requires":{"bins":["ocli"],"env":[]}}} homepage: https://github.com/EvilFreelancer/openapi-to-cli --- -# ocli — OpenAPI to CLI +# ocli — API specification to CLI -Call any HTTP API described by an OpenAPI/Swagger spec as CLI commands. +Call any HTTP API described by an OpenAPI, Swagger, or OpenRPC spec as CLI commands. No MCP server, no code generation, no JSON schemas in context. ## When to use - You need to call a REST API (internal, cloud, SaaS) - You have an OpenAPI or Swagger spec (URL or local file) +- You have an OpenRPC JSON or YAML document for a JSON-RPC API - You want minimal token overhead (1 tool, ~158 tokens/turn) ## Setup (one-time) @@ -32,6 +33,16 @@ ocli profiles add \ ocli use ``` +For OpenRPC, use the same profile command. `ocli` builds one command per RPC method and wraps documented named parameters in a JSON-RPC 2.0 request automatically: + +```bash +ocli profiles add rpc-api \ + --api-base-url https://api.example.com/rpc \ + --openapi-spec ./openrpc.json + +ocli getWidget --widgetId widget-7 +``` + ## Workflow 1. **Search** for the right command: @@ -76,6 +87,7 @@ ocli repos_get --profile github --owner octocat --repo Hello-World - Always search before guessing a command name. - Always check `--help` before calling a command you haven't used before. - Never fabricate parameter names — use the ones from `--help` output. +- For OpenRPC, pass only documented RPC parameters. Do not add `jsonrpc`, `method`, or `id`; `ocli` supplies them. - If a command returns an error, read the response body before retrying. ## Failure handling diff --git a/src/cli.ts b/src/cli.ts index f79d1ec..0012e6f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -420,6 +420,24 @@ function buildRequestPayload( .filter(([key]) => !knownOptionNames.has(key)) .map(([key, value]) => [key, parseBodyFlagValue(value)] as const); + if (command.jsonRpcMethod) { + const params = Object.fromEntries([ + ...bodyOptions + .filter((opt) => flags[opt.name] !== undefined) + .map((opt) => [opt.name, parseBodyFlagValue(flags[opt.name])] as const), + ...extraBodyEntries, + ]); + return { + data: { + jsonrpc: "2.0", + method: command.jsonRpcMethod, + params, + id: 1, + }, + contentType: "application/json", + }; + } + if (bodyOptions.length === 1 && bodyOptions[0].name === "body" && flags.body !== undefined) { return { data: parseBodyFlagValue(flags.body), diff --git a/src/openapi-to-commands.ts b/src/openapi-to-commands.ts index 4de4294..b309a34 100644 --- a/src/openapi-to-commands.ts +++ b/src/openapi-to-commands.ts @@ -26,6 +26,7 @@ export interface CliCommand { requestContentType?: string; serverUrl?: string; serverUrlOverridesProfile?: boolean; + jsonRpcMethod?: string; } type HttpMethod = "get" | "post" | "put" | "delete" | "patch" | "head" | "options" | "trace"; @@ -86,8 +87,26 @@ interface ServerLike { variables?: Record; } +interface OpenRpcMethodLike { + name?: string; + summary?: string; + description?: string; + params?: unknown[]; +} + +interface OpenRpcParameterLike { + name?: string; + description?: string; + required?: boolean; + schema?: SchemaLike; +} + export class OpenapiToCommands { buildCommands(spec: OpenapiSpecLike, profile: Profile): CliCommand[] { + if (this.isOpenRpcSpec(spec)) { + return this.buildOpenRpcCommands(spec, profile); + } + const operations = this.collectOperations(spec); const methodsByPath: Record> = {}; @@ -111,6 +130,63 @@ export class OpenapiToCommands { return commands; } + private isOpenRpcSpec(spec: OpenapiSpecLike): boolean { + return Boolean( + spec && + typeof spec === "object" && + typeof spec.openrpc === "string" && + Array.isArray(spec.methods) + ); + } + + private buildOpenRpcCommands(spec: OpenapiSpecLike, profile: Profile): CliCommand[] { + const serverUrl = this.resolveServers(spec.servers); + const methods = spec.methods as OpenRpcMethodLike[]; + const commands: CliCommand[] = []; + + for (const rawMethod of methods) { + const method = this.resolveValue(rawMethod, spec) as OpenRpcMethodLike; + if (!method.name) { + continue; + } + + const options = (method.params ?? []) + .map((rawParameter) => this.resolveValue(rawParameter, spec) as OpenRpcParameterLike) + .filter((parameter) => Boolean(parameter.name)) + .map((parameter) => { + const schema = this.resolveSchema(parameter.schema, spec); + return { + name: parameter.name as string, + location: "body" as const, + required: Boolean(parameter.required), + schemaType: this.describeSchemaType(schema), + description: parameter.description, + ...this.extractSchemaHints(schema), + }; + }); + + commands.push({ + name: method.name, + method: "post", + path: "", + options, + description: method.summary ?? method.description, + requestContentType: "application/json", + serverUrl, + serverUrlOverridesProfile: false, + jsonRpcMethod: method.name, + }); + } + + if (profile.commandPrefix) { + for (const command of commands) { + command.name = `${profile.commandPrefix}${command.name}`; + } + } + + return commands; + } + private collectOperations(spec: OpenapiSpecLike): PathOperation[] { const result: PathOperation[] = []; const paths = spec.paths ?? {}; diff --git a/tests/fixtures/openrpc.json b/tests/fixtures/openrpc.json new file mode 100644 index 0000000..0a17306 --- /dev/null +++ b/tests/fixtures/openrpc.json @@ -0,0 +1,41 @@ +{ + "openrpc": "1.0.0-rc1", + "info": { + "title": "Example RPC API", + "version": "1.0.0" + }, + "servers": [ + { + "name": "example", + "url": "https://api.example.test/rpc" + } + ], + "methods": [ + { + "name": "getWidget", + "description": "Get one widget", + "params": [ + { + "name": "widgetId", + "description": "Widget identifier", + "required": true, + "schema": { + "$ref": "#/components/schemas/Integer" + } + } + ] + }, + { + "name": "listWidgets", + "description": "List widgets", + "params": [] + } + ], + "components": { + "schemas": { + "Integer": { + "type": "integer" + } + } + } +} diff --git a/tests/openrpc-cli.test.ts b/tests/openrpc-cli.test.ts new file mode 100644 index 0000000..236d153 --- /dev/null +++ b/tests/openrpc-cli.test.ts @@ -0,0 +1,110 @@ +import { ConfigLocator } from "../src/config"; +import { ProfileStore } from "../src/profile-store"; +import { OpenapiLoader } from "../src/openapi-loader"; +import { HttpClient, run } from "../src/cli"; + +interface MemoryFsEntry { + type: "file" | "dir"; + content?: string; +} + +class MemoryFs { + private readonly entries: Record = {}; + + constructor(initialFiles: Record) { + for (const [filePath, content] of Object.entries(initialFiles)) { + this.addFile(filePath, content); + } + } + + existsSync(path: string): boolean { + return Boolean(this.entries[path]); + } + + readFileSync(path: string, encoding: BufferEncoding): string { + if (encoding !== "utf-8") { + throw new Error("MemoryFs supports only utf-8 encoding"); + } + const entry = this.entries[path]; + if (!entry || entry.type !== "file" || entry.content === undefined) { + throw new Error(`File not found: ${path}`); + } + return entry.content; + } + + writeFileSync(path: string, data: string): void { + this.addFile(path, data); + } + + mkdirSync(path: string): void { + this.entries[path] = { type: "dir" }; + } + + private addFile(path: string, content: string): void { + this.entries[path] = { type: "file", content }; + } +} + +describe("cli with OpenRPC", () => { + it("sends documented parameters in a JSON-RPC request envelope", async () => { + const cwd = "/project"; + const homeDir = "/home/user"; + const specCache = `${cwd}/.ocli/specs/rpc.json`; + const spec = { + openrpc: "1.0.0-rc1", + info: { title: "Example", version: "1.0.0" }, + servers: [{ url: "https://server.example.test/rpc" }], + methods: [ + { + name: "getWidget", + params: [ + { + name: "widgetId", + required: true, + schema: { type: "string" }, + }, + ], + }, + ], + }; + const fs = new MemoryFs({ + [`${cwd}/.ocli/current`]: "rpc", + [`${cwd}/.ocli/profiles.ini`]: [ + "[rpc]", + "api_base_url = https://profile.example.test/rpc", + `openapi_spec_cache = ${specCache}`, + "", + ].join("\n"), + [specCache]: JSON.stringify(spec), + }); + const locator = new ConfigLocator({ fs, homeDir }); + const profileStore = new ProfileStore({ fs, locator }); + const openapiLoader = new OpenapiLoader({ fs }); + const capturedConfigs: unknown[] = []; + const httpClient: HttpClient = { + request: async (config) => { + capturedConfigs.push(config); + return { data: { jsonrpc: "2.0", result: { id: "widget-7" }, id: 1 } } as never; + }, + }; + + await run( + ["getWidget", "--widgetId", "widget-7"], + { cwd, profileStore, openapiLoader, httpClient, stdout: () => {} } + ); + + expect(capturedConfigs).toEqual([ + expect.objectContaining({ + method: "POST", + url: "https://profile.example.test/rpc", + headers: { "Content-Type": "application/json" }, + data: { + jsonrpc: "2.0", + method: "getWidget", + params: { widgetId: "widget-7" }, + id: 1, + }, + }), + ]); + }); +}); diff --git a/tests/openrpc-to-commands.test.ts b/tests/openrpc-to-commands.test.ts new file mode 100644 index 0000000..060a0da --- /dev/null +++ b/tests/openrpc-to-commands.test.ts @@ -0,0 +1,46 @@ +import fs from "fs"; +import path from "path"; + +import { OpenapiToCommands, OpenapiSpecLike } from "../src/openapi-to-commands"; +import { Profile } from "../src/profile-store"; + +const baseProfile: Profile = { + name: "rpc", + apiBaseUrl: "", + apiBasicAuth: "", + apiBearerToken: "", + openapiSpecSource: "", + openapiSpecCache: "/tmp/rpc.json", + includeEndpoints: [], + excludeEndpoints: [], + commandPrefix: "", + customHeaders: {}, +}; + +const fixturePath = path.join(__dirname, "fixtures", "openrpc.json"); +const openrpcSpec = JSON.parse(fs.readFileSync(fixturePath, "utf-8")) as OpenapiSpecLike; + +describe("OpenapiToCommands with OpenRPC", () => { + it("builds JSON-RPC commands from documented methods", () => { + const commands = new OpenapiToCommands().buildCommands(openrpcSpec, baseProfile); + + expect(commands).toHaveLength(2); + expect(commands.map((command) => command.name)).toEqual(["getWidget", "listWidgets"]); + + const getWidget = commands[0]; + expect(getWidget.method).toBe("post"); + expect(getWidget.path).toBe(""); + expect(getWidget.serverUrl).toBe("https://api.example.test/rpc"); + expect(getWidget.jsonRpcMethod).toBe("getWidget"); + expect(getWidget.requestContentType).toBe("application/json"); + expect(getWidget.options).toEqual([ + expect.objectContaining({ + name: "widgetId", + location: "body", + required: true, + schemaType: "integer", + description: "Widget identifier", + }), + ]); + }); +});