diff --git a/packages/agent/src/commands/run.ts b/packages/agent/src/commands/run.ts index 61a9bce5..d32ab0fe 100644 --- a/packages/agent/src/commands/run.ts +++ b/packages/agent/src/commands/run.ts @@ -289,9 +289,9 @@ export const runCommand = async ( options.agentTool || workflowManager.defaults?.tool || 'claude'; // Temporarily ACP usage decision during ACP migration process: - // force Claude and OpenCode to always use ACP mode - const useACPMode = - tool.toLowerCase() === 'claude' || tool.toLowerCase() === 'opencode'; + // force Claude, Copilot, and OpenCode to always use ACP mode + const acpEnabledTools = ['claude', 'copilot', 'opencode']; + const useACPMode = acpEnabledTools.includes(tool.toLowerCase()); if (useACPMode) { console.log(colors.cyan('\nšŸ”— ACP Mode enabled')); diff --git a/packages/agent/src/lib/acp-runner.ts b/packages/agent/src/lib/acp-runner.ts index ad731f26..f310ee52 100644 --- a/packages/agent/src/lib/acp-runner.ts +++ b/packages/agent/src/lib/acp-runner.ts @@ -11,6 +11,7 @@ import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION, + type ContentBlock, } from '@agentclientprotocol/sdk'; import colors from 'ansi-colors'; import { existsSync, readFileSync } from 'node:fs'; @@ -51,6 +52,11 @@ function getACPSpawnCommand(tool: string): { command: string; args: string[] } { command: 'npx', args: ['-y', '@zed-industries/claude-code-acp'], }; + case 'copilot': + return { + command: 'copilot', + args: ['--acp'], + }; case 'opencode': return { command: 'opencode', @@ -207,9 +213,26 @@ export class ACPRunner { /** * Send a prompt to the current session * Maps to the ACP session/prompt method + * + * Supports text, images (base64 or file:// URIs), and embedded resources. */ async sendPrompt( - prompt: string + prompt: string, + options?: { + /** Image attachments (base64 data or file:// URIs) */ + images?: Array<{ + data: string; + mimeType: string; + uri?: string; + }>; + /** Embedded text or blob resources */ + resources?: Array<{ + uri: string; + mimeType?: string; + text?: string; + blob?: string; + }>; + } ): Promise<{ stopReason: string; response: string }> { if (!this.isConnectionInitialized || !this.connection) { throw new Error( @@ -229,14 +252,54 @@ export class ACPRunner { // Start capturing agent messages this.client.startCapturing(); + // Build prompt content array + const promptContent: ContentBlock[] = [ + { + type: 'text', + text: prompt, + }, + ]; + + // Add image attachments if provided + if (options?.images) { + for (const image of options.images) { + promptContent.push({ + type: 'image', + data: image.data, + mimeType: image.mimeType, + uri: image.uri, + }); + } + } + + // Add embedded resources if provided + if (options?.resources) { + for (const resource of options.resources) { + if (resource.text !== undefined) { + promptContent.push({ + type: 'resource', + resource: { + uri: resource.uri, + mimeType: resource.mimeType, + text: resource.text, + }, + }); + } else if (resource.blob !== undefined) { + promptContent.push({ + type: 'resource', + resource: { + uri: resource.uri, + mimeType: resource.mimeType, + blob: resource.blob, + }, + }); + } + } + } + const promptResult = await this.connection.prompt({ sessionId: this.sessionId, - prompt: [ - { - type: 'text', - text: prompt, - }, - ], + prompt: promptContent, }); // Stop capturing and get the accumulated response @@ -252,6 +315,69 @@ export class ACPRunner { } } + /** + * Set the model for the current session + * Maps to the ACP session/set_model method (experimental) + * + * Allows changing the AI model used for subsequent prompts in the session. + */ + async setModel(modelId: string): Promise { + if (!this.isConnectionInitialized || !this.connection) { + throw new Error( + 'Connection not initialized. Call initializeConnection() first.' + ); + } + + if (!this.isSessionCreated || !this.sessionId) { + throw new Error('No active session. Call createSession() first.'); + } + + try { + await this.connection.unstable_setSessionModel({ + sessionId: this.sessionId, + modelId, + }); + + console.log(colors.gray(`šŸ”„ Model changed to: ${modelId}`)); + } catch (error) { + throw new Error( + `Failed to set model: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + /** + * Cancel an ongoing prompt operation + * Maps to the ACP session/cancel notification + * + * Aborts any running language model requests and tool calls. + */ + async cancelPrompt(): Promise { + if (!this.isConnectionInitialized || !this.connection) { + throw new Error( + 'Connection not initialized. Call initializeConnection() first.' + ); + } + + if (!this.isSessionCreated || !this.sessionId) { + throw new Error('No active session. Call createSession() first.'); + } + + try { + await this.connection.cancel({ + sessionId: this.sessionId, + }); + + console.log( + colors.yellow(`āš ļø Prompt cancelled for session: ${this.sessionId}`) + ); + } catch (error) { + throw new Error( + `Failed to cancel prompt: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + /** * Run a single workflow step using the ACP session */ diff --git a/packages/agent/src/lib/agents/copilot.ts b/packages/agent/src/lib/agents/copilot.ts new file mode 100644 index 00000000..41b2b53e --- /dev/null +++ b/packages/agent/src/lib/agents/copilot.ts @@ -0,0 +1,177 @@ +import { + existsSync, + copyFileSync, + cpSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import colors from 'ansi-colors'; +import { AgentCredentialFile } from './types.js'; +import { BaseAgent } from './base.js'; + +export class CopilotAgent extends BaseAgent { + name = 'Copilot'; + binary = 'copilot'; + + constructor(version: string = 'latest', model?: string) { + super(version, model); + } + + getInstallCommand(): string { + const packageSpec = `@github/copilot@${this.version}`; + return `npm install -g ${packageSpec}`; + } + + getRequiredCredentials(): AgentCredentialFile[] { + return [ + { + path: '/.copilot', + description: 'Copilot configuration directory', + required: true, + }, + ]; + } + + async copyCredentials(targetDir: string): Promise { + console.log(colors.bold(`\nCopying ${this.name} credentials`)); + + const targetCopilotDir = join(targetDir, '.copilot'); + // Ensure .copilot directory exists + this.ensureDirectory(targetCopilotDir); + + const credentials = this.getRequiredCredentials(); + for (const cred of credentials) { + if (existsSync(cred.path)) { + // Copy the entire .copilot directory + cpSync(cred.path, targetCopilotDir, { recursive: true }); + console.log(colors.gray('ā”œā”€ā”€ Copied: ') + colors.cyan(cred.path)); + } + } + + console.log(colors.green(`āœ“ ${this.name} credentials copied successfully`)); + } + + async configureMCP( + name: string, + commandOrUrl: string, + transport: string, + envs: string[], + headers: string[] + ): Promise { + const configPath = join(homedir(), '.copilot', 'mcp-config.json'); + + // Read existing config or initialize with empty mcpServers + let config: { mcpServers: Record } = { mcpServers: {} }; + if (existsSync(configPath)) { + try { + const content = readFileSync(configPath, 'utf-8'); + config = JSON.parse(content); + if (!config.mcpServers) { + config.mcpServers = {}; + } + } catch (error: any) { + console.log( + colors.yellow( + `Warning: Could not parse existing config: ${error.message}` + ) + ); + config = { mcpServers: {} }; + } + } + + // Parse environment variables (KEY=VALUE format) + const env: Record = {}; + envs.forEach(envVar => { + const match = envVar.match(/^(\w+)=(.*)$/); + if (match) { + env[match[1]] = match[2]; + } else { + console.log( + colors.yellow( + `Warning: Invalid environment variable format: ${envVar} (expected KEY=VALUE)` + ) + ); + } + }); + + // Parse headers (KEY: VALUE format) + const headersObj: Record = {}; + headers.forEach(header => { + const match = header.match(/^([\w-]+)\s*:\s*(.+)$/); + if (match) { + headersObj[match[1]] = match[2]; + } else { + console.log( + colors.yellow( + `Warning: Invalid header format: ${header} (expected "KEY: VALUE")` + ) + ); + } + }); + + const serverConfig: any = { + tools: ['*'], + }; + + serverConfig.type = transport; + + if (transport === 'stdio') { + const parts = commandOrUrl.split(' '); + serverConfig.command = parts[0]; + // Copilot CLI requires 'args' array even if empty + serverConfig.args = parts.length > 1 ? parts.slice(1) : []; + if (Object.keys(env).length > 0) { + serverConfig.env = env; + } + } else if (['http', 'sse'].includes(transport)) { + serverConfig.url = commandOrUrl; + if (Object.keys(headersObj).length > 0) { + serverConfig.headers = headersObj; + } + if (Object.keys(env).length > 0) { + serverConfig.env = env; + } + } else { + throw new Error( + `Unsupported transport type: ${transport}. Use 'stdio', 'http', or 'sse'.` + ); + } + + // Add or update the server configuration + config.mcpServers[name] = serverConfig; + + // Ensure the .copilot directory exists + this.ensureDirectory(join(homedir(), '.copilot')); + + // Write the configuration back to disk + writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); + + console.log( + colors.green(`āœ“ MCP server "${name}" configured for ${this.name}`) + ); + } + + toolArguments(): string[] { + const args = ['--allow-all-tools', '--silent']; + if (this.model) { + args.push('--model', this.model); + } + args.push('-p'); + return args; + } + + toolInteractiveArguments( + precontext: string, + initialPrompt?: string + ): string[] { + let prompt = precontext; + + if (initialPrompt) { + prompt += `\n\nInitial User Prompt:\n\n${initialPrompt}`; + } + + return ['-p', prompt]; + } +} diff --git a/packages/agent/src/lib/agents/index.ts b/packages/agent/src/lib/agents/index.ts index 0fe578d3..a350b91c 100644 --- a/packages/agent/src/lib/agents/index.ts +++ b/packages/agent/src/lib/agents/index.ts @@ -1,6 +1,7 @@ import { Agent } from './types.js'; import { ClaudeAgent } from './claude.js'; import { CodexAgent } from './codex.js'; +import { CopilotAgent } from './copilot.js'; import { CursorAgent } from './cursor.js'; import { GeminiAgent } from './gemini.js'; import { OpenCodeAgent } from './opencode.js'; @@ -10,6 +11,7 @@ import { AI_AGENT } from 'rover-core'; export * from './types.js'; export { ClaudeAgent } from './claude.js'; export { CodexAgent } from './codex.js'; +export { CopilotAgent } from './copilot.js'; export { CursorAgent } from './cursor.js'; export { GeminiAgent } from './gemini.js'; export { OpenCodeAgent } from './opencode.js'; @@ -25,6 +27,8 @@ export function createAgent( return new ClaudeAgent(version, model); case 'codex': return new CodexAgent(version, model); + case 'copilot': + return new CopilotAgent(version, model); case 'cursor': return new CursorAgent(version, model); case 'gemini': diff --git a/packages/cli/src/lib/agent-models.ts b/packages/cli/src/lib/agent-models.ts index 6da53f5b..ad4b9401 100644 --- a/packages/cli/src/lib/agent-models.ts +++ b/packages/cli/src/lib/agent-models.ts @@ -27,6 +27,30 @@ export const AGENT_MODELS: Record = { { name: 'opus', description: 'Highest capability' }, { name: 'haiku', description: 'Fastest and cheapest' }, ], + // Copilot models: run `copilot --help | grep -A 10 "model"` to get the list + [AI_AGENT.Copilot]: [ + // OpenAI models + { + name: 'gpt-5.2-codex', + description: 'Latest code-specialized model', + isDefault: true, + }, + { name: 'gpt-5.2', description: 'Latest general model' }, + { name: 'gpt-5.1-codex-max', description: 'High-capacity coding model' }, + { name: 'gpt-5.1-codex', description: 'Code-focused model' }, + { name: 'gpt-5.1-codex-mini', description: 'Lightweight coding model' }, + { name: 'gpt-5.1', description: 'Improved general model' }, + { name: 'gpt-5', description: 'Base GPT-5 model' }, + { name: 'gpt-5-mini', description: 'Efficient variant' }, + { name: 'gpt-4.1', description: 'General-purpose model' }, + // Anthropic Claude models + { name: 'claude-sonnet-4.5', description: 'Balanced Claude variant' }, + { name: 'claude-haiku-4.5', description: 'Fast, efficient Claude' }, + { name: 'claude-opus-4.5', description: 'High capability Claude' }, + { name: 'claude-sonnet-4', description: 'Balanced capability Claude' }, + // Google Gemini models + { name: 'gemini-3-pro-preview', description: 'Professional-grade Gemini' }, + ], [AI_AGENT.Gemini]: [ { name: 'flash', diff --git a/packages/cli/src/lib/agents/copilot.ts b/packages/cli/src/lib/agents/copilot.ts new file mode 100644 index 00000000..b01dfc72 --- /dev/null +++ b/packages/cli/src/lib/agents/copilot.ts @@ -0,0 +1,191 @@ +import { launch } from 'rover-core'; +import { + AIAgentTool, + InvokeAIAgentError, + MissingAIAgentError, +} from './index.js'; +import { PromptBuilder, IPromptTask } from '../prompts/index.js'; +import { parseJsonResponse } from '../../utils/json-parser.js'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import type { WorkflowInput } from 'rover-schemas'; + +// Environment variables for GitHub Copilot CLI +const COPILOT_ENV_VARS = ['GITHUB_TOKEN', 'GH_TOKEN']; + +class CopilotAI implements AIAgentTool { + public AGENT_BIN = 'copilot'; + private promptBuilder = new PromptBuilder('copilot'); + + async checkAgent(): Promise { + try { + await launch(this.AGENT_BIN, ['--version']); + } catch (_err) { + throw new MissingAIAgentError(this.AGENT_BIN); + } + } + + async invoke( + prompt: string, + json: boolean = false, + cwd?: string + ): Promise { + if (json) { + prompt = `${prompt} + +You MUST output a valid JSON string as an output. Just output the JSON string and nothing else. If you had any error, still return a JSON string with an "error" property.`; + } + + // Use -s (silent) to get clean output without stats + const copilotArgs = ['-s', '-p', prompt]; + + try { + const { stdout } = await launch(this.AGENT_BIN, copilotArgs, { + cwd, + }); + + // Copilot does not have a --output-format json flag like Claude/Cursor, + // so we just return the raw result and let parseJsonResponse handle it + return stdout?.toString().trim() || ''; + } catch (error) { + throw new InvokeAIAgentError(this.AGENT_BIN, error); + } + } + + async expandTask( + briefDescription: string, + projectPath: string + ): Promise { + const prompt = this.promptBuilder.expandTaskPrompt(briefDescription); + + try { + const response = await this.invoke(prompt, true, projectPath); + return parseJsonResponse(response); + } catch (error) { + console.error('Failed to expand task with Copilot:', error); + return null; + } + } + + async expandIterationInstructions( + instructions: string, + previousPlan?: string, + previousChanges?: string + ): Promise { + const prompt = this.promptBuilder.expandIterationInstructionsPrompt( + instructions, + previousPlan, + previousChanges + ); + + try { + const response = await this.invoke(prompt, true); + return parseJsonResponse(response); + } catch (error) { + console.error( + 'Failed to expand iteration instructions with Copilot:', + error + ); + return null; + } + } + + async generateCommitMessage( + taskTitle: string, + taskDescription: string, + recentCommits: string[], + summaries: string[] + ): Promise { + try { + const prompt = this.promptBuilder.generateCommitMessagePrompt( + taskTitle, + taskDescription, + recentCommits, + summaries + ); + const response = await this.invoke(prompt, false); + + if (!response) { + return null; + } + + const lines = response + .split('\n') + .filter((line: string) => line.trim() !== ''); + return lines[0] || null; + } catch (error) { + return null; + } + } + + async resolveMergeConflicts( + filePath: string, + diffContext: string, + conflictedContent: string + ): Promise { + try { + const prompt = this.promptBuilder.resolveMergeConflictsPrompt( + filePath, + diffContext, + conflictedContent + ); + const response = await this.invoke(prompt, false); + + return response; + } catch (err) { + return null; + } + } + + async extractGithubInputs( + issueDescription: string, + inputs: WorkflowInput[] + ): Promise | null> { + const prompt = this.promptBuilder.extractGithubInputsPrompt( + issueDescription, + inputs + ); + + try { + const response = await this.invoke(prompt, true); + return parseJsonResponse>(response); + } catch (error) { + console.error('Failed to extract GitHub inputs with Copilot:', error); + return null; + } + } + + getContainerMounts(): string[] { + const dockerMounts: string[] = []; + const copilotDir = join(homedir(), '.copilot'); + + if (existsSync(copilotDir)) { + dockerMounts.push(`-v`, `${copilotDir}:/.copilot:Z,ro`); + } + + return dockerMounts; + } + + getEnvironmentVariables(): string[] { + const envVars: string[] = []; + + // Look for COPILOT_* and GITHUB_* env vars + for (const key in process.env) { + if (key.startsWith('COPILOT_') || key.startsWith('GITHUB_')) { + envVars.push('-e', key); + } + } + + // Add other specific environment variables + for (const key of COPILOT_ENV_VARS) { + if (process.env[key] !== undefined) { + envVars.push('-e', key); + } + } + + return envVars; + } +} + +export default CopilotAI; diff --git a/packages/cli/src/lib/agents/index.ts b/packages/cli/src/lib/agents/index.ts index 23729d19..9fba3b71 100644 --- a/packages/cli/src/lib/agents/index.ts +++ b/packages/cli/src/lib/agents/index.ts @@ -1,5 +1,6 @@ import ClaudeAI from './claude.js'; import CodexAI from './codex.js'; +import CopilotAI from './copilot.js'; import CursorAI from './cursor.js'; import GeminiAI from './gemini.js'; import OpenCodeAI from './opencode.js'; @@ -101,6 +102,8 @@ export const getAIAgentTool = (agent: string): AIAgentTool => { return new ClaudeAI(); case 'codex': return new CodexAI(); + case 'copilot': + return new CopilotAI(); case 'cursor': return new CursorAI(); case 'gemini': diff --git a/packages/cli/src/utils/__tests__/agent-parser.test.ts b/packages/cli/src/utils/__tests__/agent-parser.test.ts index 6a9a8371..095af192 100644 --- a/packages/cli/src/utils/__tests__/agent-parser.test.ts +++ b/packages/cli/src/utils/__tests__/agent-parser.test.ts @@ -28,6 +28,12 @@ describe('parseAgentString', () => { expect(result.model).toBeUndefined(); }); + it('should parse "copilot" as copilot agent with no model', () => { + const result = parseAgentString('copilot'); + expect(result.agent).toBe(AI_AGENT.Copilot); + expect(result.model).toBeUndefined(); + }); + it('should parse "cursor" as cursor agent with no model', () => { const result = parseAgentString('cursor'); expect(result.agent).toBe(AI_AGENT.Cursor); diff --git a/packages/cli/src/utils/agent-parser.ts b/packages/cli/src/utils/agent-parser.ts index 2a2a41f5..c37efbc4 100644 --- a/packages/cli/src/utils/agent-parser.ts +++ b/packages/cli/src/utils/agent-parser.ts @@ -38,6 +38,9 @@ export function parseAgentString(agentString: string): ParsedAgent { case 'codex': normalizedAgent = AI_AGENT.Codex; break; + case 'copilot': + normalizedAgent = AI_AGENT.Copilot; + break; case 'cursor': normalizedAgent = AI_AGENT.Cursor; break; diff --git a/packages/schemas/src/agent.ts b/packages/schemas/src/agent.ts index f75aa460..77627c47 100644 --- a/packages/schemas/src/agent.ts +++ b/packages/schemas/src/agent.ts @@ -2,6 +2,7 @@ export enum AI_AGENT { Claude = 'claude', Codex = 'codex', + Copilot = 'copilot', Cursor = 'cursor', Gemini = 'gemini', OpenCode = 'opencode',