From b87aeaeb01800b11640b14fafa36412d82d0f522 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 17 Dec 2025 21:36:59 -0500 Subject: [PATCH 01/28] feat(config): add copilot configuration types and loader - add CopilotConfig interface with account_type, port, model settings - add DEFAULT_COPILOT_CONFIG with safe opt-in defaults - bump UNIFIED_CONFIG_VERSION from 3 to 4 - add YAML generation for copilot section - add 'copilot' to reserved profile names --- src/config/reserved-names.ts | 2 ++ src/config/unified-config-loader.ts | 32 ++++++++++++++++++ src/config/unified-config-types.ts | 52 +++++++++++++++++++++++++++-- 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/config/reserved-names.ts b/src/config/reserved-names.ts index 657e5d78..25262265 100644 --- a/src/config/reserved-names.ts +++ b/src/config/reserved-names.ts @@ -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', diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 04394694..72f27ceb 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -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 { // 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,27 @@ 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('# WARNING: This uses a reverse-engineered API. Use responsibly.'); + lines.push('# Excessive automated usage may trigger GitHub abuse detection.'); + 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-opus-4-5-20250514, claude-sonnet-4-20250514, gpt-4.1, o3'); + 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'); } diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 75cbfe31..0834ad48 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -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,36 @@ 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. + * + * WARNING: This uses a reverse-engineered API. Excessive automated usage + * may trigger GitHub's abuse-detection systems. + */ +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; + /** Selected model (default: claude-opus-4-5-20250514) */ + model: string; +} + /** * WebSearch configuration. * Uses CLI tools (Gemini CLI, Grok CLI, OpenCode) for third-party profiles. @@ -183,7 +214,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 +228,8 @@ export interface UnifiedConfig { preferences: PreferencesConfig; /** WebSearch configuration */ websearch?: WebSearchConfig; + /** Copilot API configuration (GitHub Copilot proxy) */ + copilot?: CopilotConfig; } /** @@ -211,6 +244,20 @@ export interface SecretsConfig { profiles: Record>; } +/** + * Default Copilot configuration. + * Strictly opt-in - disabled by default. + */ +export const DEFAULT_COPILOT_CONFIG: CopilotConfig = { + enabled: false, + auto_start: false, + port: 4141, + account_type: 'individual', + rate_limit: null, + wait_on_limit: true, + model: 'claude-opus-4-5-20250514', +}; + /** * Create an empty unified config with defaults. */ @@ -253,6 +300,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { }, }, }, + copilot: { ...DEFAULT_COPILOT_CONFIG }, }; } From 3b8a85c9ef1d4a227b8ea77f9c892ba547e92eed Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 17 Dec 2025 21:38:11 -0500 Subject: [PATCH 02/28] 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; +} From e5a1f60bb6f97dba5ccd021087941ddf733292d5 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 17 Dec 2025 21:38:33 -0500 Subject: [PATCH 03/28] feat(auth): add copilot profile detection - add 'copilot' to ProfileType union - detect copilot profile with enabled check - include copilot in listAvailableProfiles() when enabled --- src/auth/profile-detector.ts | 43 ++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index aa537df7..cefa4fbb 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -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; + /** 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) { From d25db1fce10ea9c37b949a8c3c39115f357812d8 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 17 Dec 2025 21:39:01 -0500 Subject: [PATCH 04/28] feat(cli): add copilot CLI commands - add 'ccs copilot' subcommand with auth, status, models, start, stop, enable, disable - route 'ccs copilot' as profile name to copilot executor - integrate copilot command into main CLI entry point --- src/ccs.ts | 18 +++ src/commands/copilot-command.ts | 261 ++++++++++++++++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 src/commands/copilot-command.ts diff --git a/src/ccs.ts b/src/ccs.ts index 3e1c22ef..9dd50056 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -338,6 +338,14 @@ async function main(): Promise { return; } + // Special case: copilot command (GitHub Copilot integration) + if (firstArg === 'copilot' && args.length > 1) { + // `ccs copilot ` - 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'); @@ -384,6 +392,16 @@ async function main(): Promise { 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 diff --git a/src/commands/copilot-command.ts b/src/commands/copilot-command.ts new file mode 100644 index 00000000..5606cb82 --- /dev/null +++ b/src/commands/copilot-command.ts @@ -0,0 +1,261 @@ +/** + * Copilot CLI Command + * + * Handles `ccs copilot ` 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 { + 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 '); + 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('After setup, use: ccs copilot (as profile name)'); + console.log(''); + return 0; +} + +/** + * Handle auth subcommand. + */ +async function handleAuth(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const config = loadOrCreateUnifiedConfig(); + + if (config.copilot) { + config.copilot.enabled = false; + saveUnifiedConfig(config); + } + + console.log('[OK] Copilot integration disabled'); + + return 0; +} From c84db38f6a16598955334fc02e0ccbe50187655f Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 17 Dec 2025 21:39:37 -0500 Subject: [PATCH 05/28] feat(api): add copilot REST API endpoints - GET/PUT /api/copilot/config for configuration - GET /api/copilot/status for integration status - POST /api/copilot/auth/start for OAuth flow - GET /api/copilot/models for available models - POST /api/copilot/daemon/start|stop for daemon control --- src/web-server/routes.ts | 164 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 493cc006..c503ba97 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -1592,3 +1592,167 @@ 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, +} 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) + */ +apiRoutes.get('/copilot/status', async (_req: Request, res: Response): Promise => { + try { + const config = loadOrCreateUnifiedConfig(); + const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG; + const status = await getCopilotStatus(copilotConfig); + const installed = isCopilotApiInstalled(); + + res.json({ + enabled: copilotConfig.enabled, + installed, + 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, + }; + + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + try { + const result = await stopCopilotDaemon(); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); From 6b04532f419622f9bfcd99d9e499445310f850d8 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 17 Dec 2025 21:41:06 -0500 Subject: [PATCH 06/28] feat(ui): add copilot dashboard page - use-copilot.ts: React hook for copilot state management - copilot-status-card.tsx: status display with auth/daemon controls - copilot-config-form.tsx: configuration form with model selector - copilot.tsx: main page combining status and config - add /copilot route and sidebar navigation --- ui/src/App.tsx | 2 + ui/src/components/app-sidebar.tsx | 2 + .../copilot/copilot-config-form.tsx | 233 ++++++++++++++++++ .../copilot/copilot-status-card.tsx | 173 +++++++++++++ ui/src/hooks/use-copilot.ts | 174 +++++++++++++ ui/src/pages/copilot.tsx | 24 ++ 6 files changed, 608 insertions(+) create mode 100644 ui/src/components/copilot/copilot-config-form.tsx create mode 100644 ui/src/components/copilot/copilot-status-card.tsx create mode 100644 ui/src/hooks/use-copilot.ts create mode 100644 ui/src/pages/copilot.tsx diff --git a/ui/src/App.tsx b/ui/src/App.tsx index dc10a280..2604f341 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -21,6 +21,7 @@ const CliproxyPage = lazy(() => const CliproxyControlPanelPage = lazy(() => import('@/pages/cliproxy-control-panel').then((m) => ({ default: m.CliproxyControlPanelPage })) ); +const CopilotPage = lazy(() => import('@/pages/copilot').then((m) => ({ default: m.CopilotPage }))); const AccountsPage = lazy(() => import('@/pages/accounts').then((m) => ({ default: m.AccountsPage })) ); @@ -43,6 +44,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/components/app-sidebar.tsx b/ui/src/components/app-sidebar.tsx index ec12c94c..ccf4ced7 100644 --- a/ui/src/components/app-sidebar.tsx +++ b/ui/src/components/app-sidebar.tsx @@ -10,6 +10,7 @@ import { ChevronRight, BarChart3, Gauge, + Github, } from 'lucide-react'; import { Sidebar, @@ -54,6 +55,7 @@ const navGroups = [ { path: '/cliproxy/control-panel', icon: Gauge, label: 'Control Panel' }, ], }, + { path: '/copilot', icon: Github, label: 'GitHub Copilot' }, { path: '/accounts', icon: Users, diff --git a/ui/src/components/copilot/copilot-config-form.tsx b/ui/src/components/copilot/copilot-config-form.tsx new file mode 100644 index 00000000..8d3e0dcd --- /dev/null +++ b/ui/src/components/copilot/copilot-config-form.tsx @@ -0,0 +1,233 @@ +/** + * Copilot Config Form + * + * Form for configuring GitHub Copilot integration settings. + */ + +import { useState } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useCopilot } from '@/hooks/use-copilot'; +import { Loader2, Save } from 'lucide-react'; +import { toast } from 'sonner'; + +export function CopilotConfigForm() { + const { config, configLoading, models, modelsLoading, updateConfigAsync, isUpdating } = + useCopilot(); + + // Track local overrides for form fields + const [localOverrides, setLocalOverrides] = useState<{ + enabled?: boolean; + autoStart?: boolean; + port?: number; + accountType?: 'individual' | 'business' | 'enterprise'; + model?: string; + rateLimit?: string; + waitOnLimit?: boolean; + }>({}); + + // Use local overrides if set, otherwise use config values + const enabled = localOverrides.enabled ?? config?.enabled ?? false; + const autoStart = localOverrides.autoStart ?? config?.auto_start ?? false; + const port = localOverrides.port ?? config?.port ?? 4141; + const accountType = localOverrides.accountType ?? config?.account_type ?? 'individual'; + const model = localOverrides.model ?? config?.model ?? 'claude-opus-4-5-20250514'; + const rateLimit = localOverrides.rateLimit ?? config?.rate_limit?.toString() ?? ''; + const waitOnLimit = localOverrides.waitOnLimit ?? config?.wait_on_limit ?? true; + + const updateField = ( + key: K, + value: (typeof localOverrides)[K] + ) => { + setLocalOverrides((prev) => ({ ...prev, [key]: value })); + }; + + const handleSave = async () => { + try { + await updateConfigAsync({ + enabled, + auto_start: autoStart, + port, + account_type: accountType, + model, + rate_limit: rateLimit ? parseInt(rateLimit, 10) : null, + wait_on_limit: waitOnLimit, + }); + // Clear local overrides after successful save + setLocalOverrides({}); + toast.success('Copilot configuration has been updated.'); + } catch { + toast.error('Failed to save settings.'); + } + }; + + if (configLoading) { + return ( + + + Configuration + + + + + + ); + } + + return ( + + + Configuration + Configure GitHub Copilot integration settings + + + {/* Enable Toggle */} +
+
+ +

