mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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(' ');
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user