diff --git a/README.md b/README.md index aabcde0..ec39b6d 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,9 @@ npm install -g @anthropic-ai/claude-code # Or OpenCode npm i -g opencode-ai + +# Or GitHub Copilot CLI +npm install -g @github/copilot ``` ### Supported Adapters @@ -58,6 +61,7 @@ npm i -g opencode-ai | Claude Code | Complete | `claude-code-acp` | | OpenCode | Complete | `opencode acp` | | Gemini CLI | Under Development | `gemini --experimental-acp` | +| GitHub Copilot CLI | Complete | `copilot --acp` | All adapters communicate via ACP (Agent Client Protocol). diff --git a/packages/cli/src/adapters/acp.ts b/packages/cli/src/adapters/acp.ts index 3c538d3..b0f90f7 100644 --- a/packages/cli/src/adapters/acp.ts +++ b/packages/cli/src/adapters/acp.ts @@ -77,6 +77,7 @@ export abstract class AcpAdapter { private currentOptions?: AcpAdapterOptions; private readonly permissionCache = new Map(); private readonly toolNameCache = new Map(); + private cleanupPromise?: Promise; /** * Check if the adapter command is available. @@ -102,6 +103,14 @@ export abstract class AcpAdapter { this.handler = handler; this.currentOptions = options; + // Wait for any cleanup from a previous run to finish before spawning a + // new subprocess/connection. Without this, a late cleanup() from the + // prior iteration can null out this.connection mid-run, causing + // "undefined is not an object (evaluating 'this.connection.newSession')". + if (this.cleanupPromise) { + await this.cleanupPromise; + } + try { // Spawn the ACP subprocess this.process = spawn([this.command, ...this.args], { @@ -375,6 +384,16 @@ export abstract class AcpAdapter { } private async cleanup(): Promise { + if (this.cleanupPromise) { + return this.cleanupPromise; + } + this.cleanupPromise = this.doCleanup().finally(() => { + this.cleanupPromise = undefined; + }); + return this.cleanupPromise; + } + + private async doCleanup(): Promise { if (this.process) { this.process.kill(); await this.process.exited; diff --git a/packages/cli/src/adapters/copilot.ts b/packages/cli/src/adapters/copilot.ts new file mode 100644 index 0000000..19da02a --- /dev/null +++ b/packages/cli/src/adapters/copilot.ts @@ -0,0 +1,28 @@ +import { AcpAdapter, type ResumeCommand } from "./acp"; + +/** + * GitHub Copilot CLI ACP adapter. + * Uses `copilot --acp` command. + * + * Sets the session mode to Autopilot, which is described by the agent as + * "Autonomous mode that enables allow-all and runs until task completion + * without user interaction." Permission requests still arrive over ACP and + * are handled by the base adapter's yolo/callback flow. + */ + +const AUTOPILOT_MODE_ID = + "https://agentclientprotocol.com/protocol/session-modes#autopilot"; + +export class CopilotAcpAdapter extends AcpAdapter { + readonly name = "copilot"; + readonly command = "copilot"; + readonly args = ["--acp"]; + + getResumeCommand(sessionId: string): ResumeCommand { + return { command: "copilot", args: [`--resume=${sessionId}`] }; + } + + protected override getPreferredModeId(): string | null { + return AUTOPILOT_MODE_ID; + } +} diff --git a/packages/cli/src/adapters/index.test.ts b/packages/cli/src/adapters/index.test.ts index 1407062..a705c62 100644 --- a/packages/cli/src/adapters/index.test.ts +++ b/packages/cli/src/adapters/index.test.ts @@ -6,6 +6,7 @@ import { describe, expect, test } from "bun:test"; import { ClaudeAcpAdapter } from "./claude"; +import { CopilotAcpAdapter } from "./copilot"; import { GeminiAcpAdapter } from "./gemini"; import { getAdapter } from "./index"; import { OpenCodeAcpAdapter } from "./opencode"; @@ -35,6 +36,15 @@ describe("getAdapter factory", () => { expect(adapter.name).toBe("gemini"); expect(adapter.command).toBe("gemini"); }); + + test("returns CopilotAcpAdapter for 'copilot' type", () => { + const adapter = getAdapter("copilot"); + + expect(adapter).toBeInstanceOf(CopilotAcpAdapter); + expect(adapter.name).toBe("copilot"); + expect(adapter.command).toBe("copilot"); + expect(adapter.args).toEqual(["--acp"]); + }); }); describe("ACP adapter interface", () => { @@ -70,6 +80,27 @@ describe("getAdapter factory", () => { expect(typeof adapter.run).toBe("function"); expect(typeof adapter.cancel).toBe("function"); }); + + test("copilot adapter has required interface properties", () => { + const adapter = getAdapter("copilot"); + + expect(adapter).toHaveProperty("name"); + expect(adapter).toHaveProperty("command"); + expect(adapter).toHaveProperty("args"); + expect(typeof adapter.isAvailable).toBe("function"); + expect(typeof adapter.run).toBe("function"); + expect(typeof adapter.cancel).toBe("function"); + }); + + test("copilot adapter returns equals-form resume command", () => { + const adapter = getAdapter("copilot"); + const resume = adapter.getResumeCommand("abc123"); + + expect(resume).toEqual({ + command: "copilot", + args: ["--resume=abc123"], + }); + }); }); describe("error handling", () => { @@ -101,10 +132,14 @@ describe("getAdapter factory", () => { const claude = getAdapter("claude"); const opencode = getAdapter("opencode"); const gemini = getAdapter("gemini"); + const copilot = getAdapter("copilot"); expect(claude.constructor).not.toBe(opencode.constructor); expect(claude.constructor).not.toBe(gemini.constructor); + expect(claude.constructor).not.toBe(copilot.constructor); + expect(gemini.constructor).not.toBe(copilot.constructor); expect(claude.name).not.toBe(opencode.name); + expect(claude.name).not.toBe(copilot.name); }); }); }); diff --git a/packages/cli/src/adapters/index.ts b/packages/cli/src/adapters/index.ts index bcbaaef..104092e 100644 --- a/packages/cli/src/adapters/index.ts +++ b/packages/cli/src/adapters/index.ts @@ -1,6 +1,7 @@ import type { AdapterType } from "#config/schema"; import type { AcpAdapter } from "./acp"; import { ClaudeAcpAdapter } from "./claude"; +import { CopilotAcpAdapter } from "./copilot"; import { GeminiAcpAdapter } from "./gemini"; import { OpenCodeAcpAdapter } from "./opencode"; @@ -8,6 +9,7 @@ const adapters: Record AcpAdapter> = { claude: () => new ClaudeAcpAdapter(), opencode: () => new OpenCodeAcpAdapter(), gemini: () => new GeminiAcpAdapter(), + copilot: () => new CopilotAcpAdapter(), }; export function getAdapter(type: AdapterType): AcpAdapter { diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index 0b0dd57..87a2dee 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -26,6 +26,8 @@ const initSource = readFileSync(INIT_SOURCE_PATH, "utf-8"); // Regex patterns for source code validation (defined at top level for performance) const CHOICE_3_PATTERN = /choice\s*===\s*["']3["']/; const RETURN_GEMINI_PATTERN = /return\s*["']gemini["']/; +const CHOICE_4_PATTERN = /choice\s*===\s*["']4["']/; +const RETURN_COPILOT_PATTERN = /return\s*["']copilot["']/; describe("init command", () => { describe("command metadata", () => { @@ -90,10 +92,16 @@ describe("init command", () => { expect(initSource).toContain("3. gemini"); }); - test("selectAdapter lists all three adapters", () => { + test("selectAdapter includes copilot option", () => { + expect(initSource).toContain("copilot"); + expect(initSource).toContain("4. copilot"); + }); + + test("selectAdapter lists all four adapters", () => { expect(initSource).toContain("claude (Claude Code CLI)"); expect(initSource).toContain("opencode (OpenCode CLI)"); expect(initSource).toContain("gemini (Gemini CLI)"); + expect(initSource).toContain("copilot (GitHub Copilot CLI)"); }); test("selectAdapter returns gemini for choice 3", () => { @@ -101,5 +109,10 @@ describe("init command", () => { expect(initSource).toMatch(CHOICE_3_PATTERN); expect(initSource).toMatch(RETURN_GEMINI_PATTERN); }); + + test("selectAdapter returns copilot for choice 4", () => { + expect(initSource).toMatch(CHOICE_4_PATTERN); + expect(initSource).toMatch(RETURN_COPILOT_PATTERN); + }); }); }); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 4a4e9cf..59be61c 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -75,6 +75,7 @@ async function selectAdapter(): Promise { console.log(" 1. claude (Claude Code CLI)"); console.log(" 2. opencode (OpenCode CLI)"); console.log(" 3. gemini (Gemini CLI)"); + console.log(" 4. copilot (GitHub Copilot CLI)"); const choice = await prompt("Choice", "1"); if (choice === "2") { @@ -83,6 +84,9 @@ async function selectAdapter(): Promise { if (choice === "3") { return "gemini"; } + if (choice === "4") { + return "copilot"; + } return "claude"; } diff --git a/packages/cli/src/config/schema.test.ts b/packages/cli/src/config/schema.test.ts index 7d69252..39d761f 100644 --- a/packages/cli/src/config/schema.test.ts +++ b/packages/cli/src/config/schema.test.ts @@ -39,6 +39,18 @@ describe("AdapterType", () => { } }); + /** + * Tests that 'copilot' is a valid adapter type. + * Copilot uses `copilot --acp` to speak the Agent Client Protocol. + */ + test("accepts 'copilot' as valid adapter", () => { + const result = AdapterType.safeParse("copilot"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe("copilot"); + } + }); + /** * Tests that unknown adapter types are rejected. * This prevents typos and invalid configurations from causing issues. diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 89b00ac..5eaba99 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -export const AdapterType = z.enum(["claude", "gemini", "opencode"]); +export const AdapterType = z.enum(["claude", "gemini", "opencode", "copilot"]); export type AdapterType = z.infer; export const HooksSchema = z.object({ diff --git a/packages/docs/src/content/docs/announcements/acp.mdx b/packages/docs/src/content/docs/announcements/acp.mdx index 224d5ac..3843a36 100644 --- a/packages/docs/src/content/docs/announcements/acp.mdx +++ b/packages/docs/src/content/docs/announcements/acp.mdx @@ -11,13 +11,14 @@ Ralph's agent communication layer has been rebuilt around the **Agent Client Pro ## Expanded agent support -Three adapters are available, all using ACP: +Four adapters are available, all using ACP: | Adapter | Command | |---------|---------| | Claude Code | `claude-code-acp` | | OpenCode | `opencode acp` | | Gemini (experimental) | `gemini --experimental-acp` | +| GitHub Copilot CLI | `copilot --acp` | Because adapters share a common protocol, adding support for new agents is now straightforward. @@ -41,7 +42,7 @@ ralph run --yolo # auto-approve all permissions ## Session continuity -Each run now has a session ID. If a run is interrupted, Claude Code and OpenCode sessions can be resumed — either by ralph itself on the next iteration, or by opening the session directly in the native CLI tool from the TUI. +Each run now has a session ID. If a run is interrupted, Claude Code, OpenCode, and GitHub Copilot CLI sessions can be resumed — either by ralph itself on the next iteration, or by opening the session directly in the native CLI tool from the TUI. --- diff --git a/packages/docs/src/content/docs/guides/installation.mdx b/packages/docs/src/content/docs/guides/installation.mdx index cf6d035..ec3fd41 100644 --- a/packages/docs/src/content/docs/guides/installation.mdx +++ b/packages/docs/src/content/docs/guides/installation.mdx @@ -14,7 +14,7 @@ ralph is distributed as an npm package and works on macOS, Linux, and Windows (v Before installing ralph, ensure you have: - **Node.js 18+** — ralph uses modern JavaScript features -- **An AI CLI tool** — Claude Code or OpenCode currently supported. +- **An AI CLI tool** — Claude Code, OpenCode, Gemini CLI, or GitHub Copilot CLI currently supported. - **VCS** — For state persistence between iterations use git, jj or your preferred version control system.