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
This commit is contained in:
kaitranntt
2025-12-17 21:38:11 -05:00
parent b87aeaeb01
commit 3b8a85c9ef
6 changed files with 697 additions and 0 deletions
+131
View File
@@ -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<CopilotDebugInfo | null> {
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<CopilotAuthStatus> {
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}`,
});
});
});
}
+231
View File
@@ -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<boolean> {
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<CopilotDaemonStatus> {
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}`,
};
}
}
+133
View File
@@ -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<CopilotStatus> {
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<string, string> {
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<number> {
// 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);
});
});
}
+114
View File
@@ -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<CopilotModel[]> {
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<CopilotModel[]> {
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<string, string> = {
'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;
}
+30
View File
@@ -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';
+58
View File
@@ -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;
}