Skip to content
Merged
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
17 changes: 7 additions & 10 deletions .github/workflows/tag-on-merge.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/tests-on-pr.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
name: Tests on PR
name: CI

on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
Expand Down
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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`):
Expand Down Expand Up @@ -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.

Expand Down
8 changes: 6 additions & 2 deletions examples/skill-ocli-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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:<method>` 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
Expand All @@ -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 <name>` (or `-p <name>`) 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.
````
8 changes: 6 additions & 2 deletions skills/ocli-api/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ ocli profiles add <name> \
ocli use <name>
```

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 \
Expand All @@ -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:<method>` selectors with `--include-endpoints` or `--exclude-endpoints`, for example `rpc:getWidget`. OpenRPC method names must be unique.

## Workflow

1. **Search** for the right command:
Expand Down Expand Up @@ -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
Expand Down
84 changes: 77 additions & 7 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, string>
): 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<string, string>; positional: string[] } {
const flags: Record<string, string> = {};
const positional: string[] = [];
Expand Down Expand Up @@ -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",
Expand Down
41 changes: 38 additions & 3 deletions src/openapi-to-commands.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string>();

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)
Expand All @@ -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),
};
});
Expand All @@ -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[] {
Expand Down Expand Up @@ -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<string, Set<HttpMethod>>,
Expand Down
Loading
Loading