+ Allow using GitHub Copilot subscription with Claude Code +

+
+ updateField('enabled', v)} + /> +
+ + {/* Port */} +
+ + updateField('port', parseInt(e.target.value, 10))} + min={1024} + max={65535} + /> +

+ Local port for copilot-api proxy (default: 4141) +

+
+ + {/* Account Type */} +
+ + +

Your GitHub Copilot subscription type

+
+ + {/* Model */} +
+ + +

+ Model to use via Copilot (default: Claude Opus 4.5) +

+
+ + {/* Rate Limit */} +
+ + updateField('rateLimit', e.target.value)} + placeholder="No limit" + min={0} + /> +

+ Minimum seconds between requests (leave empty for no limit) +

+
+ + {/* Wait on Limit */} +
+
+ +

+ Wait instead of error when rate limit is hit +

+
+ updateField('waitOnLimit', v)} + /> +
+ + {/* Auto Start */} +
+
+ +

+ Automatically start copilot-api when using profile +

+
+ updateField('autoStart', v)} + /> +
+ + {/* Save Button */} + +
+
+ ); +} diff --git a/ui/src/components/copilot/copilot-status-card.tsx b/ui/src/components/copilot/copilot-status-card.tsx new file mode 100644 index 00000000..f2a7866a --- /dev/null +++ b/ui/src/components/copilot/copilot-status-card.tsx @@ -0,0 +1,173 @@ +/** + * Copilot Status Card + * + * Displays GitHub Copilot integration status overview. + */ + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { useCopilot } from '@/hooks/use-copilot'; +import { CheckCircle2, XCircle, AlertTriangle, Loader2 } from 'lucide-react'; + +export function CopilotStatusCard() { + const { + status, + statusLoading, + startAuth, + isAuthenticating, + startDaemon, + isStartingDaemon, + stopDaemon, + isStoppingDaemon, + } = useCopilot(); + + if (statusLoading) { + return ( + + + GitHub Copilot Status + + + + + + ); + } + + if (!status) { + return ( + + + GitHub Copilot Status + + +

Failed to load status

+
+
+ ); + } + + return ( + + + + GitHub Copilot Status + {status.enabled ? ( + Enabled + ) : ( + Disabled + )} + + Use your GitHub Copilot subscription with Claude Code + + + {/* Warning Banner */} +
+ +

+ This uses a reverse-engineered API. Excessive usage may trigger GitHub abuse detection. +

+
+ + {/* Status Grid */} +
+ {/* Installed */} +
+ {status.installed ? ( + + ) : ( + + )} + + copilot-api {status.installed ? 'Installed' : 'Not Installed'} + +
+ + {/* Authenticated */} +
+ {status.authenticated ? ( + + ) : ( + + )} + + {status.authenticated ? 'Authenticated' : 'Not Authenticated'} + +
+ + {/* Daemon */} +
+ {status.daemon_running ? ( + + ) : ( + + )} + Daemon {status.daemon_running ? 'Running' : 'Stopped'} +
+
+ + {/* Quick Info */} +
+ Port: {status.port} + Model: {status.model} + Auto-start: {status.auto_start ? 'Yes' : 'No'} +
+ + {/* Actions */} +
+ {!status.authenticated && ( + + )} + + {status.daemon_running ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/ui/src/hooks/use-copilot.ts b/ui/src/hooks/use-copilot.ts new file mode 100644 index 00000000..b250be50 --- /dev/null +++ b/ui/src/hooks/use-copilot.ts @@ -0,0 +1,174 @@ +/** + * Copilot API Hook + * + * React hook for managing GitHub Copilot integration state. + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +const API_BASE = '/api'; + +// Types +export interface CopilotStatus { + enabled: boolean; + installed: boolean; + authenticated: boolean; + daemon_running: boolean; + port: number; + model: string; + account_type: 'individual' | 'business' | 'enterprise'; + auto_start: boolean; + rate_limit: number | null; + wait_on_limit: boolean; +} + +export interface CopilotConfig { + enabled: boolean; + auto_start: boolean; + port: number; + account_type: 'individual' | 'business' | 'enterprise'; + rate_limit: number | null; + wait_on_limit: boolean; + model: string; +} + +export interface CopilotModel { + id: string; + name: string; + provider: 'openai' | 'anthropic'; + isDefault?: boolean; + isCurrent?: boolean; +} + +// API functions +async function fetchCopilotStatus(): Promise { + const res = await fetch(`${API_BASE}/copilot/status`); + if (!res.ok) throw new Error('Failed to fetch copilot status'); + return res.json(); +} + +async function fetchCopilotConfig(): Promise { + const res = await fetch(`${API_BASE}/copilot/config`); + if (!res.ok) throw new Error('Failed to fetch copilot config'); + return res.json(); +} + +async function fetchCopilotModels(): Promise<{ models: CopilotModel[]; current: string }> { + const res = await fetch(`${API_BASE}/copilot/models`); + if (!res.ok) throw new Error('Failed to fetch copilot models'); + return res.json(); +} + +async function updateCopilotConfig(config: Partial): Promise<{ success: boolean }> { + const res = await fetch(`${API_BASE}/copilot/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }); + if (!res.ok) throw new Error('Failed to update copilot config'); + return res.json(); +} + +async function startCopilotAuth(): Promise<{ success: boolean; error?: string }> { + const res = await fetch(`${API_BASE}/copilot/auth/start`, { method: 'POST' }); + if (!res.ok) throw new Error('Failed to start auth'); + return res.json(); +} + +async function startCopilotDaemon(): Promise<{ success: boolean; pid?: number; error?: string }> { + const res = await fetch(`${API_BASE}/copilot/daemon/start`, { method: 'POST' }); + if (!res.ok) throw new Error('Failed to start daemon'); + return res.json(); +} + +async function stopCopilotDaemon(): Promise<{ success: boolean; error?: string }> { + const res = await fetch(`${API_BASE}/copilot/daemon/stop`, { method: 'POST' }); + if (!res.ok) throw new Error('Failed to stop daemon'); + return res.json(); +} + +// Hook +export function useCopilot() { + const queryClient = useQueryClient(); + + // Queries + const statusQuery = useQuery({ + queryKey: ['copilot-status'], + queryFn: fetchCopilotStatus, + refetchInterval: 5000, // Refresh every 5 seconds + }); + + const configQuery = useQuery({ + queryKey: ['copilot-config'], + queryFn: fetchCopilotConfig, + }); + + const modelsQuery = useQuery({ + queryKey: ['copilot-models'], + queryFn: fetchCopilotModels, + }); + + // Mutations + const updateConfigMutation = useMutation({ + mutationFn: updateCopilotConfig, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['copilot-status'] }); + queryClient.invalidateQueries({ queryKey: ['copilot-config'] }); + }, + }); + + const startAuthMutation = useMutation({ + mutationFn: startCopilotAuth, + onSuccess: () => { + // Delay refetch to allow auth to complete + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ['copilot-status'] }); + }, 2000); + }, + }); + + const startDaemonMutation = useMutation({ + mutationFn: startCopilotDaemon, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['copilot-status'] }); + }, + }); + + const stopDaemonMutation = useMutation({ + mutationFn: stopCopilotDaemon, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['copilot-status'] }); + }, + }); + + return { + // Status + status: statusQuery.data, + statusLoading: statusQuery.isLoading, + statusError: statusQuery.error, + refetchStatus: statusQuery.refetch, + + // Config + config: configQuery.data, + configLoading: configQuery.isLoading, + + // Models + models: modelsQuery.data?.models ?? [], + currentModel: modelsQuery.data?.current, + modelsLoading: modelsQuery.isLoading, + + // Mutations + updateConfig: updateConfigMutation.mutate, + updateConfigAsync: updateConfigMutation.mutateAsync, + isUpdating: updateConfigMutation.isPending, + + startAuth: startAuthMutation.mutate, + isAuthenticating: startAuthMutation.isPending, + + startDaemon: startDaemonMutation.mutate, + isStartingDaemon: startDaemonMutation.isPending, + + stopDaemon: stopDaemonMutation.mutate, + isStoppingDaemon: stopDaemonMutation.isPending, + }; +} diff --git a/ui/src/pages/copilot.tsx b/ui/src/pages/copilot.tsx new file mode 100644 index 00000000..3e990050 --- /dev/null +++ b/ui/src/pages/copilot.tsx @@ -0,0 +1,24 @@ +/** + * Copilot Page + * + * GitHub Copilot integration settings page. + */ + +import { CopilotStatusCard } from '@/components/copilot/copilot-status-card'; +import { CopilotConfigForm } from '@/components/copilot/copilot-config-form'; + +export function CopilotPage() { + return ( +
+
+

GitHub Copilot

+

+ Use your GitHub Copilot subscription with Claude Code +

+
+ + + +
+ ); +} From 2145e62ddd18986a1e5f15001e79b80ec21fd80d Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 17 Dec 2025 22:15:19 -0500 Subject: [PATCH 07/28] docs(help): add GitHub Copilot section to --help output - add new major section for copilot integration - document all copilot subcommands (auth, status, models, start, stop, enable, disable) - show prerequisite: npm install -g copilot-api --- src/commands/help-command.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 350e7d9b..618dfd23 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -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) // ═══════════════════════════════════════════════════════════════════════════ From d21908ab63aca6e96f14f6e0c78eb882de1177b9 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 00:19:11 -0500 Subject: [PATCH 08/28] feat(copilot): add model tier mapping support - add opus_model, sonnet_model, haiku_model to CopilotConfig - update generateCopilotEnv to use tier-specific models - fall back to default model when tier not configured --- src/config/unified-config-types.ts | 6 +++++- src/copilot/copilot-executor.ts | 14 +++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 0834ad48..4653f3a2 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -175,8 +175,12 @@ export interface CopilotConfig { rate_limit: number | null; /** Wait instead of error when rate limit is hit (default: true) */ wait_on_limit: boolean; - /** Selected model (default: claude-opus-4-5-20250514) */ + /** Default model ID (e.g., claude-opus-4-5-20250514) */ model: string; + /** Model mapping for Claude tiers - maps opus/sonnet/haiku to specific models */ + opus_model?: string; + sonnet_model?: string; + haiku_model?: string; } /** diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts index da86d3cc..77fd7623 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -31,15 +31,23 @@ export async function getCopilotStatus(config: CopilotConfig): Promise { + // 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; + 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, + // 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', From a3e2153498ac8a1a9b84319f6b0835fa8f085b3d Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 00:19:32 -0500 Subject: [PATCH 09/28] feat(copilot): add raw settings API and model mapping routes - add GET/PUT /api/copilot/settings/raw for copilot.settings.json - update PUT /api/copilot/config to handle model mapping fields - sync model mappings between settings file and unified config --- src/web-server/routes.ts | 101 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index c503ba97..30e9d4a0 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -1675,6 +1675,13 @@ apiRoutes.put('/copilot/config', (req: Request, res: Response): void => { 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); @@ -1756,3 +1763,97 @@ apiRoutes.post('/copilot/daemon/stop', async (_req: Request, res: Response): Pro 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 + const defaultSettings = { + env: { + ANTHROPIC_BASE_URL: `http://localhost:${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 }); + } +}); From 882792a4fbdd48b81840b4bb3f093831fe60de76 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 00:21:33 -0500 Subject: [PATCH 10/28] feat(copilot): add raw settings support to useCopilot hook Add CopilotRawSettings interface and API functions for reading/writing copilot.settings.json with conflict detection via expectedMtime. --- ui/src/hooks/use-copilot.ts | 57 +++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/ui/src/hooks/use-copilot.ts b/ui/src/hooks/use-copilot.ts index b250be50..66d2b6fa 100644 --- a/ui/src/hooks/use-copilot.ts +++ b/ui/src/hooks/use-copilot.ts @@ -30,6 +30,10 @@ export interface CopilotConfig { rate_limit: number | null; wait_on_limit: boolean; model: string; + // Model mapping for Claude tiers + opus_model?: string; + sonnet_model?: string; + haiku_model?: string; } export interface CopilotModel { @@ -40,6 +44,15 @@ export interface CopilotModel { isCurrent?: boolean; } +export interface CopilotRawSettings { + settings: { + env?: Record; + }; + mtime: number; + path: string; + exists: boolean; +} + // API functions async function fetchCopilotStatus(): Promise { const res = await fetch(`${API_BASE}/copilot/status`); @@ -59,6 +72,12 @@ async function fetchCopilotModels(): Promise<{ models: CopilotModel[]; current: return res.json(); } +async function fetchCopilotRawSettings(): Promise { + const res = await fetch(`${API_BASE}/copilot/settings/raw`); + if (!res.ok) throw new Error('Failed to fetch copilot raw settings'); + return res.json(); +} + async function updateCopilotConfig(config: Partial): Promise<{ success: boolean }> { const res = await fetch(`${API_BASE}/copilot/config`, { method: 'PUT', @@ -69,6 +88,20 @@ async function updateCopilotConfig(config: Partial): Promise<{ su return res.json(); } +async function saveCopilotRawSettings(data: { + settings: CopilotRawSettings['settings']; + expectedMtime?: number; +}): Promise<{ success: boolean; mtime: number }> { + const res = await fetch(`${API_BASE}/copilot/settings/raw`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (res.status === 409) throw new Error('CONFLICT'); + if (!res.ok) throw new Error('Failed to save copilot raw settings'); + return res.json(); +} + async function startCopilotAuth(): Promise<{ success: boolean; error?: string }> { const res = await fetch(`${API_BASE}/copilot/auth/start`, { method: 'POST' }); if (!res.ok) throw new Error('Failed to start auth'); @@ -108,12 +141,27 @@ export function useCopilot() { queryFn: fetchCopilotModels, }); + const rawSettingsQuery = useQuery({ + queryKey: ['copilot-raw-settings'], + queryFn: fetchCopilotRawSettings, + }); + // Mutations const updateConfigMutation = useMutation({ mutationFn: updateCopilotConfig, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['copilot-status'] }); queryClient.invalidateQueries({ queryKey: ['copilot-config'] }); + queryClient.invalidateQueries({ queryKey: ['copilot-raw-settings'] }); + }, + }); + + const saveRawSettingsMutation = useMutation({ + mutationFn: saveCopilotRawSettings, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['copilot-status'] }); + queryClient.invalidateQueries({ queryKey: ['copilot-config'] }); + queryClient.invalidateQueries({ queryKey: ['copilot-raw-settings'] }); }, }); @@ -157,11 +205,20 @@ export function useCopilot() { currentModel: modelsQuery.data?.current, modelsLoading: modelsQuery.isLoading, + // Raw Settings + rawSettings: rawSettingsQuery.data, + rawSettingsLoading: rawSettingsQuery.isLoading, + refetchRawSettings: rawSettingsQuery.refetch, + // Mutations updateConfig: updateConfigMutation.mutate, updateConfigAsync: updateConfigMutation.mutateAsync, isUpdating: updateConfigMutation.isPending, + saveRawSettings: saveRawSettingsMutation.mutate, + saveRawSettingsAsync: saveRawSettingsMutation.mutateAsync, + isSavingRawSettings: saveRawSettingsMutation.isPending, + startAuth: startAuthMutation.mutate, isAuthenticating: startAuthMutation.isPending, From 7886259c363914224b6717ff4d4a74104141d696 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 00:22:14 -0500 Subject: [PATCH 11/28] feat(copilot): redesign config form to match CLIProxy pattern - Split-view layout (40% left / 60% right) matching CLIProxy design - Left panel: Model Config tab with FlexibleModelSelector components, Settings tab, and Info tab - Right panel: Raw JSON editor for copilot.settings.json - Add preset buttons for quick model tier mapping - Bidirectional sync between UI and raw JSON - Simplify copilot.tsx page layout with narrower sidebar --- .../copilot/copilot-config-form.tsx | 774 ++++++++++++++---- ui/src/pages/copilot.tsx | 277 ++++++- 2 files changed, 875 insertions(+), 176 deletions(-) diff --git a/ui/src/components/copilot/copilot-config-form.tsx b/ui/src/components/copilot/copilot-config-form.tsx index 8d3e0dcd..cb9a37e3 100644 --- a/ui/src/components/copilot/copilot-config-form.tsx +++ b/ui/src/components/copilot/copilot-config-form.tsx @@ -2,28 +2,129 @@ * Copilot Config Form * * Form for configuring GitHub Copilot integration settings. + * Split-view layout matching CLIProxy provider editor: + * - Left: Friendly UI with model mapping selectors + * - Right: Raw JSON editor for copilot.settings.json */ -import { useState } from 'react'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { useState, useMemo, useCallback, lazy, Suspense } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; +import { Separator } from '@/components/ui/separator'; +import { Skeleton } from '@/components/ui/skeleton'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { CopyButton } from '@/components/ui/copy-button'; +import { Badge } from '@/components/ui/badge'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, + SelectGroup, + SelectLabel, } from '@/components/ui/select'; import { useCopilot } from '@/hooks/use-copilot'; -import { Loader2, Save } from 'lucide-react'; +import { Loader2, Save, Code2, X, Info, RefreshCw, Sparkles, Zap, Check } from 'lucide-react'; import { toast } from 'sonner'; +import { ConfirmDialog } from '@/components/confirm-dialog'; + +// Lazy load CodeEditor +const CodeEditor = lazy(() => + import('@/components/code-editor').then((m) => ({ default: m.CodeEditor })) +); + +// Model presets for quick configuration +const MODEL_PRESETS = [ + { + name: 'Claude Opus 4.5', + default: 'claude-opus-4-5-20250514', + opus: 'claude-opus-4-5-20250514', + sonnet: 'claude-sonnet-4-20250514', + haiku: 'claude-haiku-3-5-20250514', + }, + { + name: 'Claude Sonnet 4', + default: 'claude-sonnet-4-20250514', + opus: 'claude-sonnet-4-20250514', + sonnet: 'claude-sonnet-4-20250514', + haiku: 'claude-haiku-3-5-20250514', + }, + { + name: 'GPT-4o', + default: 'gpt-4o', + opus: 'gpt-4o', + sonnet: 'gpt-4o', + haiku: 'gpt-4o-mini', + }, +]; + +interface FlexibleModelSelectorProps { + label: string; + description?: string; + value: string | undefined; + onChange: (model: string) => void; + models: { id: string }[]; + disabled?: boolean; +} + +function FlexibleModelSelector({ + label, + description, + value, + onChange, + models, + disabled, +}: FlexibleModelSelectorProps) { + return ( +
+
+ + {description &&

{description}

} +
+ +
+ ); +} export function CopilotConfigForm() { - const { config, configLoading, models, modelsLoading, updateConfigAsync, isUpdating } = - useCopilot(); + const { + config, + configLoading, + models, + modelsLoading, + rawSettings, + rawSettingsLoading, + updateConfigAsync, + isUpdating, + saveRawSettingsAsync, + isSavingRawSettings, + refetchRawSettings, + } = useCopilot(); // Track local overrides for form fields const [localOverrides, setLocalOverrides] = useState<{ @@ -34,16 +135,26 @@ export function CopilotConfigForm() { model?: string; rateLimit?: string; waitOnLimit?: boolean; + opusModel?: string; + sonnetModel?: string; + haikuModel?: string; }>({}); + // Raw JSON editor state + const [rawJsonEdits, setRawJsonEdits] = useState(null); + const [conflictDialog, setConflictDialog] = useState(false); + // Use local overrides if set, otherwise use config values const enabled = localOverrides.enabled ?? config?.enabled ?? false; const autoStart = localOverrides.autoStart ?? config?.auto_start ?? false; const port = localOverrides.port ?? config?.port ?? 4141; const accountType = localOverrides.accountType ?? config?.account_type ?? 'individual'; - const model = localOverrides.model ?? config?.model ?? 'claude-opus-4-5-20250514'; + const currentModel = localOverrides.model ?? config?.model ?? 'claude-opus-4-5-20250514'; const rateLimit = localOverrides.rateLimit ?? config?.rate_limit?.toString() ?? ''; const waitOnLimit = localOverrides.waitOnLimit ?? config?.wait_on_limit ?? true; + const opusModel = localOverrides.opusModel ?? config?.opus_model ?? ''; + const sonnetModel = localOverrides.sonnetModel ?? config?.sonnet_model ?? ''; + const haikuModel = localOverrides.haikuModel ?? config?.haiku_model ?? ''; const updateField = ( key: K, @@ -52,182 +163,521 @@ export function CopilotConfigForm() { setLocalOverrides((prev) => ({ ...prev, [key]: value })); }; + // Batch update for presets + const applyPreset = (preset: (typeof MODEL_PRESETS)[0]) => { + setLocalOverrides((prev) => ({ + ...prev, + model: preset.default, + opusModel: preset.opus, + sonnetModel: preset.sonnet, + haikuModel: preset.haiku, + })); + toast.success(`Applied "${preset.name}" preset`); + }; + + // Raw JSON content + const rawJsonContent = useMemo(() => { + if (rawJsonEdits !== null) return rawJsonEdits; + if (rawSettings?.settings) return JSON.stringify(rawSettings.settings, null, 2); + return '{\n "env": {}\n}'; + }, [rawJsonEdits, rawSettings]); + + const handleRawJsonChange = useCallback((value: string) => { + setRawJsonEdits(value); + }, []); + + // Check if JSON is valid + const isRawJsonValid = useMemo(() => { + try { + JSON.parse(rawJsonContent); + return true; + } catch { + return false; + } + }, [rawJsonContent]); + + // Check for unsaved changes + const hasChanges = useMemo(() => { + const hasLocalChanges = Object.keys(localOverrides).length > 0; + const hasJsonChanges = + rawJsonEdits !== null && rawJsonEdits !== JSON.stringify(rawSettings?.settings, null, 2); + return hasLocalChanges || hasJsonChanges; + }, [localOverrides, rawJsonEdits, rawSettings]); + const handleSave = async () => { try { - await updateConfigAsync({ - enabled, - auto_start: autoStart, - port, - account_type: accountType, - model, - rate_limit: rateLimit ? parseInt(rateLimit, 10) : null, - wait_on_limit: waitOnLimit, - }); - // Clear local overrides after successful save + // Save config changes + if (Object.keys(localOverrides).length > 0) { + await updateConfigAsync({ + enabled, + auto_start: autoStart, + port, + account_type: accountType, + model: currentModel, + rate_limit: rateLimit ? parseInt(rateLimit, 10) : null, + wait_on_limit: waitOnLimit, + opus_model: opusModel || undefined, + sonnet_model: sonnetModel || undefined, + haiku_model: haikuModel || undefined, + }); + } + + // Save raw JSON changes + if (rawJsonEdits !== null && isRawJsonValid) { + const settingsToSave = JSON.parse(rawJsonContent); + await saveRawSettingsAsync({ + settings: settingsToSave, + expectedMtime: rawSettings?.mtime, + }); + } + + // Clear local state setLocalOverrides({}); - toast.success('Copilot configuration has been updated.'); - } catch { - toast.error('Failed to save settings.'); + setRawJsonEdits(null); + toast.success('Copilot configuration saved'); + } catch (error) { + if ((error as Error).message === 'CONFLICT') { + setConflictDialog(true); + } else { + toast.error('Failed to save settings'); + } } }; - if (configLoading) { + const handleConflictResolve = async (overwrite: boolean) => { + setConflictDialog(false); + if (overwrite) { + await refetchRawSettings(); + handleSave(); + } else { + setRawJsonEdits(null); + } + }; + + if (configLoading || rawSettingsLoading) { return ( - - - Configuration - - - - - +
+ + + + +
); } - return ( - - - Configuration - Configure GitHub Copilot integration settings - - - {/* Enable Toggle */} -
-
- -

- Allow using GitHub Copilot subscription with Claude Code -

-
- updateField('enabled', v)} - /> + // Left column: Friendly UI + const renderFriendlyUI = () => ( +
+ +
+ + + Model Config + + + Settings + + + Info + +
- {/* Port */} -
- - updateField('port', parseInt(e.target.value, 10))} - min={1024} - max={65535} - /> -

