From 4b2691a18d0fa3fda2a53f945af4fc86fbdaf461 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 20 Jul 2026 15:58:14 +0530 Subject: [PATCH] feat(cli): register acp command --- packages/cli/src/commands/commands.ts | 1 + packages/cli/src/commands/handlers/acp.ts | 36 ++++++++ packages/cli/src/index.ts | 1 + packages/cli/test/acp/command.test.ts | 101 ++++++++++++++++++++++ 4 files changed, 139 insertions(+) create mode 100644 packages/cli/src/commands/handlers/acp.ts create mode 100644 packages/cli/test/acp/command.test.ts diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index 771f57f42a..a287c3c9ec 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -34,6 +34,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO ), }, commands: [ + Spec.make("acp", { description: "Start an Agent Client Protocol server" }), Spec.make("api", { description: "Make a request to the running server", params: { diff --git a/packages/cli/src/commands/handlers/acp.ts b/packages/cli/src/commands/handlers/acp.ts new file mode 100644 index 0000000000..3d0e9f73bb --- /dev/null +++ b/packages/cli/src/commands/handlers/acp.ts @@ -0,0 +1,36 @@ +import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk" +import { OpenCode } from "@opencode-ai/client/promise" +import { Service } from "@opencode-ai/client/effect/service" +import { Effect } from "effect" +import { ACP } from "../../acp/agent" +import { Commands } from "../commands" +import { Runtime } from "../../framework/runtime" +import { Standalone } from "../../services/standalone" + +export default Runtime.handler( + Commands.commands.acp, + Effect.fn("cli.acp")(function* () { + process.env.OPENCODE_CLIENT = "acp" + const endpoint = yield* Standalone.start() + const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) + const input = new WritableStream({ + write: (chunk) => + new Promise((resolve, reject) => { + process.stdout.write(chunk, (error) => (error ? reject(error) : resolve())) + }), + }) + const output = new ReadableStream({ + start(controller) { + process.stdin.on("data", (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk))) + process.stdin.on("end", () => controller.close()) + process.stdin.on("error", (error) => controller.error(error)) + }, + }) + const stream = ndJsonStream(input, output) + const connection = new AgentSideConnection((connection) => ACP.create(client, connection), stream) + process.stdin.resume() + yield* Effect.promise(() => connection.closed) + // EOF owns this stdio process; exiting also closes the private server's lease pipe. + yield* Effect.sync(() => process.exit(0)) + }), +) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7ee80846ed..9dad0fa469 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -15,6 +15,7 @@ import { Npm } from "@opencode-ai/core/npm" const Handlers = Runtime.handlers(Commands, { $: () => import("./commands/handlers/default"), + acp: () => import("./commands/handlers/acp"), api: () => import("./commands/handlers/api"), auth: { connect: () => import("./commands/handlers/auth/connect"), diff --git a/packages/cli/test/acp/command.test.ts b/packages/cli/test/acp/command.test.ts new file mode 100644 index 0000000000..6d64523ef2 --- /dev/null +++ b/packages/cli/test/acp/command.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, test } from "bun:test" +import path from "node:path" + +type Message = { readonly id?: number; readonly result?: unknown; readonly error?: unknown } +const children: Bun.Subprocess[] = [] + +afterEach(async () => { + await Promise.all( + children.splice(0).map(async (child) => { + child.kill("SIGKILL") + await child.exited + }), + ) +}) + +describe("acp command", () => { + test("is registered", async () => { + const result = await cli(["--help"]) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain("acp Start an Agent Client Protocol server") + }) + + test("initializes over ndjson and exits on stdin eof", async () => { + const child = spawn() + const stderr = new Response(child.stderr).text() + child.stdin.write( + new TextEncoder().encode( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: 1, + clientCapabilities: {}, + clientInfo: { name: "test", version: "1.0.0" }, + }, + }) + "\n", + ), + ) + child.stdin.flush() + const response = await readMessage(child.stdout) + expect(response.id).toBe(1) + expect(response.error).toBeUndefined() + expect(response.result).toMatchObject({ + protocolVersion: 1, + agentCapabilities: { loadSession: true }, + agentInfo: { name: "OpenCode" }, + }) + + child.stdin.end() + const exitCode = await child.exited + const errorOutput = await stderr + if (exitCode !== 0) throw new Error(`ACP exited with ${exitCode}: ${errorOutput}`) + children.splice(children.indexOf(child), 1) + }, 30_000) +}) + +function spawn() { + const child = Bun.spawn([process.execPath, "run", "src/index.ts", "acp"], { + cwd: path.join(import.meta.dir, "../.."), + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }) + children.push(child) + return child +} + +async function readMessage(stream: ReadableStream) { + const reader = stream.getReader() + const decoder = new TextDecoder() + let output = "" + while (true) { + const result = await Promise.race([ + reader.read(), + Bun.sleep(20_000).then(() => { + throw new Error("timed out waiting for ACP response") + }), + ]) + if (result.done) throw new Error(`ACP exited before responding: ${output}`) + output += decoder.decode(result.value, { stream: true }) + const newline = output.indexOf("\n") + if (newline === -1) continue + reader.releaseLock() + return JSON.parse(output.slice(0, newline)) as Message + } +} + +async function cli(args: string[]) { + const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], { + cwd: path.join(import.meta.dir, "../.."), + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]) + return { stdout, stderr, exitCode } +}