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:
parent
45663342c6
commit
17232f9940
482
src/__tests__/copilot-client.test.ts
Normal file
482
src/__tests__/copilot-client.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
184
src/__tests__/copilot-provider.test.ts
Normal file
184
src/__tests__/copilot-provider.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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<string, McpServerConfig>;
|
||||
|
||||
@ -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 <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('-t, --task <string>', 'Task content (as alternative to GitHub issue)')
|
||||
.option('--pipeline', 'Pipeline mode: non-interactive, no worktree, direct branch creation')
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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' },
|
||||
};
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
3
src/infra/config/env/config-env-overrides.ts
vendored
3
src/infra/config/env/config-env-overrides.ts
vendored
@ -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<string, unknown>): void {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,8 @@ export {
|
||||
resolveCodexCliPath,
|
||||
resolveClaudeCliPath,
|
||||
resolveCursorCliPath,
|
||||
resolveCopilotCliPath,
|
||||
resolveCopilotGithubToken,
|
||||
resolveOpencodeApiKey,
|
||||
resolveCursorApiKey,
|
||||
validateCliPath,
|
||||
|
||||
@ -56,12 +56,13 @@ export async function promptLanguageSelection(): Promise<Language> {
|
||||
* 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(
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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 */
|
||||
|
||||
503
src/infra/copilot/client.ts
Normal file
503
src/infra/copilot/client.ts
Normal 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);
|
||||
}
|
||||
6
src/infra/copilot/index.ts
Normal file
6
src/infra/copilot/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copilot integration exports
|
||||
*/
|
||||
|
||||
export { CopilotClient, callCopilot, callCopilotCustom } from './client.js';
|
||||
export type { CopilotCallOptions } from './types.js';
|
||||
21
src/infra/copilot/types.ts
Normal file
21
src/infra/copilot/types.ts
Normal 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;
|
||||
}
|
||||
62
src/infra/providers/copilot.ts
Normal file
62
src/infra/providers/copilot.ts
Normal 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));
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -36,6 +36,7 @@ export interface ProviderCallOptions {
|
||||
openaiApiKey?: string;
|
||||
opencodeApiKey?: string;
|
||||
cursorApiKey?: string;
|
||||
copilotGithubToken?: string;
|
||||
outputSchema?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user