- Local port for copilot-api proxy (default: 4141) -

-
- - {/* Account Type */} -
- - -

Your GitHub Copilot subscription type

-
+ +
+ {/* Quick Presets */} +
+

+ + Presets +

+

+ Apply pre-configured model mappings +

+
+ {MODEL_PRESETS.map((preset) => ( + + ))} +
+
- {/* Model */} -
- - updateField('port', parseInt(e.target.value, 10))} + min={1024} + max={65535} + className="max-w-[150px] h-8" + /> +
+ + {/* Account Type */} +
+ + +
+
+ + + + {/* Rate Limiting */} +
+

Rate Limiting

+ +
+ + updateField('rateLimit', e.target.value)} + placeholder="No limit" + min={0} + className="max-w-[150px] h-8" + /> +
+ +
+
+ +

+ Wait instead of error when limit hit +

+
+ updateField('waitOnLimit', v)} + /> +
+
+ + + + {/* Daemon Settings */} +
+

Daemon Settings

+ +
+
+ +

+ Start copilot-api when using profile +

+
+ updateField('autoStart', v)} + /> +
+
+
+ + + + {/* Info Tab */} + + +
+
+

+ + Configuration Info +

+
+
+ Provider + GitHub Copilot +
+ {rawSettings && ( + <> +
+ File Path +
+ + {rawSettings.path} + + +
+
+
+ Status + + {rawSettings.exists ? 'File exists' : 'Using defaults'} + +
+ + )} +
+
+ +
+

