Merge pull request #138 from kaitranntt/kai/feat/copilot-integration

feat(copilot): add GitHub Copilot integration with CLI, API, and dashboard
This commit is contained in:
Kai (Tam Nhu) Tran
2025-12-18 05:41:38 -05:00
committed by GitHub
21 changed files with 3869 additions and 4 deletions
+41 -2
View File
@@ -14,12 +14,12 @@ import * as path from 'path';
import * as os from 'os';
import { findSimilarStrings } from '../utils/helpers';
import { Config, Settings, ProfileMetadata } from '../types';
import { UnifiedConfig } from '../config/unified-config-types';
import { UnifiedConfig, CopilotConfig } from '../config/unified-config-types';
import { hasUnifiedConfig, loadUnifiedConfig } from '../config/unified-config-loader';
import { getProfileSecrets } from '../config/secrets-manager';
import { isUnifiedConfigEnabled } from '../config/feature-flags';
export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'default';
export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'default';
/** CLIProxy profile names (OAuth-based, zero config) */
export const CLIPROXY_PROFILES = ['gemini', 'codex', 'agy', 'qwen'] as const;
@@ -35,6 +35,8 @@ export interface ProfileDetectionResult {
provider?: CLIProxyProfileName;
/** For unified config profiles: merged env vars (config + secrets) */
env?: Record<string, string>;
/** For copilot profile: the copilot config */
copilotConfig?: CopilotConfig;
}
export interface AllProfiles {
@@ -182,6 +184,7 @@ class ProfileDetector {
*
* Priority order:
* 0. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen)
* 0.5. Copilot profile (if enabled in config)
* 1. Unified config profiles (if config.yaml exists or CCS_UNIFIED_CONFIG=1)
* 2. User-defined CLIProxy variants (config.cliproxy section) [legacy]
* 3. Settings-based profiles (config.profiles section) [legacy]
@@ -202,6 +205,36 @@ class ProfileDetector {
};
}
// Priority 0.5: Check Copilot profile - GitHub Copilot subscription via copilot-api
if (profileName === 'copilot') {
const unifiedConfig = this.readUnifiedConfig();
const copilotConfig = unifiedConfig?.copilot;
if (!copilotConfig?.enabled) {
const error = new Error(
'Copilot profile is not enabled.\n\n' +
'To enable GitHub Copilot integration:\n' +
' 1. Run: ccs config\n' +
' 2. Go to "GitHub Copilot" section\n' +
' 3. Enable the integration\n' +
' 4. Authenticate with GitHub: npx copilot-api auth\n\n' +
'Or manually edit ~/.ccs/config.yaml:\n' +
' copilot:\n' +
' enabled: true'
) as ProfileNotFoundError;
error.profileName = profileName;
error.suggestions = [];
error.availableProfiles = this.listAvailableProfiles();
throw error;
}
return {
type: 'copilot',
name: 'copilot',
copilotConfig,
};
}
// Priority 1: Try unified config if available
const unifiedConfig = this.readUnifiedConfig();
if (unifiedConfig) {
@@ -327,6 +360,12 @@ class ProfileDetector {
// Check unified config first
const unifiedConfig = this.readUnifiedConfig();
if (unifiedConfig) {
// Copilot profile (if enabled)
if (unifiedConfig.copilot?.enabled) {
lines.push('GitHub Copilot (via copilot-api):');
lines.push(` - copilot (model: ${unifiedConfig.copilot.model})`);
}
// CLIProxy variants from unified config
const variants = Object.keys(unifiedConfig.cliproxy?.variants || {});
if (variants.length > 0) {
+31
View File
@@ -343,6 +343,27 @@ async function main(): Promise<void> {
return;
}
// Special case: copilot command (GitHub Copilot integration)
// Only route to command handler for known subcommands, otherwise treat as profile
const COPILOT_SUBCOMMANDS = [
'auth',
'status',
'models',
'start',
'stop',
'enable',
'disable',
'help',
'--help',
'-h',
];
if (firstArg === 'copilot' && args.length > 1 && COPILOT_SUBCOMMANDS.includes(args[1])) {
// `ccs copilot <subcommand>` - route to copilot command handler
const { handleCopilotCommand } = await import('./commands/copilot-command');
const exitCode = await handleCopilotCommand(args.slice(1));
process.exit(exitCode);
}
// Special case: headless delegation (-p flag)
if (args.includes('-p') || args.includes('--prompt')) {
const { DelegationHandler } = await import('./delegation/delegation-handler');
@@ -389,6 +410,16 @@ async function main(): Promise<void> {
const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider);
const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles
await execClaudeWithCLIProxy(claudeCli, provider, remainingArgs, { customSettingsPath });
} else if (profileInfo.type === 'copilot') {
// COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy
const { executeCopilotProfile } = await import('./copilot');
const copilotConfig = profileInfo.copilotConfig;
if (!copilotConfig) {
console.error('[X] Copilot configuration not found');
process.exit(1);
}
const exitCode = await executeCopilotProfile(copilotConfig, remainingArgs);
process.exit(exitCode);
} else if (profileInfo.type === 'settings') {
// Settings-based profiles (glm, glmt, kimi) are third-party providers
// WebSearch is server-side tool - third-party providers have no access
+266
View File
@@ -0,0 +1,266 @@
/**
* Copilot CLI Command
*
* Handles `ccs copilot <subcommand>` commands.
*/
import {
startAuthFlow,
getCopilotStatus,
startDaemon,
stopDaemon,
getAvailableModels,
isCopilotApiInstalled,
} from '../copilot';
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../config/unified-config-loader';
import { DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types';
/**
* Handle copilot subcommand.
*/
export async function handleCopilotCommand(args: string[]): Promise<number> {
const subcommand = args[0];
switch (subcommand) {
case 'auth':
return handleAuth();
case 'status':
return handleStatus();
case 'models':
return handleModels();
case 'start':
return handleStart();
case 'stop':
return handleStop();
case 'enable':
return handleEnable();
case 'disable':
return handleDisable();
case undefined:
case 'help':
case '--help':
case '-h':
return handleHelp();
default:
console.error(`[X] Unknown subcommand: ${subcommand}`);
console.error('');
return handleHelp();
}
}
/**
* Show help for copilot commands.
*/
function handleHelp(): number {
console.log('GitHub Copilot Integration');
console.log('');
console.log('Usage: ccs copilot <subcommand>');
console.log('');
console.log('Subcommands:');
console.log(' auth Start GitHub OAuth authentication');
console.log(' status Show authentication and daemon status');
console.log(' models List available models');
console.log(' start Start copilot-api daemon');
console.log(' stop Stop copilot-api daemon');
console.log(' enable Enable copilot integration');
console.log(' disable Disable copilot integration');
console.log(' help Show this help message');
console.log('');
console.log('Quick start:');
console.log(' 1. ccs copilot auth # Authenticate with GitHub');
console.log(' 2. ccs copilot enable # Enable integration');
console.log(' 3. ccs copilot start # Start daemon');
console.log('');
console.log('Or use the web UI: ccs config → Copilot tab');
console.log('');
return 0;
}
/**
* Handle auth subcommand.
*/
async function handleAuth(): Promise<number> {
if (!isCopilotApiInstalled()) {
console.error('[X] copilot-api is not installed.');
console.error('');
console.error('Install with: npm install -g copilot-api');
return 1;
}
const result = await startAuthFlow();
if (result.success) {
console.log('');
console.log('[OK] Authentication successful!');
console.log('');
console.log('Next steps:');
console.log(' 1. Enable copilot: ccs copilot enable');
console.log(' 2. Start daemon: npx copilot-api start');
console.log(' 3. Use copilot: ccs copilot');
return 0;
} else {
console.error('');
console.error(`[X] ${result.error}`);
return 1;
}
}
/**
* Handle status subcommand.
*/
async function handleStatus(): Promise<number> {
const config = loadOrCreateUnifiedConfig();
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
const status = await getCopilotStatus(copilotConfig);
console.log('GitHub Copilot Status');
console.log('─────────────────────');
console.log('');
// Enabled status
const enabledIcon = copilotConfig.enabled ? '[OK]' : '[X]';
const enabledText = copilotConfig.enabled ? 'Enabled' : 'Disabled';
console.log(`Integration: ${enabledIcon} ${enabledText}`);
// Auth status
const authIcon = status.auth.authenticated ? '[OK]' : '[X]';
const authText = status.auth.authenticated ? 'Authenticated' : 'Not authenticated';
console.log(`Authentication: ${authIcon} ${authText}`);
// Daemon status
const daemonIcon = status.daemon.running ? '[OK]' : '[X]';
const daemonText = status.daemon.running ? 'Running' : 'Not running';
console.log(`Daemon: ${daemonIcon} ${daemonText}`);
console.log('');
console.log('Configuration:');
console.log(` Port: ${copilotConfig.port}`);
console.log(` Model: ${copilotConfig.model}`);
console.log(` Account Type: ${copilotConfig.account_type}`);
console.log(` Auto-start: ${copilotConfig.auto_start ? 'Yes' : 'No'}`);
if (copilotConfig.rate_limit !== null) {
console.log(` Rate Limit: ${copilotConfig.rate_limit}s`);
}
console.log('');
// Show next steps if not fully configured
if (!copilotConfig.enabled || !status.auth.authenticated || !status.daemon.running) {
console.log('Next steps:');
if (!copilotConfig.enabled) {
console.log(' - Enable: ccs copilot enable');
}
if (!status.auth.authenticated) {
console.log(' - Auth: ccs copilot auth');
}
if (!status.daemon.running) {
console.log(' - Start: ccs copilot start');
}
}
return 0;
}
/**
* Handle models subcommand.
*/
async function handleModels(): Promise<number> {
const config = loadOrCreateUnifiedConfig();
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
console.log('Available Copilot Models');
console.log('────────────────────────');
console.log('');
const models = await getAvailableModels(copilotConfig.port);
for (const model of models) {
const current = model.id === copilotConfig.model ? ' [CURRENT]' : '';
const defaultMark = model.isDefault ? ' (default)' : '';
console.log(` ${model.id}${current}${defaultMark}`);
console.log(` Provider: ${model.provider}`);
}
console.log('');
console.log('To change model: ccs config (Copilot section)');
return 0;
}
/**
* Handle start subcommand.
*/
async function handleStart(): Promise<number> {
const config = loadOrCreateUnifiedConfig();
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
console.log(`[i] Starting copilot-api daemon on port ${copilotConfig.port}...`);
const result = await startDaemon(copilotConfig);
if (result.success) {
console.log(`[OK] Daemon started (PID: ${result.pid})`);
return 0;
} else {
console.error(`[X] ${result.error}`);
return 1;
}
}
/**
* Handle stop subcommand.
*/
async function handleStop(): Promise<number> {
console.log('[i] Stopping copilot-api daemon...');
const result = await stopDaemon();
if (result.success) {
console.log('[OK] Daemon stopped');
return 0;
} else {
console.error(`[X] ${result.error}`);
return 1;
}
}
/**
* Handle enable subcommand.
*/
async function handleEnable(): Promise<number> {
const config = loadOrCreateUnifiedConfig();
if (!config.copilot) {
config.copilot = { ...DEFAULT_COPILOT_CONFIG };
}
config.copilot.enabled = true;
saveUnifiedConfig(config);
console.log('[OK] Copilot integration enabled');
console.log('');
console.log('Next steps:');
console.log(' 1. Authenticate: ccs copilot auth');
console.log(' 2. Start daemon: ccs copilot start');
console.log(' 3. Use: ccs copilot');
return 0;
}
/**
* Handle disable subcommand.
*/
async function handleDisable(): Promise<number> {
const config = loadOrCreateUnifiedConfig();
if (config.copilot) {
config.copilot.enabled = false;
saveUnifiedConfig(config);
}
console.log('[OK] Copilot integration disabled');
return 0;
}
+21
View File
@@ -173,6 +173,27 @@ Claude Code Profile & Model Switcher`.trim();
]
);
// ═══════════════════════════════════════════════════════════════════════════
// MAJOR SECTION 4: GitHub Copilot Integration
// ═══════════════════════════════════════════════════════════════════════════
printMajorSection(
'GitHub Copilot Integration',
[
'Use your GitHub Copilot subscription with Claude Code',
'Requires: npm install -g copilot-api',
],
[
['ccs copilot', 'Use Copilot as API backend'],
['ccs copilot auth', 'Authenticate with GitHub'],
['ccs copilot status', 'Show integration status'],
['ccs copilot models', 'List available models'],
['ccs copilot start', 'Start copilot-api daemon'],
['ccs copilot stop', 'Stop copilot-api daemon'],
['ccs copilot enable', 'Enable integration'],
['ccs copilot disable', 'Disable integration'],
]
);
// ═══════════════════════════════════════════════════════════════════════════
// SUB-SECTIONS (simpler styling)
// ═══════════════════════════════════════════════════════════════════════════
+2
View File
@@ -9,6 +9,8 @@ export const RESERVED_PROFILE_NAMES = [
'agy',
'qwen',
'iflow',
// Copilot API (GitHub Copilot proxy)
'copilot',
// CLI commands and special names
'default',
'config',
+34
View File
@@ -14,6 +14,7 @@ import {
isUnifiedConfig,
createEmptyUnifiedConfig,
UNIFIED_CONFIG_VERSION,
DEFAULT_COPILOT_CONFIG,
} from './unified-config-types';
import { isUnifiedConfigEnabled } from './feature-flags';
@@ -159,6 +160,16 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
// Legacy fields (keep for backwards compatibility during read)
gemini: partial.websearch?.gemini,
},
// Copilot config - strictly opt-in, merge with defaults
copilot: {
enabled: partial.copilot?.enabled ?? DEFAULT_COPILOT_CONFIG.enabled,
auto_start: partial.copilot?.auto_start ?? DEFAULT_COPILOT_CONFIG.auto_start,
port: partial.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port,
account_type: partial.copilot?.account_type ?? DEFAULT_COPILOT_CONFIG.account_type,
rate_limit: partial.copilot?.rate_limit ?? DEFAULT_COPILOT_CONFIG.rate_limit,
wait_on_limit: partial.copilot?.wait_on_limit ?? DEFAULT_COPILOT_CONFIG.wait_on_limit,
model: partial.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model,
},
};
}
@@ -302,6 +313,29 @@ function generateYamlWithComments(config: UnifiedConfig): string {
lines.push('');
}
// Copilot section (GitHub Copilot proxy)
if (config.copilot) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Copilot: GitHub Copilot API proxy (via copilot-api)');
lines.push('# Uses your existing GitHub Copilot subscription with Claude Code.');
lines.push('#');
lines.push('# !! DISCLAIMER - USE AT YOUR OWN RISK !!');
lines.push('# This uses an UNOFFICIAL reverse-engineered API.');
lines.push('# Excessive usage may trigger GitHub account restrictions.');
lines.push('# CCS provides NO WARRANTY and accepts NO RESPONSIBILITY for consequences.');
lines.push('#');
lines.push('# Setup: npx copilot-api auth (authenticate with GitHub)');
lines.push('# Usage: ccs copilot (switch to copilot profile)');
lines.push('#');
lines.push('# Models: claude-sonnet-4.5, claude-opus-4.5, gpt-5.1, gemini-2.5-pro');
lines.push('# Account types: individual, business, enterprise');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml.dump({ copilot: config.copilot }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
);
lines.push('');
}
return lines.join('\n');
}
+57 -2
View File
@@ -13,8 +13,9 @@
* Unified config version.
* Version 2 = YAML unified format
* Version 3 = WebSearch config with model configuration for Gemini/OpenCode
* Version 4 = Copilot API integration (GitHub Copilot proxy)
*/
export const UNIFIED_CONFIG_VERSION = 3;
export const UNIFIED_CONFIG_VERSION = 4;
/**
* Account configuration (formerly in profiles.json).
@@ -148,6 +149,42 @@ export interface WebSearchProvidersConfig {
opencode?: OpenCodeWebSearchConfig;
}
/**
* Copilot API account type.
*/
export type CopilotAccountType = 'individual' | 'business' | 'enterprise';
/**
* Copilot API configuration.
* Enables GitHub Copilot subscription usage via copilot-api proxy.
* Strictly opt-in - disabled by default.
*
* !! DISCLAIMER - USE AT YOUR OWN RISK !!
* This uses an UNOFFICIAL reverse-engineered API.
* Excessive usage may trigger GitHub account restrictions.
* CCS provides NO WARRANTY and accepts NO RESPONSIBILITY for any consequences.
*/
export interface CopilotConfig {
/** Enable Copilot integration (default: false) - must be explicitly enabled */
enabled: boolean;
/** Auto-start copilot-api daemon when using profile (default: false) */
auto_start: boolean;
/** Port for copilot-api proxy (default: 4141) */
port: number;
/** GitHub Copilot account type (default: individual) */
account_type: CopilotAccountType;
/** Rate limit in seconds between requests (null = no limit) */
rate_limit: number | null;
/** Wait instead of error when rate limit is hit (default: true) */
wait_on_limit: boolean;
/** Default model ID (e.g., claude-sonnet-4.5) */
model: string;
/** Model mapping for Claude tiers - maps opus/sonnet/haiku to specific models */
opus_model?: string;
sonnet_model?: string;
haiku_model?: string;
}
/**
* WebSearch configuration.
* Uses CLI tools (Gemini CLI, Grok CLI, OpenCode) for third-party profiles.
@@ -183,7 +220,7 @@ export interface WebSearchConfig {
* Stored in ~/.ccs/config.yaml
*/
export interface UnifiedConfig {
/** Config version (2 for unified format) */
/** Config version (4 for copilot support) */
version: number;
/** Default profile name to use when none specified */
default?: string;
@@ -197,6 +234,8 @@ export interface UnifiedConfig {
preferences: PreferencesConfig;
/** WebSearch configuration */
websearch?: WebSearchConfig;
/** Copilot API configuration (GitHub Copilot proxy) */
copilot?: CopilotConfig;
}
/**
@@ -211,6 +250,21 @@ export interface SecretsConfig {
profiles: Record<string, Record<string, string>>;
}
/**
* Default Copilot configuration.
* Strictly opt-in - disabled by default.
* Uses gpt-4.1 as default model (free tier compatible).
*/
export const DEFAULT_COPILOT_CONFIG: CopilotConfig = {
enabled: false,
auto_start: false,
port: 4141,
account_type: 'individual',
rate_limit: null,
wait_on_limit: true,
model: 'gpt-4.1', // Free tier compatible
};
/**
* Create an empty unified config with defaults.
*/
@@ -253,6 +307,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
},
},
},
copilot: { ...DEFAULT_COPILOT_CONFIG },
};
}
+206
View File
@@ -0,0 +1,206 @@
/**
* Copilot Auth Handler
*
* Handles GitHub OAuth authentication for copilot-api.
* Uses local installation from ~/.ccs/copilot/ (managed by copilot-package-manager).
*/
import { spawn } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CopilotAuthStatus, CopilotDebugInfo } from './types';
import {
isCopilotApiInstalled as checkInstalled,
getCopilotApiBinPath,
} from './copilot-package-manager';
/**
* Get the path to copilot-api's GitHub token file.
* copilot-api stores tokens in ~/.local/share/copilot-api/github_token (Linux/macOS)
* or %APPDATA%/copilot-api/github_token (Windows)
*/
export function getTokenPath(): string {
if (process.platform === 'win32') {
return path.join(process.env.APPDATA || os.homedir(), 'copilot-api', 'github_token');
}
return path.join(os.homedir(), '.local', 'share', 'copilot-api', 'github_token');
}
/**
* Check if GitHub token file exists.
* Fast check that doesn't require spawning copilot-api process.
*/
export function hasTokenFile(): boolean {
try {
return fs.existsSync(getTokenPath());
} catch {
return false;
}
}
/**
* Check if copilot-api is installed locally.
* Uses copilot-package-manager to check ~/.ccs/copilot/node_modules/.bin/copilot-api
*/
export function isCopilotApiInstalled(): boolean {
return checkInstalled();
}
/**
* Get copilot-api debug info.
* Returns authentication status and version info.
*/
export async function getCopilotDebugInfo(): Promise<CopilotDebugInfo | null> {
const binPath = getCopilotApiBinPath();
return new Promise((resolve) => {
try {
const proc = spawn(binPath, ['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.
* Uses fast token file check first, falls back to copilot-api debug if needed.
*/
export async function checkAuthStatus(): Promise<CopilotAuthStatus> {
// Fast path: check if token file exists (instant, no subprocess)
if (hasTokenFile()) {
return { authenticated: true };
}
// Slow path: try copilot-api debug --json (may timeout)
// Only used if token file doesn't exist
const debugInfo = await getCopilotDebugInfo();
if (debugInfo?.authenticated) {
return { authenticated: true };
}
return {
authenticated: false,
};
}
/**
* Auth flow result with optional device code info.
*/
export interface AuthFlowResult {
success: boolean;
error?: string;
/** Device code for user to enter at GitHub */
deviceCode?: string;
/** URL where user enters the device code */
verificationUrl?: string;
}
/**
* Start GitHub OAuth authentication flow.
* Captures device code from copilot-api output and returns it.
*
* @returns Promise that resolves with auth result including device code
*/
export function startAuthFlow(): Promise<AuthFlowResult> {
const binPath = getCopilotApiBinPath();
return new Promise((resolve) => {
console.log('[i] Starting GitHub authentication for Copilot...');
console.log('');
const proc = spawn(binPath, ['auth'], {
stdio: ['ignore', 'pipe', 'pipe'],
shell: process.platform === 'win32',
});
let stdout = '';
let deviceCode: string | undefined;
let verificationUrl: string | undefined;
proc.stdout.on('data', (data) => {
const chunk = data.toString();
stdout += chunk;
// Echo to console for terminal users
process.stdout.write(chunk);
// Parse device code from output like:
// "Please enter the code "5653-38A1" in https://github.com/login/device"
const codeMatch = stdout.match(/code\s+"([A-Z0-9]{4}-[A-Z0-9]{4})"/i);
const urlMatch = stdout.match(/(https:\/\/github\.com\/login\/device)/i);
if (codeMatch && !deviceCode) {
deviceCode = codeMatch[1];
}
if (urlMatch && !verificationUrl) {
verificationUrl = urlMatch[1];
}
});
proc.stderr.on('data', (data) => {
// Echo stderr to console
process.stderr.write(data.toString());
});
proc.on('close', (code) => {
if (code === 0) {
resolve({ success: true, deviceCode, verificationUrl });
} else {
resolve({
success: false,
error: `Authentication failed with exit code ${code}`,
deviceCode,
verificationUrl,
});
}
});
proc.on('error', (err) => {
resolve({
success: false,
error: `Failed to start auth: ${err.message}`,
});
});
});
}
+233
View File
@@ -0,0 +1,233 @@
/**
* Copilot Daemon Manager
*
* Manages the copilot-api daemon lifecycle (start/stop/status).
* Uses local installation from ~/.ccs/copilot/ (managed by copilot-package-manager).
*/
import { spawn, ChildProcess } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as http from 'http';
import { CopilotDaemonStatus } from './types';
import { CopilotConfig } from '../config/unified-config-types';
import { getCopilotDir, getCopilotApiBinPath } from './copilot-package-manager';
const PID_FILE = path.join(getCopilotDir(), 'daemon.pid');
/**
* Check if copilot-api daemon is running on the specified port.
* Uses 127.0.0.1 instead of localhost for more reliable local connections.
*/
export async function isDaemonRunning(port: number): Promise<boolean> {
return new Promise((resolve) => {
const req = http.request(
{
hostname: '127.0.0.1',
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 binPath = getCopilotApiBinPath();
const args = ['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(binPath, 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}`,
};
}
}
+156
View File
@@ -0,0 +1,156 @@
/**
* Copilot Executor
*
* Main execution flow for running Claude Code with copilot-api proxy.
* Uses local installation from ~/.ccs/copilot/ (managed by copilot-package-manager).
*/
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 { ensureCopilotApi } from './copilot-package-manager';
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.
* Uses model mapping for opus/sonnet/haiku tiers if configured.
*/
export function generateCopilotEnv(config: CopilotConfig): Record<string, string> {
// Use mapped models if configured, otherwise fall back to default model
const opusModel = config.opus_model || config.model;
const sonnetModel = config.sonnet_model || config.model;
const haikuModel = config.haiku_model || config.model;
// Use 127.0.0.1 instead of localhost for more reliable local connections
// (bypasses DNS resolution and potential IPv6 issues)
return {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${config.port}`,
ANTHROPIC_AUTH_TOKEN: 'dummy', // copilot-api handles auth internally
ANTHROPIC_MODEL: config.model,
// Model tier mapping - allows different models for opus/sonnet/haiku
ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel,
ANTHROPIC_SMALL_FAST_MODEL: haikuModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel,
// 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> {
// Ensure copilot-api is installed (auto-install if missing, auto-update if outdated)
try {
await ensureCopilotApi();
} catch (error) {
console.error('[X] Failed to install copilot-api.');
console.error('');
console.error(`Error: ${(error as Error).message}`);
console.error('');
console.error('Try installing manually:');
console.error(' npm install -g copilot-api');
return 1;
}
// Check if copilot-api is installed (should be after ensureCopilotApi)
if (!isCopilotApiInstalled()) {
console.error('[X] copilot-api is not installed.');
console.error('');
console.error('Install with: ccs copilot --install');
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);
});
});
}
+248
View File
@@ -0,0 +1,248 @@
/**
* Copilot Model Catalog
*
* Manages available models from copilot-api.
* Based on GitHub Copilot supported models:
* https://docs.github.com/copilot/reference/ai-models/supported-models
*/
import * as http from 'http';
import { CopilotModel } from './types';
/**
* Default models available through copilot-api.
* Used as fallback when API is not reachable.
* Source: GitHub Copilot Supported Models (Dec 2025)
*
* Plan tiers: free, pro, pro+, business, enterprise
* Multipliers: 0 = free tier, 0.25-0.33 = cheap, 1 = standard, 3-10 = premium
*/
export const DEFAULT_COPILOT_MODELS: CopilotModel[] = [
// Anthropic Models - All require paid Copilot subscription
{
id: 'claude-sonnet-4.5',
name: 'Claude Sonnet 4.5',
provider: 'anthropic',
minPlan: 'pro',
multiplier: 1,
},
{
id: 'claude-sonnet-4',
name: 'Claude Sonnet 4',
provider: 'anthropic',
minPlan: 'pro',
multiplier: 1,
},
{
id: 'claude-opus-4.5',
name: 'Claude Opus 4.5',
provider: 'anthropic',
minPlan: 'pro',
multiplier: 3,
preview: true,
},
{
id: 'claude-opus-4.1',
name: 'Claude Opus 4.1',
provider: 'anthropic',
minPlan: 'pro',
multiplier: 10,
},
{
id: 'claude-haiku-4.5',
name: 'Claude Haiku 4.5',
provider: 'anthropic',
minPlan: 'pro', // Requires paid Copilot subscription
multiplier: 0.33,
},
// OpenAI Models
{
id: 'gpt-5.2',
name: 'GPT-5.2',
provider: 'openai',
minPlan: 'pro',
multiplier: 1,
preview: true,
},
{
id: 'gpt-5.1-codex-max',
name: 'GPT-5.1 Codex Max',
provider: 'openai',
minPlan: 'pro',
multiplier: 1,
},
{ id: 'gpt-5.1-codex', name: 'GPT-5.1 Codex', provider: 'openai', minPlan: 'pro', multiplier: 1 },
{
id: 'gpt-5.1-codex-mini',
name: 'GPT-5.1 Codex Mini',
provider: 'openai',
minPlan: 'pro',
multiplier: 0.33,
preview: true,
},
{ id: 'gpt-5.1', name: 'GPT-5.1', provider: 'openai', minPlan: 'pro', multiplier: 1 },
{
id: 'gpt-5-codex',
name: 'GPT-5 Codex',
provider: 'openai',
minPlan: 'pro',
multiplier: 1,
preview: true,
},
{ id: 'gpt-5', name: 'GPT-5', provider: 'openai', minPlan: 'pro', multiplier: 1 },
{ id: 'gpt-5-mini', name: 'GPT-5 Mini', provider: 'openai', minPlan: 'free', multiplier: 0 },
{
id: 'gpt-4.1',
name: 'GPT-4.1',
provider: 'openai',
isDefault: true,
minPlan: 'free',
multiplier: 0,
},
// Google Models
{
id: 'gemini-3-pro',
name: 'Gemini 3 Pro',
provider: 'openai',
minPlan: 'pro',
multiplier: 1,
preview: true,
},
{
id: 'gemini-3-flash',
name: 'Gemini 3 Flash',
provider: 'openai',
minPlan: 'pro',
multiplier: 0.33,
preview: true,
},
{
id: 'gemini-2.5-pro',
name: 'Gemini 2.5 Pro',
provider: 'openai',
minPlan: 'pro',
multiplier: 1,
},
// xAI Models
{
id: 'grok-code-fast-1',
name: 'Grok Code Fast 1',
provider: 'openai',
minPlan: 'pro',
multiplier: 0.25,
},
// Fine-tuned Models
{
id: 'raptor-mini',
name: 'Raptor Mini',
provider: 'openai',
minPlan: 'free',
multiplier: 0,
preview: true,
},
];
/**
* 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(
{
// Use 127.0.0.1 instead of localhost for more reliable local connections
hostname: '127.0.0.1',
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: detectProvider(m.id),
isDefault: m.id === 'gpt-4.1', // Free tier default
}));
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.
* Uses gpt-4.1 as it's available on free tier.
*/
export function getDefaultModel(): string {
return 'gpt-4.1';
}
/**
* Detect provider from model ID.
*/
function detectProvider(modelId: string): 'openai' | 'anthropic' {
if (modelId.includes('claude')) return 'anthropic';
return 'openai';
}
/**
* Format model ID to human-readable name.
* Includes badges for preview and plan tier.
*/
function formatModelName(modelId: string): string {
// Find model in catalog for metadata
const model = DEFAULT_COPILOT_MODELS.find((m) => m.id === modelId);
if (model) {
let name = model.name;
if (model.preview) name += ' (Preview)';
return name;
}
// Fallback: convert kebab-case to title case
return modelId
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
+515
View File
@@ -0,0 +1,515 @@
/**
* Copilot Package Manager
*
* Self-managed npm package installation for copilot-api:
* - Installs copilot-api locally to ~/.ccs/copilot/
* - Auto-updates to latest version (like CLIProxy binary manager)
* - Version caching to avoid hitting npm registry on every run
* - Retry logic with exponential backoff
*
* Pattern: Mirrors CLIProxy BinaryManager but for npm packages
*/
import * as fs from 'fs';
import * as path from 'path';
import { spawn, spawnSync } from 'child_process';
import { ProgressIndicator } from '../utils/progress-indicator';
import { ok, info } from '../utils/ui';
import { getCcsDir } from '../utils/config-manager';
/** Cache duration for version check (1 hour in milliseconds) */
const VERSION_CACHE_DURATION_MS = 60 * 60 * 1000;
/** Version pin file name */
const VERSION_PIN_FILE = '.version-pin';
/** Package name to install */
const COPILOT_API_PACKAGE = 'copilot-api';
/** Version cache file structure */
interface VersionCache {
latestVersion: string;
checkedAt: number;
}
/** Update check result */
interface UpdateCheckResult {
hasUpdate: boolean;
currentVersion: string;
latestVersion: string;
fromCache: boolean;
}
/** Install result */
interface InstallResult {
success: boolean;
version?: string;
error?: string;
}
/**
* Get copilot base directory
* All copilot-related files are stored under ~/.ccs/copilot/
*/
export function getCopilotDir(): string {
return path.join(getCcsDir(), 'copilot');
}
/**
* Get path to copilot-api binary
*/
export function getCopilotApiBinPath(): string {
const binName = process.platform === 'win32' ? 'copilot-api.cmd' : 'copilot-api';
return path.join(getCopilotDir(), 'node_modules', '.bin', binName);
}
/**
* Get path to package.json
*/
function getPackageJsonPath(): string {
return path.join(getCopilotDir(), 'package.json');
}
/**
* Get path to version file
*/
function getVersionFilePath(): string {
return path.join(getCopilotDir(), '.version');
}
/**
* Get path to version cache file
*/
function getVersionCachePath(): string {
return path.join(getCopilotDir(), '.version-cache.json');
}
/**
* Get path to version pin file
*/
export function getVersionPinPath(): string {
return path.join(getCopilotDir(), VERSION_PIN_FILE);
}
/**
* Check if copilot-api is installed locally
*/
export function isCopilotApiInstalled(): boolean {
return fs.existsSync(getCopilotApiBinPath());
}
/**
* Get installed version from .version file
*/
export function getInstalledVersion(): string | null {
const versionFile = getVersionFilePath();
if (fs.existsSync(versionFile)) {
try {
return fs.readFileSync(versionFile, 'utf8').trim();
} catch {
return null;
}
}
return null;
}
/**
* Save installed version to file
*/
function saveInstalledVersion(version: string): void {
const versionFile = getVersionFilePath();
try {
fs.writeFileSync(versionFile, version, 'utf8');
} catch {
// Silent fail - not critical
}
}
/**
* Get cached latest version if still valid
*/
function getCachedLatestVersion(): string | null {
const cachePath = getVersionCachePath();
if (!fs.existsSync(cachePath)) {
return null;
}
try {
const content = fs.readFileSync(cachePath, 'utf8');
const cache: VersionCache = JSON.parse(content);
// Check if cache is still valid
if (Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) {
return cache.latestVersion;
}
// Cache expired
return null;
} catch {
return null;
}
}
/**
* Cache latest version for future checks
*/
function cacheLatestVersion(version: string): void {
const cachePath = getVersionCachePath();
const cache: VersionCache = {
latestVersion: version,
checkedAt: Date.now(),
};
try {
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
fs.writeFileSync(cachePath, JSON.stringify(cache), 'utf8');
} catch {
// Silent fail - caching is optional
}
}
/**
* Fetch latest version from npm registry
*/
async function fetchLatestVersion(): Promise<string> {
return new Promise((resolve, reject) => {
const result = spawnSync('npm', ['view', COPILOT_API_PACKAGE, 'version'], {
encoding: 'utf8',
timeout: 15000,
shell: process.platform === 'win32',
});
if (result.status === 0 && result.stdout) {
resolve(result.stdout.trim());
} else {
reject(new Error(result.stderr || 'Failed to fetch version from npm'));
}
});
}
/**
* Compare semver versions (true if latest > current)
*/
function isNewerVersion(latest: string, current: string): boolean {
const latestParts = latest.split('.').map((p) => parseInt(p, 10) || 0);
const currentParts = current.split('.').map((p) => parseInt(p, 10) || 0);
// Pad arrays to same length
while (latestParts.length < 3) latestParts.push(0);
while (currentParts.length < 3) currentParts.push(0);
for (let i = 0; i < 3; i++) {
if (latestParts[i] > currentParts[i]) return true;
if (latestParts[i] < currentParts[i]) return false;
}
return false; // Equal versions
}
/**
* Get pinned version if one exists
*/
export function getPinnedVersion(): string | null {
const pinPath = getVersionPinPath();
if (!fs.existsSync(pinPath)) {
return null;
}
try {
return fs.readFileSync(pinPath, 'utf8').trim();
} catch {
return null;
}
}
/**
* Save pinned version
*/
export function savePinnedVersion(version: string): void {
const pinPath = getVersionPinPath();
try {
fs.mkdirSync(path.dirname(pinPath), { recursive: true });
fs.writeFileSync(pinPath, version, 'utf8');
} catch {
// Silent fail
}
}
/**
* Clear pinned version
*/
export function clearPinnedVersion(): void {
const pinPath = getVersionPinPath();
if (fs.existsSync(pinPath)) {
try {
fs.unlinkSync(pinPath);
} catch {
// Silent fail
}
}
}
/**
* Check for updates
*/
export async function checkForUpdates(): Promise<UpdateCheckResult> {
const currentVersion = getInstalledVersion() || '0.0.0';
// Try cache first
const cachedVersion = getCachedLatestVersion();
if (cachedVersion) {
return {
hasUpdate: isNewerVersion(cachedVersion, currentVersion),
currentVersion,
latestVersion: cachedVersion,
fromCache: true,
};
}
// Fetch from npm registry
try {
const latestVersion = await fetchLatestVersion();
cacheLatestVersion(latestVersion);
return {
hasUpdate: isNewerVersion(latestVersion, currentVersion),
currentVersion,
latestVersion,
fromCache: false,
};
} catch {
// Return no update if fetch fails
return {
hasUpdate: false,
currentVersion,
latestVersion: currentVersion,
fromCache: false,
};
}
}
/**
* Run npm install in copilot directory
*/
async function runNpmInstall(version?: string): Promise<InstallResult> {
const copilotDir = getCopilotDir();
// Ensure directory exists
fs.mkdirSync(copilotDir, { recursive: true });
// Create package.json with versioned or latest dependency
const packageJson = {
name: 'ccs-copilot-local',
private: true,
dependencies: {
[COPILOT_API_PACKAGE]: version || 'latest',
},
};
fs.writeFileSync(getPackageJsonPath(), JSON.stringify(packageJson, null, 2), 'utf8');
return new Promise((resolve) => {
const proc = spawn('npm', ['install', '--prefer-offline', '--no-audit', '--no-fund'], {
cwd: copilotDir,
stdio: ['ignore', 'pipe', 'pipe'],
shell: process.platform === 'win32',
});
let stderr = '';
proc.stderr?.on('data', (data) => {
stderr += data.toString();
});
proc.on('close', (code) => {
if (code === 0) {
// Get installed version from package-lock.json or package.json
try {
const lockPath = path.join(copilotDir, 'package-lock.json');
if (fs.existsSync(lockPath)) {
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
const installedVersion =
lock.packages?.['node_modules/copilot-api']?.version ||
lock.dependencies?.[COPILOT_API_PACKAGE]?.version;
if (installedVersion) {
saveInstalledVersion(installedVersion);
resolve({ success: true, version: installedVersion });
return;
}
}
} catch {
// Fallback
}
resolve({ success: true, version: version || 'latest' });
} else {
resolve({ success: false, error: stderr || `npm install failed with code ${code}` });
}
});
proc.on('error', (err) => {
resolve({ success: false, error: err.message });
});
});
}
/**
* Ensure copilot-api is available (install if missing, update if outdated)
* @returns Path to copilot-api binary
*/
export async function ensureCopilotApi(verbose = false): Promise<string> {
const binPath = getCopilotApiBinPath();
const pinnedVersion = getPinnedVersion();
// Check if already installed
if (isCopilotApiInstalled()) {
if (verbose) {
console.error(`[copilot] Binary exists: ${binPath}`);
}
// Skip auto-update if version is pinned
if (pinnedVersion) {
if (verbose) {
console.error(`[copilot] Pinned version mode: skipping auto-update`);
}
return binPath;
}
// Check for updates (non-blocking for UX)
try {
const updateResult = await checkForUpdates();
if (updateResult.hasUpdate) {
console.log(
info(
`copilot-api update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}`
)
);
console.log(info('Updating copilot-api...'));
const spinner = new ProgressIndicator('Updating copilot-api');
spinner.start();
const result = await runNpmInstall(updateResult.latestVersion);
if (result.success) {
spinner.succeed('copilot-api updated');
console.log(ok(`copilot-api v${result.version} installed successfully`));
} else {
spinner.fail('Update failed (non-blocking)');
if (verbose) {
console.error(`[copilot] Update failed: ${result.error}`);
}
}
}
} catch (error) {
// Silent fail - don't block startup if update check fails
if (verbose) {
console.error(`[copilot] Update check failed: ${(error as Error).message}`);
}
}
return binPath;
}
// Not installed - install now
console.log(info('copilot-api not found, installing...'));
const spinner = new ProgressIndicator('Installing copilot-api');
spinner.start();
try {
// Get target version
let targetVersion: string | undefined;
if (pinnedVersion) {
targetVersion = pinnedVersion;
if (verbose) {
console.error(`[copilot] Using pinned version: ${pinnedVersion}`);
}
} else {
// Fetch latest version
try {
targetVersion = await fetchLatestVersion();
cacheLatestVersion(targetVersion);
} catch {
// Use 'latest' if fetch fails
targetVersion = undefined;
}
}
const result = await runNpmInstall(targetVersion);
if (result.success) {
spinner.succeed('copilot-api installed');
console.log(ok(`copilot-api v${result.version} installed successfully`));
return binPath;
} else {
spinner.fail('Installation failed');
throw new Error(result.error || 'npm install failed');
}
} catch (error) {
spinner.fail('Installation failed');
throw error;
}
}
/**
* Install a specific version of copilot-api
*/
export async function installCopilotApiVersion(version: string, _verbose = false): Promise<void> {
const spinner = new ProgressIndicator(`Installing copilot-api v${version}`);
spinner.start();
try {
const result = await runNpmInstall(version);
if (result.success) {
spinner.succeed('copilot-api installed');
console.log(ok(`copilot-api v${result.version} installed successfully`));
} else {
spinner.fail('Installation failed');
throw new Error(result.error || 'npm install failed');
}
} catch (error) {
spinner.fail('Installation failed');
throw error;
}
}
/**
* Uninstall copilot-api (remove ~/.ccs/copilot directory)
*/
export function uninstallCopilotApi(): void {
const copilotDir = getCopilotDir();
if (fs.existsSync(copilotDir)) {
fs.rmSync(copilotDir, { recursive: true, force: true });
}
}
/**
* Get copilot-api info
*/
export function getCopilotApiInfo(): {
installed: boolean;
version: string | null;
path: string;
pinnedVersion: string | null;
} {
return {
installed: isCopilotApiInstalled(),
version: getInstalledVersion(),
path: getCopilotApiBinPath(),
pinnedVersion: getPinnedVersion(),
};
}
export default {
getCopilotDir,
getCopilotApiBinPath,
isCopilotApiInstalled,
getInstalledVersion,
getPinnedVersion,
savePinnedVersion,
clearPinnedVersion,
checkForUpdates,
ensureCopilotApi,
installCopilotApiVersion,
uninstallCopilotApi,
getCopilotApiInfo,
};
+48
View File
@@ -0,0 +1,48 @@
/**
* Copilot Module Index
*
* Central exports for GitHub Copilot integration via copilot-api.
*/
// Types
export * from './types';
// Package Manager (self-managed installation)
export {
getCopilotDir,
getCopilotApiBinPath,
isCopilotApiInstalled,
getInstalledVersion,
getPinnedVersion,
savePinnedVersion,
clearPinnedVersion,
checkForUpdates,
ensureCopilotApi,
installCopilotApiVersion,
uninstallCopilotApi,
getCopilotApiInfo,
} from './copilot-package-manager';
// Auth
export {
checkAuthStatus,
startAuthFlow,
getCopilotDebugInfo,
hasTokenFile,
getTokenPath,
} from './copilot-auth';
export type { AuthFlowResult } 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';
+70
View File
@@ -0,0 +1,70 @@
/**
* 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 plan tier for model availability.
* Based on GitHub Copilot plans.
*/
export type CopilotPlanTier = 'free' | 'pro' | 'pro+' | 'business' | 'enterprise';
/**
* 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;
/** Minimum plan tier required (free = available to all) */
minPlan?: CopilotPlanTier;
/** Premium request multiplier (0 = free, higher = more expensive) */
multiplier?: number;
/** Whether this model is in preview */
preview?: boolean;
}
/**
* Copilot debug info from `copilot-api debug --json`.
*/
export interface CopilotDebugInfo {
version?: string;
runtime?: string;
authenticated?: boolean;
tokenPath?: string;
}
+312
View File
@@ -1688,3 +1688,315 @@ apiRoutes.get('/websearch/status', (_req: Request, res: Response): void => {
res.status(500).json({ error: (error as Error).message });
}
});
// ============================================================================
// COPILOT API ROUTES
// GitHub Copilot integration via copilot-api proxy
// ============================================================================
import {
checkAuthStatus as checkCopilotAuth,
startAuthFlow as startCopilotAuth,
getCopilotStatus,
startDaemon as startCopilotDaemon,
stopDaemon as stopCopilotDaemon,
getAvailableModels as getCopilotModels,
isCopilotApiInstalled,
ensureCopilotApi,
installCopilotApiVersion,
getCopilotApiInfo,
getInstalledVersion as getCopilotInstalledVersion,
} from '../copilot';
import { DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types';
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
/**
* GET /api/copilot/status - Get Copilot status (auth + daemon + install info)
*/
apiRoutes.get('/copilot/status', async (_req: Request, res: Response): Promise<void> => {
try {
const config = loadOrCreateUnifiedConfig();
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
const status = await getCopilotStatus(copilotConfig);
const installed = isCopilotApiInstalled();
const version = getCopilotInstalledVersion();
res.json({
enabled: copilotConfig.enabled,
installed,
version,
authenticated: status.auth.authenticated,
daemon_running: status.daemon.running,
port: copilotConfig.port,
model: copilotConfig.model,
account_type: copilotConfig.account_type,
auto_start: copilotConfig.auto_start,
rate_limit: copilotConfig.rate_limit,
wait_on_limit: copilotConfig.wait_on_limit,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/copilot/config - Get Copilot configuration
*/
apiRoutes.get('/copilot/config', (_req: Request, res: Response): void => {
try {
const config = loadOrCreateUnifiedConfig();
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
res.json(copilotConfig);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* PUT /api/copilot/config - Update Copilot configuration
*/
apiRoutes.put('/copilot/config', (req: Request, res: Response): void => {
try {
const updates = req.body;
const config = loadOrCreateUnifiedConfig();
// Merge updates with existing config
config.copilot = {
enabled: updates.enabled ?? config.copilot?.enabled ?? DEFAULT_COPILOT_CONFIG.enabled,
auto_start:
updates.auto_start ?? config.copilot?.auto_start ?? DEFAULT_COPILOT_CONFIG.auto_start,
port: updates.port ?? config.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port,
account_type:
updates.account_type ?? config.copilot?.account_type ?? DEFAULT_COPILOT_CONFIG.account_type,
rate_limit:
updates.rate_limit !== undefined
? updates.rate_limit
: (config.copilot?.rate_limit ?? DEFAULT_COPILOT_CONFIG.rate_limit),
wait_on_limit:
updates.wait_on_limit ??
config.copilot?.wait_on_limit ??
DEFAULT_COPILOT_CONFIG.wait_on_limit,
model: updates.model ?? config.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model,
// Model mapping for opus/sonnet/haiku tiers
opus_model:
updates.opus_model !== undefined ? updates.opus_model : config.copilot?.opus_model,
sonnet_model:
updates.sonnet_model !== undefined ? updates.sonnet_model : config.copilot?.sonnet_model,
haiku_model:
updates.haiku_model !== undefined ? updates.haiku_model : config.copilot?.haiku_model,
};
saveUnifiedConfig(config);
res.json({ success: true, copilot: config.copilot });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/copilot/auth/start - Start GitHub OAuth flow
* Note: This is a long-running operation that opens browser
*/
apiRoutes.post('/copilot/auth/start', async (_req: Request, res: Response): Promise<void> => {
try {
const result = await startCopilotAuth();
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/copilot/auth/status - Get auth status only
*/
apiRoutes.get('/copilot/auth/status', async (_req: Request, res: Response): Promise<void> => {
try {
const status = await checkCopilotAuth();
res.json(status);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/copilot/models - Get available models
*/
apiRoutes.get('/copilot/models', async (_req: Request, res: Response): Promise<void> => {
try {
const config = loadOrCreateUnifiedConfig();
const port = config.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port;
const currentModel = config.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model;
const models = await getCopilotModels(port);
// Mark current model
const modelsWithCurrent = models.map((m) => ({
...m,
isCurrent: m.id === currentModel,
}));
res.json({ models: modelsWithCurrent, current: currentModel });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/copilot/daemon/start - Start copilot-api daemon
*/
apiRoutes.post('/copilot/daemon/start', async (_req: Request, res: Response): Promise<void> => {
try {
const config = loadOrCreateUnifiedConfig();
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
const result = await startCopilotDaemon(copilotConfig);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/copilot/daemon/stop - Stop copilot-api daemon
*/
apiRoutes.post('/copilot/daemon/stop', async (_req: Request, res: Response): Promise<void> => {
try {
const result = await stopCopilotDaemon();
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/copilot/install - Install copilot-api
* Auto-installs latest version or specific version if provided
*/
apiRoutes.post('/copilot/install', async (req: Request, res: Response): Promise<void> => {
try {
const { version } = req.body || {};
if (version) {
// Install specific version
await installCopilotApiVersion(version);
} else {
// Install latest version
await ensureCopilotApi();
}
const info = getCopilotApiInfo();
res.json({
success: true,
installed: info.installed,
version: info.version,
path: info.path,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/copilot/info - Get copilot-api installation info
*/
apiRoutes.get('/copilot/info', (_req: Request, res: Response): void => {
try {
const info = getCopilotApiInfo();
res.json(info);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/copilot/settings/raw - Get raw copilot.settings.json
* Returns the raw JSON content for editing in the code editor
*/
apiRoutes.get('/copilot/settings/raw', (_req: Request, res: Response): void => {
try {
const settingsPath = path.join(getCcsDir(), 'copilot.settings.json');
const config = loadOrCreateUnifiedConfig();
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
// Default model for all tiers
const defaultModel = copilotConfig.model;
// If file doesn't exist, return default structure with all model mappings
if (!fs.existsSync(settingsPath)) {
// Create settings structure matching CLIProxy pattern - always include all model mappings
// Use 127.0.0.1 instead of localhost for more reliable local connections
const defaultSettings = {
env: {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${copilotConfig.port}`,
ANTHROPIC_AUTH_TOKEN: 'copilot-managed',
ANTHROPIC_MODEL: defaultModel,
ANTHROPIC_DEFAULT_OPUS_MODEL: copilotConfig.opus_model || defaultModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: copilotConfig.sonnet_model || defaultModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: copilotConfig.haiku_model || defaultModel,
},
};
res.json({
settings: defaultSettings,
mtime: Date.now(),
path: `~/.ccs/copilot.settings.json`,
exists: false,
});
return;
}
const content = fs.readFileSync(settingsPath, 'utf-8');
const settings = JSON.parse(content);
const stat = fs.statSync(settingsPath);
res.json({
settings,
mtime: stat.mtimeMs,
path: `~/.ccs/copilot.settings.json`,
exists: true,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* PUT /api/copilot/settings/raw - Save raw copilot.settings.json
* Saves the raw JSON content from the code editor
*/
apiRoutes.put('/copilot/settings/raw', (req: Request, res: Response): void => {
try {
const { settings, expectedMtime } = req.body;
const settingsPath = path.join(getCcsDir(), 'copilot.settings.json');
// Check for conflict if file exists and expectedMtime provided
if (fs.existsSync(settingsPath) && expectedMtime) {
const stat = fs.statSync(settingsPath);
if (Math.abs(stat.mtimeMs - expectedMtime) > 1000) {
res.status(409).json({ error: 'File modified externally', mtime: stat.mtimeMs });
return;
}
}
// Write settings file atomically
const tempPath = settingsPath + '.tmp';
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
fs.renameSync(tempPath, settingsPath);
// Also sync model mappings back to unified config
const config = loadOrCreateUnifiedConfig();
const env = settings.env || {};
config.copilot = {
...(config.copilot ?? DEFAULT_COPILOT_CONFIG),
model: env.ANTHROPIC_MODEL || config.copilot?.model || DEFAULT_COPILOT_CONFIG.model,
opus_model: env.ANTHROPIC_DEFAULT_OPUS_MODEL || undefined,
sonnet_model: env.ANTHROPIC_DEFAULT_SONNET_MODEL || undefined,
haiku_model: env.ANTHROPIC_DEFAULT_HAIKU_MODEL || undefined,
};
saveUnifiedConfig(config);
const stat = fs.statSync(settingsPath);
res.json({ success: true, mtime: stat.mtimeMs });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});