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
This commit is contained in:
Tomohisa Takaoka 2026-02-28 03:28:56 -08:00 committed by GitHub
parent 45663342c6
commit 17232f9940
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1346 additions and 22 deletions

View File

@ -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<NodeJS.ErrnoException> & { message: string };
};
type MockChildProcess = EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
};
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();
});
});

View File

@ -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);
});
});

View File

@ -13,9 +13,9 @@ export interface RunAgentOptions {
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
sessionId?: string; sessionId?: string;
model?: string; model?: string;
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock'; provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
stepModel?: string; stepModel?: string;
stepProvider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock'; stepProvider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
personaPath?: string; personaPath?: string;
allowedTools?: string[]; allowedTools?: string[];
mcpServers?: Record<string, McpServerConfig>; mcpServers?: Record<string, McpServerConfig>;

View File

@ -47,7 +47,7 @@ program
.option('--auto-pr', 'Create PR after successful execution') .option('--auto-pr', 'Create PR after successful execution')
.option('--draft', 'Create PR as draft (requires --auto-pr or auto_pr config)') .option('--draft', 'Create PR as draft (requires --auto-pr or auto_pr config)')
.option('--repo <owner/repo>', 'Repository (defaults to current)') .option('--repo <owner/repo>', 'Repository (defaults to current)')
.option('--provider <name>', 'Override agent provider (claude|codex|opencode|cursor|mock)') .option('--provider <name>', 'Override agent provider (claude|codex|opencode|cursor|copilot|mock)')
.option('--model <name>', 'Override agent model') .option('--model <name>', 'Override agent model')
.option('-t, --task <string>', 'Task content (as alternative to GitHub issue)') .option('-t, --task <string>', 'Task content (as alternative to GitHub issue)')
.option('--pipeline', 'Pipeline mode: non-interactive, no worktree, direct branch creation') .option('--pipeline', 'Pipeline mode: non-interactive, no worktree, direct branch creation')

View File

@ -6,7 +6,7 @@ import type { MovementProviderOptions, PieceRuntimeConfig } from './piece-types.
import type { ProviderPermissionProfiles } from './provider-profiles.js'; import type { ProviderPermissionProfiles } from './provider-profiles.js';
export interface PersonaProviderEntry { export interface PersonaProviderEntry {
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock'; provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
model?: string; model?: string;
} }
@ -70,7 +70,7 @@ export interface NotificationSoundEventsConfig {
export interface PersistedGlobalConfig { export interface PersistedGlobalConfig {
language: Language; language: Language;
logLevel: 'debug' | 'info' | 'warn' | 'error'; logLevel: 'debug' | 'info' | 'warn' | 'error';
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock'; provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
model?: string; model?: string;
observability?: ObservabilityConfig; observability?: ObservabilityConfig;
analytics?: AnalyticsConfig; analytics?: AnalyticsConfig;
@ -94,6 +94,10 @@ export interface PersistedGlobalConfig {
claudeCliPath?: string; claudeCliPath?: string;
/** External cursor-agent CLI path (overridden by TAKT_CURSOR_CLI_PATH env var) */ /** External cursor-agent CLI path (overridden by TAKT_CURSOR_CLI_PATH env var) */
cursorCliPath?: string; 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) */ /** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */
opencodeApiKey?: string; opencodeApiKey?: string;
/** Cursor API key for Cursor Agent CLI/API (overridden by TAKT_CURSOR_API_KEY env var) */ /** 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 */ /** Project-level configuration */
export interface ProjectConfig { export interface ProjectConfig {
piece?: string; piece?: string;
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock'; provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
model?: string; model?: string;
providerOptions?: MovementProviderOptions; providerOptions?: MovementProviderOptions;
/** Provider-specific permission profiles */ /** Provider-specific permission profiles */

View File

@ -135,7 +135,7 @@ export interface PieceMovement {
/** Resolved absolute path to persona prompt file (set by loader) */ /** Resolved absolute path to persona prompt file (set by loader) */
personaPath?: string; personaPath?: string;
/** Provider override for this movement */ /** Provider override for this movement */
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock'; provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
/** Model override for this movement */ /** Model override for this movement */
model?: string; model?: string;
/** Required minimum permission mode for tool execution in this movement */ /** Required minimum permission mode for tool execution in this movement */

View File

@ -5,7 +5,7 @@
import type { PermissionMode } from './status.js'; import type { PermissionMode } from './status.js';
/** Supported providers for profile-based permission resolution. */ /** 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. */ /** Permission profile for a single provider. */
export interface ProviderPermissionProfile { export interface ProviderPermissionProfile {

View File

@ -79,7 +79,7 @@ export const MovementProviderOptionsSchema = z.object({
}).optional(); }).optional();
/** Provider key schema for profile maps */ /** 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 */ /** Provider permission profile schema */
export const ProviderPermissionProfileSchema = z.object({ export const ProviderPermissionProfileSchema = z.object({
@ -247,7 +247,7 @@ export const ParallelSubMovementRawSchema = z.object({
knowledge: z.union([z.string(), z.array(z.string())]).optional(), knowledge: z.union([z.string(), z.array(z.string())]).optional(),
allowed_tools: z.array(z.string()).optional(), allowed_tools: z.array(z.string()).optional(),
mcp_servers: McpServersSchema, 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(), model: z.string().optional(),
/** Removed legacy field (no backward compatibility) */ /** Removed legacy field (no backward compatibility) */
permission_mode: z.never().optional(), permission_mode: z.never().optional(),
@ -280,7 +280,7 @@ export const PieceMovementRawSchema = z.object({
knowledge: z.union([z.string(), z.array(z.string())]).optional(), knowledge: z.union([z.string(), z.array(z.string())]).optional(),
allowed_tools: z.array(z.string()).optional(), allowed_tools: z.array(z.string()).optional(),
mcp_servers: McpServersSchema, 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(), model: z.string().optional(),
/** Removed legacy field (no backward compatibility) */ /** Removed legacy field (no backward compatibility) */
permission_mode: z.never().optional(), permission_mode: z.never().optional(),
@ -369,7 +369,7 @@ export const PieceConfigRawSchema = z.object({
}); });
export const PersonaProviderEntrySchema = 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(), model: z.string().optional(),
}); });
@ -425,7 +425,7 @@ export const PieceCategoryConfigSchema = z.record(z.string(), PieceCategoryConfi
export const GlobalConfigSchema = z.object({ export const GlobalConfigSchema = z.object({
language: LanguageSchema.optional().default(DEFAULT_LANGUAGE), language: LanguageSchema.optional().default(DEFAULT_LANGUAGE),
log_level: z.enum(['debug', 'info', 'warn', 'error']).optional().default('info'), 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(), model: z.string().optional(),
observability: ObservabilityConfigSchema.optional(), observability: ObservabilityConfigSchema.optional(),
analytics: AnalyticsConfigSchema.optional(), analytics: AnalyticsConfigSchema.optional(),
@ -449,6 +449,10 @@ export const GlobalConfigSchema = z.object({
claude_cli_path: z.string().optional(), claude_cli_path: z.string().optional(),
/** External cursor-agent CLI path (overridden by TAKT_CURSOR_CLI_PATH env var) */ /** External cursor-agent CLI path (overridden by TAKT_CURSOR_CLI_PATH env var) */
cursor_cli_path: z.string().optional(), 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 for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */
opencode_api_key: z.string().optional(), opencode_api_key: z.string().optional(),
/** Cursor API key for Cursor Agent CLI/API (overridden by TAKT_CURSOR_API_KEY env var) */ /** 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(), piece_categories_file: z.string().optional(),
/** Per-persona provider and model overrides. */ /** Per-persona provider and model overrides. */
persona_providers: z.record(z.string(), z.union([ persona_providers: z.record(z.string(), z.union([
z.enum(['claude', 'codex', 'opencode', 'cursor', 'mock']), z.enum(['claude', 'codex', 'opencode', 'cursor', 'copilot', 'mock']),
PersonaProviderEntrySchema, PersonaProviderEntrySchema,
])).optional(), ])).optional(),
/** Global provider-specific options (lowest priority) */ /** Global provider-specific options (lowest priority) */
@ -503,7 +507,7 @@ export const GlobalConfigSchema = z.object({
/** Project config schema */ /** Project config schema */
export const ProjectConfigSchema = z.object({ export const ProjectConfigSchema = z.object({
piece: z.string().optional(), 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(), model: z.string().optional(),
provider_options: MovementProviderOptionsSchema, provider_options: MovementProviderOptionsSchema,
provider_profiles: ProviderPermissionProfilesSchema, provider_profiles: ProviderPermissionProfilesSchema,
@ -528,4 +532,6 @@ export const ProjectConfigSchema = z.object({
codex_cli_path: z.string().optional(), codex_cli_path: z.string().optional(),
/** cursor-agent CLI path override (project-level) */ /** cursor-agent CLI path override (project-level) */
cursor_cli_path: z.string().optional(), cursor_cli_path: z.string().optional(),
/** Copilot CLI path override (project-level) */
copilot_cli_path: z.string().optional(),
}); });

View File

@ -28,7 +28,7 @@ export interface DecomposeTaskOptions {
personaPath?: string; personaPath?: string;
language?: Language; language?: Language;
model?: string; model?: string;
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock'; provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
} }
export interface MorePartsResponse { export interface MorePartsResponse {

View File

@ -14,6 +14,7 @@ export const DEFAULT_PROVIDER_PERMISSION_PROFILES: ProviderPermissionProfiles =
codex: { defaultPermissionMode: 'edit' }, codex: { defaultPermissionMode: 'edit' },
opencode: { defaultPermissionMode: 'edit' }, opencode: { defaultPermissionMode: 'edit' },
cursor: { defaultPermissionMode: 'edit' }, cursor: { defaultPermissionMode: 'edit' },
copilot: { defaultPermissionMode: 'edit' },
mock: { defaultPermissionMode: 'edit' }, mock: { defaultPermissionMode: 'edit' },
}; };

View File

@ -11,7 +11,7 @@ import type { PersonaProviderEntry } from '../models/persisted-global-config.js'
import type { ProviderPermissionProfiles } from '../models/provider-profiles.js'; import type { ProviderPermissionProfiles } from '../models/provider-profiles.js';
import type { MovementProviderOptions } from '../models/piece-types.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 type ProviderOptionsSource = 'env' | 'project' | 'global' | 'default';
export interface StreamInitEventData { export interface StreamInitEventData {

View File

@ -96,6 +96,8 @@ const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [
{ path: 'codex_cli_path', type: 'string' }, { path: 'codex_cli_path', type: 'string' },
{ path: 'claude_cli_path', type: 'string' }, { path: 'claude_cli_path', type: 'string' },
{ path: 'cursor_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: 'opencode_api_key', type: 'string' },
{ path: 'cursor_api_key', type: 'string' }, { path: 'cursor_api_key', type: 'string' },
{ path: 'pipeline', type: 'json' }, { path: 'pipeline', type: 'json' },
@ -150,6 +152,7 @@ const PROJECT_ENV_SPECS: readonly EnvSpec[] = [
{ path: 'claude_cli_path', type: 'string' }, { path: 'claude_cli_path', type: 'string' },
{ path: 'codex_cli_path', type: 'string' }, { path: 'codex_cli_path', type: 'string' },
{ path: 'cursor_cli_path', type: 'string' }, { path: 'cursor_cli_path', type: 'string' },
{ path: 'copilot_cli_path', type: 'string' },
]; ];
export function applyGlobalConfigEnvOverrides(target: Record<string, unknown>): void { export function applyGlobalConfigEnvOverrides(target: Record<string, unknown>): void {

View File

@ -423,7 +423,7 @@ export function setLanguage(language: Language): void {
saveGlobalConfig(config); saveGlobalConfig(config);
} }
export function setProvider(provider: 'claude' | 'codex' | 'opencode' | 'cursor'): void { export function setProvider(provider: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot'): void {
const config = loadGlobalConfig(); const config = loadGlobalConfig();
config.provider = provider; config.provider = provider;
saveGlobalConfig(config); saveGlobalConfig(config);
@ -570,3 +570,45 @@ export function resolveCursorApiKey(): string | undefined {
return 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;
}
}

View File

@ -17,6 +17,8 @@ export {
resolveCodexCliPath, resolveCodexCliPath,
resolveClaudeCliPath, resolveClaudeCliPath,
resolveCursorCliPath, resolveCursorCliPath,
resolveCopilotCliPath,
resolveCopilotGithubToken,
resolveOpencodeApiKey, resolveOpencodeApiKey,
resolveCursorApiKey, resolveCursorApiKey,
validateCliPath, validateCliPath,

View File

@ -56,12 +56,13 @@ export async function promptLanguageSelection(): Promise<Language> {
* Prompt user to select provider for resources. * Prompt user to select provider for resources.
* Exits process if cancelled (initial setup is required). * Exits process if cancelled (initial setup is required).
*/ */
export async function promptProviderSelection(): Promise<'claude' | 'codex' | 'opencode' | 'cursor'> { export async function promptProviderSelection(): Promise<'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot'> {
const options: { label: string; value: 'claude' | 'codex' | 'opencode' | 'cursor' }[] = [ const options: { label: string; value: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' }[] = [
{ label: 'Claude Code', value: 'claude' }, { label: 'Claude Code', value: 'claude' },
{ label: 'Codex', value: 'codex' }, { label: 'Codex', value: 'codex' },
{ label: 'OpenCode', value: 'opencode' }, { label: 'OpenCode', value: 'opencode' },
{ label: 'Cursor Agent', value: 'cursor' }, { label: 'Cursor Agent', value: 'cursor' },
{ label: 'GitHub Copilot', value: 'copilot' },
]; ];
const result = await selectOptionWithDefault( const result = await selectOptionWithDefault(

View File

@ -160,6 +160,7 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
claude_cli_path, claude_cli_path,
codex_cli_path, codex_cli_path,
cursor_cli_path, cursor_cli_path,
copilot_cli_path,
...rest ...rest
} = parsedConfig; } = parsedConfig;
@ -190,6 +191,7 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
claudeCliPath: claude_cli_path as string | undefined, claudeCliPath: claude_cli_path as string | undefined,
codexCliPath: codex_cli_path as string | undefined, codexCliPath: codex_cli_path as string | undefined,
cursorCliPath: cursor_cli_path as string | undefined, cursorCliPath: cursor_cli_path as string | undefined,
copilotCliPath: copilot_cli_path as string | undefined,
}; };
} }

View File

@ -11,7 +11,7 @@ export interface ProjectLocalConfig {
/** Current piece name */ /** Current piece name */
piece?: string; piece?: string;
/** Provider selection for agent runtime */ /** Provider selection for agent runtime */
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock'; provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
/** Model selection for agent runtime */ /** Model selection for agent runtime */
model?: string; model?: string;
/** Auto-create PR after worktree execution */ /** Auto-create PR after worktree execution */
@ -40,6 +40,8 @@ export interface ProjectLocalConfig {
codexCliPath?: string; codexCliPath?: string;
/** cursor-agent CLI path override (project-level) */ /** cursor-agent CLI path override (project-level) */
cursorCliPath?: string; cursorCliPath?: string;
/** Copilot CLI path override (project-level) */
copilotCliPath?: string;
} }
/** Persona session data for persistence */ /** Persona session data for persistence */

503
src/infra/copilot/client.ts Normal file
View File

@ -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<CopilotExecResult> {
return new Promise<CopilotExecResult>((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<typeof setTimeout> | 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<string | undefined> {
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<AgentResponse> {
// 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<AgentResponse> {
return this.call(agentName, prompt, {
...options,
systemPrompt,
});
}
}
const defaultClient = new CopilotClient();
export async function callCopilot(
agentType: string,
prompt: string,
options: CopilotCallOptions,
): Promise<AgentResponse> {
return defaultClient.call(agentType, prompt, options);
}
export async function callCopilotCustom(
agentName: string,
prompt: string,
systemPrompt: string,
options: CopilotCallOptions,
): Promise<AgentResponse> {
return defaultClient.callCustom(agentName, prompt, systemPrompt, options);
}

View File

@ -0,0 +1,6 @@
/**
* Copilot integration exports
*/
export { CopilotClient, callCopilot, callCopilotCustom } from './client.js';
export type { CopilotCallOptions } from './types.js';

View File

@ -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;
}

View File

@ -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<AgentResponse> => {
return callCopilotCustom(name, prompt, systemPrompt, toCopilotOptions(options));
},
};
}
return {
call: async (prompt: string, options: ProviderCallOptions): Promise<AgentResponse> => {
return callCopilot(name, prompt, toCopilotOptions(options));
},
};
}
}

View File

@ -9,6 +9,7 @@ import { ClaudeProvider } from './claude.js';
import { CodexProvider } from './codex.js'; import { CodexProvider } from './codex.js';
import { OpenCodeProvider } from './opencode.js'; import { OpenCodeProvider } from './opencode.js';
import { CursorProvider } from './cursor.js'; import { CursorProvider } from './cursor.js';
import { CopilotProvider } from './copilot.js';
import { MockProvider } from './mock.js'; import { MockProvider } from './mock.js';
import type { Provider, ProviderType } from './types.js'; import type { Provider, ProviderType } from './types.js';
@ -28,6 +29,7 @@ export class ProviderRegistry {
codex: new CodexProvider(), codex: new CodexProvider(),
opencode: new OpenCodeProvider(), opencode: new OpenCodeProvider(),
cursor: new CursorProvider(), cursor: new CursorProvider(),
copilot: new CopilotProvider(),
mock: new MockProvider(), mock: new MockProvider(),
}; };
} }

View File

@ -36,6 +36,7 @@ export interface ProviderCallOptions {
openaiApiKey?: string; openaiApiKey?: string;
opencodeApiKey?: string; opencodeApiKey?: string;
cursorApiKey?: string; cursorApiKey?: string;
copilotGithubToken?: string;
outputSchema?: Record<string, unknown>; outputSchema?: Record<string, unknown>;
} }
@ -50,4 +51,4 @@ export interface Provider {
} }
/** Provider type */ /** Provider type */
export type ProviderType = 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock'; export type ProviderType = 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';