Quick Usage

+
+ + + + +
+
+
+
+
+
+ + + ); + + // Right column: Raw JSON Editor + const renderRawEditor = () => ( + + + Loading editor... + + } + > +
+ {!isRawJsonValid && rawJsonEdits !== null && ( +
+ + Invalid JSON syntax +
+ )} +
+
+ +
+
+
+
+ ); + + return ( +
+ {/* Header */} +
+
+
+
+

Copilot Configuration

+ {rawSettings && ( + + copilot.settings.json + )} - - -

- Model to use via Copilot (default: Claude Opus 4.5) -

-
- - {/* Rate Limit */} -
- - updateField('rateLimit', e.target.value)} - placeholder="No limit" - min={0} - /> -

- Minimum seconds between requests (leave empty for no limit) -

-
- - {/* Wait on Limit */} -
-
- -

- Wait instead of error when rate limit is hit -

+
+ {rawSettings && ( +

+ Last modified:{' '} + {rawSettings.exists ? new Date(rawSettings.mtime).toLocaleString() : 'Never saved'} +

+ )}
- updateField('waitOnLimit', v)} - />
+
+ + +
+
- {/* Auto Start */} -
-
- -

- Automatically start copilot-api when using profile -

+ {/* Split Layout (40% Left / 60% Right) */} +
+ {/* Left Column: Friendly UI */} +
{renderFriendlyUI()}
+ + {/* Right Column: Raw Editor */} +
+
+ + + Raw Configuration (JSON) +
- updateField('autoStart', v)} - /> + {renderRawEditor()}
+
- {/* Save Button */} - - - + handleConflictResolve(true)} + onCancel={() => handleConflictResolve(false)} + /> +
+ ); +} + +/** Usage command with copy button */ +function UsageCommand({ label, command }: { label: string; command: string }) { + return ( +
+ +
+ + {command} + + +
+
); } diff --git a/ui/src/pages/copilot.tsx b/ui/src/pages/copilot.tsx index 3e990050..57c81ad2 100644 --- a/ui/src/pages/copilot.tsx +++ b/ui/src/pages/copilot.tsx @@ -1,24 +1,273 @@ /** - * Copilot Page - * - * GitHub Copilot integration settings page. + * Copilot Page - Master-Detail Layout + * Left sidebar: Status overview + Quick actions + * Right panel: Split-view configuration form (matches CLIProxy design) */ -import { CopilotStatusCard } from '@/components/copilot/copilot-status-card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Github, + CheckCircle2, + XCircle, + AlertTriangle, + RefreshCw, + Power, + PowerOff, + Key, + Server, + Cpu, +} from 'lucide-react'; +import { useCopilot } from '@/hooks/use-copilot'; import { CopilotConfigForm } from '@/components/copilot/copilot-config-form'; +import { cn } from '@/lib/utils'; -export function CopilotPage() { +// Status section component +function StatusSection({ title, children }: { title: string; children: React.ReactNode }) { return ( -
-
-

