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) { diff --git a/src/ccs.ts b/src/ccs.ts index 437156a1..288b6873 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -343,6 +343,27 @@ async function main(): Promise { return; } + // Special case: copilot command (GitHub Copilot integration) + // Only route to command handler for known subcommands, otherwise treat as profile + const COPILOT_SUBCOMMANDS = [ + 'auth', + 'status', + 'models', + 'start', + 'stop', + 'enable', + 'disable', + 'help', + '--help', + '-h', + ]; + if (firstArg === 'copilot' && args.length > 1 && COPILOT_SUBCOMMANDS.includes(args[1])) { + // `ccs copilot ` - route to copilot command handler + const { handleCopilotCommand } = await import('./commands/copilot-command'); + const exitCode = await handleCopilotCommand(args.slice(1)); + process.exit(exitCode); + } + // Special case: headless delegation (-p flag) if (args.includes('-p') || args.includes('--prompt')) { const { DelegationHandler } = await import('./delegation/delegation-handler'); @@ -389,6 +410,16 @@ async function main(): Promise { 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..09c0904b --- /dev/null +++ b/src/commands/copilot-command.ts @@ -0,0 +1,266 @@ +/** + * 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('Quick start:'); + console.log(' 1. ccs copilot auth # Authenticate with GitHub'); + console.log(' 2. ccs copilot enable # Enable integration'); + console.log(' 3. ccs copilot start # Start daemon'); + console.log(''); + console.log('Or use the web UI: ccs config → Copilot tab'); + console.log(''); + return 0; +} + +/** + * Handle auth subcommand. + */ +async function handleAuth(): Promise { + 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; +} 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) // ═══════════════════════════════════════════════════════════════════════════ 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..de6efa48 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,29 @@ function generateYamlWithComments(config: UnifiedConfig): string { lines.push(''); } + // Copilot section (GitHub Copilot proxy) + if (config.copilot) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Copilot: GitHub Copilot API proxy (via copilot-api)'); + lines.push('# Uses your existing GitHub Copilot subscription with Claude Code.'); + lines.push('#'); + lines.push('# !! DISCLAIMER - USE AT YOUR OWN RISK !!'); + lines.push('# This uses an UNOFFICIAL reverse-engineered API.'); + lines.push('# Excessive usage may trigger GitHub account restrictions.'); + lines.push('# CCS provides NO WARRANTY and accepts NO RESPONSIBILITY for consequences.'); + lines.push('#'); + lines.push('# Setup: npx copilot-api auth (authenticate with GitHub)'); + lines.push('# Usage: ccs copilot (switch to copilot profile)'); + lines.push('#'); + lines.push('# Models: claude-sonnet-4.5, claude-opus-4.5, gpt-5.1, gemini-2.5-pro'); + lines.push('# Account types: individual, business, enterprise'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml.dump({ copilot: config.copilot }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() + ); + lines.push(''); + } + return lines.join('\n'); } diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 75cbfe31..a692173c 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,42 @@ export interface WebSearchProvidersConfig { opencode?: OpenCodeWebSearchConfig; } +/** + * Copilot API account type. + */ +export type CopilotAccountType = 'individual' | 'business' | 'enterprise'; + +/** + * Copilot API configuration. + * Enables GitHub Copilot subscription usage via copilot-api proxy. + * Strictly opt-in - disabled by default. + * + * !! DISCLAIMER - USE AT YOUR OWN RISK !! + * This uses an UNOFFICIAL reverse-engineered API. + * Excessive usage may trigger GitHub account restrictions. + * CCS provides NO WARRANTY and accepts NO RESPONSIBILITY for any consequences. + */ +export interface CopilotConfig { + /** Enable Copilot integration (default: false) - must be explicitly enabled */ + enabled: boolean; + /** Auto-start copilot-api daemon when using profile (default: false) */ + auto_start: boolean; + /** Port for copilot-api proxy (default: 4141) */ + port: number; + /** GitHub Copilot account type (default: individual) */ + account_type: CopilotAccountType; + /** Rate limit in seconds between requests (null = no limit) */ + rate_limit: number | null; + /** Wait instead of error when rate limit is hit (default: true) */ + wait_on_limit: boolean; + /** Default model ID (e.g., claude-sonnet-4.5) */ + model: string; + /** Model mapping for Claude tiers - maps opus/sonnet/haiku to specific models */ + opus_model?: string; + sonnet_model?: string; + haiku_model?: string; +} + /** * WebSearch configuration. * Uses CLI tools (Gemini CLI, Grok CLI, OpenCode) for third-party profiles. @@ -183,7 +220,7 @@ export interface WebSearchConfig { * Stored in ~/.ccs/config.yaml */ export interface UnifiedConfig { - /** Config version (2 for unified format) */ + /** Config version (4 for copilot support) */ version: number; /** Default profile name to use when none specified */ default?: string; @@ -197,6 +234,8 @@ export interface UnifiedConfig { preferences: PreferencesConfig; /** WebSearch configuration */ websearch?: WebSearchConfig; + /** Copilot API configuration (GitHub Copilot proxy) */ + copilot?: CopilotConfig; } /** @@ -211,6 +250,21 @@ export interface SecretsConfig { profiles: Record>; } +/** + * Default Copilot configuration. + * Strictly opt-in - disabled by default. + * Uses gpt-4.1 as default model (free tier compatible). + */ +export const DEFAULT_COPILOT_CONFIG: CopilotConfig = { + enabled: false, + auto_start: false, + port: 4141, + account_type: 'individual', + rate_limit: null, + wait_on_limit: true, + model: 'gpt-4.1', // Free tier compatible +}; + /** * Create an empty unified config with defaults. */ @@ -253,6 +307,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { }, }, }, + copilot: { ...DEFAULT_COPILOT_CONFIG }, }; } diff --git a/src/copilot/copilot-auth.ts b/src/copilot/copilot-auth.ts new file mode 100644 index 00000000..81d6fdf6 --- /dev/null +++ b/src/copilot/copilot-auth.ts @@ -0,0 +1,206 @@ +/** + * Copilot Auth Handler + * + * Handles GitHub OAuth authentication for copilot-api. + * Uses local installation from ~/.ccs/copilot/ (managed by copilot-package-manager). + */ + +import { spawn } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CopilotAuthStatus, CopilotDebugInfo } from './types'; +import { + isCopilotApiInstalled as checkInstalled, + getCopilotApiBinPath, +} from './copilot-package-manager'; + +/** + * Get the path to copilot-api's GitHub token file. + * copilot-api stores tokens in ~/.local/share/copilot-api/github_token (Linux/macOS) + * or %APPDATA%/copilot-api/github_token (Windows) + */ +export function getTokenPath(): string { + if (process.platform === 'win32') { + return path.join(process.env.APPDATA || os.homedir(), 'copilot-api', 'github_token'); + } + return path.join(os.homedir(), '.local', 'share', 'copilot-api', 'github_token'); +} + +/** + * Check if GitHub token file exists. + * Fast check that doesn't require spawning copilot-api process. + */ +export function hasTokenFile(): boolean { + try { + return fs.existsSync(getTokenPath()); + } catch { + return false; + } +} + +/** + * Check if copilot-api is installed locally. + * Uses copilot-package-manager to check ~/.ccs/copilot/node_modules/.bin/copilot-api + */ +export function isCopilotApiInstalled(): boolean { + return checkInstalled(); +} + +/** + * Get copilot-api debug info. + * Returns authentication status and version info. + */ +export async function getCopilotDebugInfo(): Promise { + const binPath = getCopilotApiBinPath(); + + return new Promise((resolve) => { + try { + const proc = spawn(binPath, ['debug', '--json'], { + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32', + timeout: 15000, + }); + + let stdout = ''; + let _stderr = ''; + + proc.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + proc.stderr.on('data', (data) => { + _stderr += data.toString(); + }); + + proc.on('close', (code) => { + if (code === 0 && stdout) { + try { + const info = JSON.parse(stdout.trim()) as CopilotDebugInfo; + resolve(info); + } catch { + resolve(null); + } + } else { + resolve(null); + } + }); + + proc.on('error', () => { + resolve(null); + }); + + // Timeout after 15 seconds + setTimeout(() => { + proc.kill(); + resolve(null); + }, 15000); + } catch { + resolve(null); + } + }); +} + +/** + * Check copilot authentication status. + * Uses fast token file check first, falls back to copilot-api debug if needed. + */ +export async function checkAuthStatus(): Promise { + // Fast path: check if token file exists (instant, no subprocess) + if (hasTokenFile()) { + return { authenticated: true }; + } + + // Slow path: try copilot-api debug --json (may timeout) + // Only used if token file doesn't exist + const debugInfo = await getCopilotDebugInfo(); + + if (debugInfo?.authenticated) { + return { authenticated: true }; + } + + return { + authenticated: false, + }; +} + +/** + * Auth flow result with optional device code info. + */ +export interface AuthFlowResult { + success: boolean; + error?: string; + /** Device code for user to enter at GitHub */ + deviceCode?: string; + /** URL where user enters the device code */ + verificationUrl?: string; +} + +/** + * Start GitHub OAuth authentication flow. + * Captures device code from copilot-api output and returns it. + * + * @returns Promise that resolves with auth result including device code + */ +export function startAuthFlow(): Promise { + const binPath = getCopilotApiBinPath(); + + return new Promise((resolve) => { + console.log('[i] Starting GitHub authentication for Copilot...'); + console.log(''); + + const proc = spawn(binPath, ['auth'], { + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32', + }); + + let stdout = ''; + let deviceCode: string | undefined; + let verificationUrl: string | undefined; + + proc.stdout.on('data', (data) => { + const chunk = data.toString(); + stdout += chunk; + + // Echo to console for terminal users + process.stdout.write(chunk); + + // Parse device code from output like: + // "Please enter the code "5653-38A1" in https://github.com/login/device" + const codeMatch = stdout.match(/code\s+"([A-Z0-9]{4}-[A-Z0-9]{4})"/i); + const urlMatch = stdout.match(/(https:\/\/github\.com\/login\/device)/i); + + if (codeMatch && !deviceCode) { + deviceCode = codeMatch[1]; + } + if (urlMatch && !verificationUrl) { + verificationUrl = urlMatch[1]; + } + }); + + proc.stderr.on('data', (data) => { + // Echo stderr to console + process.stderr.write(data.toString()); + }); + + proc.on('close', (code) => { + if (code === 0) { + resolve({ success: true, deviceCode, verificationUrl }); + } else { + resolve({ + success: false, + error: `Authentication failed with exit code ${code}`, + deviceCode, + verificationUrl, + }); + } + }); + + proc.on('error', (err) => { + resolve({ + success: false, + error: `Failed to start auth: ${err.message}`, + }); + }); + }); +} diff --git a/src/copilot/copilot-daemon.ts b/src/copilot/copilot-daemon.ts new file mode 100644 index 00000000..36d492f7 --- /dev/null +++ b/src/copilot/copilot-daemon.ts @@ -0,0 +1,233 @@ +/** + * Copilot Daemon Manager + * + * Manages the copilot-api daemon lifecycle (start/stop/status). + * Uses local installation from ~/.ccs/copilot/ (managed by copilot-package-manager). + */ + +import { spawn, ChildProcess } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as http from 'http'; +import { CopilotDaemonStatus } from './types'; +import { CopilotConfig } from '../config/unified-config-types'; +import { getCopilotDir, getCopilotApiBinPath } from './copilot-package-manager'; + +const PID_FILE = path.join(getCopilotDir(), 'daemon.pid'); + +/** + * Check if copilot-api daemon is running on the specified port. + * Uses 127.0.0.1 instead of localhost for more reliable local connections. + */ +export async function isDaemonRunning(port: number): Promise { + return new Promise((resolve) => { + const req = http.request( + { + hostname: '127.0.0.1', + port, + path: '/usage', + method: 'GET', + timeout: 3000, + }, + (res) => { + resolve(res.statusCode === 200); + } + ); + + req.on('error', () => { + resolve(false); + }); + + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + + req.end(); + }); +} + +/** + * Get daemon status. + */ +export async function getDaemonStatus(port: number): Promise { + const running = await isDaemonRunning(port); + const pid = getPidFromFile(); + + return { + running, + port, + pid: running ? (pid ?? undefined) : undefined, + }; +} + +/** + * Read PID from file. + */ +function getPidFromFile(): number | null { + try { + if (fs.existsSync(PID_FILE)) { + const content = fs.readFileSync(PID_FILE, 'utf8').trim(); + const pid = parseInt(content, 10); + return isNaN(pid) ? null : pid; + } + } catch { + // Ignore errors + } + return null; +} + +/** + * Write PID to file. + */ +function writePidToFile(pid: number): void { + try { + const dir = path.dirname(PID_FILE); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(PID_FILE, pid.toString(), { mode: 0o600 }); + } catch { + // Ignore errors + } +} + +/** + * Remove PID file. + */ +function removePidFile(): void { + try { + if (fs.existsSync(PID_FILE)) { + fs.unlinkSync(PID_FILE); + } + } catch { + // Ignore errors + } +} + +/** + * Start the copilot-api daemon. + * + * @param config Copilot configuration + * @returns Promise that resolves when daemon is ready + */ +export async function startDaemon( + config: CopilotConfig +): Promise<{ success: boolean; pid?: number; error?: string }> { + // Check if already running + if (await isDaemonRunning(config.port)) { + return { success: true, pid: getPidFromFile() ?? undefined }; + } + + const binPath = getCopilotApiBinPath(); + const args = ['start', '--port', config.port.toString()]; + + // Add account type + if (config.account_type !== 'individual') { + args.push('--account-type', config.account_type); + } + + // Add rate limiting + if (config.rate_limit !== null && config.rate_limit > 0) { + args.push('--rate-limit', config.rate_limit.toString()); + if (config.wait_on_limit) { + args.push('--wait'); + } + } + + return new Promise((resolve) => { + let proc: ChildProcess; + + try { + proc = spawn(binPath, args, { + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + shell: process.platform === 'win32', + }); + + // Unref so parent can exit + proc.unref(); + + if (proc.pid) { + writePidToFile(proc.pid); + } + + // Wait for daemon to be ready (poll for up to 30 seconds) + let attempts = 0; + const maxAttempts = 30; + const checkInterval = setInterval(async () => { + attempts++; + + if (await isDaemonRunning(config.port)) { + clearInterval(checkInterval); + resolve({ success: true, pid: proc.pid }); + } else if (attempts >= maxAttempts) { + clearInterval(checkInterval); + resolve({ + success: false, + error: 'Daemon did not start within 30 seconds', + }); + } + }, 1000); + + proc.on('error', (err) => { + clearInterval(checkInterval); + resolve({ + success: false, + error: `Failed to start daemon: ${err.message}`, + }); + }); + } catch (err) { + resolve({ + success: false, + error: `Failed to spawn daemon: ${(err as Error).message}`, + }); + } + }); +} + +/** + * Stop the copilot-api daemon. + */ +export async function stopDaemon(): Promise<{ success: boolean; error?: string }> { + const pid = getPidFromFile(); + + if (!pid) { + // No PID file, try to find by port + removePidFile(); + return { success: true }; + } + + try { + // Send SIGTERM to the process + process.kill(pid, 'SIGTERM'); + + // Wait for process to exit (up to 5 seconds) + let attempts = 0; + while (attempts < 10) { + await new Promise((resolve) => setTimeout(resolve, 500)); + try { + // Check if process still exists (kill(pid, 0) throws if not) + process.kill(pid, 0); + attempts++; + } catch { + // Process no longer exists + break; + } + } + + removePidFile(); + return { success: true }; + } catch (err) { + const error = err as NodeJS.ErrnoException; + if (error.code === 'ESRCH') { + // Process doesn't exist + removePidFile(); + return { success: true }; + } + return { + success: false, + error: `Failed to stop daemon: ${error.message}`, + }; + } +} diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts new file mode 100644 index 00000000..012fb385 --- /dev/null +++ b/src/copilot/copilot-executor.ts @@ -0,0 +1,156 @@ +/** + * Copilot Executor + * + * Main execution flow for running Claude Code with copilot-api proxy. + * Uses local installation from ~/.ccs/copilot/ (managed by copilot-package-manager). + */ + +import { spawn } from 'child_process'; +import { CopilotConfig } from '../config/unified-config-types'; +import { checkAuthStatus, isCopilotApiInstalled } from './copilot-auth'; +import { isDaemonRunning, startDaemon } from './copilot-daemon'; +import { ensureCopilotApi } from './copilot-package-manager'; +import { CopilotStatus } from './types'; + +/** + * Get full copilot status (auth + daemon). + */ +export async function getCopilotStatus(config: CopilotConfig): Promise { + const [auth, daemonRunning] = await Promise.all([ + checkAuthStatus(), + isDaemonRunning(config.port), + ]); + + return { + auth, + daemon: { + running: daemonRunning, + port: config.port, + }, + }; +} + +/** + * Generate environment variables for Claude Code to use copilot-api. + * Uses model mapping for opus/sonnet/haiku tiers if configured. + */ +export function generateCopilotEnv(config: CopilotConfig): Record { + // Use mapped models if configured, otherwise fall back to default model + const opusModel = config.opus_model || config.model; + const sonnetModel = config.sonnet_model || config.model; + const haikuModel = config.haiku_model || config.model; + + // Use 127.0.0.1 instead of localhost for more reliable local connections + // (bypasses DNS resolution and potential IPv6 issues) + return { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${config.port}`, + ANTHROPIC_AUTH_TOKEN: 'dummy', // copilot-api handles auth internally + ANTHROPIC_MODEL: config.model, + // Model tier mapping - allows different models for opus/sonnet/haiku + ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel, + ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel, + ANTHROPIC_SMALL_FAST_MODEL: haikuModel, + ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel, + // Disable non-essential traffic to avoid rate limiting + DISABLE_NON_ESSENTIAL_MODEL_CALLS: '1', + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', + }; +} + +/** + * Execute Claude Code with copilot-api proxy. + * + * @param config Copilot configuration + * @param claudeArgs Arguments to pass to Claude CLI + * @returns Exit code + */ +export async function executeCopilotProfile( + config: CopilotConfig, + claudeArgs: string[] +): Promise { + // Ensure copilot-api is installed (auto-install if missing, auto-update if outdated) + try { + await ensureCopilotApi(); + } catch (error) { + console.error('[X] Failed to install copilot-api.'); + console.error(''); + console.error(`Error: ${(error as Error).message}`); + console.error(''); + console.error('Try installing manually:'); + console.error(' npm install -g copilot-api'); + return 1; + } + + // Check if copilot-api is installed (should be after ensureCopilotApi) + if (!isCopilotApiInstalled()) { + console.error('[X] copilot-api is not installed.'); + console.error(''); + console.error('Install with: ccs copilot --install'); + return 1; + } + + // Check authentication + const authStatus = await checkAuthStatus(); + if (!authStatus.authenticated) { + console.error('[X] Not authenticated with GitHub.'); + console.error(''); + console.error('Run: npx copilot-api auth'); + console.error('Or: ccs copilot auth'); + return 1; + } + + // Check if daemon is running or needs to be started + let daemonRunning = await isDaemonRunning(config.port); + + if (!daemonRunning) { + if (config.auto_start) { + console.log('[i] Starting copilot-api daemon...'); + const result = await startDaemon(config); + if (!result.success) { + console.error(`[X] Failed to start daemon: ${result.error}`); + return 1; + } + console.log(`[OK] Daemon started on port ${config.port}`); + daemonRunning = true; + } else { + console.error('[X] copilot-api daemon is not running.'); + console.error(''); + console.error('Start the daemon manually:'); + console.error(` npx copilot-api start --port ${config.port}`); + console.error(''); + console.error('Or enable auto_start in config:'); + console.error(' ccs config (then enable auto_start in Copilot section)'); + return 1; + } + } + + // Generate environment for Claude + const copilotEnv = generateCopilotEnv(config); + + // Merge with current environment + const env = { + ...process.env, + ...copilotEnv, + }; + + console.log(`[i] Using GitHub Copilot proxy (model: ${config.model})`); + console.log(''); + + // Spawn Claude CLI + return new Promise((resolve) => { + const proc = spawn('claude', claudeArgs, { + stdio: 'inherit', + env, + shell: process.platform === 'win32', + }); + + proc.on('close', (code) => { + resolve(code ?? 0); + }); + + proc.on('error', (err) => { + console.error(`[X] Failed to start Claude: ${err.message}`); + resolve(1); + }); + }); +} diff --git a/src/copilot/copilot-models.ts b/src/copilot/copilot-models.ts new file mode 100644 index 00000000..07aaf195 --- /dev/null +++ b/src/copilot/copilot-models.ts @@ -0,0 +1,248 @@ +/** + * Copilot Model Catalog + * + * Manages available models from copilot-api. + * Based on GitHub Copilot supported models: + * https://docs.github.com/copilot/reference/ai-models/supported-models + */ + +import * as http from 'http'; +import { CopilotModel } from './types'; + +/** + * Default models available through copilot-api. + * Used as fallback when API is not reachable. + * Source: GitHub Copilot Supported Models (Dec 2025) + * + * Plan tiers: free, pro, pro+, business, enterprise + * Multipliers: 0 = free tier, 0.25-0.33 = cheap, 1 = standard, 3-10 = premium + */ +export const DEFAULT_COPILOT_MODELS: CopilotModel[] = [ + // Anthropic Models - All require paid Copilot subscription + { + id: 'claude-sonnet-4.5', + name: 'Claude Sonnet 4.5', + provider: 'anthropic', + minPlan: 'pro', + multiplier: 1, + }, + { + id: 'claude-sonnet-4', + name: 'Claude Sonnet 4', + provider: 'anthropic', + minPlan: 'pro', + multiplier: 1, + }, + { + id: 'claude-opus-4.5', + name: 'Claude Opus 4.5', + provider: 'anthropic', + minPlan: 'pro', + multiplier: 3, + preview: true, + }, + { + id: 'claude-opus-4.1', + name: 'Claude Opus 4.1', + provider: 'anthropic', + minPlan: 'pro', + multiplier: 10, + }, + { + id: 'claude-haiku-4.5', + name: 'Claude Haiku 4.5', + provider: 'anthropic', + minPlan: 'pro', // Requires paid Copilot subscription + multiplier: 0.33, + }, + + // OpenAI Models + { + id: 'gpt-5.2', + name: 'GPT-5.2', + provider: 'openai', + minPlan: 'pro', + multiplier: 1, + preview: true, + }, + { + id: 'gpt-5.1-codex-max', + name: 'GPT-5.1 Codex Max', + provider: 'openai', + minPlan: 'pro', + multiplier: 1, + }, + { id: 'gpt-5.1-codex', name: 'GPT-5.1 Codex', provider: 'openai', minPlan: 'pro', multiplier: 1 }, + { + id: 'gpt-5.1-codex-mini', + name: 'GPT-5.1 Codex Mini', + provider: 'openai', + minPlan: 'pro', + multiplier: 0.33, + preview: true, + }, + { id: 'gpt-5.1', name: 'GPT-5.1', provider: 'openai', minPlan: 'pro', multiplier: 1 }, + { + id: 'gpt-5-codex', + name: 'GPT-5 Codex', + provider: 'openai', + minPlan: 'pro', + multiplier: 1, + preview: true, + }, + { id: 'gpt-5', name: 'GPT-5', provider: 'openai', minPlan: 'pro', multiplier: 1 }, + { id: 'gpt-5-mini', name: 'GPT-5 Mini', provider: 'openai', minPlan: 'free', multiplier: 0 }, + { + id: 'gpt-4.1', + name: 'GPT-4.1', + provider: 'openai', + isDefault: true, + minPlan: 'free', + multiplier: 0, + }, + + // Google Models + { + id: 'gemini-3-pro', + name: 'Gemini 3 Pro', + provider: 'openai', + minPlan: 'pro', + multiplier: 1, + preview: true, + }, + { + id: 'gemini-3-flash', + name: 'Gemini 3 Flash', + provider: 'openai', + minPlan: 'pro', + multiplier: 0.33, + preview: true, + }, + { + id: 'gemini-2.5-pro', + name: 'Gemini 2.5 Pro', + provider: 'openai', + minPlan: 'pro', + multiplier: 1, + }, + + // xAI Models + { + id: 'grok-code-fast-1', + name: 'Grok Code Fast 1', + provider: 'openai', + minPlan: 'pro', + multiplier: 0.25, + }, + + // Fine-tuned Models + { + id: 'raptor-mini', + name: 'Raptor Mini', + provider: 'openai', + minPlan: 'free', + multiplier: 0, + preview: true, + }, +]; + +/** + * Fetch available models from running copilot-api daemon. + * + * @param port The port copilot-api is running on + * @returns List of available models + */ +export async function fetchModelsFromDaemon(port: number): Promise { + return new Promise((resolve) => { + const req = http.request( + { + // Use 127.0.0.1 instead of localhost for more reliable local connections + hostname: '127.0.0.1', + port, + path: '/v1/models', + method: 'GET', + timeout: 5000, + }, + (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + try { + const response = JSON.parse(data) as { data?: Array<{ id: string }> }; + if (response.data && Array.isArray(response.data)) { + const models: CopilotModel[] = response.data.map((m) => ({ + id: m.id, + name: formatModelName(m.id), + provider: detectProvider(m.id), + isDefault: m.id === 'gpt-4.1', // Free tier default + })); + resolve(models.length > 0 ? models : DEFAULT_COPILOT_MODELS); + } else { + resolve(DEFAULT_COPILOT_MODELS); + } + } catch { + resolve(DEFAULT_COPILOT_MODELS); + } + }); + } + ); + + req.on('error', () => { + resolve(DEFAULT_COPILOT_MODELS); + }); + + req.on('timeout', () => { + req.destroy(); + resolve(DEFAULT_COPILOT_MODELS); + }); + + req.end(); + }); +} + +/** + * Get available models (from daemon or defaults). + */ +export async function getAvailableModels(port: number): Promise { + return fetchModelsFromDaemon(port); +} + +/** + * Get the default model. + * Uses gpt-4.1 as it's available on free tier. + */ +export function getDefaultModel(): string { + return 'gpt-4.1'; +} + +/** + * Detect provider from model ID. + */ +function detectProvider(modelId: string): 'openai' | 'anthropic' { + if (modelId.includes('claude')) return 'anthropic'; + return 'openai'; +} + +/** + * Format model ID to human-readable name. + * Includes badges for preview and plan tier. + */ +function formatModelName(modelId: string): string { + // Find model in catalog for metadata + const model = DEFAULT_COPILOT_MODELS.find((m) => m.id === modelId); + if (model) { + let name = model.name; + if (model.preview) name += ' (Preview)'; + return name; + } + + // Fallback: convert kebab-case to title case + return modelId + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} 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, +}; diff --git a/src/copilot/index.ts b/src/copilot/index.ts new file mode 100644 index 00000000..b1f9d184 --- /dev/null +++ b/src/copilot/index.ts @@ -0,0 +1,48 @@ +/** + * Copilot Module Index + * + * Central exports for GitHub Copilot integration via copilot-api. + */ + +// Types +export * from './types'; + +// Package Manager (self-managed installation) +export { + getCopilotDir, + getCopilotApiBinPath, + isCopilotApiInstalled, + getInstalledVersion, + getPinnedVersion, + savePinnedVersion, + clearPinnedVersion, + checkForUpdates, + ensureCopilotApi, + installCopilotApiVersion, + uninstallCopilotApi, + getCopilotApiInfo, +} from './copilot-package-manager'; + +// Auth +export { + checkAuthStatus, + startAuthFlow, + getCopilotDebugInfo, + hasTokenFile, + getTokenPath, +} from './copilot-auth'; +export type { AuthFlowResult } from './copilot-auth'; + +// Daemon +export { isDaemonRunning, getDaemonStatus, startDaemon, stopDaemon } from './copilot-daemon'; + +// Models +export { + DEFAULT_COPILOT_MODELS, + fetchModelsFromDaemon, + getAvailableModels, + getDefaultModel, +} from './copilot-models'; + +// Executor +export { getCopilotStatus, generateCopilotEnv, executeCopilotProfile } from './copilot-executor'; diff --git a/src/copilot/types.ts b/src/copilot/types.ts new file mode 100644 index 00000000..e0429a1d --- /dev/null +++ b/src/copilot/types.ts @@ -0,0 +1,70 @@ +/** + * Copilot API Types + * + * Type definitions for GitHub Copilot proxy integration. + */ + +/** + * Copilot authentication status. + */ +export interface CopilotAuthStatus { + authenticated: boolean; + /** GitHub username if authenticated */ + username?: string; + /** Error message if auth check failed */ + error?: string; +} + +/** + * Copilot daemon status. + */ +export interface CopilotDaemonStatus { + running: boolean; + port: number; + /** Process ID if running */ + pid?: number; + /** Version if available */ + version?: string; +} + +/** + * Combined copilot status. + */ +export interface CopilotStatus { + auth: CopilotAuthStatus; + daemon: CopilotDaemonStatus; +} + +/** + * Copilot plan tier for model availability. + * Based on GitHub Copilot plans. + */ +export type CopilotPlanTier = 'free' | 'pro' | 'pro+' | 'business' | 'enterprise'; + +/** + * Copilot model information. + */ +export interface CopilotModel { + id: string; + name: string; + /** Provider: openai or anthropic */ + provider: 'openai' | 'anthropic'; + /** Whether this is the default model */ + isDefault?: boolean; + /** Minimum plan tier required (free = available to all) */ + minPlan?: CopilotPlanTier; + /** Premium request multiplier (0 = free, higher = more expensive) */ + multiplier?: number; + /** Whether this model is in preview */ + preview?: boolean; +} + +/** + * Copilot debug info from `copilot-api debug --json`. + */ +export interface CopilotDebugInfo { + version?: string; + runtime?: string; + authenticated?: boolean; + tokenPath?: string; +} diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 74066c0c..e689ed5d 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -1688,3 +1688,315 @@ apiRoutes.get('/websearch/status', (_req: Request, res: Response): void => { res.status(500).json({ error: (error as Error).message }); } }); + +// ============================================================================ +// COPILOT API ROUTES +// GitHub Copilot integration via copilot-api proxy +// ============================================================================ + +import { + checkAuthStatus as checkCopilotAuth, + startAuthFlow as startCopilotAuth, + getCopilotStatus, + startDaemon as startCopilotDaemon, + stopDaemon as stopCopilotDaemon, + getAvailableModels as getCopilotModels, + isCopilotApiInstalled, + ensureCopilotApi, + installCopilotApiVersion, + getCopilotApiInfo, + getInstalledVersion as getCopilotInstalledVersion, +} from '../copilot'; +import { DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types'; +import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; + +/** + * GET /api/copilot/status - Get Copilot status (auth + daemon + install info) + */ +apiRoutes.get('/copilot/status', async (_req: Request, res: Response): Promise => { + try { + const config = loadOrCreateUnifiedConfig(); + const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG; + const status = await getCopilotStatus(copilotConfig); + const installed = isCopilotApiInstalled(); + const version = getCopilotInstalledVersion(); + + res.json({ + enabled: copilotConfig.enabled, + installed, + version, + authenticated: status.auth.authenticated, + daemon_running: status.daemon.running, + port: copilotConfig.port, + model: copilotConfig.model, + account_type: copilotConfig.account_type, + auto_start: copilotConfig.auto_start, + rate_limit: copilotConfig.rate_limit, + wait_on_limit: copilotConfig.wait_on_limit, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/copilot/config - Get Copilot configuration + */ +apiRoutes.get('/copilot/config', (_req: Request, res: Response): void => { + try { + const config = loadOrCreateUnifiedConfig(); + const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG; + res.json(copilotConfig); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/copilot/config - Update Copilot configuration + */ +apiRoutes.put('/copilot/config', (req: Request, res: Response): void => { + try { + const updates = req.body; + const config = loadOrCreateUnifiedConfig(); + + // Merge updates with existing config + config.copilot = { + enabled: updates.enabled ?? config.copilot?.enabled ?? DEFAULT_COPILOT_CONFIG.enabled, + auto_start: + updates.auto_start ?? config.copilot?.auto_start ?? DEFAULT_COPILOT_CONFIG.auto_start, + port: updates.port ?? config.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port, + account_type: + updates.account_type ?? config.copilot?.account_type ?? DEFAULT_COPILOT_CONFIG.account_type, + rate_limit: + updates.rate_limit !== undefined + ? updates.rate_limit + : (config.copilot?.rate_limit ?? DEFAULT_COPILOT_CONFIG.rate_limit), + wait_on_limit: + updates.wait_on_limit ?? + config.copilot?.wait_on_limit ?? + DEFAULT_COPILOT_CONFIG.wait_on_limit, + model: updates.model ?? config.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model, + // Model mapping for opus/sonnet/haiku tiers + opus_model: + updates.opus_model !== undefined ? updates.opus_model : config.copilot?.opus_model, + sonnet_model: + updates.sonnet_model !== undefined ? updates.sonnet_model : config.copilot?.sonnet_model, + haiku_model: + updates.haiku_model !== undefined ? updates.haiku_model : config.copilot?.haiku_model, + }; + + saveUnifiedConfig(config); + res.json({ success: true, copilot: config.copilot }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/copilot/auth/start - Start GitHub OAuth flow + * Note: This is a long-running operation that opens browser + */ +apiRoutes.post('/copilot/auth/start', async (_req: Request, res: Response): Promise => { + 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 }); + } +}); + +/** + * POST /api/copilot/install - Install copilot-api + * Auto-installs latest version or specific version if provided + */ +apiRoutes.post('/copilot/install', async (req: Request, res: Response): Promise => { + try { + const { version } = req.body || {}; + + if (version) { + // Install specific version + await installCopilotApiVersion(version); + } else { + // Install latest version + await ensureCopilotApi(); + } + + const info = getCopilotApiInfo(); + res.json({ + success: true, + installed: info.installed, + version: info.version, + path: info.path, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/copilot/info - Get copilot-api installation info + */ +apiRoutes.get('/copilot/info', (_req: Request, res: Response): void => { + try { + const info = getCopilotApiInfo(); + res.json(info); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/copilot/settings/raw - Get raw copilot.settings.json + * Returns the raw JSON content for editing in the code editor + */ +apiRoutes.get('/copilot/settings/raw', (_req: Request, res: Response): void => { + try { + const settingsPath = path.join(getCcsDir(), 'copilot.settings.json'); + const config = loadOrCreateUnifiedConfig(); + const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG; + + // Default model for all tiers + const defaultModel = copilotConfig.model; + + // If file doesn't exist, return default structure with all model mappings + if (!fs.existsSync(settingsPath)) { + // Create settings structure matching CLIProxy pattern - always include all model mappings + // Use 127.0.0.1 instead of localhost for more reliable local connections + const defaultSettings = { + env: { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${copilotConfig.port}`, + ANTHROPIC_AUTH_TOKEN: 'copilot-managed', + ANTHROPIC_MODEL: defaultModel, + ANTHROPIC_DEFAULT_OPUS_MODEL: copilotConfig.opus_model || defaultModel, + ANTHROPIC_DEFAULT_SONNET_MODEL: copilotConfig.sonnet_model || defaultModel, + ANTHROPIC_DEFAULT_HAIKU_MODEL: copilotConfig.haiku_model || defaultModel, + }, + }; + + res.json({ + settings: defaultSettings, + mtime: Date.now(), + path: `~/.ccs/copilot.settings.json`, + exists: false, + }); + return; + } + + const content = fs.readFileSync(settingsPath, 'utf-8'); + const settings = JSON.parse(content); + const stat = fs.statSync(settingsPath); + + res.json({ + settings, + mtime: stat.mtimeMs, + path: `~/.ccs/copilot.settings.json`, + exists: true, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/copilot/settings/raw - Save raw copilot.settings.json + * Saves the raw JSON content from the code editor + */ +apiRoutes.put('/copilot/settings/raw', (req: Request, res: Response): void => { + try { + const { settings, expectedMtime } = req.body; + const settingsPath = path.join(getCcsDir(), 'copilot.settings.json'); + + // Check for conflict if file exists and expectedMtime provided + if (fs.existsSync(settingsPath) && expectedMtime) { + const stat = fs.statSync(settingsPath); + if (Math.abs(stat.mtimeMs - expectedMtime) > 1000) { + res.status(409).json({ error: 'File modified externally', mtime: stat.mtimeMs }); + return; + } + } + + // Write settings file atomically + const tempPath = settingsPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); + fs.renameSync(tempPath, settingsPath); + + // Also sync model mappings back to unified config + const config = loadOrCreateUnifiedConfig(); + const env = settings.env || {}; + + config.copilot = { + ...(config.copilot ?? DEFAULT_COPILOT_CONFIG), + model: env.ANTHROPIC_MODEL || config.copilot?.model || DEFAULT_COPILOT_CONFIG.model, + opus_model: env.ANTHROPIC_DEFAULT_OPUS_MODEL || undefined, + sonnet_model: env.ANTHROPIC_DEFAULT_SONNET_MODEL || undefined, + haiku_model: env.ANTHROPIC_DEFAULT_HAIKU_MODEL || undefined, + }; + saveUnifiedConfig(config); + + const stat = fs.statSync(settingsPath); + res.json({ success: true, mtime: stat.mtimeMs }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); 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..80cce9db --- /dev/null +++ b/ui/src/components/copilot/copilot-config-form.tsx @@ -0,0 +1,839 @@ +/** + * Copilot Config Form + * + * Form for configuring GitHub Copilot integration settings. + * Split-view layout matching CLIProxy provider editor: + * - 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'; +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, 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'; + +// Lazy load CodeEditor +const CodeEditor = lazy(() => + import('@/components/code-editor').then((m) => ({ default: m.CodeEditor })) +); + +// Model presets for quick configuration +// Grouped by tier: Free (available to all) and Paid (requires Pro+) +// Note: ALL Claude models require paid Copilot subscription +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: 'Raptor Mini (Free)', + description: 'Free tier - fine-tuned for coding', + default: 'raptor-mini', + opus: 'raptor-mini', + sonnet: 'raptor-mini', + haiku: 'raptor-mini', + }, +]; + +const PAID_PRESETS = [ + { + name: 'Claude Opus 4.5', + 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.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-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', + }, +]; + +interface FlexibleModelSelectorProps { + label: string; + description?: string; + value: string | undefined; + onChange: (model: string) => void; + 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, + value, + onChange, + models, + disabled, +}: FlexibleModelSelectorProps) { + // Find current model for display + const currentModel = models.find((m) => m.id === value); + + return ( +
+
+ + {description &&

{description}

} +
+ +
+ ); +} + +export function CopilotConfigForm() { + const { + config, + configLoading, + models, + modelsLoading, + rawSettings, + rawSettingsLoading, + updateConfigAsync, + isUpdating, + saveRawSettingsAsync, + isSavingRawSettings, + refetchRawSettings, + } = 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; + 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 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, + value: (typeof localOverrides)[K] + ) => { + setLocalOverrides((prev) => ({ ...prev, [key]: value })); + }; + + // Batch update for presets + const applyPreset = (preset: (typeof FREE_PRESETS)[0] | (typeof PAID_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 { + // 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({}); + setRawJsonEdits(null); + toast.success('Copilot configuration saved'); + } catch (error) { + if ((error as Error).message === 'CONFLICT') { + setConflictDialog(true); + } else { + toast.error('Failed to save settings'); + } + } + }; + + const handleConflictResolve = async (overwrite: boolean) => { + setConflictDialog(false); + if (overwrite) { + await refetchRawSettings(); + handleSave(); + } else { + setRawJsonEdits(null); + } + }; + + if (configLoading || rawSettingsLoading) { + return ( +
+ + + + +
+ ); + } + + // Left column: Friendly UI + const renderFriendlyUI = () => ( +
+ +
+ + + Model Config + + + Settings + + + Info + + +
+ +
+ {/* Model Config Tab */} + + +
+ {/* Quick Presets */} +
+

