From 3b8a85c9ef1d4a227b8ea77f9c892ba547e92eed Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 17 Dec 2025 21:38:11 -0500 Subject: [PATCH] feat(copilot): add copilot manager module - copilot-auth.ts: GitHub OAuth handling via copilot-api - copilot-daemon.ts: daemon lifecycle (start/stop/status) - copilot-models.ts: model catalog with Anthropic/OpenAI models - copilot-executor.ts: profile execution and env generation - types.ts: CopilotStatus, CopilotModel interfaces --- src/copilot/copilot-auth.ts | 131 ++++++++++++++++++ src/copilot/copilot-daemon.ts | 231 ++++++++++++++++++++++++++++++++ src/copilot/copilot-executor.ts | 133 ++++++++++++++++++ src/copilot/copilot-models.ts | 114 ++++++++++++++++ src/copilot/index.ts | 30 +++++ src/copilot/types.ts | 58 ++++++++ 6 files changed, 697 insertions(+) create mode 100644 src/copilot/copilot-auth.ts create mode 100644 src/copilot/copilot-daemon.ts create mode 100644 src/copilot/copilot-executor.ts create mode 100644 src/copilot/copilot-models.ts create mode 100644 src/copilot/index.ts create mode 100644 src/copilot/types.ts diff --git a/src/copilot/copilot-auth.ts b/src/copilot/copilot-auth.ts new file mode 100644 index 00000000..62b87528 --- /dev/null +++ b/src/copilot/copilot-auth.ts @@ -0,0 +1,131 @@ +/** + * Copilot Auth Handler + * + * Handles GitHub OAuth authentication for copilot-api. + */ + +import { spawn, spawnSync } from 'child_process'; +import { CopilotAuthStatus, CopilotDebugInfo } from './types'; + +/** + * Check if copilot-api is installed (available via npx). + */ +export function isCopilotApiInstalled(): boolean { + try { + const result = spawnSync('npx', ['copilot-api', '--version'], { + stdio: 'pipe', + timeout: 10000, + shell: process.platform === 'win32', + }); + return result.status === 0; + } catch { + return false; + } +} + +/** + * Get copilot-api debug info. + * Returns authentication status and version info. + */ +export async function getCopilotDebugInfo(): Promise { + return new Promise((resolve) => { + try { + const proc = spawn('npx', ['copilot-api', 'debug', '--json'], { + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32', + timeout: 15000, + }); + + let stdout = ''; + let _stderr = ''; + + proc.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + proc.stderr.on('data', (data) => { + _stderr += data.toString(); + }); + + proc.on('close', (code) => { + if (code === 0 && stdout) { + try { + const info = JSON.parse(stdout.trim()) as CopilotDebugInfo; + resolve(info); + } catch { + resolve(null); + } + } else { + resolve(null); + } + }); + + proc.on('error', () => { + resolve(null); + }); + + // Timeout after 15 seconds + setTimeout(() => { + proc.kill(); + resolve(null); + }, 15000); + } catch { + resolve(null); + } + }); +} + +/** + * Check copilot authentication status. + */ +export async function checkAuthStatus(): Promise { + const debugInfo = await getCopilotDebugInfo(); + + if (!debugInfo) { + return { + authenticated: false, + error: 'Could not check auth status. Is copilot-api installed?', + }; + } + + return { + authenticated: debugInfo.authenticated ?? false, + }; +} + +/** + * Start GitHub OAuth authentication flow. + * Opens browser for user to authenticate with GitHub. + * + * @returns Promise that resolves when auth flow completes + */ +export function startAuthFlow(): Promise<{ success: boolean; error?: string }> { + return new Promise((resolve) => { + console.log('[i] Starting GitHub authentication for Copilot...'); + console.log('[i] A browser window will open for GitHub OAuth.'); + console.log(''); + + const proc = spawn('npx', ['copilot-api', 'auth'], { + stdio: 'inherit', + shell: process.platform === 'win32', + }); + + proc.on('close', (code) => { + if (code === 0) { + resolve({ success: true }); + } else { + resolve({ + success: false, + error: `Authentication failed with exit code ${code}`, + }); + } + }); + + proc.on('error', (err) => { + resolve({ + success: false, + error: `Failed to start auth: ${err.message}`, + }); + }); + }); +} diff --git a/src/copilot/copilot-daemon.ts b/src/copilot/copilot-daemon.ts new file mode 100644 index 00000000..f004bf4b --- /dev/null +++ b/src/copilot/copilot-daemon.ts @@ -0,0 +1,231 @@ +/** + * Copilot Daemon Manager + * + * Manages the copilot-api daemon lifecycle (start/stop/status). + * Only used when auto_start is enabled in config. + */ + +import { spawn, ChildProcess } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as http from 'http'; +import { CopilotDaemonStatus } from './types'; +import { CopilotConfig } from '../config/unified-config-types'; + +const PID_FILE = path.join(os.homedir(), '.ccs', 'copilot.pid'); + +/** + * Check if copilot-api daemon is running on the specified port. + */ +export async function isDaemonRunning(port: number): Promise { + return new Promise((resolve) => { + const req = http.request( + { + hostname: 'localhost', + port, + path: '/usage', + method: 'GET', + timeout: 3000, + }, + (res) => { + resolve(res.statusCode === 200); + } + ); + + req.on('error', () => { + resolve(false); + }); + + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + + req.end(); + }); +} + +/** + * Get daemon status. + */ +export async function getDaemonStatus(port: number): Promise { + const running = await isDaemonRunning(port); + const pid = getPidFromFile(); + + return { + running, + port, + pid: running ? (pid ?? undefined) : undefined, + }; +} + +/** + * Read PID from file. + */ +function getPidFromFile(): number | null { + try { + if (fs.existsSync(PID_FILE)) { + const content = fs.readFileSync(PID_FILE, 'utf8').trim(); + const pid = parseInt(content, 10); + return isNaN(pid) ? null : pid; + } + } catch { + // Ignore errors + } + return null; +} + +/** + * Write PID to file. + */ +function writePidToFile(pid: number): void { + try { + const dir = path.dirname(PID_FILE); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(PID_FILE, pid.toString(), { mode: 0o600 }); + } catch { + // Ignore errors + } +} + +/** + * Remove PID file. + */ +function removePidFile(): void { + try { + if (fs.existsSync(PID_FILE)) { + fs.unlinkSync(PID_FILE); + } + } catch { + // Ignore errors + } +} + +/** + * Start the copilot-api daemon. + * + * @param config Copilot configuration + * @returns Promise that resolves when daemon is ready + */ +export async function startDaemon( + config: CopilotConfig +): Promise<{ success: boolean; pid?: number; error?: string }> { + // Check if already running + if (await isDaemonRunning(config.port)) { + return { success: true, pid: getPidFromFile() ?? undefined }; + } + + const args = ['copilot-api', 'start', '--port', config.port.toString()]; + + // Add account type + if (config.account_type !== 'individual') { + args.push('--account-type', config.account_type); + } + + // Add rate limiting + if (config.rate_limit !== null && config.rate_limit > 0) { + args.push('--rate-limit', config.rate_limit.toString()); + if (config.wait_on_limit) { + args.push('--wait'); + } + } + + return new Promise((resolve) => { + let proc: ChildProcess; + + try { + proc = spawn('npx', args, { + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + shell: process.platform === 'win32', + }); + + // Unref so parent can exit + proc.unref(); + + if (proc.pid) { + writePidToFile(proc.pid); + } + + // Wait for daemon to be ready (poll for up to 30 seconds) + let attempts = 0; + const maxAttempts = 30; + const checkInterval = setInterval(async () => { + attempts++; + + if (await isDaemonRunning(config.port)) { + clearInterval(checkInterval); + resolve({ success: true, pid: proc.pid }); + } else if (attempts >= maxAttempts) { + clearInterval(checkInterval); + resolve({ + success: false, + error: 'Daemon did not start within 30 seconds', + }); + } + }, 1000); + + proc.on('error', (err) => { + clearInterval(checkInterval); + resolve({ + success: false, + error: `Failed to start daemon: ${err.message}`, + }); + }); + } catch (err) { + resolve({ + success: false, + error: `Failed to spawn daemon: ${(err as Error).message}`, + }); + } + }); +} + +/** + * Stop the copilot-api daemon. + */ +export async function stopDaemon(): Promise<{ success: boolean; error?: string }> { + const pid = getPidFromFile(); + + if (!pid) { + // No PID file, try to find by port + removePidFile(); + return { success: true }; + } + + try { + // Send SIGTERM to the process + process.kill(pid, 'SIGTERM'); + + // Wait for process to exit (up to 5 seconds) + let attempts = 0; + while (attempts < 10) { + await new Promise((resolve) => setTimeout(resolve, 500)); + try { + // Check if process still exists (kill(pid, 0) throws if not) + process.kill(pid, 0); + attempts++; + } catch { + // Process no longer exists + break; + } + } + + removePidFile(); + return { success: true }; + } catch (err) { + const error = err as NodeJS.ErrnoException; + if (error.code === 'ESRCH') { + // Process doesn't exist + removePidFile(); + return { success: true }; + } + return { + success: false, + error: `Failed to stop daemon: ${error.message}`, + }; + } +} diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts new file mode 100644 index 00000000..da86d3cc --- /dev/null +++ b/src/copilot/copilot-executor.ts @@ -0,0 +1,133 @@ +/** + * Copilot Executor + * + * Main execution flow for running Claude Code with copilot-api proxy. + * Similar to CLIProxy executor but for GitHub Copilot. + */ + +import { spawn } from 'child_process'; +import { CopilotConfig } from '../config/unified-config-types'; +import { checkAuthStatus, isCopilotApiInstalled } from './copilot-auth'; +import { isDaemonRunning, startDaemon } from './copilot-daemon'; +import { CopilotStatus } from './types'; + +/** + * Get full copilot status (auth + daemon). + */ +export async function getCopilotStatus(config: CopilotConfig): Promise { + const [auth, daemonRunning] = await Promise.all([ + checkAuthStatus(), + isDaemonRunning(config.port), + ]); + + return { + auth, + daemon: { + running: daemonRunning, + port: config.port, + }, + }; +} + +/** + * Generate environment variables for Claude Code to use copilot-api. + */ +export function generateCopilotEnv(config: CopilotConfig): Record { + return { + ANTHROPIC_BASE_URL: `http://localhost:${config.port}`, + ANTHROPIC_AUTH_TOKEN: 'dummy', // copilot-api handles auth internally + ANTHROPIC_MODEL: config.model, + ANTHROPIC_DEFAULT_SONNET_MODEL: config.model, + ANTHROPIC_SMALL_FAST_MODEL: config.model, + ANTHROPIC_DEFAULT_HAIKU_MODEL: config.model, + // Disable non-essential traffic to avoid rate limiting + DISABLE_NON_ESSENTIAL_MODEL_CALLS: '1', + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', + }; +} + +/** + * Execute Claude Code with copilot-api proxy. + * + * @param config Copilot configuration + * @param claudeArgs Arguments to pass to Claude CLI + * @returns Exit code + */ +export async function executeCopilotProfile( + config: CopilotConfig, + claudeArgs: string[] +): Promise { + // Check if copilot-api is installed + if (!isCopilotApiInstalled()) { + console.error('[X] copilot-api is not installed.'); + console.error(''); + console.error('Install with: npm install -g copilot-api'); + console.error('Or run via npx: npx copilot-api auth'); + return 1; + } + + // Check authentication + const authStatus = await checkAuthStatus(); + if (!authStatus.authenticated) { + console.error('[X] Not authenticated with GitHub.'); + console.error(''); + console.error('Run: npx copilot-api auth'); + console.error('Or: ccs copilot auth'); + return 1; + } + + // Check if daemon is running or needs to be started + let daemonRunning = await isDaemonRunning(config.port); + + if (!daemonRunning) { + if (config.auto_start) { + console.log('[i] Starting copilot-api daemon...'); + const result = await startDaemon(config); + if (!result.success) { + console.error(`[X] Failed to start daemon: ${result.error}`); + return 1; + } + console.log(`[OK] Daemon started on port ${config.port}`); + daemonRunning = true; + } else { + console.error('[X] copilot-api daemon is not running.'); + console.error(''); + console.error('Start the daemon manually:'); + console.error(` npx copilot-api start --port ${config.port}`); + console.error(''); + console.error('Or enable auto_start in config:'); + console.error(' ccs config (then enable auto_start in Copilot section)'); + return 1; + } + } + + // Generate environment for Claude + const copilotEnv = generateCopilotEnv(config); + + // Merge with current environment + const env = { + ...process.env, + ...copilotEnv, + }; + + console.log(`[i] Using GitHub Copilot proxy (model: ${config.model})`); + console.log(''); + + // Spawn Claude CLI + return new Promise((resolve) => { + const proc = spawn('claude', claudeArgs, { + stdio: 'inherit', + env, + shell: process.platform === 'win32', + }); + + proc.on('close', (code) => { + resolve(code ?? 0); + }); + + proc.on('error', (err) => { + console.error(`[X] Failed to start Claude: ${err.message}`); + resolve(1); + }); + }); +} diff --git a/src/copilot/copilot-models.ts b/src/copilot/copilot-models.ts new file mode 100644 index 00000000..c95bc94d --- /dev/null +++ b/src/copilot/copilot-models.ts @@ -0,0 +1,114 @@ +/** + * Copilot Model Catalog + * + * Manages available models from copilot-api. + */ + +import * as http from 'http'; +import { CopilotModel } from './types'; + +/** + * Default models available through copilot-api. + * Used as fallback when API is not reachable. + */ +export const DEFAULT_COPILOT_MODELS: CopilotModel[] = [ + { + id: 'claude-opus-4-5-20250514', + name: 'Claude Opus 4.5', + provider: 'anthropic', + isDefault: true, + }, + { id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', provider: 'anthropic' }, + { id: 'gpt-4.1', name: 'GPT-4.1', provider: 'openai' }, + { id: 'gpt-4.1-mini', name: 'GPT-4.1 Mini', provider: 'openai' }, + { id: 'o3', name: 'O3', provider: 'openai' }, + { id: 'o4-mini', name: 'O4 Mini', provider: 'openai' }, +]; + +/** + * Fetch available models from running copilot-api daemon. + * + * @param port The port copilot-api is running on + * @returns List of available models + */ +export async function fetchModelsFromDaemon(port: number): Promise { + return new Promise((resolve) => { + const req = http.request( + { + hostname: 'localhost', + port, + path: '/v1/models', + method: 'GET', + timeout: 5000, + }, + (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + try { + const response = JSON.parse(data) as { data?: Array<{ id: string }> }; + if (response.data && Array.isArray(response.data)) { + const models: CopilotModel[] = response.data.map((m) => ({ + id: m.id, + name: formatModelName(m.id), + provider: m.id.includes('claude') ? 'anthropic' : 'openai', + isDefault: m.id === 'claude-opus-4-5-20250514', + })); + resolve(models.length > 0 ? models : DEFAULT_COPILOT_MODELS); + } else { + resolve(DEFAULT_COPILOT_MODELS); + } + } catch { + resolve(DEFAULT_COPILOT_MODELS); + } + }); + } + ); + + req.on('error', () => { + resolve(DEFAULT_COPILOT_MODELS); + }); + + req.on('timeout', () => { + req.destroy(); + resolve(DEFAULT_COPILOT_MODELS); + }); + + req.end(); + }); +} + +/** + * Get available models (from daemon or defaults). + */ +export async function getAvailableModels(port: number): Promise { + return fetchModelsFromDaemon(port); +} + +/** + * Get the default model. + */ +export function getDefaultModel(): string { + return 'claude-opus-4-5-20250514'; +} + +/** + * Format model ID to human-readable name. + */ +function formatModelName(modelId: string): string { + // Convert model IDs to readable names + const nameMap: Record = { + 'claude-opus-4-5-20250514': 'Claude Opus 4.5', + 'claude-sonnet-4-20250514': 'Claude Sonnet 4', + 'gpt-4.1': 'GPT-4.1', + 'gpt-4.1-mini': 'GPT-4.1 Mini', + o3: 'O3', + 'o4-mini': 'O4 Mini', + }; + + return nameMap[modelId] || modelId; +} diff --git a/src/copilot/index.ts b/src/copilot/index.ts new file mode 100644 index 00000000..6a36d4ea --- /dev/null +++ b/src/copilot/index.ts @@ -0,0 +1,30 @@ +/** + * Copilot Module Index + * + * Central exports for GitHub Copilot integration via copilot-api. + */ + +// Types +export * from './types'; + +// Auth +export { + isCopilotApiInstalled, + checkAuthStatus, + startAuthFlow, + getCopilotDebugInfo, +} from './copilot-auth'; + +// Daemon +export { isDaemonRunning, getDaemonStatus, startDaemon, stopDaemon } from './copilot-daemon'; + +// Models +export { + DEFAULT_COPILOT_MODELS, + fetchModelsFromDaemon, + getAvailableModels, + getDefaultModel, +} from './copilot-models'; + +// Executor +export { getCopilotStatus, generateCopilotEnv, executeCopilotProfile } from './copilot-executor'; diff --git a/src/copilot/types.ts b/src/copilot/types.ts new file mode 100644 index 00000000..51b308f8 --- /dev/null +++ b/src/copilot/types.ts @@ -0,0 +1,58 @@ +/** + * Copilot API Types + * + * Type definitions for GitHub Copilot proxy integration. + */ + +/** + * Copilot authentication status. + */ +export interface CopilotAuthStatus { + authenticated: boolean; + /** GitHub username if authenticated */ + username?: string; + /** Error message if auth check failed */ + error?: string; +} + +/** + * Copilot daemon status. + */ +export interface CopilotDaemonStatus { + running: boolean; + port: number; + /** Process ID if running */ + pid?: number; + /** Version if available */ + version?: string; +} + +/** + * Combined copilot status. + */ +export interface CopilotStatus { + auth: CopilotAuthStatus; + daemon: CopilotDaemonStatus; +} + +/** + * Copilot model information. + */ +export interface CopilotModel { + id: string; + name: string; + /** Provider: openai or anthropic */ + provider: 'openai' | 'anthropic'; + /** Whether this is the default model */ + isDefault?: boolean; +} + +/** + * Copilot debug info from `copilot-api debug --json`. + */ +export interface CopilotDebugInfo { + version?: string; + runtime?: string; + authenticated?: boolean; + tokenPath?: string; +}