GitHub Copilot

-

- Use your GitHub Copilot subscription with Claude Code -

+
+
+ {title} +
+
{children}
+
+ ); +} + +// Status item component +function StatusItem({ + icon: Icon, + label, + status, + statusText, + variant = 'default', +}: { + icon: React.ElementType; + label: string; + status: boolean; + statusText?: string; + variant?: 'default' | 'warning'; +}) { + return ( +
+ +
+ {label} +
+
+ {status ? ( + <> + + + {statusText || 'Yes'} + + + ) : ( + <> + + {statusText || 'No'} + + )} +
+
+ ); +} + +// Empty state when loading +function LoadingSidebar() { + return ( +
+ + + + +
+ ); +} + +export function CopilotPage() { + const { + status, + statusLoading, + refetchStatus, + startAuth, + isAuthenticating, + startDaemon, + isStartingDaemon, + stopDaemon, + isStoppingDaemon, + } = useCopilot(); + + return ( +
+ {/* Left Sidebar - Status Overview */} +
+ {/* Header */} +
+
+
+ +

Copilot

+
+ +
+

GitHub Copilot proxy

+
+ + {/* Status Overview */} + + {statusLoading ? ( + + ) : ( +
+ {/* Warning Banner */} +
+ +

+ Reverse-engineered API +

+
+ + {/* Integration Status */} + + + + + + {/* Authentication */} + + + {!status?.authenticated && status?.installed && ( + + )} + + + {/* Daemon */} + + +
+ Port: {status?.port ?? 4141} +
+ {status?.authenticated && ( +
+ {status?.daemon_running ? ( + + ) : ( + + )} +
+ )} +
+ + {/* Quick Status */} + +
+
+ Model + + {status?.model ?? 'N/A'} + +
+
+ Account + + {status?.account_type ?? 'individual'} + +
+
+
+
+ )} +
+ + {/* Footer */} +
+
+ Proxy + {status?.daemon_running ? ( + + + Active + + ) : ( + + + Inactive + + )} +
+
+
+ + {/* Right Panel - Split-view Configuration Form */} +
+
- - -
); } From 63bdc3ae3990472d91c190bd29ee95c5181e4d12 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 01:38:02 -0500 Subject: [PATCH 12/28] fix(copilot): widen sidebar and balance split-view layout - Increase sidebar width from w-64 to w-80 (320px) - Increase model name truncation from 100px to 160px - Balance config form split-view to 50%/50% --- ui/src/components/copilot/copilot-config-form.tsx | 8 ++++---- ui/src/pages/copilot.tsx | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ui/src/components/copilot/copilot-config-form.tsx b/ui/src/components/copilot/copilot-config-form.tsx index cb9a37e3..1df9a38d 100644 --- a/ui/src/components/copilot/copilot-config-form.tsx +++ b/ui/src/components/copilot/copilot-config-form.tsx @@ -3,8 +3,8 @@ * * Form for configuring GitHub Copilot integration settings. * Split-view layout matching CLIProxy provider editor: - * - Left: Friendly UI with model mapping selectors - * - Right: Raw JSON editor for copilot.settings.json + * - Left (50%): Friendly UI with model mapping selectors + * - Right (50%): Raw JSON editor for copilot.settings.json */ import { useState, useMemo, useCallback, lazy, Suspense } from 'react'; @@ -637,8 +637,8 @@ export function CopilotConfigForm() {
- {/* Split Layout (40% Left / 60% Right) */} -
+ {/* Split Layout (50% Left / 50% Right) */} +
{/* Left Column: Friendly UI */}
{renderFriendlyUI()}
diff --git a/ui/src/pages/copilot.tsx b/ui/src/pages/copilot.tsx index 57c81ad2..fd9eef3c 100644 --- a/ui/src/pages/copilot.tsx +++ b/ui/src/pages/copilot.tsx @@ -113,7 +113,7 @@ export function CopilotPage() { return (
{/* Left Sidebar - Status Overview */} -
+
{/* Header */}
@@ -229,7 +229,7 @@ export function CopilotPage() {
Model - + {status?.model ?? 'N/A'}
From ecdad1d6d0c21f47547573f666d68bb6d1164437 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 02:26:11 -0500 Subject: [PATCH 13/28] feat(copilot): add self-managed package manager for copilot-api - install copilot-api locally to ~/.ccs/copilot/ - auto-update with version caching (1hr TTL) - version pinning support via .version-pin file - mirrors CLIProxy binary-manager pattern --- src/copilot/copilot-package-manager.ts | 515 +++++++++++++++++++++++++ 1 file changed, 515 insertions(+) create mode 100644 src/copilot/copilot-package-manager.ts diff --git a/src/copilot/copilot-package-manager.ts b/src/copilot/copilot-package-manager.ts new file mode 100644 index 00000000..e5c72fba --- /dev/null +++ b/src/copilot/copilot-package-manager.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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, +}; From 9a8eea82c52e4ab6de261ccf566058e1d5d4a77d Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 02:26:46 -0500 Subject: [PATCH 14/28] refactor(copilot): use local installation from package manager - copilot-auth: use getCopilotApiBinPath() instead of npx - copilot-daemon: move PID file to ~/.ccs/copilot/daemon.pid - copilot-executor: auto-install copilot-api on first use - index: export package manager functions --- src/copilot/copilot-auth.ts | 29 +++++++++++++++-------------- src/copilot/copilot-daemon.ts | 11 ++++++----- src/copilot/copilot-executor.ts | 21 +++++++++++++++++---- src/copilot/index.ts | 21 ++++++++++++++++----- 4 files changed, 54 insertions(+), 28 deletions(-) diff --git a/src/copilot/copilot-auth.ts b/src/copilot/copilot-auth.ts index 62b87528..e2f2d5e3 100644 --- a/src/copilot/copilot-auth.ts +++ b/src/copilot/copilot-auth.ts @@ -2,25 +2,22 @@ * Copilot Auth Handler * * Handles GitHub OAuth authentication for copilot-api. + * Uses local installation from ~/.ccs/copilot/ (managed by copilot-package-manager). */ -import { spawn, spawnSync } from 'child_process'; +import { spawn } from 'child_process'; import { CopilotAuthStatus, CopilotDebugInfo } from './types'; +import { + isCopilotApiInstalled as checkInstalled, + getCopilotApiBinPath, +} from './copilot-package-manager'; /** - * Check if copilot-api is installed (available via npx). + * Check if copilot-api is installed locally. + * Uses copilot-package-manager to check ~/.ccs/copilot/node_modules/.bin/copilot-api */ 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; - } + return checkInstalled(); } /** @@ -28,9 +25,11 @@ export function isCopilotApiInstalled(): boolean { * Returns authentication status and version info. */ export async function getCopilotDebugInfo(): Promise { + const binPath = getCopilotApiBinPath(); + return new Promise((resolve) => { try { - const proc = spawn('npx', ['copilot-api', 'debug', '--json'], { + const proc = spawn(binPath, ['debug', '--json'], { stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32', timeout: 15000, @@ -100,12 +99,14 @@ export async function checkAuthStatus(): Promise { * @returns Promise that resolves when auth flow completes */ export function startAuthFlow(): Promise<{ success: boolean; error?: string }> { + const binPath = getCopilotApiBinPath(); + 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'], { + const proc = spawn(binPath, ['auth'], { stdio: 'inherit', shell: process.platform === 'win32', }); diff --git a/src/copilot/copilot-daemon.ts b/src/copilot/copilot-daemon.ts index f004bf4b..73fe69e6 100644 --- a/src/copilot/copilot-daemon.ts +++ b/src/copilot/copilot-daemon.ts @@ -2,18 +2,18 @@ * Copilot Daemon Manager * * Manages the copilot-api daemon lifecycle (start/stop/status). - * Only used when auto_start is enabled in config. + * 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 os from 'os'; 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(os.homedir(), '.ccs', 'copilot.pid'); +const PID_FILE = path.join(getCopilotDir(), 'daemon.pid'); /** * Check if copilot-api daemon is running on the specified port. @@ -118,7 +118,8 @@ export async function startDaemon( return { success: true, pid: getPidFromFile() ?? undefined }; } - const args = ['copilot-api', 'start', '--port', config.port.toString()]; + const binPath = getCopilotApiBinPath(); + const args = ['start', '--port', config.port.toString()]; // Add account type if (config.account_type !== 'individual') { @@ -137,7 +138,7 @@ export async function startDaemon( let proc: ChildProcess; try { - proc = spawn('npx', args, { + proc = spawn(binPath, args, { stdio: ['ignore', 'pipe', 'pipe'], detached: true, shell: process.platform === 'win32', diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts index 77fd7623..c6e67633 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -2,13 +2,14 @@ * Copilot Executor * * Main execution flow for running Claude Code with copilot-api proxy. - * Similar to CLIProxy executor but for GitHub Copilot. + * 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'; /** @@ -65,12 +66,24 @@ export async function executeCopilotProfile( config: CopilotConfig, claudeArgs: string[] ): Promise { - // Check if copilot-api is installed + // 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: npm install -g copilot-api'); - console.error('Or run via npx: npx copilot-api auth'); + console.error('Install with: ccs copilot --install'); return 1; } diff --git a/src/copilot/index.ts b/src/copilot/index.ts index 6a36d4ea..d7d45f41 100644 --- a/src/copilot/index.ts +++ b/src/copilot/index.ts @@ -7,13 +7,24 @@ // Types export * from './types'; -// Auth +// Package Manager (self-managed installation) export { + getCopilotDir, + getCopilotApiBinPath, isCopilotApiInstalled, - checkAuthStatus, - startAuthFlow, - getCopilotDebugInfo, -} from './copilot-auth'; + getInstalledVersion, + getPinnedVersion, + savePinnedVersion, + clearPinnedVersion, + checkForUpdates, + ensureCopilotApi, + installCopilotApiVersion, + uninstallCopilotApi, + getCopilotApiInfo, +} from './copilot-package-manager'; + +// Auth +export { checkAuthStatus, startAuthFlow, getCopilotDebugInfo } from './copilot-auth'; // Daemon export { isDaemonRunning, getDaemonStatus, startDaemon, stopDaemon } from './copilot-daemon'; From fee241d00be4a573e04011dd84712f90ed2d4a1f Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 02:27:11 -0500 Subject: [PATCH 15/28] feat(api): add copilot install and info endpoints - POST /api/copilot/install: install copilot-api (optional version) - GET /api/copilot/info: get install status, version, path - GET /api/copilot/status: include version in response --- src/web-server/routes.ts | 48 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 30e9d4a0..14424d10 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -1606,12 +1606,16 @@ import { 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) + * GET /api/copilot/status - Get Copilot status (auth + daemon + install info) */ apiRoutes.get('/copilot/status', async (_req: Request, res: Response): Promise => { try { @@ -1619,10 +1623,12 @@ apiRoutes.get('/copilot/status', async (_req: Request, res: Response): Promise => { + 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 From f813ad06f61ad6146698d82c4aed4b3a8e90dc5d Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 02:27:35 -0500 Subject: [PATCH 16/28] feat(ui): add copilot-api install button and version display - add CopilotInfo type and install mutation to useCopilot hook - show version in status card when installed - add Install button with loading state when not installed --- .../copilot/copilot-status-card.tsx | 22 +++++++- ui/src/hooks/use-copilot.ts | 53 +++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/ui/src/components/copilot/copilot-status-card.tsx b/ui/src/components/copilot/copilot-status-card.tsx index f2a7866a..a877d3d8 100644 --- a/ui/src/components/copilot/copilot-status-card.tsx +++ b/ui/src/components/copilot/copilot-status-card.tsx @@ -8,7 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { useCopilot } from '@/hooks/use-copilot'; -import { CheckCircle2, XCircle, AlertTriangle, Loader2 } from 'lucide-react'; +import { CheckCircle2, XCircle, AlertTriangle, Loader2, Download } from 'lucide-react'; export function CopilotStatusCard() { const { @@ -20,6 +20,8 @@ export function CopilotStatusCard() { isStartingDaemon, stopDaemon, isStoppingDaemon, + install, + isInstalling, } = useCopilot(); if (statusLoading) { @@ -80,7 +82,7 @@ export function CopilotStatusCard() { )} - copilot-api {status.installed ? 'Installed' : 'Not Installed'} + copilot-api {status.installed ? `v${status.version}` : 'Not Installed'}
@@ -116,6 +118,22 @@ export function CopilotStatusCard() { {/* Actions */}
+ {!status.installed && ( + + )} + {!status.authenticated && ( + )} {/* Authentication */} From 47836329580711bc551ebc52e20136a38bf15320 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 03:00:43 -0500 Subject: [PATCH 18/28] fix(copilot): use token file check for instant auth status - add hasTokenFile() for fast auth check without subprocess - capture device code from copilot-api auth output - export AuthFlowResult type with deviceCode and verificationUrl - echo auth output to terminal while capturing for UI --- src/copilot/copilot-auth.ts | 102 +++++++++++++++++++++++++++++++----- src/copilot/index.ts | 9 +++- 2 files changed, 96 insertions(+), 15 deletions(-) diff --git a/src/copilot/copilot-auth.ts b/src/copilot/copilot-auth.ts index e2f2d5e3..81d6fdf6 100644 --- a/src/copilot/copilot-auth.ts +++ b/src/copilot/copilot-auth.ts @@ -6,12 +6,39 @@ */ 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 @@ -76,48 +103,95 @@ export async function getCopilotDebugInfo(): Promise { /** * Check copilot authentication status. + * Uses fast token file check first, falls back to copilot-api debug if needed. */ export async function checkAuthStatus(): Promise { + // 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) { - return { - authenticated: false, - error: 'Could not check auth status. Is copilot-api installed?', - }; + if (debugInfo?.authenticated) { + return { authenticated: true }; } return { - authenticated: debugInfo.authenticated ?? false, + authenticated: false, }; } /** - * Start GitHub OAuth authentication flow. - * Opens browser for user to authenticate with GitHub. - * - * @returns Promise that resolves when auth flow completes + * Auth flow result with optional device code info. */ -export function startAuthFlow(): Promise<{ success: boolean; error?: string }> { +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 { const binPath = getCopilotApiBinPath(); 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(binPath, ['auth'], { - stdio: 'inherit', + 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 }); + resolve({ success: true, deviceCode, verificationUrl }); } else { resolve({ success: false, error: `Authentication failed with exit code ${code}`, + deviceCode, + verificationUrl, }); } }); diff --git a/src/copilot/index.ts b/src/copilot/index.ts index d7d45f41..b1f9d184 100644 --- a/src/copilot/index.ts +++ b/src/copilot/index.ts @@ -24,7 +24,14 @@ export { } from './copilot-package-manager'; // Auth -export { checkAuthStatus, startAuthFlow, getCopilotDebugInfo } from './copilot-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'; From 5f0fde9a612e7ec1ea13dd5fc807d033bf215fb8 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 03:01:12 -0500 Subject: [PATCH 19/28] feat(ui): expose auth result with device code in hook - add CopilotAuthResult interface with deviceCode field - expose startAuthAsync and authResult from useCopilot hook - immediately refetch status on auth success --- ui/src/hooks/use-copilot.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/ui/src/hooks/use-copilot.ts b/ui/src/hooks/use-copilot.ts index 03f136ae..e3e71b77 100644 --- a/ui/src/hooks/use-copilot.ts +++ b/ui/src/hooks/use-copilot.ts @@ -117,7 +117,14 @@ async function saveCopilotRawSettings(data: { return res.json(); } -async function startCopilotAuth(): Promise<{ success: boolean; error?: string }> { +export interface CopilotAuthResult { + success: boolean; + error?: string; + deviceCode?: string; + verificationUrl?: string; +} + +async function startCopilotAuth(): Promise { const res = await fetch(`${API_BASE}/copilot/auth/start`, { method: 'POST' }); if (!res.ok) throw new Error('Failed to start auth'); return res.json(); @@ -204,10 +211,8 @@ export function useCopilot() { const startAuthMutation = useMutation({ mutationFn: startCopilotAuth, onSuccess: () => { - // Delay refetch to allow auth to complete - setTimeout(() => { - queryClient.invalidateQueries({ queryKey: ['copilot-status'] }); - }, 2000); + // Auth completed - immediately refetch status + queryClient.invalidateQueries({ queryKey: ['copilot-status'] }); }, }); @@ -264,7 +269,9 @@ export function useCopilot() { isSavingRawSettings: saveRawSettingsMutation.isPending, startAuth: startAuthMutation.mutate, + startAuthAsync: startAuthMutation.mutateAsync, isAuthenticating: startAuthMutation.isPending, + authResult: startAuthMutation.data, startDaemon: startDaemonMutation.mutate, isStartingDaemon: startDaemonMutation.isPending, From 7653caba710f847ff4c25fcc5b68f1b0381dd2d2 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 03:48:47 -0500 Subject: [PATCH 20/28] feat(copilot): add complete model catalog with plan tiers Add 19 GitHub Copilot supported models with metadata: - Anthropic: claude-sonnet-4.5, claude-sonnet-4, claude-opus-4.5, claude-opus-4.1, claude-haiku-4.5 - OpenAI: gpt-5.2, gpt-5.1-codex-max, gpt-5.1-codex, gpt-5.1-codex-mini, gpt-5.1, gpt-5-codex, gpt-5, gpt-5-mini, gpt-4.1 - Google: gemini-3-pro, gemini-3-flash, gemini-2.5-pro - xAI: grok-code-fast-1 - Fine-tuned: raptor-mini Each model includes: - minPlan: free, pro, pro+, business, enterprise - multiplier: 0 = free tier, 0.25-0.33 = cheap, 1 = standard, 3-10 = premium - preview: boolean for non-GA models Update default model to claude-sonnet-4.5 (standard multiplier). --- src/config/unified-config-loader.ts | 2 +- src/config/unified-config-types.ts | 4 +- src/copilot/copilot-models.ts | 166 ++++++++++++++++++++++++---- src/copilot/types.ts | 12 ++ 4 files changed, 161 insertions(+), 23 deletions(-) diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 72f27ceb..632d2d6d 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -325,7 +325,7 @@ function generateYamlWithComments(config: UnifiedConfig): string { 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-opus-4-5-20250514, claude-sonnet-4-20250514, gpt-4.1, o3'); + 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( diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 4653f3a2..46fb5e76 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -175,7 +175,7 @@ export interface CopilotConfig { 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-opus-4-5-20250514) */ + /** 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; @@ -259,7 +259,7 @@ export const DEFAULT_COPILOT_CONFIG: CopilotConfig = { account_type: 'individual', rate_limit: null, wait_on_limit: true, - model: 'claude-opus-4-5-20250514', + model: 'claude-sonnet-4.5', }; /** diff --git a/src/copilot/copilot-models.ts b/src/copilot/copilot-models.ts index c95bc94d..5dfc62ea 100644 --- a/src/copilot/copilot-models.ts +++ b/src/copilot/copilot-models.ts @@ -2,6 +2,8 @@ * 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'; @@ -10,19 +12,132 @@ 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 { - id: 'claude-opus-4-5-20250514', - name: 'Claude Opus 4.5', + id: 'claude-sonnet-4.5', + name: 'Claude Sonnet 4.5', provider: 'anthropic', isDefault: true, + 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: 'free', + 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', 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, }, - { 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' }, ]; /** @@ -55,8 +170,8 @@ export async function fetchModelsFromDaemon(port: number): Promise ({ id: m.id, name: formatModelName(m.id), - provider: m.id.includes('claude') ? 'anthropic' : 'openai', - isDefault: m.id === 'claude-opus-4-5-20250514', + provider: detectProvider(m.id), + isDefault: m.id === 'claude-sonnet-4.5', })); resolve(models.length > 0 ? models : DEFAULT_COPILOT_MODELS); } else { @@ -93,22 +208,33 @@ export async function getAvailableModels(port: number): Promise * Get the default model. */ export function getDefaultModel(): string { - return 'claude-opus-4-5-20250514'; + return 'claude-sonnet-4.5'; +} + +/** + * 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 { - // 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', - }; + // 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; + } - return nameMap[modelId] || modelId; + // Fallback: convert kebab-case to title case + return modelId + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); } diff --git a/src/copilot/types.ts b/src/copilot/types.ts index 51b308f8..e0429a1d 100644 --- a/src/copilot/types.ts +++ b/src/copilot/types.ts @@ -35,6 +35,12 @@ export interface CopilotStatus { daemon: CopilotDaemonStatus; } +/** + * Copilot plan tier for model availability. + * Based on GitHub Copilot plans. + */ +export type CopilotPlanTier = 'free' | 'pro' | 'pro+' | 'business' | 'enterprise'; + /** * Copilot model information. */ @@ -45,6 +51,12 @@ export interface CopilotModel { 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; } /** From 87c2acc416c35b15377e4bce2ee45bc6d20d761a Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 04:15:08 -0500 Subject: [PATCH 21/28] feat(ui): display plan tiers and presets in copilot model selector - Add CopilotPlanTier type and extend CopilotModel with minPlan, multiplier, preview fields - Show plan tier badges (free/pro/pro+/business/enterprise) with color-coded styling in model dropdown - Display multiplier info (0x=free, 1x=standard, 3x-10x=premium) - Add Preview badge for non-GA models - Create 3 free tier presets: GPT-4.1, GPT-5 Mini, Claude Haiku 4.5 - Create 5 paid presets: Claude Opus/Sonnet 4.5, GPT-5.2, GPT-5.1 Codex Max, Gemini 2.5 Pro - Separate presets into Free Tier and Pro+ Required sections --- .../copilot/copilot-config-form.tsx | 215 +++++++++++++++--- ui/src/hooks/use-copilot.ts | 9 + 2 files changed, 193 insertions(+), 31 deletions(-) diff --git a/ui/src/components/copilot/copilot-config-form.tsx b/ui/src/components/copilot/copilot-config-form.tsx index 1df9a38d..47603aa7 100644 --- a/ui/src/components/copilot/copilot-config-form.tsx +++ b/ui/src/components/copilot/copilot-config-form.tsx @@ -27,7 +27,7 @@ import { SelectGroup, SelectLabel, } from '@/components/ui/select'; -import { useCopilot } from '@/hooks/use-copilot'; +import { useCopilot, type CopilotModel, type CopilotPlanTier } from '@/hooks/use-copilot'; import { Loader2, Save, Code2, X, Info, RefreshCw, Sparkles, Zap, Check } from 'lucide-react'; import { toast } from 'sonner'; import { ConfirmDialog } from '@/components/confirm-dialog'; @@ -38,27 +38,74 @@ const CodeEditor = lazy(() => ); // Model presets for quick configuration -const MODEL_PRESETS = [ +// Grouped by tier: Free (available to all) and Paid (requires Pro+) +const FREE_PRESETS = [ + { + name: 'GPT-4.1 (Free)', + description: 'Free tier - no premium usage', + default: 'gpt-4.1', + opus: 'gpt-4.1', + sonnet: 'gpt-4.1', + haiku: 'gpt-4.1', + }, + { + name: 'GPT-5 Mini (Free)', + description: 'Free tier - lightweight model', + default: 'gpt-5-mini', + opus: 'gpt-5-mini', + sonnet: 'gpt-5-mini', + haiku: 'gpt-5-mini', + }, + { + name: 'Claude Haiku 4.5 (Free)', + description: 'Free tier - fast Anthropic model', + default: 'claude-haiku-4.5', + opus: 'claude-haiku-4.5', + sonnet: 'claude-haiku-4.5', + haiku: 'claude-haiku-4.5', + }, +]; + +const PAID_PRESETS = [ { name: 'Claude Opus 4.5', - default: 'claude-opus-4-5-20250514', - opus: 'claude-opus-4-5-20250514', - sonnet: 'claude-sonnet-4-20250514', - haiku: 'claude-haiku-3-5-20250514', + description: 'Pro+ (3x) - Most capable reasoning', + default: 'claude-opus-4.5', + opus: 'claude-opus-4.5', + sonnet: 'claude-sonnet-4.5', + haiku: 'claude-haiku-4.5', }, { - name: 'Claude Sonnet 4', - default: 'claude-sonnet-4-20250514', - opus: 'claude-sonnet-4-20250514', - sonnet: 'claude-sonnet-4-20250514', - haiku: 'claude-haiku-3-5-20250514', + name: 'Claude Sonnet 4.5', + description: 'Pro+ (1x) - Balanced performance', + default: 'claude-sonnet-4.5', + opus: 'claude-opus-4.5', + sonnet: 'claude-sonnet-4.5', + haiku: 'claude-haiku-4.5', }, { - name: 'GPT-4o', - default: 'gpt-4o', - opus: 'gpt-4o', - sonnet: 'gpt-4o', - haiku: 'gpt-4o-mini', + name: 'GPT-5.2', + description: 'Pro+ (1x) - Latest OpenAI (Preview)', + default: 'gpt-5.2', + opus: 'gpt-5.2', + sonnet: 'gpt-5.1', + haiku: 'gpt-5-mini', + }, + { + name: 'GPT-5.1 Codex Max', + description: 'Pro+ (1x) - Best for coding', + default: 'gpt-5.1-codex-max', + opus: 'gpt-5.1-codex-max', + sonnet: 'gpt-5.1-codex', + haiku: 'gpt-5.1-codex-mini', + }, + { + name: 'Gemini 2.5 Pro', + description: 'Pro+ (1x) - Google latest', + default: 'gemini-2.5-pro', + opus: 'gemini-2.5-pro', + sonnet: 'gemini-2.5-pro', + haiku: 'gemini-3-flash', }, ]; @@ -67,10 +114,37 @@ interface FlexibleModelSelectorProps { description?: string; value: string | undefined; onChange: (model: string) => void; - models: { id: string }[]; + models: CopilotModel[]; disabled?: boolean; } +/** Get badge style for plan tier */ +function getPlanBadgeStyle(plan?: CopilotPlanTier): string { + switch (plan) { + case 'free': + return 'bg-green-100 text-green-700 border-green-200'; + case 'pro': + return 'bg-blue-100 text-blue-700 border-blue-200'; + case 'pro+': + return 'bg-purple-100 text-purple-700 border-purple-200'; + case 'business': + return 'bg-orange-100 text-orange-700 border-orange-200'; + case 'enterprise': + return 'bg-red-100 text-red-700 border-red-200'; + default: + return 'bg-muted text-muted-foreground'; + } +} + +/** Get multiplier display */ +function getMultiplierDisplay(multiplier?: number): string | null { + if (multiplier === undefined || multiplier === null) return null; + if (multiplier === 0) return 'Free'; + if (multiplier < 1) return `${multiplier}x`; + if (multiplier === 1) return '1x'; + return `${multiplier}x`; +} + function FlexibleModelSelector({ label, description, @@ -79,6 +153,9 @@ function FlexibleModelSelector({ models, disabled, }: FlexibleModelSelectorProps) { + // Find current model for display + const currentModel = models.find((m) => m.id === value); + return (
@@ -88,7 +165,19 @@ function FlexibleModelSelector({