diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index 39594e9951..902b20ee3d 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -6,6 +6,29 @@ declare const OPENCODE_CLI_NAME: string | undefined export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", { description: "OpenCode 2.0 preview command line interface", commands: [ + Spec.make("api", { + description: "Make a request to the running server", + params: { + request: Argument.string("operation | method path").pipe( + Argument.withDescription("OpenAPI operation ID, or an HTTP method followed by a path"), + Argument.variadic({ min: 1, max: 2 }), + ), + data: Flag.string("data").pipe( + Flag.withAlias("d"), + Flag.withDescription("Request body"), + Flag.optional, + ), + header: Flag.string("header").pipe( + Flag.withAlias("H"), + Flag.withDescription("Request header in name:value form"), + Flag.atMost(100), + ), + param: Flag.keyValuePair("param").pipe( + Flag.withDescription("OpenAPI path or query parameter"), + Flag.optional, + ), + }, + }), Spec.make("debug", { description: "Debugging and troubleshooting tools", commands: [Spec.make("agents", { description: "List all agents" })], diff --git a/packages/cli/src/commands/handlers/api.test.ts b/packages/cli/src/commands/handlers/api.test.ts new file mode 100644 index 0000000000..e8e579dc26 --- /dev/null +++ b/packages/cli/src/commands/handlers/api.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test" +import { rawRequest, resolveOperation } from "./api" + +describe("api request resolution", () => { + test("resolves an operation ID with path and query parameters", () => { + expect( + resolveOperation( + { + paths: { + "/api/session/{sessionID}": { + get: { operationId: "v2.session.get" }, + }, + }, + }, + "v2.session.get", + { sessionID: "ses/a", workspace: "work" }, + ), + ).toEqual({ method: "GET", path: "/api/session/ses%2Fa?workspace=work" }) + }) + + test("rejects a missing path parameter", () => { + expect(() => + resolveOperation( + { paths: { "/api/session/{sessionID}": { get: { operationId: "v2.session.get" } } } }, + "v2.session.get", + {}, + ), + ).toThrow("Missing path parameter: sessionID") + }) + + test("resolves curl-like method and path input", () => { + expect(rawRequest(["post", "/api/foo"])).toEqual({ method: "POST", path: "/api/foo" }) + expect(rawRequest(["v2.session.list"])).toBeUndefined() + }) +}) diff --git a/packages/cli/src/commands/handlers/api.ts b/packages/cli/src/commands/handlers/api.ts new file mode 100644 index 0000000000..cf00394cb9 --- /dev/null +++ b/packages/cli/src/commands/handlers/api.ts @@ -0,0 +1,85 @@ +import { EOL } from "node:os" +import { Effect, Option } from "effect" +import { Commands } from "../commands" +import { Runtime } from "../../framework/runtime" +import { Daemon } from "../../services/daemon" + +const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"]) + +type Operation = { + operationId?: string +} + +type OpenApi = { + paths?: Record> +} + +export default Runtime.handler( + Commands.commands.api, + Effect.fn("cli.api")(function* (input) { + const daemon = yield* Daemon.Service + const transport = yield* daemon.transport() + const params = Option.getOrElse(input.param, () => ({})) + const request = yield* resolveRequest(transport, input.request, params) + const headers = new Headers(transport.headers) + for (const header of input.header) { + const index = header.indexOf(":") + if (index < 1) return yield* Effect.fail(new Error(`Invalid header, expected name:value: ${header}`)) + headers.set(header.slice(0, index).trim(), header.slice(index + 1).trim()) + } + const body = Option.getOrUndefined(input.data) + if (body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json") + + const response = yield* Effect.tryPromise(() => + fetch(new URL(request.path, transport.url), { + method: request.method, + headers, + body, + }), + ) + const output = yield* Effect.promise(() => response.text()) + if (output) process.stdout.write(output + (output.endsWith(EOL) ? "" : EOL)) + }), +) + +export function resolveOperation(spec: OpenApi, operationID: string, params: Record) { + for (const [path, operations] of Object.entries(spec.paths ?? {})) { + for (const [method, operation] of Object.entries(operations)) { + if (!methods.has(method) || operation.operationId !== operationID) continue + return { method: method.toUpperCase(), path: interpolate(path, params) } + } + } + throw new Error(`Operation not found: ${operationID}`) +} + +export function rawRequest(input: readonly string[]) { + if (input.length !== 2 || !methods.has(input[0].toLowerCase()) || !input[1].startsWith("/")) return + return { method: input[0].toUpperCase(), path: input[1] } +} + +function resolveRequest( + transport: { url: string; headers: RequestInit["headers"] }, + input: readonly string[], + params: Record, +) { + const raw = rawRequest(input) + if (raw) return Effect.succeed(raw) + if (input.length !== 1) return Effect.fail(new Error("Expected an operation name or an HTTP method and path")) + return Effect.tryPromise(async () => { + const response = await fetch(new URL("/openapi.json", transport.url), { headers: transport.headers }) + if (!response.ok) throw new Error(`Failed to load OpenAPI document: HTTP ${response.status}`) + return resolveOperation((await response.json()) as OpenApi, input[0], params) + }) +} + +function interpolate(path: string, params: Record) { + const used = new Set() + const pathname = path.replaceAll(/\{([^}]+)\}/g, (_, name: string) => { + const value = params[name] + if (value === undefined) throw new Error(`Missing path parameter: ${name}`) + used.add(name) + return encodeURIComponent(value) + }) + const query = new URLSearchParams(Object.entries(params).filter(([name]) => !used.has(name))).toString() + return query ? `${pathname}?${query}` : pathname +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c15362ac4f..4507fcba6e 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -9,6 +9,7 @@ import { Daemon } from "./services/daemon" const Handlers = Runtime.handlers(Commands, { $: () => import("./commands/handlers/default"), + api: () => import("./commands/handlers/api"), debug: { agents: () => import("./commands/handlers/debug/agents"), },