From 17232f9940083cf3a49b58db2efc7e73e32123b8 Mon Sep 17 00:00:00 2001 From: Tomohisa Takaoka Date: Sat, 28 Feb 2026 03:28:56 -0800 Subject: [PATCH] feat: add GitHub Copilot CLI as a new provider (#425) * feat: add GitHub Copilot CLI as a new provider Add support for GitHub Copilot CLI (@github/copilot) as a takt provider, enabling the 'copilot' command to be used for AI-driven task execution. New files: - src/infra/copilot/client.ts: CLI client with streaming, session ID extraction via --share, and permission mode mapping - src/infra/copilot/types.ts: CopilotCallOptions type definitions - src/infra/copilot/index.ts: barrel exports - src/infra/providers/copilot.ts: CopilotProvider implementing Provider - src/__tests__/copilot-client.test.ts: 20 unit tests for client - src/__tests__/copilot-provider.test.ts: 8 unit tests for provider Key features: - Spawns 'copilot -p' in non-interactive mode with --silent --no-color - Permission modes: full (--yolo), edit (--allow-all-tools --no-ask-user), readonly (no permission flags) - Session ID extraction from --share transcript files - Real-time stdout streaming via onStream callbacks - Configurable via COPILOT_CLI_PATH and COPILOT_GITHUB_TOKEN env vars * fix: remove unused COPILOT_DEFAULT_MAX_AUTOPILOT_CONTINUES constant * fix: address review feedback for copilot provider - Remove excess maxAutopilotContinues property from test (#1 High) - Extract cleanupTmpDir() helper to eliminate DRY violation (#2 Medium) - Deduplicate chunk string conversion in stdout handler (#3 Medium) - Remove 5 what/how comments that restate code (#4 Medium) - Log readFile failure instead of silently swallowing (#5 Medium) - Add credential scrubbing (ghp_/ghs_/gho_/github_pat_) for stderr (#6 Medium) - Add buffer overflow tests for stdout and stderr (#7 Medium) - Add pre-aborted AbortSignal test (#8 Low) - Add mkdtemp failure fallback test (#9 Low) - Add rm cleanup verification to fallback test (#10 Low) - Log mkdtemp failure with debug level (#11 Persist) - Add createLogger('copilot-client') for structured logging --- src/__tests__/copilot-client.test.ts | 482 +++++++++++++++++ src/__tests__/copilot-provider.test.ts | 184 +++++++ src/agents/types.ts | 4 +- src/app/cli/program.ts | 2 +- src/core/models/persisted-global-config.ts | 10 +- src/core/models/piece-types.ts | 2 +- src/core/models/provider-profiles.ts | 2 +- src/core/models/schemas.ts | 20 +- src/core/piece/agent-usecases.ts | 2 +- .../piece/permission-profile-resolution.ts | 1 + src/core/piece/types.ts | 2 +- src/infra/config/env/config-env-overrides.ts | 3 + src/infra/config/global/globalConfig.ts | 44 +- src/infra/config/global/index.ts | 2 + src/infra/config/global/initialization.ts | 5 +- src/infra/config/project/projectConfig.ts | 2 + src/infra/config/types.ts | 4 +- src/infra/copilot/client.ts | 503 ++++++++++++++++++ src/infra/copilot/index.ts | 6 + src/infra/copilot/types.ts | 21 + src/infra/providers/copilot.ts | 62 +++ src/infra/providers/index.ts | 2 + src/infra/providers/types.ts | 3 +- 23 files changed, 1346 insertions(+), 22 deletions(-) create mode 100644 src/__tests__/copilot-client.test.ts create mode 100644 src/__tests__/copilot-provider.test.ts create mode 100644 src/infra/copilot/client.ts create mode 100644 src/infra/copilot/index.ts create mode 100644 src/infra/copilot/types.ts create mode 100644 src/infra/providers/copilot.ts diff --git a/src/__tests__/copilot-client.test.ts b/src/__tests__/copilot-client.test.ts new file mode 100644 index 0000000..7fd93f6 --- /dev/null +++ b/src/__tests__/copilot-client.test.ts @@ -0,0 +1,482 @@ +/** + * Tests for GitHub Copilot CLI client + */ + +import { EventEmitter } from 'node:events'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockSpawn, mockMkdtemp, mockReadFile, mockRm } = vi.hoisted(() => ({ + mockSpawn: vi.fn(), + mockMkdtemp: vi.fn(), + mockReadFile: vi.fn(), + mockRm: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ + spawn: mockSpawn, +})); + +vi.mock('node:fs/promises', () => ({ + mkdtemp: mockMkdtemp, + readFile: mockReadFile, + rm: mockRm, +})); + +import { callCopilot, extractSessionIdFromShareFile } from '../infra/copilot/client.js'; + +type SpawnScenario = { + stdout?: string; + stderr?: string; + code?: number | null; + signal?: NodeJS.Signals | null; + error?: Partial & { message: string }; +}; + +type MockChildProcess = EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + kill: ReturnType; +}; + +function createMockChildProcess(): MockChildProcess { + const child = new EventEmitter() as MockChildProcess; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = vi.fn(() => true); + return child; +} + +function mockSpawnWithScenario(scenario: SpawnScenario): void { + mockSpawn.mockImplementation((_cmd: string, _args: string[], _options: object) => { + const child = createMockChildProcess(); + + queueMicrotask(() => { + if (scenario.stdout) { + child.stdout.emit('data', Buffer.from(scenario.stdout, 'utf-8')); + } + if (scenario.stderr) { + child.stderr.emit('data', Buffer.from(scenario.stderr, 'utf-8')); + } + + if (scenario.error) { + const error = Object.assign(new Error(scenario.error.message), scenario.error); + child.emit('error', error); + return; + } + + child.emit('close', scenario.code ?? 0, scenario.signal ?? null); + }); + + return child; + }); +} + +describe('callCopilot', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.COPILOT_GITHUB_TOKEN; + // Default: mkdtemp creates a temp dir, readFile returns a session transcript, rm succeeds + mockMkdtemp.mockResolvedValue('/tmp/takt-copilot-XXXXXX'); + mockReadFile.mockResolvedValue( + '# 🤖 Copilot CLI Session\n\n> **Session ID:** `aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee`\n', + ); + mockRm.mockResolvedValue(undefined); + }); + + it('should invoke copilot with required args including --silent, --no-color', async () => { + mockSpawnWithScenario({ + stdout: 'Implementation complete. All tests pass.', + code: 0, + }); + + const result = await callCopilot('coder', 'implement feature', { + cwd: '/repo', + model: 'claude-sonnet-4.6', + sessionId: 'sess-prev', + permissionMode: 'full', + copilotGithubToken: 'gh-token', + }); + + expect(result.status).toBe('done'); + expect(result.content).toBe('Implementation complete. All tests pass.'); + // Session ID extracted from --share file + expect(result.sessionId).toBe('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'); + + expect(mockSpawn).toHaveBeenCalledTimes(1); + const [command, args, options] = mockSpawn.mock.calls[0] as [string, string[], { env?: NodeJS.ProcessEnv; stdio?: unknown }]; + + expect(command).toBe('copilot'); + // --yolo is used for full permission; --share is included for session extraction + expect(args).toContain('-p'); + expect(args).toContain('--silent'); + expect(args).toContain('--no-color'); + expect(args).toContain('--no-auto-update'); + expect(args).toContain('--model'); + expect(args).toContain('--resume'); + expect(args).toContain('--yolo'); + expect(args).toContain('--share'); + expect(options.env?.COPILOT_GITHUB_TOKEN).toBe('gh-token'); + expect(options.stdio).toEqual(['ignore', 'pipe', 'pipe']); + }); + + it('should use --allow-all-tools --no-ask-user for edit permission mode (no --autopilot)', async () => { + mockSpawnWithScenario({ + stdout: 'done', + code: 0, + }); + + await callCopilot('coder', 'implement feature', { + cwd: '/repo', + permissionMode: 'edit', + }); + + const [, args] = mockSpawn.mock.calls[0] as [string, string[]]; + expect(args).toContain('--allow-all-tools'); + expect(args).toContain('--no-ask-user'); + expect(args).not.toContain('--yolo'); + expect(args).not.toContain('--autopilot'); + }); + + it('should not add permission flags for readonly mode', async () => { + mockSpawnWithScenario({ + stdout: 'done', + code: 0, + }); + + await callCopilot('coder', 'implement feature', { + cwd: '/repo', + permissionMode: 'readonly', + }); + + const [, args] = mockSpawn.mock.calls[0] as [string, string[]]; + expect(args).not.toContain('--yolo'); + expect(args).not.toContain('--allow-all-tools'); + expect(args).not.toContain('--no-ask-user'); + expect(args).not.toContain('--autopilot'); + }); + + it('should not inject COPILOT_GITHUB_TOKEN when copilotGithubToken is undefined', async () => { + mockSpawnWithScenario({ + stdout: 'done', + code: 0, + }); + + const result = await callCopilot('coder', 'implement feature', { + cwd: '/repo', + }); + + expect(result.status).toBe('done'); + + const [, , options] = mockSpawn.mock.calls[0] as [string, string[], { env?: NodeJS.ProcessEnv }]; + expect(options.env).toBe(process.env); + }); + + it('should use custom CLI path when copilotCliPath is specified', async () => { + mockSpawnWithScenario({ + stdout: 'done', + code: 0, + }); + + await callCopilot('coder', 'implement', { + cwd: '/repo', + copilotCliPath: '/custom/bin/copilot', + }); + + const [command] = mockSpawn.mock.calls[0] as [string]; + expect(command).toBe('/custom/bin/copilot'); + }); + + it('should not include --autopilot or --max-autopilot-continues flags', async () => { + mockSpawnWithScenario({ + stdout: 'done', + code: 0, + }); + + await callCopilot('coder', 'implement', { + cwd: '/repo', + permissionMode: 'readonly', + }); + + const [, args] = mockSpawn.mock.calls[0] as [string, string[]]; + expect(args).not.toContain('--max-autopilot-continues'); + expect(args).not.toContain('--autopilot'); + }); + + it('should prepend system prompt to user prompt', async () => { + mockSpawnWithScenario({ + stdout: 'reviewed', + code: 0, + }); + + await callCopilot('reviewer', 'review this code', { + cwd: '/repo', + systemPrompt: 'You are a strict reviewer.', + }); + + const [, args] = mockSpawn.mock.calls[0] as [string, string[]]; + // -p is at index 0, prompt is at index 1 + expect(args[1]).toBe('You are a strict reviewer.\n\nreview this code'); + }); + + it('should return structured error when copilot binary is not found', async () => { + mockSpawnWithScenario({ + error: { code: 'ENOENT', message: 'spawn copilot ENOENT' }, + }); + + const result = await callCopilot('coder', 'implement feature', { cwd: '/repo' }); + + expect(result.status).toBe('error'); + expect(result.content).toContain('copilot binary not found'); + expect(result.content).toContain('npm install -g @github/copilot'); + }); + + it('should classify authentication errors', async () => { + mockSpawnWithScenario({ + code: 1, + stderr: 'Authentication required. Not logged in.', + }); + + const result = await callCopilot('coder', 'implement feature', { cwd: '/repo' }); + + expect(result.status).toBe('error'); + expect(result.content).toContain('Copilot authentication failed'); + expect(result.content).toContain('TAKT_COPILOT_GITHUB_TOKEN'); + }); + + it('should classify non-zero exits with detail', async () => { + mockSpawnWithScenario({ + code: 2, + stderr: 'unexpected failure', + }); + + const result = await callCopilot('coder', 'implement feature', { cwd: '/repo' }); + + expect(result.status).toBe('error'); + expect(result.content).toContain('code 2'); + expect(result.content).toContain('unexpected failure'); + }); + + it('should return error when stdout is empty', async () => { + mockSpawnWithScenario({ + stdout: '', + code: 0, + }); + + const result = await callCopilot('coder', 'implement feature', { cwd: '/repo' }); + + expect(result.status).toBe('error'); + expect(result.content).toContain('copilot returned empty output'); + }); + + it('should return plain text content (no JSON parsing needed)', async () => { + const output = 'Here is the implementation:\n\n```typescript\nconsole.log("hello");\n```'; + mockSpawnWithScenario({ + stdout: output, + code: 0, + }); + + const result = await callCopilot('coder', 'implement feature', { cwd: '/repo' }); + + expect(result.status).toBe('done'); + expect(result.content).toBe(output); + }); + + it('should call onStream callback with text and result events on success', async () => { + mockSpawnWithScenario({ + stdout: 'stream content', + code: 0, + }); + + const onStream = vi.fn(); + await callCopilot('coder', 'implement', { + cwd: '/repo', + onStream, + }); + + expect(onStream).toHaveBeenCalledTimes(2); + expect(onStream).toHaveBeenNthCalledWith(1, { + type: 'text', + data: { text: 'stream content' }, + }); + expect(onStream).toHaveBeenNthCalledWith(2, { + type: 'result', + data: expect.objectContaining({ + result: 'stream content', + success: true, + }), + }); + }); + + it('should call onStream callback with error result on failure', async () => { + mockSpawnWithScenario({ + error: { code: 'ENOENT', message: 'spawn copilot ENOENT' }, + }); + + const onStream = vi.fn(); + await callCopilot('coder', 'implement', { + cwd: '/repo', + onStream, + }); + + expect(onStream).toHaveBeenCalledWith({ + type: 'result', + data: expect.objectContaining({ + success: false, + error: expect.stringContaining('copilot binary not found'), + }), + }); + }); + + it('should handle abort signal', async () => { + const controller = new AbortController(); + + mockSpawn.mockImplementation(() => { + const child = createMockChildProcess(); + + queueMicrotask(() => { + // Simulate abort + controller.abort(); + child.emit('close', null, 'SIGTERM'); + }); + + return child; + }); + + const result = await callCopilot('coder', 'implement', { + cwd: '/repo', + abortSignal: controller.signal, + }); + + expect(result.status).toBe('error'); + expect(result.content).toContain('Copilot execution aborted'); + }); + + it('should fall back to options.sessionId when share file extraction fails', async () => { + mockReadFile.mockRejectedValue(new Error('ENOENT')); + mockSpawnWithScenario({ + stdout: 'done', + code: 0, + }); + + const result = await callCopilot('coder', 'implement', { + cwd: '/repo', + sessionId: 'fallback-session-id', + }); + + expect(result.status).toBe('done'); + expect(result.sessionId).toBe('fallback-session-id'); + expect(mockRm).toHaveBeenCalledWith('/tmp/takt-copilot-XXXXXX', { recursive: true, force: true }); + }); + + it('should extract session ID from --share file on success', async () => { + mockReadFile.mockResolvedValue( + '# Session\n\n> **Session ID:** `12345678-abcd-1234-ef01-123456789012`\n', + ); + mockSpawnWithScenario({ + stdout: 'hello', + code: 0, + }); + + const result = await callCopilot('coder', 'implement', { + cwd: '/repo', + }); + + expect(result.status).toBe('done'); + expect(result.sessionId).toBe('12345678-abcd-1234-ef01-123456789012'); + }); + + it('should return error when stdout buffer overflows', async () => { + mockSpawn.mockImplementation(() => { + const child = createMockChildProcess(); + queueMicrotask(() => { + child.stdout.emit('data', Buffer.alloc(10 * 1024 * 1024 + 1)); + }); + return child; + }); + + const result = await callCopilot('coder', 'implement', { cwd: '/repo' }); + + expect(result.status).toBe('error'); + expect(result.content).toContain('Copilot CLI output exceeded buffer limit'); + }); + + it('should return error when stderr buffer overflows', async () => { + mockSpawn.mockImplementation(() => { + const child = createMockChildProcess(); + queueMicrotask(() => { + child.stderr.emit('data', Buffer.alloc(10 * 1024 * 1024 + 1)); + }); + return child; + }); + + const result = await callCopilot('coder', 'implement', { cwd: '/repo' }); + + expect(result.status).toBe('error'); + expect(result.content).toContain('Copilot CLI output exceeded buffer limit'); + }); + + it('should return error when abort signal is already aborted before call', async () => { + const controller = new AbortController(); + controller.abort(); + + mockSpawn.mockImplementation(() => { + const child = createMockChildProcess(); + queueMicrotask(() => { + child.emit('close', null, 'SIGTERM'); + }); + return child; + }); + + const result = await callCopilot('coder', 'implement', { + cwd: '/repo', + abortSignal: controller.signal, + }); + + expect(result.status).toBe('error'); + expect(result.content).toContain('Copilot execution aborted'); + }); + + it('should proceed without session extraction when mkdtemp fails', async () => { + mockMkdtemp.mockRejectedValue(new Error('ENOSPC')); + mockSpawnWithScenario({ + stdout: 'done', + code: 0, + }); + + const result = await callCopilot('coder', 'implement', { + cwd: '/repo', + sessionId: 'existing-session-id', + }); + + expect(result.status).toBe('done'); + expect(result.sessionId).toBe('existing-session-id'); + }); + + it('should redact credentials from error stderr', async () => { + mockSpawnWithScenario({ + code: 2, + stderr: 'config error: secret ghp_abcdefghijklmnopqrstuvwxyz1234567890 is wrong', + }); + + const result = await callCopilot('coder', 'implement', { cwd: '/repo' }); + + expect(result.status).toBe('error'); + expect(result.content).not.toContain('ghp_abcdefghijklmnopqrstuvwxyz1234567890'); + expect(result.content).toContain('[REDACTED]'); + }); +}); + +describe('extractSessionIdFromShareFile', () => { + it('should extract UUID from standard share file format', () => { + const content = '# 🤖 Copilot CLI Session\n\n> **Session ID:** `107256ee-226c-4677-bf55-7b6b158ddadf`\n'; + expect(extractSessionIdFromShareFile(content)).toBe('107256ee-226c-4677-bf55-7b6b158ddadf'); + }); + + it('should return undefined for content without session ID', () => { + expect(extractSessionIdFromShareFile('no session here')).toBeUndefined(); + }); + + it('should return undefined for empty content', () => { + expect(extractSessionIdFromShareFile('')).toBeUndefined(); + }); +}); diff --git a/src/__tests__/copilot-provider.test.ts b/src/__tests__/copilot-provider.test.ts new file mode 100644 index 0000000..e72f9fb --- /dev/null +++ b/src/__tests__/copilot-provider.test.ts @@ -0,0 +1,184 @@ +/** + * Tests for Copilot provider implementation + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockCallCopilot, + mockCallCopilotCustom, +} = vi.hoisted(() => ({ + mockCallCopilot: vi.fn(), + mockCallCopilotCustom: vi.fn(), +})); + +const { + mockResolveCopilotGithubToken, + mockResolveCopilotCliPath, + mockLoadProjectConfig, +} = vi.hoisted(() => ({ + mockResolveCopilotGithubToken: vi.fn(() => undefined), + mockResolveCopilotCliPath: vi.fn(() => undefined), + mockLoadProjectConfig: vi.fn(() => ({})), +})); + +vi.mock('../infra/copilot/index.js', () => ({ + callCopilot: mockCallCopilot, + callCopilotCustom: mockCallCopilotCustom, +})); + +vi.mock('../infra/config/index.js', () => ({ + resolveCopilotGithubToken: mockResolveCopilotGithubToken, + resolveCopilotCliPath: mockResolveCopilotCliPath, + loadProjectConfig: mockLoadProjectConfig, +})); + +import { CopilotProvider } from '../infra/providers/copilot.js'; +import { ProviderRegistry } from '../infra/providers/index.js'; + +function doneResponse(persona: string) { + return { + persona, + status: 'done' as const, + content: 'ok', + timestamp: new Date(), + }; +} + +describe('CopilotProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockResolveCopilotGithubToken.mockReturnValue(undefined); + mockResolveCopilotCliPath.mockReturnValue(undefined); + mockLoadProjectConfig.mockReturnValue({}); + }); + + it('should throw when claudeAgent is specified', () => { + const provider = new CopilotProvider(); + + expect(() => provider.setup({ + name: 'test', + claudeAgent: 'some-agent', + })).toThrow('Claude Code agent calls are not supported by the Copilot provider'); + }); + + it('should throw when claudeSkill is specified', () => { + const provider = new CopilotProvider(); + + expect(() => provider.setup({ + name: 'test', + claudeSkill: 'some-skill', + })).toThrow('Claude Code skill calls are not supported by the Copilot provider'); + }); + + it('should pass model/session/permission and resolved token to callCopilot', async () => { + mockResolveCopilotGithubToken.mockReturnValue('resolved-token'); + mockCallCopilot.mockResolvedValue(doneResponse('coder')); + + const provider = new CopilotProvider(); + const agent = provider.setup({ name: 'coder' }); + + await agent.call('implement', { + cwd: '/tmp/work', + model: 'claude-sonnet-4.6', + sessionId: 'sess-1', + permissionMode: 'full', + }); + + expect(mockCallCopilot).toHaveBeenCalledWith( + 'coder', + 'implement', + expect.objectContaining({ + cwd: '/tmp/work', + model: 'claude-sonnet-4.6', + sessionId: 'sess-1', + permissionMode: 'full', + copilotGithubToken: 'resolved-token', + }), + ); + }); + + it('should prefer explicit copilotGithubToken over resolver', async () => { + mockResolveCopilotGithubToken.mockReturnValue('resolved-token'); + mockCallCopilot.mockResolvedValue(doneResponse('coder')); + + const provider = new CopilotProvider(); + const agent = provider.setup({ name: 'coder' }); + + await agent.call('implement', { + cwd: '/tmp/work', + copilotGithubToken: 'explicit-token', + }); + + expect(mockCallCopilot).toHaveBeenCalledWith( + 'coder', + 'implement', + expect.objectContaining({ + copilotGithubToken: 'explicit-token', + }), + ); + }); + + it('should delegate to callCopilotCustom when systemPrompt is specified', async () => { + mockCallCopilotCustom.mockResolvedValue(doneResponse('reviewer')); + + const provider = new CopilotProvider(); + const agent = provider.setup({ + name: 'reviewer', + systemPrompt: 'You are a strict reviewer.', + }); + + await agent.call('review this', { + cwd: '/tmp/work', + }); + + expect(mockCallCopilotCustom).toHaveBeenCalledWith( + 'reviewer', + 'review this', + 'You are a strict reviewer.', + expect.objectContaining({ cwd: '/tmp/work' }), + ); + }); + + it('should pass resolved copilotCliPath to callCopilot', async () => { + mockResolveCopilotCliPath.mockReturnValue('/custom/bin/copilot'); + mockCallCopilot.mockResolvedValue(doneResponse('coder')); + + const provider = new CopilotProvider(); + const agent = provider.setup({ name: 'coder' }); + + await agent.call('implement', { cwd: '/tmp/work' }); + + expect(mockCallCopilot).toHaveBeenCalledWith( + 'coder', + 'implement', + expect.objectContaining({ + copilotCliPath: '/custom/bin/copilot', + }), + ); + }); + + it('should pass undefined copilotCliPath when resolver returns undefined', async () => { + mockResolveCopilotCliPath.mockReturnValue(undefined); + mockCallCopilot.mockResolvedValue(doneResponse('coder')); + + const provider = new CopilotProvider(); + const agent = provider.setup({ name: 'coder' }); + + await agent.call('implement', { cwd: '/tmp/work' }); + + const opts = mockCallCopilot.mock.calls[0]?.[2]; + expect(opts.copilotCliPath).toBeUndefined(); + }); +}); + +describe('ProviderRegistry with Copilot', () => { + it('should return Copilot provider from registry', () => { + ProviderRegistry.resetInstance(); + const registry = ProviderRegistry.getInstance(); + const provider = registry.get('copilot'); + + expect(provider).toBeDefined(); + expect(provider).toBeInstanceOf(CopilotProvider); + }); +}); diff --git a/src/agents/types.ts b/src/agents/types.ts index 2dda854..7389465 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -13,9 +13,9 @@ export interface RunAgentOptions { abortSignal?: AbortSignal; sessionId?: string; model?: string; - provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock'; + provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock'; stepModel?: string; - stepProvider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock'; + stepProvider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock'; personaPath?: string; allowedTools?: string[]; mcpServers?: Record; diff --git a/src/app/cli/program.ts b/src/app/cli/program.ts index 05f0cd2..be27a9b 100644 --- a/src/app/cli/program.ts +++ b/src/app/cli/program.ts @@ -47,7 +47,7 @@ program .option('--auto-pr', 'Create PR after successful execution') .option('--draft', 'Create PR as draft (requires --auto-pr or auto_pr config)') .option('--repo ', 'Repository (defaults to current)') - .option('--provider ', 'Override agent provider (claude|codex|opencode|cursor|mock)') + .option('--provider ', 'Override agent provider (claude|codex|opencode|cursor|copilot|mock)') .option('--model ', 'Override agent model') .option('-t, --task ', 'Task content (as alternative to GitHub issue)') .option('--pipeline', 'Pipeline mode: non-interactive, no worktree, direct branch creation') diff --git a/src/core/models/persisted-global-config.ts b/src/core/models/persisted-global-config.ts index 552f9bd..4f3562e 100644 --- a/src/core/models/persisted-global-config.ts +++ b/src/core/models/persisted-global-config.ts @@ -6,7 +6,7 @@ import type { MovementProviderOptions, PieceRuntimeConfig } from './piece-types. import type { ProviderPermissionProfiles } from './provider-profiles.js'; export interface PersonaProviderEntry { - provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock'; + provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock'; model?: string; } @@ -70,7 +70,7 @@ export interface NotificationSoundEventsConfig { export interface PersistedGlobalConfig { language: Language; logLevel: 'debug' | 'info' | 'warn' | 'error'; - provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock'; + provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock'; model?: string; observability?: ObservabilityConfig; analytics?: AnalyticsConfig; @@ -94,6 +94,10 @@ export interface PersistedGlobalConfig { claudeCliPath?: string; /** External cursor-agent CLI path (overridden by TAKT_CURSOR_CLI_PATH env var) */ cursorCliPath?: string; + /** External Copilot CLI path (overridden by TAKT_COPILOT_CLI_PATH env var) */ + copilotCliPath?: string; + /** Copilot GitHub token (overridden by TAKT_COPILOT_GITHUB_TOKEN env var) */ + copilotGithubToken?: string; /** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */ opencodeApiKey?: string; /** Cursor API key for Cursor Agent CLI/API (overridden by TAKT_CURSOR_API_KEY env var) */ @@ -139,7 +143,7 @@ export interface PersistedGlobalConfig { /** Project-level configuration */ export interface ProjectConfig { piece?: string; - provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock'; + provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock'; model?: string; providerOptions?: MovementProviderOptions; /** Provider-specific permission profiles */ diff --git a/src/core/models/piece-types.ts b/src/core/models/piece-types.ts index 1bf444b..665b736 100644 --- a/src/core/models/piece-types.ts +++ b/src/core/models/piece-types.ts @@ -135,7 +135,7 @@ export interface PieceMovement { /** Resolved absolute path to persona prompt file (set by loader) */ personaPath?: string; /** Provider override for this movement */ - provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock'; + provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock'; /** Model override for this movement */ model?: string; /** Required minimum permission mode for tool execution in this movement */ diff --git a/src/core/models/provider-profiles.ts b/src/core/models/provider-profiles.ts index e2165fc..ff4a264 100644 --- a/src/core/models/provider-profiles.ts +++ b/src/core/models/provider-profiles.ts @@ -5,7 +5,7 @@ import type { PermissionMode } from './status.js'; /** Supported providers for profile-based permission resolution. */ -export type ProviderProfileName = 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock'; +export type ProviderProfileName = 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock'; /** Permission profile for a single provider. */ export interface ProviderPermissionProfile { diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index 7d816ed..dce0501 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -79,7 +79,7 @@ export const MovementProviderOptionsSchema = z.object({ }).optional(); /** Provider key schema for profile maps */ -export const ProviderProfileNameSchema = z.enum(['claude', 'codex', 'opencode', 'cursor', 'mock']); +export const ProviderProfileNameSchema = z.enum(['claude', 'codex', 'opencode', 'cursor', 'copilot', 'mock']); /** Provider permission profile schema */ export const ProviderPermissionProfileSchema = z.object({ @@ -247,7 +247,7 @@ export const ParallelSubMovementRawSchema = z.object({ knowledge: z.union([z.string(), z.array(z.string())]).optional(), allowed_tools: z.array(z.string()).optional(), mcp_servers: McpServersSchema, - provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'mock']).optional(), + provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'copilot', 'mock']).optional(), model: z.string().optional(), /** Removed legacy field (no backward compatibility) */ permission_mode: z.never().optional(), @@ -280,7 +280,7 @@ export const PieceMovementRawSchema = z.object({ knowledge: z.union([z.string(), z.array(z.string())]).optional(), allowed_tools: z.array(z.string()).optional(), mcp_servers: McpServersSchema, - provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'mock']).optional(), + provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'copilot', 'mock']).optional(), model: z.string().optional(), /** Removed legacy field (no backward compatibility) */ permission_mode: z.never().optional(), @@ -369,7 +369,7 @@ export const PieceConfigRawSchema = z.object({ }); export const PersonaProviderEntrySchema = z.object({ - provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'mock']).optional(), + provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'copilot', 'mock']).optional(), model: z.string().optional(), }); @@ -425,7 +425,7 @@ export const PieceCategoryConfigSchema = z.record(z.string(), PieceCategoryConfi export const GlobalConfigSchema = z.object({ language: LanguageSchema.optional().default(DEFAULT_LANGUAGE), log_level: z.enum(['debug', 'info', 'warn', 'error']).optional().default('info'), - provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'mock']).optional().default('claude'), + provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'copilot', 'mock']).optional().default('claude'), model: z.string().optional(), observability: ObservabilityConfigSchema.optional(), analytics: AnalyticsConfigSchema.optional(), @@ -449,6 +449,10 @@ export const GlobalConfigSchema = z.object({ claude_cli_path: z.string().optional(), /** External cursor-agent CLI path (overridden by TAKT_CURSOR_CLI_PATH env var) */ cursor_cli_path: z.string().optional(), + /** External Copilot CLI path (overridden by TAKT_COPILOT_CLI_PATH env var) */ + copilot_cli_path: z.string().optional(), + /** Copilot GitHub token (overridden by TAKT_COPILOT_GITHUB_TOKEN env var) */ + copilot_github_token: z.string().optional(), /** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */ opencode_api_key: z.string().optional(), /** Cursor API key for Cursor Agent CLI/API (overridden by TAKT_CURSOR_API_KEY env var) */ @@ -463,7 +467,7 @@ export const GlobalConfigSchema = z.object({ piece_categories_file: z.string().optional(), /** Per-persona provider and model overrides. */ persona_providers: z.record(z.string(), z.union([ - z.enum(['claude', 'codex', 'opencode', 'cursor', 'mock']), + z.enum(['claude', 'codex', 'opencode', 'cursor', 'copilot', 'mock']), PersonaProviderEntrySchema, ])).optional(), /** Global provider-specific options (lowest priority) */ @@ -503,7 +507,7 @@ export const GlobalConfigSchema = z.object({ /** Project config schema */ export const ProjectConfigSchema = z.object({ piece: z.string().optional(), - provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'mock']).optional(), + provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'copilot', 'mock']).optional(), model: z.string().optional(), provider_options: MovementProviderOptionsSchema, provider_profiles: ProviderPermissionProfilesSchema, @@ -528,4 +532,6 @@ export const ProjectConfigSchema = z.object({ codex_cli_path: z.string().optional(), /** cursor-agent CLI path override (project-level) */ cursor_cli_path: z.string().optional(), + /** Copilot CLI path override (project-level) */ + copilot_cli_path: z.string().optional(), }); diff --git a/src/core/piece/agent-usecases.ts b/src/core/piece/agent-usecases.ts index b35f944..55e7037 100644 --- a/src/core/piece/agent-usecases.ts +++ b/src/core/piece/agent-usecases.ts @@ -28,7 +28,7 @@ export interface DecomposeTaskOptions { personaPath?: string; language?: Language; model?: string; - provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock'; + provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock'; } export interface MorePartsResponse { diff --git a/src/core/piece/permission-profile-resolution.ts b/src/core/piece/permission-profile-resolution.ts index 7afeb82..e603a02 100644 --- a/src/core/piece/permission-profile-resolution.ts +++ b/src/core/piece/permission-profile-resolution.ts @@ -14,6 +14,7 @@ export const DEFAULT_PROVIDER_PERMISSION_PROFILES: ProviderPermissionProfiles = codex: { defaultPermissionMode: 'edit' }, opencode: { defaultPermissionMode: 'edit' }, cursor: { defaultPermissionMode: 'edit' }, + copilot: { defaultPermissionMode: 'edit' }, mock: { defaultPermissionMode: 'edit' }, }; diff --git a/src/core/piece/types.ts b/src/core/piece/types.ts index 4fca7cf..b479b18 100644 --- a/src/core/piece/types.ts +++ b/src/core/piece/types.ts @@ -11,7 +11,7 @@ import type { PersonaProviderEntry } from '../models/persisted-global-config.js' import type { ProviderPermissionProfiles } from '../models/provider-profiles.js'; import type { MovementProviderOptions } from '../models/piece-types.js'; -export type ProviderType = 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock'; +export type ProviderType = 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock'; export type ProviderOptionsSource = 'env' | 'project' | 'global' | 'default'; export interface StreamInitEventData { diff --git a/src/infra/config/env/config-env-overrides.ts b/src/infra/config/env/config-env-overrides.ts index 271b3d3..44c2bf8 100644 --- a/src/infra/config/env/config-env-overrides.ts +++ b/src/infra/config/env/config-env-overrides.ts @@ -96,6 +96,8 @@ const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [ { path: 'codex_cli_path', type: 'string' }, { path: 'claude_cli_path', type: 'string' }, { path: 'cursor_cli_path', type: 'string' }, + { path: 'copilot_cli_path', type: 'string' }, + { path: 'copilot_github_token', type: 'string' }, { path: 'opencode_api_key', type: 'string' }, { path: 'cursor_api_key', type: 'string' }, { path: 'pipeline', type: 'json' }, @@ -150,6 +152,7 @@ const PROJECT_ENV_SPECS: readonly EnvSpec[] = [ { path: 'claude_cli_path', type: 'string' }, { path: 'codex_cli_path', type: 'string' }, { path: 'cursor_cli_path', type: 'string' }, + { path: 'copilot_cli_path', type: 'string' }, ]; export function applyGlobalConfigEnvOverrides(target: Record): void { diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index 25e73e9..52fedca 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -423,7 +423,7 @@ export function setLanguage(language: Language): void { saveGlobalConfig(config); } -export function setProvider(provider: 'claude' | 'codex' | 'opencode' | 'cursor'): void { +export function setProvider(provider: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot'): void { const config = loadGlobalConfig(); config.provider = provider; saveGlobalConfig(config); @@ -570,3 +570,45 @@ export function resolveCursorApiKey(): string | undefined { return undefined; } } + +/** + * Resolve the Copilot CLI path override. + * Priority: TAKT_COPILOT_CLI_PATH env var > project config > global config > undefined (default 'copilot') + */ +export function resolveCopilotCliPath(projectConfig?: { copilotCliPath?: string }): string | undefined { + const envPath = process.env[envVarNameFromPath('copilot_cli_path')]; + if (envPath !== undefined) { + return validateCliPath(envPath, 'TAKT_COPILOT_CLI_PATH'); + } + + if (projectConfig?.copilotCliPath !== undefined) { + return validateCliPath(projectConfig.copilotCliPath, 'copilot_cli_path (project)'); + } + + let config: PersistedGlobalConfig; + try { + config = loadGlobalConfig(); + } catch { + return undefined; + } + if (config.copilotCliPath === undefined) { + return undefined; + } + return validateCliPath(config.copilotCliPath, 'copilot_cli_path'); +} + +/** + * Resolve the Copilot GitHub token. + * Priority: TAKT_COPILOT_GITHUB_TOKEN env var > config.yaml > undefined + */ +export function resolveCopilotGithubToken(): string | undefined { + const envKey = process.env[envVarNameFromPath('copilot_github_token')]; + if (envKey) return envKey; + + try { + const config = loadGlobalConfig(); + return config.copilotGithubToken; + } catch { + return undefined; + } +} diff --git a/src/infra/config/global/index.ts b/src/infra/config/global/index.ts index c969f9e..b89c3ab 100644 --- a/src/infra/config/global/index.ts +++ b/src/infra/config/global/index.ts @@ -17,6 +17,8 @@ export { resolveCodexCliPath, resolveClaudeCliPath, resolveCursorCliPath, + resolveCopilotCliPath, + resolveCopilotGithubToken, resolveOpencodeApiKey, resolveCursorApiKey, validateCliPath, diff --git a/src/infra/config/global/initialization.ts b/src/infra/config/global/initialization.ts index 9255d25..e16ed80 100644 --- a/src/infra/config/global/initialization.ts +++ b/src/infra/config/global/initialization.ts @@ -56,12 +56,13 @@ export async function promptLanguageSelection(): Promise { * Prompt user to select provider for resources. * Exits process if cancelled (initial setup is required). */ -export async function promptProviderSelection(): Promise<'claude' | 'codex' | 'opencode' | 'cursor'> { - const options: { label: string; value: 'claude' | 'codex' | 'opencode' | 'cursor' }[] = [ +export async function promptProviderSelection(): Promise<'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot'> { + const options: { label: string; value: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' }[] = [ { label: 'Claude Code', value: 'claude' }, { label: 'Codex', value: 'codex' }, { label: 'OpenCode', value: 'opencode' }, { label: 'Cursor Agent', value: 'cursor' }, + { label: 'GitHub Copilot', value: 'copilot' }, ]; const result = await selectOptionWithDefault( diff --git a/src/infra/config/project/projectConfig.ts b/src/infra/config/project/projectConfig.ts index 70c666d..4bf0bea 100644 --- a/src/infra/config/project/projectConfig.ts +++ b/src/infra/config/project/projectConfig.ts @@ -160,6 +160,7 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig { claude_cli_path, codex_cli_path, cursor_cli_path, + copilot_cli_path, ...rest } = parsedConfig; @@ -190,6 +191,7 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig { claudeCliPath: claude_cli_path as string | undefined, codexCliPath: codex_cli_path as string | undefined, cursorCliPath: cursor_cli_path as string | undefined, + copilotCliPath: copilot_cli_path as string | undefined, }; } diff --git a/src/infra/config/types.ts b/src/infra/config/types.ts index 2e11fc1..bab0bf0 100644 --- a/src/infra/config/types.ts +++ b/src/infra/config/types.ts @@ -11,7 +11,7 @@ export interface ProjectLocalConfig { /** Current piece name */ piece?: string; /** Provider selection for agent runtime */ - provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock'; + provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock'; /** Model selection for agent runtime */ model?: string; /** Auto-create PR after worktree execution */ @@ -40,6 +40,8 @@ export interface ProjectLocalConfig { codexCliPath?: string; /** cursor-agent CLI path override (project-level) */ cursorCliPath?: string; + /** Copilot CLI path override (project-level) */ + copilotCliPath?: string; } /** Persona session data for persistence */ diff --git a/src/infra/copilot/client.ts b/src/infra/copilot/client.ts new file mode 100644 index 0000000..2dc35bc --- /dev/null +++ b/src/infra/copilot/client.ts @@ -0,0 +1,503 @@ +/** + * GitHub Copilot CLI integration for agent interactions + * + * Wraps the `copilot` CLI (@github/copilot) as a child process, + * following the same pattern as the Cursor provider. + */ + +import { spawn } from 'node:child_process'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { AgentResponse } from '../../core/models/index.js'; +import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; +import type { CopilotCallOptions } from './types.js'; + +const log = createLogger('copilot-client'); + +export type { CopilotCallOptions } from './types.js'; + +const COPILOT_COMMAND = 'copilot'; +const COPILOT_ABORTED_MESSAGE = 'Copilot execution aborted'; +const COPILOT_MAX_BUFFER_BYTES = 10 * 1024 * 1024; +const COPILOT_FORCE_KILL_DELAY_MS_DEFAULT = 1_000; +const COPILOT_ERROR_DETAIL_MAX_LENGTH = 400; + +function resolveForceKillDelayMs(): number { + const raw = process.env.TAKT_COPILOT_FORCE_KILL_DELAY_MS; + if (!raw) { + return COPILOT_FORCE_KILL_DELAY_MS_DEFAULT; + } + + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return COPILOT_FORCE_KILL_DELAY_MS_DEFAULT; + } + + return parsed; +} + +type CopilotExecResult = { + stdout: string; + stderr: string; +}; + +type CopilotExecError = Error & { + code?: string | number; + stdout?: string; + stderr?: string; + signal?: NodeJS.Signals | null; +}; + +function buildPrompt(prompt: string, systemPrompt?: string): string { + if (!systemPrompt) { + return prompt; + } + return `${systemPrompt}\n\n${prompt}`; +} + +function buildArgs(prompt: string, options: CopilotCallOptions & { shareFilePath?: string }): string[] { + const args = [ + '-p', + buildPrompt(prompt, options.systemPrompt), + '--silent', + '--no-color', + '--no-auto-update', + ]; + + if (options.model) { + args.push('--model', options.model); + } + + if (options.sessionId) { + args.push('--resume', options.sessionId); + } + + // Permission mode mapping + // Note: -p mode is already non-interactive. --autopilot and + // --max-autopilot-continues are not used because they conflict with + // permission flags in Copilot CLI v0.0.418+ and -p already implies + // single-prompt execution. + if (options.permissionMode === 'full') { + args.push('--yolo'); + } else if (options.permissionMode === 'edit') { + args.push('--allow-all-tools', '--no-ask-user'); + } + // 'readonly' / undefined: no permission flags (copilot runs without tool access) + + // --share exports session transcript to a markdown file, which we parse + // to extract the session ID for later resumption. + if (options.shareFilePath) { + args.push('--share', options.shareFilePath); + } + + return args; +} + +function buildEnv(copilotGithubToken?: string): NodeJS.ProcessEnv { + if (!copilotGithubToken) { + return process.env; + } + + return { + ...process.env, + COPILOT_GITHUB_TOKEN: copilotGithubToken, + }; +} + +function createExecError( + message: string, + params: { + code?: string | number; + stdout?: string; + stderr?: string; + signal?: NodeJS.Signals | null; + name?: string; + } = {}, +): CopilotExecError { + const error = new Error(message) as CopilotExecError; + if (params.name) { + error.name = params.name; + } + error.code = params.code; + error.stdout = params.stdout; + error.stderr = params.stderr; + error.signal = params.signal; + return error; +} + +function execCopilot(args: string[], options: CopilotCallOptions): Promise { + return new Promise((resolve, reject) => { + const child = spawn(options.copilotCliPath ?? COPILOT_COMMAND, args, { + cwd: options.cwd, + env: buildEnv(options.copilotGithubToken), + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + let stdoutBytes = 0; + let stderrBytes = 0; + let settled = false; + let abortTimer: ReturnType | undefined; + + const abortHandler = (): void => { + if (settled) return; + child.kill('SIGTERM'); + const forceKillDelayMs = resolveForceKillDelayMs(); + abortTimer = setTimeout(() => { + if (!settled) { + child.kill('SIGKILL'); + } + }, forceKillDelayMs); + abortTimer.unref?.(); + }; + + const cleanup = (): void => { + if (abortTimer !== undefined) { + clearTimeout(abortTimer); + } + if (options.abortSignal) { + options.abortSignal.removeEventListener('abort', abortHandler); + } + }; + + const resolveOnce = (result: CopilotExecResult): void => { + if (settled) return; + settled = true; + cleanup(); + resolve(result); + }; + + const rejectOnce = (error: CopilotExecError): void => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + + const appendChunk = (target: 'stdout' | 'stderr', chunk: Buffer | string): void => { + const text = typeof chunk === 'string' ? chunk : chunk.toString('utf-8'); + const byteLength = Buffer.byteLength(text); + + if (target === 'stdout') { + stdoutBytes += byteLength; + if (stdoutBytes > COPILOT_MAX_BUFFER_BYTES) { + child.kill('SIGTERM'); + rejectOnce(createExecError('copilot stdout exceeded buffer limit', { + code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER', + stdout, + stderr, + })); + return; + } + stdout += text; + return; + } + + stderrBytes += byteLength; + if (stderrBytes > COPILOT_MAX_BUFFER_BYTES) { + child.kill('SIGTERM'); + rejectOnce(createExecError('copilot stderr exceeded buffer limit', { + code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER', + stdout, + stderr, + })); + return; + } + stderr += text; + }; + + child.stdout?.on('data', (chunk: Buffer | string) => { + appendChunk('stdout', chunk); + if (options.onStream) { + const text = typeof chunk === 'string' ? chunk : chunk.toString('utf-8'); + if (text) { + options.onStream({ type: 'text', data: { text } }); + } + } + }); + child.stderr?.on('data', (chunk: Buffer | string) => appendChunk('stderr', chunk)); + + child.on('error', (error: NodeJS.ErrnoException) => { + rejectOnce(createExecError(error.message, { + code: error.code, + stdout, + stderr, + })); + }); + + child.on('close', (code: number | null, signal: NodeJS.Signals | null) => { + if (settled) return; + + if (options.abortSignal?.aborted) { + rejectOnce(createExecError(COPILOT_ABORTED_MESSAGE, { + name: 'AbortError', + stdout, + stderr, + signal, + })); + return; + } + + if (code === 0) { + resolveOnce({ stdout, stderr }); + return; + } + + rejectOnce(createExecError( + signal + ? `copilot terminated by signal ${signal}` + : `copilot exited with code ${code ?? 'unknown'}`, + { + code: code ?? undefined, + stdout, + stderr, + signal, + }, + )); + }); + + if (options.abortSignal) { + if (options.abortSignal.aborted) { + abortHandler(); + } else { + options.abortSignal.addEventListener('abort', abortHandler, { once: true }); + } + } + }); +} + +const CREDENTIAL_PATTERNS = [ + /ghp_[A-Za-z0-9_]{36,}/g, + /ghs_[A-Za-z0-9_]{36,}/g, + /gho_[A-Za-z0-9_]{36,}/g, + /github_pat_[A-Za-z0-9_]{82,}/g, +]; + +function redactCredentials(text: string): string { + let result = text; + for (const pattern of CREDENTIAL_PATTERNS) { + result = result.replace(pattern, '[REDACTED]'); + } + return result; +} + +function trimDetail(value: string | undefined, fallback = ''): string { + const normalized = (value ?? '').trim(); + if (!normalized) { + return fallback; + } + const redacted = redactCredentials(normalized); + return redacted.length > COPILOT_ERROR_DETAIL_MAX_LENGTH + ? `${redacted.slice(0, COPILOT_ERROR_DETAIL_MAX_LENGTH)}...` + : redacted; +} + +function isAuthenticationError(error: CopilotExecError): boolean { + const message = [ + trimDetail(error.message), + trimDetail(error.stderr), + trimDetail(error.stdout), + ].join('\n').toLowerCase(); + + const patterns = [ + 'authentication', + 'unauthorized', + 'forbidden', + 'not logged in', + 'login required', + 'token', + 'copilot_github_token', + 'gh_token', + 'github_token', + ]; + return patterns.some((pattern) => message.includes(pattern)); +} + +function classifyExecutionError(error: CopilotExecError, options: CopilotCallOptions): string { + if (options.abortSignal?.aborted || error.name === 'AbortError') { + return COPILOT_ABORTED_MESSAGE; + } + + if (error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') { + return 'Copilot CLI output exceeded buffer limit'; + } + + if (error.code === 'ENOENT') { + return 'copilot binary not found. Install GitHub Copilot CLI (`npm install -g @github/copilot`) and ensure `copilot` is in PATH.'; + } + + if (isAuthenticationError(error)) { + return 'Copilot authentication failed. Run `copilot auth login` or set TAKT_COPILOT_GITHUB_TOKEN / COPILOT_GITHUB_TOKEN / GH_TOKEN.'; + } + + if (typeof error.code === 'number') { + const detail = trimDetail(error.stderr, trimDetail(error.stdout, getErrorMessage(error))); + return `Copilot CLI exited with code ${error.code}: ${detail}`; + } + + return getErrorMessage(error); +} + +/** + * Extract session ID from the --share markdown file content. + * + * The file format includes a line like: + * > **Session ID:** `107256ee-226c-4677-bf55-7b6b158ddadf` + */ +const SESSION_ID_PATTERN = /\*\*Session ID:\*\*\s*`([0-9a-f-]{36})`/i; + +export function extractSessionIdFromShareFile(content: string): string | undefined { + const match = content.match(SESSION_ID_PATTERN); + return match?.[1]; +} + +/** + * Read and extract session ID from a --share transcript file, then clean up. + */ +function cleanupTmpDir(dir?: string): void { + if (dir) { + rm(dir, { recursive: true, force: true }).catch((err) => { + log.debug('Failed to clean up tmp dir', { dir, err }); + }); + } +} + +async function extractAndCleanupSessionId(shareFilePath: string, shareTmpDir: string): Promise { + try { + const content = await readFile(shareFilePath, 'utf-8'); + return extractSessionIdFromShareFile(content); + } catch (err) { + log.debug('readFile share transcript failed', { shareFilePath, err }); + return undefined; + } finally { + cleanupTmpDir(shareTmpDir); + } +} + +/** + * Parse Copilot CLI output. + * + * Since Copilot CLI does not support JSON output mode, + * we use --silent --no-color and treat stdout as plain text content. + */ +function parseCopilotOutput(stdout: string): { content: string } | { error: string } { + const trimmed = stdout.trim(); + if (!trimmed) { + return { error: 'copilot returned empty output' }; + } + + return { content: trimmed }; +} + +/** + * Client for GitHub Copilot CLI interactions. + */ +export class CopilotClient { + async call(agentType: string, prompt: string, options: CopilotCallOptions): Promise { + // Create a temp directory for --share session transcript + let shareTmpDir: string | undefined; + let shareFilePath: string | undefined; + try { + shareTmpDir = await mkdtemp(join(tmpdir(), 'takt-copilot-')); + shareFilePath = join(shareTmpDir, 'session.md'); + } catch (err) { + log.debug('mkdtemp failed, skipping session extraction', { err }); + } + + const args = buildArgs(prompt, { ...options, shareFilePath }); + + try { + const { stdout } = await execCopilot(args, options); + const parsed = parseCopilotOutput(stdout); + if ('error' in parsed) { + cleanupTmpDir(shareTmpDir); + return { + persona: agentType, + status: 'error', + content: parsed.error, + timestamp: new Date(), + sessionId: options.sessionId, + }; + } + + const extractedSessionId = (shareFilePath && shareTmpDir) + ? await extractAndCleanupSessionId(shareFilePath, shareTmpDir) + : undefined; + const sessionId = extractedSessionId ?? options.sessionId; + + if (options.onStream) { + options.onStream({ + type: 'result', + data: { + result: parsed.content, + success: true, + sessionId: sessionId ?? '', + }, + }); + } + + return { + persona: agentType, + status: 'done', + content: parsed.content, + timestamp: new Date(), + sessionId, + }; + } catch (rawError) { + cleanupTmpDir(shareTmpDir); + const error = rawError as CopilotExecError; + const message = classifyExecutionError(error, options); + if (options.onStream) { + options.onStream({ + type: 'result', + data: { + result: '', + success: false, + error: message, + sessionId: options.sessionId ?? '', + }, + }); + } + return { + persona: agentType, + status: 'error', + content: message, + timestamp: new Date(), + sessionId: options.sessionId, + }; + } + } + + async callCustom( + agentName: string, + prompt: string, + systemPrompt: string, + options: CopilotCallOptions, + ): Promise { + return this.call(agentName, prompt, { + ...options, + systemPrompt, + }); + } +} + +const defaultClient = new CopilotClient(); + +export async function callCopilot( + agentType: string, + prompt: string, + options: CopilotCallOptions, +): Promise { + return defaultClient.call(agentType, prompt, options); +} + +export async function callCopilotCustom( + agentName: string, + prompt: string, + systemPrompt: string, + options: CopilotCallOptions, +): Promise { + return defaultClient.callCustom(agentName, prompt, systemPrompt, options); +} diff --git a/src/infra/copilot/index.ts b/src/infra/copilot/index.ts new file mode 100644 index 0000000..df53424 --- /dev/null +++ b/src/infra/copilot/index.ts @@ -0,0 +1,6 @@ +/** + * Copilot integration exports + */ + +export { CopilotClient, callCopilot, callCopilotCustom } from './client.js'; +export type { CopilotCallOptions } from './types.js'; diff --git a/src/infra/copilot/types.ts b/src/infra/copilot/types.ts new file mode 100644 index 0000000..75f9ad4 --- /dev/null +++ b/src/infra/copilot/types.ts @@ -0,0 +1,21 @@ +/** + * Type definitions for GitHub Copilot CLI integration + */ + +import type { StreamCallback } from '../claude/index.js'; +import type { PermissionMode } from '../../core/models/index.js'; + +/** Options for calling GitHub Copilot CLI */ +export interface CopilotCallOptions { + cwd: string; + abortSignal?: AbortSignal; + sessionId?: string; + model?: string; + systemPrompt?: string; + permissionMode?: PermissionMode; + onStream?: StreamCallback; + /** GitHub token for Copilot authentication */ + copilotGithubToken?: string; + /** Custom path to copilot executable */ + copilotCliPath?: string; +} diff --git a/src/infra/providers/copilot.ts b/src/infra/providers/copilot.ts new file mode 100644 index 0000000..77ee946 --- /dev/null +++ b/src/infra/providers/copilot.ts @@ -0,0 +1,62 @@ +/** + * Copilot provider implementation + */ + +import { callCopilot, callCopilotCustom, type CopilotCallOptions } from '../copilot/index.js'; +import { resolveCopilotGithubToken, resolveCopilotCliPath, loadProjectConfig } from '../config/index.js'; +import { createLogger } from '../../shared/utils/index.js'; +import type { AgentResponse } from '../../core/models/index.js'; +import type { AgentSetup, Provider, ProviderAgent, ProviderCallOptions } from './types.js'; + +const log = createLogger('copilot-provider'); + +function toCopilotOptions(options: ProviderCallOptions): CopilotCallOptions { + if (options.allowedTools && options.allowedTools.length > 0) { + log.info('Copilot provider does not support allowedTools; ignoring'); + } + if (options.mcpServers && Object.keys(options.mcpServers).length > 0) { + log.info('Copilot provider does not support mcpServers in non-interactive mode; ignoring'); + } + if (options.outputSchema) { + log.info('Copilot provider does not support outputSchema; ignoring'); + } + + const projectConfig = loadProjectConfig(options.cwd); + return { + cwd: options.cwd, + abortSignal: options.abortSignal, + sessionId: options.sessionId, + model: options.model, + permissionMode: options.permissionMode, + onStream: options.onStream, + copilotGithubToken: options.copilotGithubToken ?? resolveCopilotGithubToken(), + copilotCliPath: resolveCopilotCliPath(projectConfig), + }; +} + +/** Copilot provider — delegates to GitHub Copilot CLI */ +export class CopilotProvider implements Provider { + setup(config: AgentSetup): ProviderAgent { + if (config.claudeAgent) { + throw new Error('Claude Code agent calls are not supported by the Copilot provider'); + } + if (config.claudeSkill) { + throw new Error('Claude Code skill calls are not supported by the Copilot provider'); + } + + const { name, systemPrompt } = config; + if (systemPrompt) { + return { + call: async (prompt: string, options: ProviderCallOptions): Promise => { + return callCopilotCustom(name, prompt, systemPrompt, toCopilotOptions(options)); + }, + }; + } + + return { + call: async (prompt: string, options: ProviderCallOptions): Promise => { + return callCopilot(name, prompt, toCopilotOptions(options)); + }, + }; + } +} diff --git a/src/infra/providers/index.ts b/src/infra/providers/index.ts index 82b7a06..762fb1f 100644 --- a/src/infra/providers/index.ts +++ b/src/infra/providers/index.ts @@ -9,6 +9,7 @@ import { ClaudeProvider } from './claude.js'; import { CodexProvider } from './codex.js'; import { OpenCodeProvider } from './opencode.js'; import { CursorProvider } from './cursor.js'; +import { CopilotProvider } from './copilot.js'; import { MockProvider } from './mock.js'; import type { Provider, ProviderType } from './types.js'; @@ -28,6 +29,7 @@ export class ProviderRegistry { codex: new CodexProvider(), opencode: new OpenCodeProvider(), cursor: new CursorProvider(), + copilot: new CopilotProvider(), mock: new MockProvider(), }; } diff --git a/src/infra/providers/types.ts b/src/infra/providers/types.ts index 4313150..c660139 100644 --- a/src/infra/providers/types.ts +++ b/src/infra/providers/types.ts @@ -36,6 +36,7 @@ export interface ProviderCallOptions { openaiApiKey?: string; opencodeApiKey?: string; cursorApiKey?: string; + copilotGithubToken?: string; outputSchema?: Record; } @@ -50,4 +51,4 @@ export interface Provider { } /** Provider type */ -export type ProviderType = 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock'; +export type ProviderType = 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';