+ + Presets +

+

+ Apply pre-configured model mappings +

+ + {/* Free Tier Presets */} +
+
+ + Free Tier + + + No premium usage count + +
+
+ {FREE_PRESETS.map((preset) => ( + + ))} +
+
+ + {/* Paid Tier Presets */} +
+
+ + Pro+ Required + + + Uses premium request quota + +
+
+ {PAID_PRESETS.map((preset) => ( + + ))} +
+
+
+ + + + {/* Model Mapping */} +
+

Model Mapping

+

+ Configure which models to use for each tier +

+
+ updateField('model', model)} + models={models} + disabled={modelsLoading} + /> + updateField('opusModel', model)} + models={models} + disabled={modelsLoading} + /> + updateField('sonnetModel', model)} + models={models} + disabled={modelsLoading} + /> + updateField('haikuModel', model)} + models={models} + disabled={modelsLoading} + /> +
+
+
+
+
+ + {/* Settings Tab */} + + +
+ {/* Enable Toggle */} +
+
+ +

+ Allow using GitHub Copilot subscription +

+
+ updateField('enabled', v)} + /> +
+ + {/* Basic Settings */} +
+

Basic Settings

+ + {/* Port */} +
+ + 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 + + )} +
+ {rawSettings && ( +

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

+ )} +
+
+
+ + +
+
+ + {/* Split Layout - Left panel constrained, Right panel flexible */} +
+ {/* Left Column: Friendly UI - constrained width */} +
+ {renderFriendlyUI()} +
+ + {/* Right Column: Raw Editor - takes remaining space */} +
+
+ + + Raw Configuration (JSON) + +
+ {renderRawEditor()} +
+
+ + 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/components/copilot/copilot-status-card.tsx b/ui/src/components/copilot/copilot-status-card.tsx new file mode 100644 index 00000000..a877d3d8 --- /dev/null +++ b/ui/src/components/copilot/copilot-status-card.tsx @@ -0,0 +1,191 @@ +/** + * 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, Download } from 'lucide-react'; + +export function CopilotStatusCard() { + const { + status, + statusLoading, + startAuth, + isAuthenticating, + startDaemon, + isStartingDaemon, + stopDaemon, + isStoppingDaemon, + install, + isInstalling, + } = 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 ? `v${status.version}` : '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.installed && ( + + )} + + {!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..3431ffdb --- /dev/null +++ b/ui/src/hooks/use-copilot.ts @@ -0,0 +1,300 @@ +/** + * 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; + version: string | null; + 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 CopilotInfo { + installed: boolean; + version: string | null; + path: string; + pinnedVersion: string | null; +} + +export interface CopilotInstallResult { + success: boolean; + installed: boolean; + version: string | null; + path: string; +} + +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; + // Model mapping for Claude tiers + opus_model?: string; + sonnet_model?: string; + haiku_model?: string; +} + +/** GitHub Copilot plan tiers */ +export type CopilotPlanTier = 'free' | 'pro' | 'pro+' | 'business' | 'enterprise'; + +export interface CopilotModel { + id: string; + name: string; + provider: 'openai' | 'anthropic'; + isDefault?: boolean; + isCurrent?: 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; +} + +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`); + 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 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', + 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 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(); +} + +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(); +} + +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(); +} + +async function fetchCopilotInfo(): Promise { + const res = await fetch(`${API_BASE}/copilot/info`); + if (!res.ok) throw new Error('Failed to fetch copilot info'); + return res.json(); +} + +async function installCopilotApi(version?: string): Promise { + const res = await fetch(`${API_BASE}/copilot/install`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(version ? { version } : {}), + }); + if (!res.ok) throw new Error('Failed to install copilot-api'); + 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, + }); + + const rawSettingsQuery = useQuery({ + queryKey: ['copilot-raw-settings'], + queryFn: fetchCopilotRawSettings, + }); + + const infoQuery = useQuery({ + queryKey: ['copilot-info'], + queryFn: fetchCopilotInfo, + }); + + // 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'] }); + }, + }); + + const startAuthMutation = useMutation({ + mutationFn: startCopilotAuth, + onSuccess: () => { + // Auth completed - immediately refetch status + queryClient.invalidateQueries({ queryKey: ['copilot-status'] }); + }, + }); + + const startDaemonMutation = useMutation({ + mutationFn: startCopilotDaemon, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['copilot-status'] }); + }, + }); + + const stopDaemonMutation = useMutation({ + mutationFn: stopCopilotDaemon, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['copilot-status'] }); + }, + }); + + const installMutation = useMutation({ + mutationFn: installCopilotApi, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['copilot-status'] }); + queryClient.invalidateQueries({ queryKey: ['copilot-info'] }); + }, + }); + + 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, + + // 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, + startAuthAsync: startAuthMutation.mutateAsync, + isAuthenticating: startAuthMutation.isPending, + authResult: startAuthMutation.data, + + startDaemon: startDaemonMutation.mutate, + isStartingDaemon: startDaemonMutation.isPending, + + stopDaemon: stopDaemonMutation.mutate, + isStoppingDaemon: stopDaemonMutation.isPending, + + // Install + info: infoQuery.data, + infoLoading: infoQuery.isLoading, + refetchInfo: infoQuery.refetch, + + install: installMutation.mutate, + installAsync: installMutation.mutateAsync, + isInstalling: installMutation.isPending, + }; +} diff --git a/ui/src/pages/copilot.tsx b/ui/src/pages/copilot.tsx new file mode 100644 index 00000000..97c6d3f1 --- /dev/null +++ b/ui/src/pages/copilot.tsx @@ -0,0 +1,295 @@ +/** + * Copilot Page - Master-Detail Layout + * Left sidebar: Status overview + Quick actions + * Right panel: Split-view configuration form (matches CLIProxy design) + */ + +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Github, + CheckCircle2, + XCircle, + AlertTriangle, + RefreshCw, + Power, + PowerOff, + Key, + Server, + Cpu, + Download, + Loader2, +} from 'lucide-react'; +import { useCopilot } from '@/hooks/use-copilot'; +import { CopilotConfigForm } from '@/components/copilot/copilot-config-form'; +import { cn } from '@/lib/utils'; + +// Status section component +function StatusSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
+ {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, + install, + isInstalling, + } = useCopilot(); + + return ( +
+ {/* Left Sidebar - Status Overview */} +
+ {/* Header */} +
+
+
+ +

Copilot

+
+ +
+

GitHub Copilot proxy

+
+ + {/* Status Overview */} + + {statusLoading ? ( + + ) : ( +
+ {/* Warning Banner - Disclaimer */} +
+
+ + + Unofficial API - Use at Your Own Risk + +
+
    +
  • Reverse-engineered API - may break anytime
  • +
  • Excessive use may trigger account restrictions
  • +
  • No warranty, no responsibility from CCS
  • +
+
+ + {/* Setup - Binary first, then enabled status */} + + + {!status?.installed && ( + + )} + {status?.installed && ( + + )} + + + {/* Authentication - only show after binary installed */} + {status?.installed && ( + + + {!status?.authenticated && ( + + )} + + )} + + {/* Daemon - only show after authenticated */} + {status?.authenticated && ( + + +
+ Port: {status?.port ?? 4141} +
+
+ {status?.daemon_running ? ( + + ) : ( + + )} +
+
+ )} +
+ )} +
+ + {/* Footer */} +
+
+ Proxy + {status?.daemon_running ? ( + + + Active + + ) : ( + + + Inactive + + )} +
+
+
+ + {/* Right Panel - Split-view Configuration Form */} +
+ +
+
+ ); +}