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

feat(copilot): add GitHub Copilot integration with CLI, API, and dashboard
This commit is contained in:
Kai (Tam Nhu) Tran
2025-12-18 05:41:38 -05:00
committed by GitHub
21 changed files with 3869 additions and 4 deletions
+41 -2
View File
@@ -14,12 +14,12 @@ import * as path from 'path';
import * as os from 'os';
import { findSimilarStrings } from '../utils/helpers';
import { Config, Settings, ProfileMetadata } from '../types';
import { UnifiedConfig } from '../config/unified-config-types';
import { UnifiedConfig, CopilotConfig } from '../config/unified-config-types';
import { hasUnifiedConfig, loadUnifiedConfig } from '../config/unified-config-loader';
import { getProfileSecrets } from '../config/secrets-manager';
import { isUnifiedConfigEnabled } from '../config/feature-flags';
export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'default';
export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'default';
/** CLIProxy profile names (OAuth-based, zero config) */
export const CLIPROXY_PROFILES = ['gemini', 'codex', 'agy', 'qwen'] as const;
@@ -35,6 +35,8 @@ export interface ProfileDetectionResult {
provider?: CLIProxyProfileName;
/** For unified config profiles: merged env vars (config + secrets) */
env?: Record<string, string>;
/** For copilot profile: the copilot config */
copilotConfig?: CopilotConfig;
}
export interface AllProfiles {
@@ -182,6 +184,7 @@ class ProfileDetector {
*
* Priority order:
* 0. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen)
* 0.5. Copilot profile (if enabled in config)
* 1. Unified config profiles (if config.yaml exists or CCS_UNIFIED_CONFIG=1)
* 2. User-defined CLIProxy variants (config.cliproxy section) [legacy]
* 3. Settings-based profiles (config.profiles section) [legacy]
@@ -202,6 +205,36 @@ class ProfileDetector {
};
}
// Priority 0.5: Check Copilot profile - GitHub Copilot subscription via copilot-api
if (profileName === 'copilot') {
const unifiedConfig = this.readUnifiedConfig();
const copilotConfig = unifiedConfig?.copilot;
if (!copilotConfig?.enabled) {
const error = new Error(
'Copilot profile is not enabled.\n\n' +
'To enable GitHub Copilot integration:\n' +
' 1. Run: ccs config\n' +
' 2. Go to "GitHub Copilot" section\n' +
' 3. Enable the integration\n' +
' 4. Authenticate with GitHub: npx copilot-api auth\n\n' +
'Or manually edit ~/.ccs/config.yaml:\n' +
' copilot:\n' +
' enabled: true'
) as ProfileNotFoundError;
error.profileName = profileName;
error.suggestions = [];
error.availableProfiles = this.listAvailableProfiles();
throw error;
}
return {
type: 'copilot',
name: 'copilot',
copilotConfig,
};
}
// Priority 1: Try unified config if available
const unifiedConfig = this.readUnifiedConfig();
if (unifiedConfig) {
@@ -327,6 +360,12 @@ class ProfileDetector {
// Check unified config first
const unifiedConfig = this.readUnifiedConfig();
if (unifiedConfig) {
// Copilot profile (if enabled)
if (unifiedConfig.copilot?.enabled) {
lines.push('GitHub Copilot (via copilot-api):');
lines.push(` - copilot (model: ${unifiedConfig.copilot.model})`);
}
// CLIProxy variants from unified config
const variants = Object.keys(unifiedConfig.cliproxy?.variants || {});
if (variants.length > 0) {
+31
View File
@@ -343,6 +343,27 @@ async function main(): Promise<void> {
return;
}
// Special case: copilot command (GitHub Copilot integration)
// Only route to command handler for known subcommands, otherwise treat as profile
const COPILOT_SUBCOMMANDS = [
'auth',
'status',
'models',
'start',
'stop',
'enable',
'disable',
'help',
'--help',
'-h',
];
if (firstArg === 'copilot' && args.length > 1 && COPILOT_SUBCOMMANDS.includes(args[1])) {
// `ccs copilot <subcommand>` - route to copilot command handler
const { handleCopilotCommand } = await import('./commands/copilot-command');
const exitCode = await handleCopilotCommand(args.slice(1));
process.exit(exitCode);
}
// Special case: headless delegation (-p flag)
if (args.includes('-p') || args.includes('--prompt')) {
const { DelegationHandler } = await import('./delegation/delegation-handler');
@@ -389,6 +410,16 @@ async function main(): Promise<void> {
const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider);
const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles
await execClaudeWithCLIProxy(claudeCli, provider, remainingArgs, { customSettingsPath });
} else if (profileInfo.type === 'copilot') {
// COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy
const { executeCopilotProfile } = await import('./copilot');
const copilotConfig = profileInfo.copilotConfig;
if (!copilotConfig) {
console.error('[X] Copilot configuration not found');
process.exit(1);
}
const exitCode = await executeCopilotProfile(copilotConfig, remainingArgs);
process.exit(exitCode);
} else if (profileInfo.type === 'settings') {
// Settings-based profiles (glm, glmt, kimi) are third-party providers
// WebSearch is server-side tool - third-party providers have no access
+266
View File
@@ -0,0 +1,266 @@
/**
* Copilot CLI Command
*
* Handles `ccs copilot <subcommand>` commands.
*/
import {
startAuthFlow,
getCopilotStatus,
startDaemon,
stopDaemon,
getAvailableModels,
isCopilotApiInstalled,
} from '../copilot';
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../config/unified-config-loader';
import { DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types';
/**
* Handle copilot subcommand.
*/
export async function handleCopilotCommand(args: string[]): Promise<number> {
const subcommand = args[0];
switch (subcommand) {
case 'auth':
return handleAuth();
case 'status':
return handleStatus();
case 'models':
return handleModels();
case 'start':
return handleStart();
case 'stop':
return handleStop();
case 'enable':
return handleEnable();
case 'disable':
return handleDisable();
case undefined:
case 'help':
case '--help':
case '-h':
return handleHelp();
default:
console.error(`[X] Unknown subcommand: ${subcommand}`);
console.error('');
return handleHelp();
}
}
/**
* Show help for copilot commands.
*/
function handleHelp(): number {
console.log('GitHub Copilot Integration');
console.log('');
console.log('Usage: ccs copilot <subcommand>');
console.log('');
console.log('Subcommands:');
console.log(' auth Start GitHub OAuth authentication');
console.log(' status Show authentication and daemon status');
console.log(' models List available models');
console.log(' start Start copilot-api daemon');
console.log(' stop Stop copilot-api daemon');
console.log(' enable Enable copilot integration');
console.log(' disable Disable copilot integration');
console.log(' help Show this help message');
console.log('');
console.log('Quick start:');
console.log(' 1. ccs copilot auth # Authenticate with GitHub');
console.log(' 2. ccs copilot enable # Enable integration');
console.log(' 3. ccs copilot start # Start daemon');
console.log('');
console.log('Or use the web UI: ccs config → Copilot tab');
console.log('');
return 0;
}
/**
* Handle auth subcommand.
*/
async function handleAuth(): Promise<number> {
if (!isCopilotApiInstalled()) {
console.error('[X] copilot-api is not installed.');
console.error('');
console.error('Install with: npm install -g copilot-api');
return 1;
}
const result = await startAuthFlow();
if (result.success) {
console.log('');
console.log('[OK] Authentication successful!');
console.log('');
console.log('Next steps:');
console.log(' 1. Enable copilot: ccs copilot enable');
console.log(' 2. Start daemon: npx copilot-api start');
console.log(' 3. Use copilot: ccs copilot');
return 0;
} else {
console.error('');
console.error(`[X] ${result.error}`);
return 1;
}
}
/**
* Handle status subcommand.
*/
async function handleStatus(): Promise<number> {
const config = loadOrCreateUnifiedConfig();
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
const status = await getCopilotStatus(copilotConfig);
console.log('GitHub Copilot Status');
console.log('─────────────────────');
console.log('');
// Enabled status
const enabledIcon = copilotConfig.enabled ? '[OK]' : '[X]';
const enabledText = copilotConfig.enabled ? 'Enabled' : 'Disabled';
console.log(`Integration: ${enabledIcon} ${enabledText}`);
// Auth status
const authIcon = status.auth.authenticated ? '[OK]' : '[X]';
const authText = status.auth.authenticated ? 'Authenticated' : 'Not authenticated';
console.log(`Authentication: ${authIcon} ${authText}`);
// Daemon status
const daemonIcon = status.daemon.running ? '[OK]' : '[X]';
const daemonText = status.daemon.running ? 'Running' : 'Not running';
console.log(`Daemon: ${daemonIcon} ${daemonText}`);
console.log('');
console.log('Configuration:');
console.log(` Port: ${copilotConfig.port}`);
console.log(` Model: ${copilotConfig.model}`);
console.log(` Account Type: ${copilotConfig.account_type}`);
console.log(` Auto-start: ${copilotConfig.auto_start ? 'Yes' : 'No'}`);
if (copilotConfig.rate_limit !== null) {
console.log(` Rate Limit: ${copilotConfig.rate_limit}s`);
}
console.log('');
// Show next steps if not fully configured
if (!copilotConfig.enabled || !status.auth.authenticated || !status.daemon.running) {
console.log('Next steps:');
if (!copilotConfig.enabled) {
console.log(' - Enable: ccs copilot enable');
}
if (!status.auth.authenticated) {
console.log(' - Auth: ccs copilot auth');
}
if (!status.daemon.running) {
console.log(' - Start: ccs copilot start');
}
}
return 0;
}
/**
* Handle models subcommand.
*/
async function handleModels(): Promise<number> {
const config = loadOrCreateUnifiedConfig();
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
console.log('Available Copilot Models');
console.log('────────────────────────');
console.log('');
const models = await getAvailableModels(copilotConfig.port);
for (const model of models) {
const current = model.id === copilotConfig.model ? ' [CURRENT]' : '';
const defaultMark = model.isDefault ? ' (default)' : '';
console.log(` ${model.id}${current}${defaultMark}`);
console.log(` Provider: ${model.provider}`);
}
console.log('');
console.log('To change model: ccs config (Copilot section)');
return 0;
}
/**
* Handle start subcommand.
*/
async function handleStart(): Promise<number> {
const config = loadOrCreateUnifiedConfig();
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
console.log(`[i] Starting copilot-api daemon on port ${copilotConfig.port}...`);
const result = await startDaemon(copilotConfig);
if (result.success) {
console.log(`[OK] Daemon started (PID: ${result.pid})`);
return 0;
} else {
console.error(`[X] ${result.error}`);
return 1;
}
}
/**
* Handle stop subcommand.
*/
async function handleStop(): Promise<number> {
console.log('[i] Stopping copilot-api daemon...');
const result = await stopDaemon();
if (result.success) {
console.log('[OK] Daemon stopped');
return 0;
} else {
console.error(`[X] ${result.error}`);
return 1;
}
}
/**
* Handle enable subcommand.
*/
async function handleEnable(): Promise<number> {
const config = loadOrCreateUnifiedConfig();
if (!config.copilot) {
config.copilot = { ...DEFAULT_COPILOT_CONFIG };
}
config.copilot.enabled = true;
saveUnifiedConfig(config);
console.log('[OK] Copilot integration enabled');
console.log('');
console.log('Next steps:');
console.log(' 1. Authenticate: ccs copilot auth');
console.log(' 2. Start daemon: ccs copilot start');
console.log(' 3. Use: ccs copilot');
return 0;
}
/**
* Handle disable subcommand.
*/
async function handleDisable(): Promise<number> {
const config = loadOrCreateUnifiedConfig();
if (config.copilot) {
config.copilot.enabled = false;
saveUnifiedConfig(config);
}
console.log('[OK] Copilot integration disabled');
return 0;
}
+21
View File
@@ -173,6 +173,27 @@ Claude Code Profile & Model Switcher`.trim();
]
);
// ═══════════════════════════════════════════════════════════════════════════
// MAJOR SECTION 4: GitHub Copilot Integration
// ═══════════════════════════════════════════════════════════════════════════
printMajorSection(
'GitHub Copilot Integration',
[
'Use your GitHub Copilot subscription with Claude Code',
'Requires: npm install -g copilot-api',
],
[
['ccs copilot', 'Use Copilot as API backend'],
['ccs copilot auth', 'Authenticate with GitHub'],
['ccs copilot status', 'Show integration status'],
['ccs copilot models', 'List available models'],
['ccs copilot start', 'Start copilot-api daemon'],
['ccs copilot stop', 'Stop copilot-api daemon'],
['ccs copilot enable', 'Enable integration'],
['ccs copilot disable', 'Disable integration'],
]
);
// ═══════════════════════════════════════════════════════════════════════════
// SUB-SECTIONS (simpler styling)
// ═══════════════════════════════════════════════════════════════════════════
+2
View File
@@ -9,6 +9,8 @@ export const RESERVED_PROFILE_NAMES = [
'agy',
'qwen',
'iflow',
// Copilot API (GitHub Copilot proxy)
'copilot',
// CLI commands and special names
'default',
'config',
+34
View File
@@ -14,6 +14,7 @@ import {
isUnifiedConfig,
createEmptyUnifiedConfig,
UNIFIED_CONFIG_VERSION,
DEFAULT_COPILOT_CONFIG,
} from './unified-config-types';
import { isUnifiedConfigEnabled } from './feature-flags';
@@ -159,6 +160,16 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
// Legacy fields (keep for backwards compatibility during read)
gemini: partial.websearch?.gemini,
},
// Copilot config - strictly opt-in, merge with defaults
copilot: {
enabled: partial.copilot?.enabled ?? DEFAULT_COPILOT_CONFIG.enabled,
auto_start: partial.copilot?.auto_start ?? DEFAULT_COPILOT_CONFIG.auto_start,
port: partial.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port,
account_type: partial.copilot?.account_type ?? DEFAULT_COPILOT_CONFIG.account_type,
rate_limit: partial.copilot?.rate_limit ?? DEFAULT_COPILOT_CONFIG.rate_limit,
wait_on_limit: partial.copilot?.wait_on_limit ?? DEFAULT_COPILOT_CONFIG.wait_on_limit,
model: partial.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model,
},
};
}
@@ -302,6 +313,29 @@ function generateYamlWithComments(config: UnifiedConfig): string {
lines.push('');
}
// Copilot section (GitHub Copilot proxy)
if (config.copilot) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Copilot: GitHub Copilot API proxy (via copilot-api)');
lines.push('# Uses your existing GitHub Copilot subscription with Claude Code.');
lines.push('#');
lines.push('# !! DISCLAIMER - USE AT YOUR OWN RISK !!');
lines.push('# This uses an UNOFFICIAL reverse-engineered API.');
lines.push('# Excessive usage may trigger GitHub account restrictions.');
lines.push('# CCS provides NO WARRANTY and accepts NO RESPONSIBILITY for consequences.');
lines.push('#');
lines.push('# Setup: npx copilot-api auth (authenticate with GitHub)');
lines.push('# Usage: ccs copilot (switch to copilot profile)');
lines.push('#');
lines.push('# Models: claude-sonnet-4.5, claude-opus-4.5, gpt-5.1, gemini-2.5-pro');
lines.push('# Account types: individual, business, enterprise');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml.dump({ copilot: config.copilot }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
);
lines.push('');
}
return lines.join('\n');
}
+57 -2
View File
@@ -13,8 +13,9 @@
* Unified config version.
* Version 2 = YAML unified format
* Version 3 = WebSearch config with model configuration for Gemini/OpenCode
* Version 4 = Copilot API integration (GitHub Copilot proxy)
*/
export const UNIFIED_CONFIG_VERSION = 3;
export const UNIFIED_CONFIG_VERSION = 4;
/**
* Account configuration (formerly in profiles.json).
@@ -148,6 +149,42 @@ export interface WebSearchProvidersConfig {
opencode?: OpenCodeWebSearchConfig;
}
/**
* Copilot API account type.
*/
export type CopilotAccountType = 'individual' | 'business' | 'enterprise';
/**
* Copilot API configuration.
* Enables GitHub Copilot subscription usage via copilot-api proxy.
* Strictly opt-in - disabled by default.
*
* !! DISCLAIMER - USE AT YOUR OWN RISK !!
* This uses an UNOFFICIAL reverse-engineered API.
* Excessive usage may trigger GitHub account restrictions.
* CCS provides NO WARRANTY and accepts NO RESPONSIBILITY for any consequences.
*/
export interface CopilotConfig {
/** Enable Copilot integration (default: false) - must be explicitly enabled */
enabled: boolean;
/** Auto-start copilot-api daemon when using profile (default: false) */
auto_start: boolean;
/** Port for copilot-api proxy (default: 4141) */
port: number;
/** GitHub Copilot account type (default: individual) */
account_type: CopilotAccountType;
/** Rate limit in seconds between requests (null = no limit) */
rate_limit: number | null;
/** Wait instead of error when rate limit is hit (default: true) */
wait_on_limit: boolean;
/** Default model ID (e.g., claude-sonnet-4.5) */
model: string;
/** Model mapping for Claude tiers - maps opus/sonnet/haiku to specific models */
opus_model?: string;
sonnet_model?: string;
haiku_model?: string;
}
/**
* WebSearch configuration.
* Uses CLI tools (Gemini CLI, Grok CLI, OpenCode) for third-party profiles.
@@ -183,7 +220,7 @@ export interface WebSearchConfig {
* Stored in ~/.ccs/config.yaml
*/
export interface UnifiedConfig {
/** Config version (2 for unified format) */
/** Config version (4 for copilot support) */
version: number;
/** Default profile name to use when none specified */
default?: string;
@@ -197,6 +234,8 @@ export interface UnifiedConfig {
preferences: PreferencesConfig;
/** WebSearch configuration */
websearch?: WebSearchConfig;
/** Copilot API configuration (GitHub Copilot proxy) */
copilot?: CopilotConfig;
}
/**
@@ -211,6 +250,21 @@ export interface SecretsConfig {
profiles: Record<string, Record<string, string>>;
}
/**
* Default Copilot configuration.
* Strictly opt-in - disabled by default.
* Uses gpt-4.1 as default model (free tier compatible).
*/
export const DEFAULT_COPILOT_CONFIG: CopilotConfig = {
enabled: false,
auto_start: false,
port: 4141,
account_type: 'individual',
rate_limit: null,
wait_on_limit: true,
model: 'gpt-4.1', // Free tier compatible
};
/**
* Create an empty unified config with defaults.
*/
@@ -253,6 +307,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
},
},
},
copilot: { ...DEFAULT_COPILOT_CONFIG },
};
}
+206
View File
@@ -0,0 +1,206 @@
/**
* Copilot Auth Handler
*
* Handles GitHub OAuth authentication for copilot-api.
* Uses local installation from ~/.ccs/copilot/ (managed by copilot-package-manager).
*/
import { spawn } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CopilotAuthStatus, CopilotDebugInfo } from './types';
import {
isCopilotApiInstalled as checkInstalled,
getCopilotApiBinPath,
} from './copilot-package-manager';
/**
* Get the path to copilot-api's GitHub token file.
* copilot-api stores tokens in ~/.local/share/copilot-api/github_token (Linux/macOS)
* or %APPDATA%/copilot-api/github_token (Windows)
*/
export function getTokenPath(): string {
if (process.platform === 'win32') {
return path.join(process.env.APPDATA || os.homedir(), 'copilot-api', 'github_token');
}
return path.join(os.homedir(), '.local', 'share', 'copilot-api', 'github_token');
}
/**
* Check if GitHub token file exists.
* Fast check that doesn't require spawning copilot-api process.
*/
export function hasTokenFile(): boolean {
try {
return fs.existsSync(getTokenPath());
} catch {
return false;
}
}
/**
* Check if copilot-api is installed locally.
* Uses copilot-package-manager to check ~/.ccs/copilot/node_modules/.bin/copilot-api
*/
export function isCopilotApiInstalled(): boolean {
return checkInstalled();
}
/**
* Get copilot-api debug info.
* Returns authentication status and version info.
*/
export async function getCopilotDebugInfo(): Promise<CopilotDebugInfo | null> {
const binPath = getCopilotApiBinPath();
return new Promise((resolve) => {
try {
const proc = spawn(binPath, ['debug', '--json'], {
stdio: ['ignore', 'pipe', 'pipe'],
shell: process.platform === 'win32',
timeout: 15000,
});
let stdout = '';
let _stderr = '';
proc.stdout.on('data', (data) => {
stdout += data.toString();
});
proc.stderr.on('data', (data) => {
_stderr += data.toString();
});
proc.on('close', (code) => {
if (code === 0 && stdout) {
try {
const info = JSON.parse(stdout.trim()) as CopilotDebugInfo;
resolve(info);
} catch {
resolve(null);
}
} else {
resolve(null);
}
});
proc.on('error', () => {
resolve(null);
});
// Timeout after 15 seconds
setTimeout(() => {
proc.kill();
resolve(null);
}, 15000);
} catch {
resolve(null);
}
});
}
/**
* Check copilot authentication status.
* Uses fast token file check first, falls back to copilot-api debug if needed.
*/
export async function checkAuthStatus(): Promise<CopilotAuthStatus> {
// Fast path: check if token file exists (instant, no subprocess)
if (hasTokenFile()) {
return { authenticated: true };
}
// Slow path: try copilot-api debug --json (may timeout)
// Only used if token file doesn't exist
const debugInfo = await getCopilotDebugInfo();
if (debugInfo?.authenticated) {
return { authenticated: true };
}
return {
authenticated: false,
};
}
/**
* Auth flow result with optional device code info.
*/
export interface AuthFlowResult {
success: boolean;
error?: string;
/** Device code for user to enter at GitHub */
deviceCode?: string;
/** URL where user enters the device code */
verificationUrl?: string;
}
/**
* Start GitHub OAuth authentication flow.
* Captures device code from copilot-api output and returns it.
*
* @returns Promise that resolves with auth result including device code
*/
export function startAuthFlow(): Promise<AuthFlowResult> {
const binPath = getCopilotApiBinPath();
return new Promise((resolve) => {
console.log('[i] Starting GitHub authentication for Copilot...');
console.log('');
const proc = spawn(binPath, ['auth'], {
stdio: ['ignore', 'pipe', 'pipe'],
shell: process.platform === 'win32',
});
let stdout = '';
let deviceCode: string | undefined;
let verificationUrl: string | undefined;
proc.stdout.on('data', (data) => {
const chunk = data.toString();
stdout += chunk;
// Echo to console for terminal users
process.stdout.write(chunk);
// Parse device code from output like:
// "Please enter the code "5653-38A1" in https://github.com/login/device"
const codeMatch = stdout.match(/code\s+"([A-Z0-9]{4}-[A-Z0-9]{4})"/i);
const urlMatch = stdout.match(/(https:\/\/github\.com\/login\/device)/i);
if (codeMatch && !deviceCode) {
deviceCode = codeMatch[1];
}
if (urlMatch && !verificationUrl) {
verificationUrl = urlMatch[1];
}
});
proc.stderr.on('data', (data) => {
// Echo stderr to console
process.stderr.write(data.toString());
});
proc.on('close', (code) => {
if (code === 0) {
resolve({ success: true, deviceCode, verificationUrl });
} else {
resolve({
success: false,
error: `Authentication failed with exit code ${code}`,
deviceCode,
verificationUrl,
});
}
});
proc.on('error', (err) => {
resolve({
success: false,
error: `Failed to start auth: ${err.message}`,
});
});
});
}
+233
View File
@@ -0,0 +1,233 @@
/**
* Copilot Daemon Manager
*
* Manages the copilot-api daemon lifecycle (start/stop/status).
* Uses local installation from ~/.ccs/copilot/ (managed by copilot-package-manager).
*/
import { spawn, ChildProcess } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as http from 'http';
import { CopilotDaemonStatus } from './types';
import { CopilotConfig } from '../config/unified-config-types';
import { getCopilotDir, getCopilotApiBinPath } from './copilot-package-manager';
const PID_FILE = path.join(getCopilotDir(), 'daemon.pid');
/**
* Check if copilot-api daemon is running on the specified port.
* Uses 127.0.0.1 instead of localhost for more reliable local connections.
*/
export async function isDaemonRunning(port: number): Promise<boolean> {
return new Promise((resolve) => {
const req = http.request(
{
hostname: '127.0.0.1',
port,
path: '/usage',
method: 'GET',
timeout: 3000,
},
(res) => {
resolve(res.statusCode === 200);
}
);
req.on('error', () => {
resolve(false);
});
req.on('timeout', () => {
req.destroy();
resolve(false);
});
req.end();
});
}
/**
* Get daemon status.
*/
export async function getDaemonStatus(port: number): Promise<CopilotDaemonStatus> {
const running = await isDaemonRunning(port);
const pid = getPidFromFile();
return {
running,
port,
pid: running ? (pid ?? undefined) : undefined,
};
}
/**
* Read PID from file.
*/
function getPidFromFile(): number | null {
try {
if (fs.existsSync(PID_FILE)) {
const content = fs.readFileSync(PID_FILE, 'utf8').trim();
const pid = parseInt(content, 10);
return isNaN(pid) ? null : pid;
}
} catch {
// Ignore errors
}
return null;
}
/**
* Write PID to file.
*/
function writePidToFile(pid: number): void {
try {
const dir = path.dirname(PID_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
fs.writeFileSync(PID_FILE, pid.toString(), { mode: 0o600 });
} catch {
// Ignore errors
}
}
/**
* Remove PID file.
*/
function removePidFile(): void {
try {
if (fs.existsSync(PID_FILE)) {
fs.unlinkSync(PID_FILE);
}
} catch {
// Ignore errors
}
}
/**
* Start the copilot-api daemon.
*
* @param config Copilot configuration
* @returns Promise that resolves when daemon is ready
*/
export async function startDaemon(
config: CopilotConfig
): Promise<{ success: boolean; pid?: number; error?: string }> {
// Check if already running
if (await isDaemonRunning(config.port)) {
return { success: true, pid: getPidFromFile() ?? undefined };
}
const binPath = getCopilotApiBinPath();
const args = ['start', '--port', config.port.toString()];
// Add account type
if (config.account_type !== 'individual') {
args.push('--account-type', config.account_type);
}
// Add rate limiting
if (config.rate_limit !== null && config.rate_limit > 0) {
args.push('--rate-limit', config.rate_limit.toString());
if (config.wait_on_limit) {
args.push('--wait');
}
}
return new Promise((resolve) => {
let proc: ChildProcess;
try {
proc = spawn(binPath, args, {
stdio: ['ignore', 'pipe', 'pipe'],
detached: true,
shell: process.platform === 'win32',
});
// Unref so parent can exit
proc.unref();
if (proc.pid) {
writePidToFile(proc.pid);
}
// Wait for daemon to be ready (poll for up to 30 seconds)
let attempts = 0;
const maxAttempts = 30;
const checkInterval = setInterval(async () => {
attempts++;
if (await isDaemonRunning(config.port)) {
clearInterval(checkInterval);
resolve({ success: true, pid: proc.pid });
} else if (attempts >= maxAttempts) {
clearInterval(checkInterval);
resolve({
success: false,
error: 'Daemon did not start within 30 seconds',
});
}
}, 1000);
proc.on('error', (err) => {
clearInterval(checkInterval);
resolve({
success: false,
error: `Failed to start daemon: ${err.message}`,
});
});
} catch (err) {
resolve({
success: false,
error: `Failed to spawn daemon: ${(err as Error).message}`,
});
}
});
}
/**
* Stop the copilot-api daemon.
*/
export async function stopDaemon(): Promise<{ success: boolean; error?: string }> {
const pid = getPidFromFile();
if (!pid) {
// No PID file, try to find by port
removePidFile();
return { success: true };
}
try {
// Send SIGTERM to the process
process.kill(pid, 'SIGTERM');
// Wait for process to exit (up to 5 seconds)
let attempts = 0;
while (attempts < 10) {
await new Promise((resolve) => setTimeout(resolve, 500));
try {
// Check if process still exists (kill(pid, 0) throws if not)
process.kill(pid, 0);
attempts++;
} catch {
// Process no longer exists
break;
}
}
removePidFile();
return { success: true };
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error.code === 'ESRCH') {
// Process doesn't exist
removePidFile();
return { success: true };
}
return {
success: false,
error: `Failed to stop daemon: ${error.message}`,
};
}
}
+156
View File
@@ -0,0 +1,156 @@
/**
* Copilot Executor
*
* Main execution flow for running Claude Code with copilot-api proxy.
* Uses local installation from ~/.ccs/copilot/ (managed by copilot-package-manager).
*/
import { spawn } from 'child_process';
import { CopilotConfig } from '../config/unified-config-types';
import { checkAuthStatus, isCopilotApiInstalled } from './copilot-auth';
import { isDaemonRunning, startDaemon } from './copilot-daemon';
import { ensureCopilotApi } from './copilot-package-manager';
import { CopilotStatus } from './types';
/**
* Get full copilot status (auth + daemon).
*/
export async function getCopilotStatus(config: CopilotConfig): Promise<CopilotStatus> {
const [auth, daemonRunning] = await Promise.all([
checkAuthStatus(),
isDaemonRunning(config.port),
]);
return {
auth,
daemon: {
running: daemonRunning,
port: config.port,
},
};
}
/**
* Generate environment variables for Claude Code to use copilot-api.
* Uses model mapping for opus/sonnet/haiku tiers if configured.
*/
export function generateCopilotEnv(config: CopilotConfig): Record<string, string> {
// Use mapped models if configured, otherwise fall back to default model
const opusModel = config.opus_model || config.model;
const sonnetModel = config.sonnet_model || config.model;
const haikuModel = config.haiku_model || config.model;
// Use 127.0.0.1 instead of localhost for more reliable local connections
// (bypasses DNS resolution and potential IPv6 issues)
return {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${config.port}`,
ANTHROPIC_AUTH_TOKEN: 'dummy', // copilot-api handles auth internally
ANTHROPIC_MODEL: config.model,
// Model tier mapping - allows different models for opus/sonnet/haiku
ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel,
ANTHROPIC_SMALL_FAST_MODEL: haikuModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel,
// Disable non-essential traffic to avoid rate limiting
DISABLE_NON_ESSENTIAL_MODEL_CALLS: '1',
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
};
}
/**
* Execute Claude Code with copilot-api proxy.
*
* @param config Copilot configuration
* @param claudeArgs Arguments to pass to Claude CLI
* @returns Exit code
*/
export async function executeCopilotProfile(
config: CopilotConfig,
claudeArgs: string[]
): Promise<number> {
// Ensure copilot-api is installed (auto-install if missing, auto-update if outdated)
try {
await ensureCopilotApi();
} catch (error) {
console.error('[X] Failed to install copilot-api.');
console.error('');
console.error(`Error: ${(error as Error).message}`);
console.error('');
console.error('Try installing manually:');
console.error(' npm install -g copilot-api');
return 1;
}
// Check if copilot-api is installed (should be after ensureCopilotApi)
if (!isCopilotApiInstalled()) {
console.error('[X] copilot-api is not installed.');
console.error('');
console.error('Install with: ccs copilot --install');
return 1;
}
// Check authentication
const authStatus = await checkAuthStatus();
if (!authStatus.authenticated) {
console.error('[X] Not authenticated with GitHub.');
console.error('');
console.error('Run: npx copilot-api auth');
console.error('Or: ccs copilot auth');
return 1;
}
// Check if daemon is running or needs to be started
let daemonRunning = await isDaemonRunning(config.port);
if (!daemonRunning) {
if (config.auto_start) {
console.log('[i] Starting copilot-api daemon...');
const result = await startDaemon(config);
if (!result.success) {
console.error(`[X] Failed to start daemon: ${result.error}`);
return 1;
}
console.log(`[OK] Daemon started on port ${config.port}`);
daemonRunning = true;
} else {
console.error('[X] copilot-api daemon is not running.');
console.error('');
console.error('Start the daemon manually:');
console.error(` npx copilot-api start --port ${config.port}`);
console.error('');
console.error('Or enable auto_start in config:');
console.error(' ccs config (then enable auto_start in Copilot section)');
return 1;
}
}
// Generate environment for Claude
const copilotEnv = generateCopilotEnv(config);
// Merge with current environment
const env = {
...process.env,
...copilotEnv,
};
console.log(`[i] Using GitHub Copilot proxy (model: ${config.model})`);
console.log('');
// Spawn Claude CLI
return new Promise((resolve) => {
const proc = spawn('claude', claudeArgs, {
stdio: 'inherit',
env,
shell: process.platform === 'win32',
});
proc.on('close', (code) => {
resolve(code ?? 0);
});
proc.on('error', (err) => {
console.error(`[X] Failed to start Claude: ${err.message}`);
resolve(1);
});
});
}
+248
View File
@@ -0,0 +1,248 @@
/**
* Copilot Model Catalog
*
* Manages available models from copilot-api.
* Based on GitHub Copilot supported models:
* https://docs.github.com/copilot/reference/ai-models/supported-models
*/
import * as http from 'http';
import { CopilotModel } from './types';
/**
* Default models available through copilot-api.
* Used as fallback when API is not reachable.
* Source: GitHub Copilot Supported Models (Dec 2025)
*
* Plan tiers: free, pro, pro+, business, enterprise
* Multipliers: 0 = free tier, 0.25-0.33 = cheap, 1 = standard, 3-10 = premium
*/
export const DEFAULT_COPILOT_MODELS: CopilotModel[] = [
// Anthropic Models - All require paid Copilot subscription
{
id: 'claude-sonnet-4.5',
name: 'Claude Sonnet 4.5',
provider: 'anthropic',
minPlan: 'pro',
multiplier: 1,
},
{
id: 'claude-sonnet-4',
name: 'Claude Sonnet 4',
provider: 'anthropic',
minPlan: 'pro',
multiplier: 1,
},
{
id: 'claude-opus-4.5',
name: 'Claude Opus 4.5',
provider: 'anthropic',
minPlan: 'pro',
multiplier: 3,
preview: true,
},
{
id: 'claude-opus-4.1',
name: 'Claude Opus 4.1',
provider: 'anthropic',
minPlan: 'pro',
multiplier: 10,
},
{
id: 'claude-haiku-4.5',
name: 'Claude Haiku 4.5',
provider: 'anthropic',
minPlan: 'pro', // Requires paid Copilot subscription
multiplier: 0.33,
},
// OpenAI Models
{
id: 'gpt-5.2',
name: 'GPT-5.2',
provider: 'openai',
minPlan: 'pro',
multiplier: 1,
preview: true,
},
{
id: 'gpt-5.1-codex-max',
name: 'GPT-5.1 Codex Max',
provider: 'openai',
minPlan: 'pro',
multiplier: 1,
},
{ id: 'gpt-5.1-codex', name: 'GPT-5.1 Codex', provider: 'openai', minPlan: 'pro', multiplier: 1 },
{
id: 'gpt-5.1-codex-mini',
name: 'GPT-5.1 Codex Mini',
provider: 'openai',
minPlan: 'pro',
multiplier: 0.33,
preview: true,
},
{ id: 'gpt-5.1', name: 'GPT-5.1', provider: 'openai', minPlan: 'pro', multiplier: 1 },
{
id: 'gpt-5-codex',
name: 'GPT-5 Codex',
provider: 'openai',
minPlan: 'pro',
multiplier: 1,
preview: true,
},
{ id: 'gpt-5', name: 'GPT-5', provider: 'openai', minPlan: 'pro', multiplier: 1 },
{ id: 'gpt-5-mini', name: 'GPT-5 Mini', provider: 'openai', minPlan: 'free', multiplier: 0 },
{
id: 'gpt-4.1',
name: 'GPT-4.1',
provider: 'openai',
isDefault: true,
minPlan: 'free',
multiplier: 0,
},
// Google Models
{
id: 'gemini-3-pro',
name: 'Gemini 3 Pro',
provider: 'openai',
minPlan: 'pro',
multiplier: 1,
preview: true,
},
{
id: 'gemini-3-flash',
name: 'Gemini 3 Flash',
provider: 'openai',
minPlan: 'pro',
multiplier: 0.33,
preview: true,
},
{
id: 'gemini-2.5-pro',
name: 'Gemini 2.5 Pro',
provider: 'openai',
minPlan: 'pro',
multiplier: 1,
},
// xAI Models
{
id: 'grok-code-fast-1',
name: 'Grok Code Fast 1',
provider: 'openai',
minPlan: 'pro',
multiplier: 0.25,
},
// Fine-tuned Models
{
id: 'raptor-mini',
name: 'Raptor Mini',
provider: 'openai',
minPlan: 'free',
multiplier: 0,
preview: true,
},
];
/**
* Fetch available models from running copilot-api daemon.
*
* @param port The port copilot-api is running on
* @returns List of available models
*/
export async function fetchModelsFromDaemon(port: number): Promise<CopilotModel[]> {
return new Promise((resolve) => {
const req = http.request(
{
// Use 127.0.0.1 instead of localhost for more reliable local connections
hostname: '127.0.0.1',
port,
path: '/v1/models',
method: 'GET',
timeout: 5000,
},
(res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const response = JSON.parse(data) as { data?: Array<{ id: string }> };
if (response.data && Array.isArray(response.data)) {
const models: CopilotModel[] = response.data.map((m) => ({
id: m.id,
name: formatModelName(m.id),
provider: detectProvider(m.id),
isDefault: m.id === 'gpt-4.1', // Free tier default
}));
resolve(models.length > 0 ? models : DEFAULT_COPILOT_MODELS);
} else {
resolve(DEFAULT_COPILOT_MODELS);
}
} catch {
resolve(DEFAULT_COPILOT_MODELS);
}
});
}
);
req.on('error', () => {
resolve(DEFAULT_COPILOT_MODELS);
});
req.on('timeout', () => {
req.destroy();
resolve(DEFAULT_COPILOT_MODELS);
});
req.end();
});
}
/**
* Get available models (from daemon or defaults).
*/
export async function getAvailableModels(port: number): Promise<CopilotModel[]> {
return fetchModelsFromDaemon(port);
}
/**
* Get the default model.
* Uses gpt-4.1 as it's available on free tier.
*/
export function getDefaultModel(): string {
return 'gpt-4.1';
}
/**
* Detect provider from model ID.
*/
function detectProvider(modelId: string): 'openai' | 'anthropic' {
if (modelId.includes('claude')) return 'anthropic';
return 'openai';
}
/**
* Format model ID to human-readable name.
* Includes badges for preview and plan tier.
*/
function formatModelName(modelId: string): string {
// Find model in catalog for metadata
const model = DEFAULT_COPILOT_MODELS.find((m) => m.id === modelId);
if (model) {
let name = model.name;
if (model.preview) name += ' (Preview)';
return name;
}
// Fallback: convert kebab-case to title case
return modelId
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
+515
View File
@@ -0,0 +1,515 @@
/**
* Copilot Package Manager
*
* Self-managed npm package installation for copilot-api:
* - Installs copilot-api locally to ~/.ccs/copilot/
* - Auto-updates to latest version (like CLIProxy binary manager)
* - Version caching to avoid hitting npm registry on every run
* - Retry logic with exponential backoff
*
* Pattern: Mirrors CLIProxy BinaryManager but for npm packages
*/
import * as fs from 'fs';
import * as path from 'path';
import { spawn, spawnSync } from 'child_process';
import { ProgressIndicator } from '../utils/progress-indicator';
import { ok, info } from '../utils/ui';
import { getCcsDir } from '../utils/config-manager';
/** Cache duration for version check (1 hour in milliseconds) */
const VERSION_CACHE_DURATION_MS = 60 * 60 * 1000;
/** Version pin file name */
const VERSION_PIN_FILE = '.version-pin';
/** Package name to install */
const COPILOT_API_PACKAGE = 'copilot-api';
/** Version cache file structure */
interface VersionCache {
latestVersion: string;
checkedAt: number;
}
/** Update check result */
interface UpdateCheckResult {
hasUpdate: boolean;
currentVersion: string;
latestVersion: string;
fromCache: boolean;
}
/** Install result */
interface InstallResult {
success: boolean;
version?: string;
error?: string;
}
/**
* Get copilot base directory
* All copilot-related files are stored under ~/.ccs/copilot/
*/
export function getCopilotDir(): string {
return path.join(getCcsDir(), 'copilot');
}
/**
* Get path to copilot-api binary
*/
export function getCopilotApiBinPath(): string {
const binName = process.platform === 'win32' ? 'copilot-api.cmd' : 'copilot-api';
return path.join(getCopilotDir(), 'node_modules', '.bin', binName);
}
/**
* Get path to package.json
*/
function getPackageJsonPath(): string {
return path.join(getCopilotDir(), 'package.json');
}
/**
* Get path to version file
*/
function getVersionFilePath(): string {
return path.join(getCopilotDir(), '.version');
}
/**
* Get path to version cache file
*/
function getVersionCachePath(): string {
return path.join(getCopilotDir(), '.version-cache.json');
}
/**
* Get path to version pin file
*/
export function getVersionPinPath(): string {
return path.join(getCopilotDir(), VERSION_PIN_FILE);
}
/**
* Check if copilot-api is installed locally
*/
export function isCopilotApiInstalled(): boolean {
return fs.existsSync(getCopilotApiBinPath());
}
/**
* Get installed version from .version file
*/
export function getInstalledVersion(): string | null {
const versionFile = getVersionFilePath();
if (fs.existsSync(versionFile)) {
try {
return fs.readFileSync(versionFile, 'utf8').trim();
} catch {
return null;
}
}
return null;
}
/**
* Save installed version to file
*/
function saveInstalledVersion(version: string): void {
const versionFile = getVersionFilePath();
try {
fs.writeFileSync(versionFile, version, 'utf8');
} catch {
// Silent fail - not critical
}
}
/**
* Get cached latest version if still valid
*/
function getCachedLatestVersion(): string | null {
const cachePath = getVersionCachePath();
if (!fs.existsSync(cachePath)) {
return null;
}
try {
const content = fs.readFileSync(cachePath, 'utf8');
const cache: VersionCache = JSON.parse(content);
// Check if cache is still valid
if (Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) {
return cache.latestVersion;
}
// Cache expired
return null;
} catch {
return null;
}
}
/**
* Cache latest version for future checks
*/
function cacheLatestVersion(version: string): void {
const cachePath = getVersionCachePath();
const cache: VersionCache = {
latestVersion: version,
checkedAt: Date.now(),
};
try {
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
fs.writeFileSync(cachePath, JSON.stringify(cache), 'utf8');
} catch {
// Silent fail - caching is optional
}
}
/**
* Fetch latest version from npm registry
*/
async function fetchLatestVersion(): Promise<string> {
return new Promise((resolve, reject) => {
const result = spawnSync('npm', ['view', COPILOT_API_PACKAGE, 'version'], {
encoding: 'utf8',
timeout: 15000,
shell: process.platform === 'win32',
});
if (result.status === 0 && result.stdout) {
resolve(result.stdout.trim());
} else {
reject(new Error(result.stderr || 'Failed to fetch version from npm'));
}
});
}
/**
* Compare semver versions (true if latest > current)
*/
function isNewerVersion(latest: string, current: string): boolean {
const latestParts = latest.split('.').map((p) => parseInt(p, 10) || 0);
const currentParts = current.split('.').map((p) => parseInt(p, 10) || 0);
// Pad arrays to same length
while (latestParts.length < 3) latestParts.push(0);
while (currentParts.length < 3) currentParts.push(0);
for (let i = 0; i < 3; i++) {
if (latestParts[i] > currentParts[i]) return true;
if (latestParts[i] < currentParts[i]) return false;
}
return false; // Equal versions
}
/**
* Get pinned version if one exists
*/
export function getPinnedVersion(): string | null {
const pinPath = getVersionPinPath();
if (!fs.existsSync(pinPath)) {
return null;
}
try {
return fs.readFileSync(pinPath, 'utf8').trim();
} catch {
return null;
}
}
/**
* Save pinned version
*/
export function savePinnedVersion(version: string): void {
const pinPath = getVersionPinPath();
try {
fs.mkdirSync(path.dirname(pinPath), { recursive: true });
fs.writeFileSync(pinPath, version, 'utf8');
} catch {
// Silent fail
}
}
/**
* Clear pinned version
*/
export function clearPinnedVersion(): void {
const pinPath = getVersionPinPath();
if (fs.existsSync(pinPath)) {
try {
fs.unlinkSync(pinPath);
} catch {
// Silent fail
}
}
}
/**
* Check for updates
*/
export async function checkForUpdates(): Promise<UpdateCheckResult> {
const currentVersion = getInstalledVersion() || '0.0.0';
// Try cache first
const cachedVersion = getCachedLatestVersion();
if (cachedVersion) {
return {
hasUpdate: isNewerVersion(cachedVersion, currentVersion),
currentVersion,
latestVersion: cachedVersion,
fromCache: true,
};
}
// Fetch from npm registry
try {
const latestVersion = await fetchLatestVersion();
cacheLatestVersion(latestVersion);
return {
hasUpdate: isNewerVersion(latestVersion, currentVersion),
currentVersion,
latestVersion,
fromCache: false,
};
} catch {
// Return no update if fetch fails
return {
hasUpdate: false,
currentVersion,
latestVersion: currentVersion,
fromCache: false,
};
}
}
/**
* Run npm install in copilot directory
*/
async function runNpmInstall(version?: string): Promise<InstallResult> {
const copilotDir = getCopilotDir();
// Ensure directory exists
fs.mkdirSync(copilotDir, { recursive: true });
// Create package.json with versioned or latest dependency
const packageJson = {
name: 'ccs-copilot-local',
private: true,
dependencies: {
[COPILOT_API_PACKAGE]: version || 'latest',
},
};
fs.writeFileSync(getPackageJsonPath(), JSON.stringify(packageJson, null, 2), 'utf8');
return new Promise((resolve) => {
const proc = spawn('npm', ['install', '--prefer-offline', '--no-audit', '--no-fund'], {
cwd: copilotDir,
stdio: ['ignore', 'pipe', 'pipe'],
shell: process.platform === 'win32',
});
let stderr = '';
proc.stderr?.on('data', (data) => {
stderr += data.toString();
});
proc.on('close', (code) => {
if (code === 0) {
// Get installed version from package-lock.json or package.json
try {
const lockPath = path.join(copilotDir, 'package-lock.json');
if (fs.existsSync(lockPath)) {
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
const installedVersion =
lock.packages?.['node_modules/copilot-api']?.version ||
lock.dependencies?.[COPILOT_API_PACKAGE]?.version;
if (installedVersion) {
saveInstalledVersion(installedVersion);
resolve({ success: true, version: installedVersion });
return;
}
}
} catch {
// Fallback
}
resolve({ success: true, version: version || 'latest' });
} else {
resolve({ success: false, error: stderr || `npm install failed with code ${code}` });
}
});
proc.on('error', (err) => {
resolve({ success: false, error: err.message });
});
});
}
/**
* Ensure copilot-api is available (install if missing, update if outdated)
* @returns Path to copilot-api binary
*/
export async function ensureCopilotApi(verbose = false): Promise<string> {
const binPath = getCopilotApiBinPath();
const pinnedVersion = getPinnedVersion();
// Check if already installed
if (isCopilotApiInstalled()) {
if (verbose) {
console.error(`[copilot] Binary exists: ${binPath}`);
}
// Skip auto-update if version is pinned
if (pinnedVersion) {
if (verbose) {
console.error(`[copilot] Pinned version mode: skipping auto-update`);
}
return binPath;
}
// Check for updates (non-blocking for UX)
try {
const updateResult = await checkForUpdates();
if (updateResult.hasUpdate) {
console.log(
info(
`copilot-api update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}`
)
);
console.log(info('Updating copilot-api...'));
const spinner = new ProgressIndicator('Updating copilot-api');
spinner.start();
const result = await runNpmInstall(updateResult.latestVersion);
if (result.success) {
spinner.succeed('copilot-api updated');
console.log(ok(`copilot-api v${result.version} installed successfully`));
} else {
spinner.fail('Update failed (non-blocking)');
if (verbose) {
console.error(`[copilot] Update failed: ${result.error}`);
}
}
}
} catch (error) {
// Silent fail - don't block startup if update check fails
if (verbose) {
console.error(`[copilot] Update check failed: ${(error as Error).message}`);
}
}
return binPath;
}
// Not installed - install now
console.log(info('copilot-api not found, installing...'));
const spinner = new ProgressIndicator('Installing copilot-api');
spinner.start();
try {
// Get target version
let targetVersion: string | undefined;
if (pinnedVersion) {
targetVersion = pinnedVersion;
if (verbose) {
console.error(`[copilot] Using pinned version: ${pinnedVersion}`);
}
} else {
// Fetch latest version
try {
targetVersion = await fetchLatestVersion();
cacheLatestVersion(targetVersion);
} catch {
// Use 'latest' if fetch fails
targetVersion = undefined;
}
}
const result = await runNpmInstall(targetVersion);
if (result.success) {
spinner.succeed('copilot-api installed');
console.log(ok(`copilot-api v${result.version} installed successfully`));
return binPath;
} else {
spinner.fail('Installation failed');
throw new Error(result.error || 'npm install failed');
}
} catch (error) {
spinner.fail('Installation failed');
throw error;
}
}
/**
* Install a specific version of copilot-api
*/
export async function installCopilotApiVersion(version: string, _verbose = false): Promise<void> {
const spinner = new ProgressIndicator(`Installing copilot-api v${version}`);
spinner.start();
try {
const result = await runNpmInstall(version);
if (result.success) {
spinner.succeed('copilot-api installed');
console.log(ok(`copilot-api v${result.version} installed successfully`));
} else {
spinner.fail('Installation failed');
throw new Error(result.error || 'npm install failed');
}
} catch (error) {
spinner.fail('Installation failed');
throw error;
}
}
/**
* Uninstall copilot-api (remove ~/.ccs/copilot directory)
*/
export function uninstallCopilotApi(): void {
const copilotDir = getCopilotDir();
if (fs.existsSync(copilotDir)) {
fs.rmSync(copilotDir, { recursive: true, force: true });
}
}
/**
* Get copilot-api info
*/
export function getCopilotApiInfo(): {
installed: boolean;
version: string | null;
path: string;
pinnedVersion: string | null;
} {
return {
installed: isCopilotApiInstalled(),
version: getInstalledVersion(),
path: getCopilotApiBinPath(),
pinnedVersion: getPinnedVersion(),
};
}
export default {
getCopilotDir,
getCopilotApiBinPath,
isCopilotApiInstalled,
getInstalledVersion,
getPinnedVersion,
savePinnedVersion,
clearPinnedVersion,
checkForUpdates,
ensureCopilotApi,
installCopilotApiVersion,
uninstallCopilotApi,
getCopilotApiInfo,
};
+48
View File
@@ -0,0 +1,48 @@
/**
* Copilot Module Index
*
* Central exports for GitHub Copilot integration via copilot-api.
*/
// Types
export * from './types';
// Package Manager (self-managed installation)
export {
getCopilotDir,
getCopilotApiBinPath,
isCopilotApiInstalled,
getInstalledVersion,
getPinnedVersion,
savePinnedVersion,
clearPinnedVersion,
checkForUpdates,
ensureCopilotApi,
installCopilotApiVersion,
uninstallCopilotApi,
getCopilotApiInfo,
} from './copilot-package-manager';
// Auth
export {
checkAuthStatus,
startAuthFlow,
getCopilotDebugInfo,
hasTokenFile,
getTokenPath,
} from './copilot-auth';
export type { AuthFlowResult } from './copilot-auth';
// Daemon
export { isDaemonRunning, getDaemonStatus, startDaemon, stopDaemon } from './copilot-daemon';
// Models
export {
DEFAULT_COPILOT_MODELS,
fetchModelsFromDaemon,
getAvailableModels,
getDefaultModel,
} from './copilot-models';
// Executor
export { getCopilotStatus, generateCopilotEnv, executeCopilotProfile } from './copilot-executor';
+70
View File
@@ -0,0 +1,70 @@
/**
* Copilot API Types
*
* Type definitions for GitHub Copilot proxy integration.
*/
/**
* Copilot authentication status.
*/
export interface CopilotAuthStatus {
authenticated: boolean;
/** GitHub username if authenticated */
username?: string;
/** Error message if auth check failed */
error?: string;
}
/**
* Copilot daemon status.
*/
export interface CopilotDaemonStatus {
running: boolean;
port: number;
/** Process ID if running */
pid?: number;
/** Version if available */
version?: string;
}
/**
* Combined copilot status.
*/
export interface CopilotStatus {
auth: CopilotAuthStatus;
daemon: CopilotDaemonStatus;
}
/**
* Copilot plan tier for model availability.
* Based on GitHub Copilot plans.
*/
export type CopilotPlanTier = 'free' | 'pro' | 'pro+' | 'business' | 'enterprise';
/**
* Copilot model information.
*/
export interface CopilotModel {
id: string;
name: string;
/** Provider: openai or anthropic */
provider: 'openai' | 'anthropic';
/** Whether this is the default model */
isDefault?: boolean;
/** Minimum plan tier required (free = available to all) */
minPlan?: CopilotPlanTier;
/** Premium request multiplier (0 = free, higher = more expensive) */
multiplier?: number;
/** Whether this model is in preview */
preview?: boolean;
}
/**
* Copilot debug info from `copilot-api debug --json`.
*/
export interface CopilotDebugInfo {
version?: string;
runtime?: string;
authenticated?: boolean;
tokenPath?: string;
}
+312
View File
@@ -1688,3 +1688,315 @@ apiRoutes.get('/websearch/status', (_req: Request, res: Response): void => {
res.status(500).json({ error: (error as Error).message });
}
});
// ============================================================================
// COPILOT API ROUTES
// GitHub Copilot integration via copilot-api proxy
// ============================================================================
import {
checkAuthStatus as checkCopilotAuth,
startAuthFlow as startCopilotAuth,
getCopilotStatus,
startDaemon as startCopilotDaemon,
stopDaemon as stopCopilotDaemon,
getAvailableModels as getCopilotModels,
isCopilotApiInstalled,
ensureCopilotApi,
installCopilotApiVersion,
getCopilotApiInfo,
getInstalledVersion as getCopilotInstalledVersion,
} from '../copilot';
import { DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types';
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
/**
* GET /api/copilot/status - Get Copilot status (auth + daemon + install info)
*/
apiRoutes.get('/copilot/status', async (_req: Request, res: Response): Promise<void> => {
try {
const config = loadOrCreateUnifiedConfig();
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
const status = await getCopilotStatus(copilotConfig);
const installed = isCopilotApiInstalled();
const version = getCopilotInstalledVersion();
res.json({
enabled: copilotConfig.enabled,
installed,
version,
authenticated: status.auth.authenticated,
daemon_running: status.daemon.running,
port: copilotConfig.port,
model: copilotConfig.model,
account_type: copilotConfig.account_type,
auto_start: copilotConfig.auto_start,
rate_limit: copilotConfig.rate_limit,
wait_on_limit: copilotConfig.wait_on_limit,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/copilot/config - Get Copilot configuration
*/
apiRoutes.get('/copilot/config', (_req: Request, res: Response): void => {
try {
const config = loadOrCreateUnifiedConfig();
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
res.json(copilotConfig);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* PUT /api/copilot/config - Update Copilot configuration
*/
apiRoutes.put('/copilot/config', (req: Request, res: Response): void => {
try {
const updates = req.body;
const config = loadOrCreateUnifiedConfig();
// Merge updates with existing config
config.copilot = {
enabled: updates.enabled ?? config.copilot?.enabled ?? DEFAULT_COPILOT_CONFIG.enabled,
auto_start:
updates.auto_start ?? config.copilot?.auto_start ?? DEFAULT_COPILOT_CONFIG.auto_start,
port: updates.port ?? config.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port,
account_type:
updates.account_type ?? config.copilot?.account_type ?? DEFAULT_COPILOT_CONFIG.account_type,
rate_limit:
updates.rate_limit !== undefined
? updates.rate_limit
: (config.copilot?.rate_limit ?? DEFAULT_COPILOT_CONFIG.rate_limit),
wait_on_limit:
updates.wait_on_limit ??
config.copilot?.wait_on_limit ??
DEFAULT_COPILOT_CONFIG.wait_on_limit,
model: updates.model ?? config.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model,
// Model mapping for opus/sonnet/haiku tiers
opus_model:
updates.opus_model !== undefined ? updates.opus_model : config.copilot?.opus_model,
sonnet_model:
updates.sonnet_model !== undefined ? updates.sonnet_model : config.copilot?.sonnet_model,
haiku_model:
updates.haiku_model !== undefined ? updates.haiku_model : config.copilot?.haiku_model,
};
saveUnifiedConfig(config);
res.json({ success: true, copilot: config.copilot });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/copilot/auth/start - Start GitHub OAuth flow
* Note: This is a long-running operation that opens browser
*/
apiRoutes.post('/copilot/auth/start', async (_req: Request, res: Response): Promise<void> => {
try {
const result = await startCopilotAuth();
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/copilot/auth/status - Get auth status only
*/
apiRoutes.get('/copilot/auth/status', async (_req: Request, res: Response): Promise<void> => {
try {
const status = await checkCopilotAuth();
res.json(status);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/copilot/models - Get available models
*/
apiRoutes.get('/copilot/models', async (_req: Request, res: Response): Promise<void> => {
try {
const config = loadOrCreateUnifiedConfig();
const port = config.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port;
const currentModel = config.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model;
const models = await getCopilotModels(port);
// Mark current model
const modelsWithCurrent = models.map((m) => ({
...m,
isCurrent: m.id === currentModel,
}));
res.json({ models: modelsWithCurrent, current: currentModel });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/copilot/daemon/start - Start copilot-api daemon
*/
apiRoutes.post('/copilot/daemon/start', async (_req: Request, res: Response): Promise<void> => {
try {
const config = loadOrCreateUnifiedConfig();
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
const result = await startCopilotDaemon(copilotConfig);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/copilot/daemon/stop - Stop copilot-api daemon
*/
apiRoutes.post('/copilot/daemon/stop', async (_req: Request, res: Response): Promise<void> => {
try {
const result = await stopCopilotDaemon();
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/copilot/install - Install copilot-api
* Auto-installs latest version or specific version if provided
*/
apiRoutes.post('/copilot/install', async (req: Request, res: Response): Promise<void> => {
try {
const { version } = req.body || {};
if (version) {
// Install specific version
await installCopilotApiVersion(version);
} else {
// Install latest version
await ensureCopilotApi();
}
const info = getCopilotApiInfo();
res.json({
success: true,
installed: info.installed,
version: info.version,
path: info.path,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/copilot/info - Get copilot-api installation info
*/
apiRoutes.get('/copilot/info', (_req: Request, res: Response): void => {
try {
const info = getCopilotApiInfo();
res.json(info);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/copilot/settings/raw - Get raw copilot.settings.json
* Returns the raw JSON content for editing in the code editor
*/
apiRoutes.get('/copilot/settings/raw', (_req: Request, res: Response): void => {
try {
const settingsPath = path.join(getCcsDir(), 'copilot.settings.json');
const config = loadOrCreateUnifiedConfig();
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
// Default model for all tiers
const defaultModel = copilotConfig.model;
// If file doesn't exist, return default structure with all model mappings
if (!fs.existsSync(settingsPath)) {
// Create settings structure matching CLIProxy pattern - always include all model mappings
// Use 127.0.0.1 instead of localhost for more reliable local connections
const defaultSettings = {
env: {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${copilotConfig.port}`,
ANTHROPIC_AUTH_TOKEN: 'copilot-managed',
ANTHROPIC_MODEL: defaultModel,
ANTHROPIC_DEFAULT_OPUS_MODEL: copilotConfig.opus_model || defaultModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: copilotConfig.sonnet_model || defaultModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: copilotConfig.haiku_model || defaultModel,
},
};
res.json({
settings: defaultSettings,
mtime: Date.now(),
path: `~/.ccs/copilot.settings.json`,
exists: false,
});
return;
}
const content = fs.readFileSync(settingsPath, 'utf-8');
const settings = JSON.parse(content);
const stat = fs.statSync(settingsPath);
res.json({
settings,
mtime: stat.mtimeMs,
path: `~/.ccs/copilot.settings.json`,
exists: true,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* PUT /api/copilot/settings/raw - Save raw copilot.settings.json
* Saves the raw JSON content from the code editor
*/
apiRoutes.put('/copilot/settings/raw', (req: Request, res: Response): void => {
try {
const { settings, expectedMtime } = req.body;
const settingsPath = path.join(getCcsDir(), 'copilot.settings.json');
// Check for conflict if file exists and expectedMtime provided
if (fs.existsSync(settingsPath) && expectedMtime) {
const stat = fs.statSync(settingsPath);
if (Math.abs(stat.mtimeMs - expectedMtime) > 1000) {
res.status(409).json({ error: 'File modified externally', mtime: stat.mtimeMs });
return;
}
}
// Write settings file atomically
const tempPath = settingsPath + '.tmp';
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
fs.renameSync(tempPath, settingsPath);
// Also sync model mappings back to unified config
const config = loadOrCreateUnifiedConfig();
const env = settings.env || {};
config.copilot = {
...(config.copilot ?? DEFAULT_COPILOT_CONFIG),
model: env.ANTHROPIC_MODEL || config.copilot?.model || DEFAULT_COPILOT_CONFIG.model,
opus_model: env.ANTHROPIC_DEFAULT_OPUS_MODEL || undefined,
sonnet_model: env.ANTHROPIC_DEFAULT_SONNET_MODEL || undefined,
haiku_model: env.ANTHROPIC_DEFAULT_HAIKU_MODEL || undefined,
};
saveUnifiedConfig(config);
const stat = fs.statSync(settingsPath);
res.json({ success: true, mtime: stat.mtimeMs });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
+2
View File
@@ -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() {
<Route path="/api" element={<ApiPage />} />
<Route path="/cliproxy" element={<CliproxyPage />} />
<Route path="/cliproxy/control-panel" element={<CliproxyControlPanelPage />} />
<Route path="/copilot" element={<CopilotPage />} />
<Route path="/accounts" element={<AccountsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/health" element={<HealthPage />} />
+2
View File
@@ -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,
@@ -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 (
<div className="space-y-1.5">
<div>
<label className="text-xs font-medium">{label}</label>
{description && <p className="text-[10px] text-muted-foreground">{description}</p>}
</div>
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select model">
{value && (
<div className="flex items-center gap-2">
<span className="truncate font-mono text-xs">{value}</span>
{currentModel?.minPlan && (
<Badge
variant="outline"
className={`text-[9px] px-1 py-0 h-4 ${getPlanBadgeStyle(currentModel.minPlan)}`}
>
{currentModel.minPlan}
</Badge>
)}
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-[300px]">
<SelectGroup>
<SelectLabel className="text-xs text-muted-foreground">
Available Models ({models.length})
</SelectLabel>
{models.map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2">
<span className="truncate font-mono text-xs">{model.name || model.id}</span>
{model.minPlan && (
<Badge
variant="outline"
className={`text-[9px] px-1 py-0 h-4 ${getPlanBadgeStyle(model.minPlan)}`}
>
{model.minPlan}
</Badge>
)}
{model.multiplier !== undefined && (
<span className="text-[9px] text-muted-foreground">
{getMultiplierDisplay(model.multiplier)}
</span>
)}
{model.preview && (
<Badge variant="secondary" className="text-[9px] px-1 py-0 h-4">
Preview
</Badge>
)}
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
);
}
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<string | null>(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 = <K extends keyof typeof localOverrides>(
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 (
<div className="space-y-6">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
}
// Left column: Friendly UI
const renderFriendlyUI = () => (
<div className="h-full flex flex-col">
<Tabs defaultValue="config" className="h-full flex flex-col">
<div className="px-4 pt-4 shrink-0">
<TabsList className="w-full">
<TabsTrigger value="config" className="flex-1">
Model Config
</TabsTrigger>
<TabsTrigger value="settings" className="flex-1">
Settings
</TabsTrigger>
<TabsTrigger value="info" className="flex-1">
Info
</TabsTrigger>
</TabsList>
</div>
<div className="flex-1 overflow-hidden flex flex-col">
{/* Model Config Tab */}
<TabsContent
value="config"
className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden"
>
<ScrollArea className="flex-1">
<div className="p-4 space-y-6">
{/* Quick Presets */}
<div>
<h3 className="text-sm font-medium mb-2 flex items-center gap-2">
<Sparkles className="w-4 h-4" />
Presets
</h3>
<p className="text-xs text-muted-foreground mb-3">
Apply pre-configured model mappings
</p>
{/* Free Tier Presets */}
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<Badge
variant="outline"
className="text-[10px] bg-green-100 text-green-700 border-green-200"
>
Free Tier
</Badge>
<span className="text-[10px] text-muted-foreground">
No premium usage count
</span>
</div>
<div className="flex flex-wrap gap-2">
{FREE_PRESETS.map((preset) => (
<Button
key={preset.name}
variant="outline"
size="sm"
className="text-xs h-7 gap-1"
onClick={() => applyPreset(preset)}
title={preset.description}
>
<Zap className="w-3 h-3 text-green-600" />
{preset.name}
</Button>
))}
</div>
</div>
{/* Paid Tier Presets */}
<div>
<div className="flex items-center gap-2 mb-2">
<Badge
variant="outline"
className="text-[10px] bg-blue-100 text-blue-700 border-blue-200"
>
Pro+ Required
</Badge>
<span className="text-[10px] text-muted-foreground">
Uses premium request quota
</span>
</div>
<div className="flex flex-wrap gap-2">
{PAID_PRESETS.map((preset) => (
<Button
key={preset.name}
variant="outline"
size="sm"
className="text-xs h-7 gap-1"
onClick={() => applyPreset(preset)}
title={preset.description}
>
<Zap className="w-3 h-3" />
{preset.name}
</Button>
))}
</div>
</div>
</div>
<Separator />
{/* Model Mapping */}
<div>
<h3 className="text-sm font-medium mb-2">Model Mapping</h3>
<p className="text-xs text-muted-foreground mb-4">
Configure which models to use for each tier
</p>
<div className="space-y-4">
<FlexibleModelSelector
label="Default Model"
description="Used when no specific tier is requested"
value={currentModel}
onChange={(model) => updateField('model', model)}
models={models}
disabled={modelsLoading}
/>
<FlexibleModelSelector
label="Opus (Most capable)"
description="For complex reasoning tasks"
value={opusModel || currentModel}
onChange={(model) => updateField('opusModel', model)}
models={models}
disabled={modelsLoading}
/>
<FlexibleModelSelector
label="Sonnet (Balanced)"
description="Balance of speed and capability"
value={sonnetModel || currentModel}
onChange={(model) => updateField('sonnetModel', model)}
models={models}
disabled={modelsLoading}
/>
<FlexibleModelSelector
label="Haiku (Fast)"
description="Quick responses for simple tasks"
value={haikuModel || currentModel}
onChange={(model) => updateField('haikuModel', model)}
models={models}
disabled={modelsLoading}
/>
</div>
</div>
</div>
</ScrollArea>
</TabsContent>
{/* Settings Tab */}
<TabsContent
value="settings"
className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden"
>
<ScrollArea className="flex-1">
<div className="p-4 space-y-6">
{/* Enable Toggle */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="enabled" className="text-sm font-medium">
Enable Copilot
</Label>
<p className="text-xs text-muted-foreground">
Allow using GitHub Copilot subscription
</p>
</div>
<Switch
id="enabled"
checked={enabled}
onCheckedChange={(v) => updateField('enabled', v)}
/>
</div>
{/* Basic Settings */}
<div className="space-y-4">
<h3 className="text-sm font-medium">Basic Settings</h3>
{/* Port */}
<div className="space-y-2">
<Label htmlFor="port" className="text-xs">
Port
</Label>
<Input
id="port"
type="number"
value={port}
onChange={(e) => updateField('port', parseInt(e.target.value, 10))}
min={1024}
max={65535}
className="max-w-[150px] h-8"
/>
</div>
{/* Account Type */}
<div className="space-y-2">
<Label htmlFor="account-type" className="text-xs">
Account Type
</Label>
<Select
value={accountType}
onValueChange={(v) => updateField('accountType', v as typeof accountType)}
>
<SelectTrigger id="account-type" className="max-w-[150px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="individual">Individual</SelectItem>
<SelectItem value="business">Business</SelectItem>
<SelectItem value="enterprise">Enterprise</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Separator />
{/* Rate Limiting */}
<div className="space-y-4">
<h3 className="text-sm font-medium">Rate Limiting</h3>
<div className="space-y-2">
<Label htmlFor="rate-limit" className="text-xs">
Rate Limit (seconds)
</Label>
<Input
id="rate-limit"
type="number"
value={rateLimit}
onChange={(e) => updateField('rateLimit', e.target.value)}
placeholder="No limit"
min={0}
className="max-w-[150px] h-8"
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="wait-on-limit" className="text-xs">
Wait on Rate Limit
</Label>
<p className="text-[10px] text-muted-foreground">
Wait instead of error when limit hit
</p>
</div>
<Switch
id="wait-on-limit"
checked={waitOnLimit}
onCheckedChange={(v) => updateField('waitOnLimit', v)}
/>
</div>
</div>
<Separator />
{/* Daemon Settings */}
<div className="space-y-4">
<h3 className="text-sm font-medium">Daemon Settings</h3>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="auto-start" className="text-xs">
Auto-start Daemon
</Label>
<p className="text-[10px] text-muted-foreground">
Start copilot-api when using profile
</p>
</div>
<Switch
id="auto-start"
checked={autoStart}
onCheckedChange={(v) => updateField('autoStart', v)}
/>
</div>
</div>
</div>
</ScrollArea>
</TabsContent>
{/* Info Tab */}
<TabsContent
value="info"
className="h-full mt-0 border-0 p-0 data-[state=inactive]:hidden"
>
<ScrollArea className="h-full">
<div className="p-4 space-y-6">
<div>
<h3 className="text-sm font-medium flex items-center gap-2 mb-3">
<Info className="w-4 h-4" />
Configuration Info
</h3>
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">Provider</span>
<span className="font-mono">GitHub Copilot</span>
</div>
{rawSettings && (
<>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">File Path</span>
<div className="flex items-center gap-2 min-w-0">
<code className="bg-muted px-1.5 py-0.5 rounded text-xs break-all">
{rawSettings.path}
</code>
<CopyButton value={rawSettings.path} size="icon" className="h-5 w-5" />
</div>
</div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">Status</span>
<Badge
variant="outline"
className={
rawSettings.exists
? 'w-fit text-green-600 border-green-200 bg-green-50'
: 'w-fit text-muted-foreground'
}
>
{rawSettings.exists ? 'File exists' : 'Using defaults'}
</Badge>
</div>
</>
)}
</div>
</div>
<div>
<h3 className="text-sm font-medium mb-3">Quick Usage</h3>
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
<UsageCommand label="Run with Copilot" command="ccs copilot" />
<UsageCommand label="Authenticate" command="ccs copilot auth" />
<UsageCommand label="Start daemon" command="ccs copilot --start" />
<UsageCommand label="Stop daemon" command="ccs copilot --stop" />
</div>
</div>
</div>
</ScrollArea>
</TabsContent>
</div>
</Tabs>
</div>
);
// Right column: Raw JSON Editor
const renderRawEditor = () => (
<Suspense
fallback={
<div className="flex items-center justify-center h-full">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading editor...</span>
</div>
}
>
<div className="h-full flex flex-col">
{!isRawJsonValid && rawJsonEdits !== null && (
<div className="mb-2 px-3 py-2 bg-destructive/10 text-destructive text-sm rounded-md flex items-center gap-2 mx-6 mt-4 shrink-0">
<X className="w-4 h-4" />
Invalid JSON syntax
</div>
)}
<div className="flex-1 overflow-hidden px-6 pb-6 pt-4">
<div className="h-full border rounded-md overflow-hidden bg-background">
<CodeEditor
value={rawJsonContent}
onChange={handleRawJsonChange}
language="json"
minHeight="100%"
/>
</div>
</div>
</div>
</Suspense>
);
return (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b bg-background flex items-center justify-between shrink-0">
<div className="flex items-center gap-3">
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">Copilot Configuration</h2>
{rawSettings && (
<Badge variant="outline" className="text-xs">
copilot.settings.json
</Badge>
)}
</div>
{rawSettings && (
<p className="text-xs text-muted-foreground mt-0.5">
Last modified:{' '}
{rawSettings.exists ? new Date(rawSettings.mtime).toLocaleString() : 'Never saved'}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => refetchRawSettings()}
disabled={rawSettingsLoading}
>
<RefreshCw className={`w-4 h-4 ${rawSettingsLoading ? 'animate-spin' : ''}`} />
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={isUpdating || isSavingRawSettings || !hasChanges || !isRawJsonValid}
>
{isUpdating || isSavingRawSettings ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-4 h-4 mr-1" />
Save
</>
)}
</Button>
</div>
</div>
{/* Split Layout - Left panel constrained, Right panel flexible */}
<div className="flex-1 flex divide-x overflow-hidden">
{/* Left Column: Friendly UI - constrained width */}
<div className="w-[540px] shrink-0 flex flex-col overflow-hidden bg-muted/5">
{renderFriendlyUI()}
</div>
{/* Right Column: Raw Editor - takes remaining space */}
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
<div className="px-6 py-2 bg-muted/30 border-b flex items-center gap-2 shrink-0 h-[45px]">
<Code2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">
Raw Configuration (JSON)
</span>
</div>
{renderRawEditor()}
</div>
</div>
<ConfirmDialog
open={conflictDialog}
title="File Modified Externally"
description="This settings file was modified by another process. Overwrite with your changes or discard?"
confirmText="Overwrite"
variant="destructive"
onConfirm={() => handleConflictResolve(true)}
onCancel={() => handleConflictResolve(false)}
/>
</div>
);
}
/** Usage command with copy button */
function UsageCommand({ label, command }: { label: string; command: string }) {
return (
<div>
<label className="text-xs text-muted-foreground">{label}</label>
<div className="mt-1 flex gap-2">
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
{command}
</code>
<CopyButton value={command} size="icon" className="h-6 w-6" />
</div>
</div>
);
}
@@ -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 (
<Card>
<CardHeader>
<CardTitle>GitHub Copilot Status</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</CardContent>
</Card>
);
}
if (!status) {
return (
<Card>
<CardHeader>
<CardTitle>GitHub Copilot Status</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">Failed to load status</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
GitHub Copilot Status
{status.enabled ? (
<Badge variant="default">Enabled</Badge>
) : (
<Badge variant="secondary">Disabled</Badge>
)}
</CardTitle>
<CardDescription>Use your GitHub Copilot subscription with Claude Code</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Warning Banner */}
<div className="flex items-start gap-2 rounded-md border border-yellow-500/20 bg-yellow-500/10 p-3">
<AlertTriangle className="h-5 w-5 text-yellow-500 shrink-0 mt-0.5" />
<p className="text-sm text-yellow-700 dark:text-yellow-300">
This uses a reverse-engineered API. Excessive usage may trigger GitHub abuse detection.
</p>
</div>
{/* Status Grid */}
<div className="grid gap-4 md:grid-cols-3">
{/* Installed */}
<div className="flex items-center gap-2">
{status.installed ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : (
<XCircle className="h-5 w-5 text-red-500" />
)}
<span className="text-sm">
copilot-api {status.installed ? `v${status.version}` : 'Not Installed'}
</span>
</div>
{/* Authenticated */}
<div className="flex items-center gap-2">
{status.authenticated ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : (
<XCircle className="h-5 w-5 text-red-500" />
)}
<span className="text-sm">
{status.authenticated ? 'Authenticated' : 'Not Authenticated'}
</span>
</div>
{/* Daemon */}
<div className="flex items-center gap-2">
{status.daemon_running ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : (
<XCircle className="h-5 w-5 text-muted-foreground" />
)}
<span className="text-sm">Daemon {status.daemon_running ? 'Running' : 'Stopped'}</span>
</div>
</div>
{/* Quick Info */}
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
<span>Port: {status.port}</span>
<span>Model: {status.model}</span>
<span>Auto-start: {status.auto_start ? 'Yes' : 'No'}</span>
</div>
{/* Actions */}
<div className="flex flex-wrap gap-2 pt-2">
{!status.installed && (
<Button onClick={() => install(undefined)} disabled={isInstalling} size="sm">
{isInstalling ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Installing...
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
Install copilot-api
</>
)}
</Button>
)}
{!status.authenticated && (
<Button
onClick={() => startAuth()}
disabled={isAuthenticating || !status.installed}
size="sm"
>
{isAuthenticating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Authenticating...
</>
) : (
'Authenticate with GitHub'
)}
</Button>
)}
{status.daemon_running ? (
<Button
onClick={() => stopDaemon()}
disabled={isStoppingDaemon}
variant="outline"
size="sm"
>
{isStoppingDaemon ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Stopping...
</>
) : (
'Stop Daemon'
)}
</Button>
) : (
<Button
onClick={() => startDaemon()}
disabled={isStartingDaemon || !status.authenticated}
variant="outline"
size="sm"
>
{isStartingDaemon ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Starting...
</>
) : (
'Start Daemon'
)}
</Button>
)}
</div>
</CardContent>
</Card>
);
}
+300
View File
@@ -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<string, string>;
};
mtime: number;
path: string;
exists: boolean;
}
// API functions
async function fetchCopilotStatus(): Promise<CopilotStatus> {
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<CopilotConfig> {
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<CopilotRawSettings> {
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<CopilotConfig>): 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<CopilotAuthResult> {
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<CopilotInfo> {
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<CopilotInstallResult> {
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,
};
}
+295
View File
@@ -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 (
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3">
{title}
</div>
<div className="space-y-1">{children}</div>
</div>
);
}
// 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 (
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-muted/50">
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm">{label}</span>
</div>
<div className="flex items-center gap-1.5">
{status ? (
<>
<CheckCircle2
className={cn(
'w-4 h-4',
variant === 'warning' ? 'text-yellow-500' : 'text-green-500'
)}
/>
<span
className={cn(
'text-xs',
variant === 'warning' ? 'text-yellow-500' : 'text-green-500'
)}
>
{statusText || 'Yes'}
</span>
</>
) : (
<>
<XCircle className="w-4 h-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">{statusText || 'No'}</span>
</>
)}
</div>
</div>
);
}
// Empty state when loading
function LoadingSidebar() {
return (
<div className="space-y-4 p-4">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
);
}
export function CopilotPage() {
const {
status,
statusLoading,
refetchStatus,
startAuth,
isAuthenticating,
startDaemon,
isStartingDaemon,
stopDaemon,
isStoppingDaemon,
install,
isInstalling,
} = useCopilot();
return (
<div className="h-[calc(100vh-100px)] flex">
{/* Left Sidebar - Status Overview */}
<div className="w-80 border-r flex flex-col bg-muted/30 shrink-0">
{/* Header */}
<div className="p-4 border-b bg-background">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<Github className="w-5 h-5 text-primary" />
<h1 className="font-semibold">Copilot</h1>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => refetchStatus()}
disabled={statusLoading}
>
<RefreshCw className={cn('w-4 h-4', statusLoading && 'animate-spin')} />
</Button>
</div>
<p className="text-xs text-muted-foreground">GitHub Copilot proxy</p>
</div>
{/* Status Overview */}
<ScrollArea className="flex-1">
{statusLoading ? (
<LoadingSidebar />
) : (
<div className="p-3 space-y-4">
{/* Warning Banner - Disclaimer */}
<div className="rounded-md border border-yellow-500/50 bg-yellow-500/15 p-3 space-y-1.5">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 shrink-0" />
<span className="text-xs font-semibold text-yellow-800 dark:text-yellow-200">
Unofficial API - Use at Your Own Risk
</span>
</div>
<ul className="text-[11px] text-yellow-700 dark:text-yellow-300 space-y-0.5 pl-6 list-disc">
<li>Reverse-engineered API - may break anytime</li>
<li>Excessive use may trigger account restrictions</li>
<li>No warranty, no responsibility from CCS</li>
</ul>
</div>
{/* Setup - Binary first, then enabled status */}
<StatusSection title="Setup">
<StatusItem
icon={Server}
label="copilot-api"
status={status?.installed ?? false}
statusText={
status?.installed
? status.version
? `v${status.version}`
: 'Installed'
: 'Missing'
}
/>
{!status?.installed && (
<Button
size="sm"
className="w-full mt-2"
onClick={() => install(undefined)}
disabled={isInstalling}
>
{isInstalling ? (
<>
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" />
Installing...
</>
) : (
<>
<Download className="w-3.5 h-3.5 mr-1.5" />
Install copilot-api
</>
)}
</Button>
)}
{status?.installed && (
<StatusItem
icon={Power}
label="Integration"
status={status?.enabled ?? false}
statusText={status?.enabled ? 'Enabled' : 'Disabled'}
/>
)}
</StatusSection>
{/* Authentication - only show after binary installed */}
{status?.installed && (
<StatusSection title="Auth">
<StatusItem
icon={Key}
label="GitHub"
status={status?.authenticated ?? false}
statusText={status?.authenticated ? 'Connected' : 'Not Connected'}
/>
{!status?.authenticated && (
<Button
size="sm"
className="w-full mt-2"
onClick={() => startAuth()}
disabled={isAuthenticating}
>
{isAuthenticating ? 'Authenticating...' : 'Authenticate'}
</Button>
)}
</StatusSection>
)}
{/* Daemon - only show after authenticated */}
{status?.authenticated && (
<StatusSection title="Daemon">
<StatusItem
icon={Cpu}
label="Status"
status={status?.daemon_running ?? false}
statusText={status?.daemon_running ? 'Running' : 'Stopped'}
/>
<div className="px-3 py-1 text-xs text-muted-foreground">
Port: {status?.port ?? 4141}
</div>
<div className="px-1">
{status?.daemon_running ? (
<Button
size="sm"
variant="outline"
className="w-full"
onClick={() => stopDaemon()}
disabled={isStoppingDaemon}
>
<PowerOff className="w-3.5 h-3.5 mr-1.5" />
{isStoppingDaemon ? 'Stopping...' : 'Stop'}
</Button>
) : (
<Button
size="sm"
variant="outline"
className="w-full"
onClick={() => startDaemon()}
disabled={isStartingDaemon}
>
<Power className="w-3.5 h-3.5 mr-1.5" />
{isStartingDaemon ? 'Starting...' : 'Start'}
</Button>
)}
</div>
</StatusSection>
)}
</div>
)}
</ScrollArea>
{/* Footer */}
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span>Proxy</span>
{status?.daemon_running ? (
<span className="flex items-center gap-1">
<CheckCircle2 className="w-3 h-3 text-green-500" />
Active
</span>
) : (
<span className="flex items-center gap-1">
<XCircle className="w-3 h-3 text-muted-foreground" />
Inactive
</span>
)}
</div>
</div>
</div>
{/* Right Panel - Split-view Configuration Form */}
<div className="flex-1 flex flex-col min-w-0 bg-background overflow-hidden">
<CopilotConfigForm />
</div>
</div>
);
}