From b825b7bc158d298a56c1074721f47a0f28ed8e4e Mon Sep 17 00:00:00 2001 From: v-bus Date: Tue, 22 Sep 2026 09:31:30 +0000 Subject: [PATCH 1/2] fix: harden OpenRPC parameter handling --- README.md | 20 ++++- examples/skill-ocli-api.md | 8 +- skills/ocli-api/SKILL.md | 8 +- src/cli.ts | 84 +++++++++++++++++++-- src/openapi-to-commands.ts | 41 ++++++++++- tests/openrpc-cli.test.ts | 117 ++++++++++++++++++++++++++++++ tests/openrpc-to-commands.test.ts | 56 ++++++++++++++ 7 files changed, 318 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index abc746a..f6000bc 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ ocli commands -p other --query "send message" ### 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. +`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 documented RPC parameters as command flags. ```bash ocli profiles add widgets \ @@ -92,6 +92,22 @@ OpenRPC commands always send an HTTP `POST` request with `Content-Type: applicat 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. +`ocli` serializes parameters from their documented schemas. Integers, numbers, and booleans are sent as JSON scalars. Arrays and objects must be supplied as JSON values. For methods with `paramStructure: "by-position"`, the same flags are emitted as a JSON array in the documented parameter order. Named and `either` methods use a JSON object. + +OpenRPC method names must be unique. `ocli` rejects an OpenRPC document that declares the same method name more than once instead of creating ambiguous CLI commands. + +For an OpenRPC profile, endpoint filters select RPC method names with the `rpc:` prefix: + +```bash +ocli profiles add widgets \ + --api-base-url https://api.example.com/rpc \ + --openapi-spec ./openrpc.json \ + --include-endpoints "rpc:getWidget,rpc:listWidgets" \ + --exclude-endpoints "rpc:deleteWidget" +``` + +REST filter keys such as `get:/widgets` apply only to OpenAPI and Swagger operations. + ### Authentication and custom headers A profile stores up to three credentials, set with `ocli profiles add` (or `ocli onboard`): @@ -143,7 +159,7 @@ npx openapi-to-cli onboard \ - 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 +- OpenRPC methods with named or positional parameters, schema-typed values, local schema references, and method filters In practice this improves compatibility with APIs that define inputs outside simple path/query parameters, especially for `POST`, `PUT`, and `PATCH` operations. diff --git a/examples/skill-ocli-api.md b/examples/skill-ocli-api.md index 044600d..786ee8e 100644 --- a/examples/skill-ocli-api.md +++ b/examples/skill-ocli-api.md @@ -74,7 +74,7 @@ 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: +`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 RPC parameters as regular flags: ```bash ocli profiles add widgets \ @@ -84,6 +84,10 @@ ocli profiles add widgets \ ocli getWidget --widgetId widget-7 ``` +`ocli` serializes integer, number, and boolean values from their documented schemas. Use JSON syntax for array and object flags. It also follows `paramStructure: "by-position"` by sending flags in documented order as a JSON-RPC parameter array. + +For OpenRPC profiles, filter methods with `rpc:` values, for example `--include-endpoints "rpc:getWidget"`. Method names must be unique in the document. + ## Tips - All responses are JSON — pipe through `jq` for filtering @@ -92,5 +96,5 @@ ocli getWidget --widgetId widget-7 - 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. +- For OpenRPC, pass documented RPC parameters as flags. `ocli` adds `jsonrpc`, `method`, and `id` to the request body and uses the method's documented parameter structure. ```` diff --git a/skills/ocli-api/SKILL.md b/skills/ocli-api/SKILL.md index def682a..1257307 100644 --- a/skills/ocli-api/SKILL.md +++ b/skills/ocli-api/SKILL.md @@ -33,7 +33,7 @@ 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: +For OpenRPC, use the same profile command. `ocli` builds one command per RPC method and wraps documented parameters in a JSON-RPC 2.0 request automatically: ```bash ocli profiles add rpc-api \ @@ -43,6 +43,10 @@ ocli profiles add rpc-api \ ocli getWidget --widgetId widget-7 ``` +`ocli` serializes integer, number, and boolean values according to the method schema. Pass arrays and objects as JSON. It follows `paramStructure: "by-position"` by sending the documented flags as a JSON-RPC parameter array in order. Named and `either` methods receive a JSON object. + +For OpenRPC profiles, use `rpc:` selectors with `--include-endpoints` or `--exclude-endpoints`, for example `rpc:getWidget`. OpenRPC method names must be unique. + ## Workflow 1. **Search** for the right command: @@ -87,7 +91,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. +- For OpenRPC, pass only documented RPC parameters. Do not add `jsonrpc`, `method`, or `id`; `ocli` supplies them and follows the documented parameter structure. - 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 0012e6f..298bf86 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -141,7 +141,7 @@ async function runApiCommand( command.options.forEach((opt: CliCommandOption) => { const key = `--${opt.name}`; const requiredLabel = opt.required ? "required" : ""; - const baseType = opt.schemaType; + const baseType = opt.schemaType?.split(":", 1)[0]; let typeLabel = "string"; if (baseType === "integer" || baseType === "number") { typeLabel = "number"; @@ -277,6 +277,74 @@ function parseBodyFlagValue(value: string): unknown { return value; } +function parseJsonRpcParameterValue(option: CliCommandOption, rawValue: string): unknown { + const parsedValue = parseBodyFlagValue(rawValue); + const schemaType = option.schemaType?.split(":", 1)[0]; + + if (parsedValue === null) { + if (option.nullable) { + return null; + } + throw new Error(`--${option.name} does not accept null`); + } + + if (schemaType === "integer" || schemaType === "number") { + const numericValue = Number(rawValue); + const validNumber = rawValue.trim().length > 0 && Number.isFinite(numericValue); + if (!validNumber || (schemaType === "integer" && !Number.isInteger(numericValue))) { + throw new Error(`--${option.name} expects an ${schemaType}`); + } + return numericValue; + } + + if (schemaType === "boolean") { + if (typeof parsedValue !== "boolean") { + throw new Error(`--${option.name} expects a boolean`); + } + return parsedValue; + } + + if (schemaType === "array") { + if (!Array.isArray(parsedValue)) { + throw new Error(`--${option.name} expects a JSON array`); + } + return parsedValue; + } + + if (schemaType === "object") { + if (typeof parsedValue !== "object" || Array.isArray(parsedValue)) { + throw new Error(`--${option.name} expects a JSON object`); + } + return parsedValue; + } + + return parsedValue; +} + +function buildJsonRpcPositionalParams( + bodyOptions: CliCommandOption[], + flags: Record +): unknown[] { + const params: unknown[] = []; + let omittedOption: string | undefined; + + for (const option of bodyOptions) { + const value = flags[option.name]; + if (value === undefined) { + omittedOption ??= option.name; + continue; + } + if (omittedOption) { + throw new Error( + `Cannot provide --${option.name} after omitting --${omittedOption} for positional JSON-RPC params` + ); + } + params.push(parseJsonRpcParameterValue(option, value)); + } + + return params; +} + function parseArgs(args: string[]): { flags: Record; positional: string[] } { const flags: Record = {}; const positional: string[] = []; @@ -421,12 +489,14 @@ function buildRequestPayload( .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, - ]); + const params = command.jsonRpcParamStructure === "by-position" + ? buildJsonRpcPositionalParams(bodyOptions, flags) + : Object.fromEntries([ + ...bodyOptions + .filter((opt) => flags[opt.name] !== undefined) + .map((opt) => [opt.name, parseJsonRpcParameterValue(opt, flags[opt.name])] as const), + ...extraBodyEntries, + ]); return { data: { jsonrpc: "2.0", diff --git a/src/openapi-to-commands.ts b/src/openapi-to-commands.ts index b309a34..1554b50 100644 --- a/src/openapi-to-commands.ts +++ b/src/openapi-to-commands.ts @@ -1,6 +1,7 @@ import { Profile } from "./profile-store"; export type ParameterLocation = "path" | "query" | "header" | "cookie" | "body" | "formData"; +export type JsonRpcParamStructure = "by-name" | "by-position" | "either"; export interface CliCommandOption { name: string; @@ -27,6 +28,7 @@ export interface CliCommand { serverUrl?: string; serverUrlOverridesProfile?: boolean; jsonRpcMethod?: string; + jsonRpcParamStructure?: JsonRpcParamStructure; } type HttpMethod = "get" | "post" | "put" | "delete" | "patch" | "head" | "options" | "trace"; @@ -91,11 +93,13 @@ interface OpenRpcMethodLike { name?: string; summary?: string; description?: string; + paramStructure?: string; params?: unknown[]; } interface OpenRpcParameterLike { name?: string; + summary?: string; description?: string; required?: boolean; schema?: SchemaLike; @@ -143,12 +147,17 @@ export class OpenapiToCommands { const serverUrl = this.resolveServers(spec.servers); const methods = spec.methods as OpenRpcMethodLike[]; const commands: CliCommand[] = []; + const methodNames = new Set(); for (const rawMethod of methods) { const method = this.resolveValue(rawMethod, spec) as OpenRpcMethodLike; if (!method.name) { continue; } + if (methodNames.has(method.name)) { + throw new Error(`Duplicate OpenRPC method name "${method.name}"`); + } + methodNames.add(method.name); const options = (method.params ?? []) .map((rawParameter) => this.resolveValue(rawParameter, spec) as OpenRpcParameterLike) @@ -160,7 +169,7 @@ export class OpenapiToCommands { location: "body" as const, required: Boolean(parameter.required), schemaType: this.describeSchemaType(schema), - description: parameter.description, + description: parameter.summary ?? parameter.description, ...this.extractSchemaHints(schema), }; }); @@ -175,16 +184,26 @@ export class OpenapiToCommands { serverUrl, serverUrlOverridesProfile: false, jsonRpcMethod: method.name, + jsonRpcParamStructure: this.resolveJsonRpcParamStructure(method.paramStructure), }); } + const filteredCommands = this.applyOpenRpcFilters(commands, profile); + if (profile.commandPrefix) { - for (const command of commands) { + for (const command of filteredCommands) { command.name = `${profile.commandPrefix}${command.name}`; } } - return commands; + return filteredCommands; + } + + private resolveJsonRpcParamStructure(paramStructure: string | undefined): JsonRpcParamStructure { + if (paramStructure === "by-name" || paramStructure === "by-position" || paramStructure === "either") { + return paramStructure; + } + return "either"; } private collectOperations(spec: OpenapiSpecLike): PathOperation[] { @@ -236,6 +255,22 @@ export class OpenapiToCommands { }); } + private applyOpenRpcFilters(commands: CliCommand[], profile: Profile): CliCommand[] { + const include = profile.includeEndpoints; + const exclude = new Set(profile.excludeEndpoints); + const includeSet = new Set(include); + + return commands.filter((command) => { + const key = `rpc:${command.jsonRpcMethod}`; + + if (exclude.has(key)) { + return false; + } + + return include.length === 0 || includeSet.has(key); + }); + } + private toCliCommands( operations: PathOperation[], methodsByPath: Record>, diff --git a/tests/openrpc-cli.test.ts b/tests/openrpc-cli.test.ts index 236d153..894ded8 100644 --- a/tests/openrpc-cli.test.ts +++ b/tests/openrpc-cli.test.ts @@ -45,6 +45,35 @@ class MemoryFs { } } +async function captureRequest(spec: object, args: string[]): Promise { + const cwd = "/project"; + const homeDir = "/home/user"; + const specCache = `${cwd}/.ocli/specs/rpc.json`; + 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: 1 } } as never; + }, + }; + + await run(args, { cwd, profileStore, openapiLoader, httpClient, stdout: () => {} }); + return capturedConfigs; +} + describe("cli with OpenRPC", () => { it("sends documented parameters in a JSON-RPC request envelope", async () => { const cwd = "/project"; @@ -107,4 +136,92 @@ describe("cli with OpenRPC", () => { }), ]); }); + + it("serializes documented scalar and structured parameter types", async () => { + const spec = { + openrpc: "1.0.0", + info: { title: "Example", version: "1.0.0" }, + methods: [ + { + name: "updateWidget", + params: [ + { name: "count", required: true, schema: { type: "integer", format: "int64" } }, + { name: "ratio", required: true, schema: { type: "number" } }, + { name: "enabled", required: true, schema: { type: "boolean" } }, + { name: "tags", required: true, schema: { type: "array", items: { type: "string" } } }, + ], + }, + ], + }; + + const capturedConfigs = await captureRequest(spec, [ + "updateWidget", + "--count", "2", + "--ratio", "1.5", + "--enabled", "false", + "--tags", '["one","two"]', + ]); + + expect(capturedConfigs).toEqual([ + expect.objectContaining({ + data: expect.objectContaining({ + params: { + count: 2, + ratio: 1.5, + enabled: false, + tags: ["one", "two"], + }, + }), + }), + ]); + }); + + it("serializes positional parameters as a JSON-RPC array", async () => { + const spec = { + openrpc: "1.0.0", + info: { title: "Example", version: "1.0.0" }, + methods: [ + { + name: "moveWidget", + paramStructure: "by-position", + params: [ + { name: "widgetId", required: true, schema: { type: "string" } }, + { name: "revision", required: true, schema: { type: "integer" } }, + ], + }, + ], + }; + + const capturedConfigs = await captureRequest(spec, [ + "moveWidget", + "--widgetId", "widget-7", + "--revision", "3", + ]); + + expect(capturedConfigs).toEqual([ + expect.objectContaining({ + data: expect.objectContaining({ + params: ["widget-7", 3], + }), + }), + ]); + }); + + it("rejects invalid values for documented integer parameters", async () => { + const spec = { + openrpc: "1.0.0", + info: { title: "Example", version: "1.0.0" }, + methods: [ + { + name: "moveWidget", + params: [ + { name: "revision", required: true, schema: { type: "integer" } }, + ], + }, + ], + }; + + await expect(captureRequest(spec, ["moveWidget", "--revision", "1.5"])) + .rejects.toThrow("--revision expects an integer"); + }); }); diff --git a/tests/openrpc-to-commands.test.ts b/tests/openrpc-to-commands.test.ts index 060a0da..4f307fa 100644 --- a/tests/openrpc-to-commands.test.ts +++ b/tests/openrpc-to-commands.test.ts @@ -43,4 +43,60 @@ describe("OpenapiToCommands with OpenRPC", () => { }), ]); }); + + it("applies RPC filters and preserves parameter and positional metadata", () => { + const spec: OpenapiSpecLike = { + openrpc: "1.0.0", + info: { title: "Example RPC API", version: "1.0.0" }, + methods: [ + { + name: "getWidget", + params: [], + }, + { + name: "moveWidget", + paramStructure: "by-position", + params: [ + { + name: "widgetId", + summary: "Widget identifier", + required: true, + schema: { type: "string" }, + }, + ], + }, + ], + }; + const profile: Profile = { + ...baseProfile, + includeEndpoints: ["rpc:getWidget", "rpc:moveWidget"], + excludeEndpoints: ["rpc:getWidget"], + }; + + const commands = new OpenapiToCommands().buildCommands(spec, profile); + + expect(commands).toHaveLength(1); + expect(commands[0]).toEqual(expect.objectContaining({ + name: "moveWidget", + jsonRpcParamStructure: "by-position", + options: [expect.objectContaining({ + name: "widgetId", + description: "Widget identifier", + })], + })); + }); + + it("rejects duplicate OpenRPC method names", () => { + const spec: OpenapiSpecLike = { + openrpc: "1.0.0", + info: { title: "Example RPC API", version: "1.0.0" }, + methods: [ + { name: "getWidget", params: [] }, + { name: "getWidget", params: [] }, + ], + }; + + expect(() => new OpenapiToCommands().buildCommands(spec, baseProfile)) + .toThrow('Duplicate OpenRPC method name "getWidget"'); + }); }); From ee629222f0d7cf5df9c66679ad0c27e119fafdd3 Mon Sep 17 00:00:00 2001 From: v-bus Date: Tue, 22 Sep 2026 09:54:14 +0000 Subject: [PATCH 2/2] ci: tag releases from main pushes --- .github/workflows/tag-on-merge.yaml | 17 ++++++--------- .github/workflows/tests-on-pr.yaml | 4 +++- tests/workflows.test.ts | 33 +++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 11 deletions(-) create mode 100644 tests/workflows.test.ts diff --git a/.github/workflows/tag-on-merge.yaml b/.github/workflows/tag-on-merge.yaml index af534b1..997e549 100644 --- a/.github/workflows/tag-on-merge.yaml +++ b/.github/workflows/tag-on-merge.yaml @@ -1,12 +1,13 @@ -name: Tag release on merge +name: Tag release on main push on: - pull_request: - types: [closed] - branches: - - main + push: + branches: [main] workflow_dispatch: +permissions: + contents: write + defaults: run: shell: bash @@ -15,17 +16,13 @@ jobs: tag-version: name: Bump and push version tag runs-on: ubuntu-latest - if: github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true - permissions: - contents: write - actions: write steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 - ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || github.event.pull_request.merge_commit_sha }} + ref: ${{ github.sha }} - name: Fetch all tags run: git fetch --tags diff --git a/.github/workflows/tests-on-pr.yaml b/.github/workflows/tests-on-pr.yaml index 7675cdf..8bbc533 100644 --- a/.github/workflows/tests-on-pr.yaml +++ b/.github/workflows/tests-on-pr.yaml @@ -1,6 +1,8 @@ -name: Tests on PR +name: CI on: + push: + branches: [main] pull_request: types: [opened, synchronize, reopened] workflow_dispatch: diff --git a/tests/workflows.test.ts b/tests/workflows.test.ts new file mode 100644 index 0000000..6612d49 --- /dev/null +++ b/tests/workflows.test.ts @@ -0,0 +1,33 @@ +import fs from "fs"; +import path from "path"; + +import yaml from "js-yaml"; + +interface Workflow { + on?: Record; + permissions?: Record; + jobs?: Record }>; +} + +function loadWorkflow(fileName: string): Workflow { + const workflowPath = path.join(__dirname, "..", ".github", "workflows", fileName); + return yaml.load(fs.readFileSync(workflowPath, "utf-8")) as Workflow; +} + +describe("GitHub Actions workflows", () => { + it("runs the test suite for pull requests and pushes to main", () => { + const workflow = loadWorkflow("tests-on-pr.yaml"); + + expect(workflow.on).toHaveProperty("pull_request"); + expect(workflow.on).toHaveProperty("push.branches", ["main"]); + }); + + it("creates version tags only from trusted main pushes", () => { + const workflow = loadWorkflow("tag-on-merge.yaml"); + + expect(workflow.on).not.toHaveProperty("pull_request"); + expect(workflow.on).toHaveProperty("push.branches", ["main"]); + expect(workflow.permissions).toEqual({ contents: "write" }); + expect(workflow.jobs?.["tag-version"].if).toBeUndefined(); + }); +});