diff --git a/CHANGELOG.md b/CHANGELOG.md index fc348674..a933c608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,74 @@ Format: [Keep a Changelog](https://keepachangelog.com/) +## [5.0.0] - 2025-11-28 + +### Added +- **CLIProxy OAuth Profiles**: Three new zero-config profiles powered by CLIProxyAPI + - `ccs gemini` - Google Gemini via OAuth (zero config) + - `ccs chatgpt` - ChatGPT/OpenAI Codex via OAuth (zero config) + - `ccs qwen` - Alibaba Qwen via OAuth (zero config) + +- **Download-on-Demand Binary**: CLIProxyAPI binary (~15MB) downloads automatically on first use + - Supports 6 platforms: darwin/linux/windows × amd64/arm64 + - SHA256 checksum verification + - 3x retry with exponential backoff + - No npm package size impact + +- **OAuth Authentication System** (`src/cliproxy/auth-handler.ts`): + - Browser-based OAuth flow with automatic token storage + - Headless mode fallback (`ccs gemini --auth --headless`) + - Token storage in `~/.ccs/cliproxy-auth//` + - 2-minute OAuth timeout protection + +- **CLIProxy Diagnostics** in `ccs doctor`: + - Binary installation status + version + - Config file validation + - OAuth status per provider (gemini/chatgpt/qwen) + - Port 8317 availability check + +- **Enhanced Error Messages** (`src/utils/error-manager.ts`): + - OAuth timeout troubleshooting + - Port conflict resolution + - Binary download failure with manual URL + +- **New CLIProxy Module** (`src/cliproxy/`): + - `binary-manager.ts` - Download, verify, extract binary + - `platform-detector.ts` - OS/arch detection for 6 platforms + - `cliproxy-executor.ts` - Spawn/kill proxy pattern + - `config-generator.ts` - Generate config.yaml per provider + - `auth-handler.ts` - OAuth token management + - `types.ts` - TypeScript type definitions + - `index.ts` - Central exports + +### Changed +- **Profile Detection**: New priority order + 1. CLIProxy profiles (gemini, chatgpt, qwen) + 2. Settings-based profiles (glm, glmt, kimi) + 3. Account-based profiles (work, personal) + 4. Default Claude CLI +- **Help Text**: Updated with new OAuth profiles (alphabetically sorted) +- **Profile Detector**: Added `cliproxy` profile type + +### Technical Details +- **Binary Version**: CLIProxyAPI v6.5.27 +- **Default Port**: 8317 (TCP polling for readiness, no PROXY_READY signal) +- **Model Mappings**: + - Gemini: gemini-2.0-flash (opus: thinking-exp, haiku: flash-lite) + - ChatGPT: gpt-4o (opus: o1, haiku: gpt-4o-mini) + - Qwen: qwen-max (sonnet: qwen-plus, haiku: qwen-turbo) +- **Storage**: + - Binary: `~/.ccs/bin/cliproxyapi` + - Tokens: `~/.ccs/cliproxy-auth//` + - Config: `~/.ccs/cliproxy.config.yaml` + +### Migration +- **No breaking changes**: All existing profiles (glm, glmt, kimi, accounts) work unchanged +- **Zero configuration**: OAuth profiles work out-of-box after browser login +- **Backward compatible**: v4.x commands and workflows unchanged + +--- + ## [4.5.0] - 2025-11-27 (Phase 02 Complete) ### Changed diff --git a/VERSION b/VERSION index a84947d6..0062ac97 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.5.0 +5.0.0 diff --git a/installers/install.ps1 b/installers/install.ps1 index 5269b903..99437d13 100644 --- a/installers/install.ps1 +++ b/installers/install.ps1 @@ -83,7 +83,7 @@ $InstallMethod = if ($ScriptDir -and ((Test-Path "$ScriptDir\lib\ccs.ps1") -or ( # IMPORTANT: Update this version when releasing new versions! # This hardcoded version is used for standalone installations (irm | iex) # For git installations, VERSION file is read if available -$CcsVersion = "4.5.0" +$CcsVersion = "5.0.0" # Try to read VERSION file for git installations if ($ScriptDir) { diff --git a/installers/install.sh b/installers/install.sh index 348fb381..44a0a336 100755 --- a/installers/install.sh +++ b/installers/install.sh @@ -84,7 +84,7 @@ fi # IMPORTANT: Update this version when releasing new versions! # This hardcoded version is used for standalone installations (curl | bash) # For git installations, VERSION file is read if available -CCS_VERSION="4.5.0" +CCS_VERSION="5.0.0" # Try to read VERSION file for git installations if [[ -f "$SCRIPT_DIR/VERSION" ]]; then diff --git a/package.json b/package.json index 0da342e4..90e44715 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "4.5.0", + "version": "5.0.0", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index 3108feba..6a577fc1 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -11,7 +11,11 @@ import * as os from 'os'; import { findSimilarStrings } from '../utils/helpers'; import { Config, Settings, ProfileMetadata } from '../types'; -export type ProfileType = 'settings' | 'account' | 'default'; +export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'default'; + +/** CLIProxy profile names (OAuth-based, zero config) */ +export const CLIPROXY_PROFILES = ['gemini', 'chatgpt', 'qwen'] as const; +export type CLIProxyProfileName = (typeof CLIPROXY_PROFILES)[number]; export interface ProfileDetectionResult { type: ProfileType; @@ -88,6 +92,14 @@ class ProfileDetector { return this.resolveDefaultProfile(); } + // Priority 0: Check CLIProxy profiles (gemini, chatgpt, qwen) - OAuth-based, zero config + if (CLIPROXY_PROFILES.includes(profileName as CLIProxyProfileName)) { + return { + type: 'cliproxy', + name: profileName, + }; + } + // Priority 1: Check settings-based profiles (glm, kimi) - BACKWARD COMPATIBILITY const config = this.readConfig(); @@ -162,6 +174,12 @@ class ProfileDetector { private listAvailableProfiles(): string { const lines: string[] = []; + // CLIProxy profiles (OAuth-based, always available) + lines.push('CLIProxy profiles (OAuth, zero config):'); + CLIPROXY_PROFILES.forEach((name) => { + lines.push(` - ${name}`); + }); + // Settings-based profiles const config = this.readConfig(); const settingsProfiles = Object.keys(config.profiles || {}); @@ -185,13 +203,6 @@ class ProfileDetector { }); } - if (lines.length === 0) { - return ( - ' (no profiles configured)\n' + - ' Run "ccs auth save " to create your first account profile.' - ); - } - return lines.join('\n'); } @@ -210,13 +221,14 @@ class ProfileDetector { /** * Get all available profile names */ - getAllProfiles(): AllProfiles { + getAllProfiles(): AllProfiles & { cliproxy: string[] } { const config = this.readConfig(); const profiles = this.readProfiles(); return { settings: Object.keys(config.profiles || {}), accounts: Object.keys(profiles.profiles || {}), + cliproxy: [...CLIPROXY_PROFILES], default: profiles.default, }; } diff --git a/src/ccs.ts b/src/ccs.ts index 04bf9179..a92ac2f1 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -13,6 +13,7 @@ import * as fs from 'fs'; import { detectClaudeCli } from './utils/claude-detector'; import { getSettingsPath } from './utils/config-manager'; import { ErrorManager } from './utils/error-manager'; +import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy'; // Import extracted command handlers import { handleVersionCommand } from './commands/version-command'; @@ -293,7 +294,10 @@ async function main(): Promise { try { const profileInfo = detector.detectProfileType(profile); - if (profileInfo.type === 'settings') { + if (profileInfo.type === 'cliproxy') { + // CLIPROXY FLOW: OAuth-based profiles (gemini, chatgpt, qwen) + await execClaudeWithCLIProxy(claudeCli, profileInfo.name as CLIProxyProvider, remainingArgs); + } else if (profileInfo.type === 'settings') { // Check if this is GLMT profile (requires proxy) if (profileInfo.name === 'glmt') { // GLMT FLOW: Settings-based with embedded proxy for thinking support diff --git a/src/cliproxy/auth-handler.ts b/src/cliproxy/auth-handler.ts new file mode 100644 index 00000000..c95ff167 --- /dev/null +++ b/src/cliproxy/auth-handler.ts @@ -0,0 +1,354 @@ +/** + * Auth Handler for CLIProxyAPI + * + * Manages OAuth authentication for CLIProxy providers (Gemini, ChatGPT, Qwen). + * CLIProxyAPI handles OAuth internally - we just need to: + * 1. Check if auth exists (token files in auth-dir) + * 2. Trigger OAuth flow by spawning binary with auth flag + * 3. Provide headless fallback (display URL for manual auth) + * + * Token storage: ~/.ccs/cliproxy-auth// + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { spawn } from 'child_process'; +import { ProgressIndicator } from '../utils/progress-indicator'; +import { getAuthDir } from './config-generator'; +import { ensureCLIProxyBinary } from './binary-manager'; +import { CLIProxyProvider } from './types'; + +/** + * Auth status for a provider + */ +export interface AuthStatus { + /** Provider name */ + provider: CLIProxyProvider; + /** Whether authentication exists */ + authenticated: boolean; + /** Path to token directory */ + tokenDir: string; + /** Token file paths found */ + tokenFiles: string[]; + /** When last authenticated (if known) */ + lastAuth?: Date; +} + +/** + * OAuth config for each provider + */ +interface ProviderOAuthConfig { + /** Provider identifier */ + provider: CLIProxyProvider; + /** Display name */ + displayName: string; + /** OAuth authorization URL (for manual flow) */ + authUrl: string; + /** Scopes required */ + scopes: string[]; + /** CLI flag for auth */ + authFlag: string; +} + +/** + * OAuth configurations per provider + * Note: CLIProxyAPI handles actual OAuth - these are for display/manual flow + */ +const OAUTH_CONFIGS: Record = { + gemini: { + provider: 'gemini', + displayName: 'Google Gemini', + authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + scopes: ['https://www.googleapis.com/auth/generative-language'], + authFlag: '--auth-gemini', + }, + chatgpt: { + provider: 'chatgpt', + displayName: 'ChatGPT/OpenAI', + authUrl: 'https://auth.openai.com/authorize', + scopes: ['openid', 'profile'], + authFlag: '--auth-codex', + }, + qwen: { + provider: 'qwen', + displayName: 'Alibaba Qwen', + authUrl: 'https://auth.aliyun.com/oauth2/authorize', + scopes: ['dashscope'], + authFlag: '--auth-qwen', + }, +}; + +/** + * Get OAuth config for provider + */ +export function getOAuthConfig(provider: CLIProxyProvider): ProviderOAuthConfig { + const config = OAUTH_CONFIGS[provider]; + if (!config) { + throw new Error(`Unknown provider: ${provider}`); + } + return config; +} + +/** + * Get token directory for provider + */ +export function getProviderTokenDir(provider: CLIProxyProvider): string { + return path.join(getAuthDir(), provider); +} + +/** + * Check if provider has valid authentication + */ +export function isAuthenticated(provider: CLIProxyProvider): boolean { + const tokenDir = getProviderTokenDir(provider); + + if (!fs.existsSync(tokenDir)) { + return false; + } + + // Check for any token files (CLIProxyAPI stores tokens with various names) + const files = fs.readdirSync(tokenDir); + const tokenFiles = files.filter( + (f) => f.endsWith('.json') || f.endsWith('.token') || f === 'credentials' + ); + + return tokenFiles.length > 0; +} + +/** + * Get detailed auth status for provider + */ +export function getAuthStatus(provider: CLIProxyProvider): AuthStatus { + const tokenDir = getProviderTokenDir(provider); + let tokenFiles: string[] = []; + let lastAuth: Date | undefined; + + if (fs.existsSync(tokenDir)) { + const files = fs.readdirSync(tokenDir); + tokenFiles = files.filter( + (f) => f.endsWith('.json') || f.endsWith('.token') || f === 'credentials' + ); + + // Get most recent modification time + for (const file of tokenFiles) { + const filePath = path.join(tokenDir, file); + const stats = fs.statSync(filePath); + if (!lastAuth || stats.mtime > lastAuth) { + lastAuth = stats.mtime; + } + } + } + + return { + provider, + authenticated: tokenFiles.length > 0, + tokenDir, + tokenFiles, + lastAuth, + }; +} + +/** + * Get auth status for all providers + */ +export function getAllAuthStatus(): AuthStatus[] { + const providers: CLIProxyProvider[] = ['gemini', 'chatgpt', 'qwen']; + return providers.map(getAuthStatus); +} + +/** + * Clear authentication for provider + */ +export function clearAuth(provider: CLIProxyProvider): boolean { + const tokenDir = getProviderTokenDir(provider); + + if (!fs.existsSync(tokenDir)) { + return false; + } + + // Remove all files in token directory + const files = fs.readdirSync(tokenDir); + for (const file of files) { + fs.unlinkSync(path.join(tokenDir, file)); + } + + // Remove directory + fs.rmdirSync(tokenDir); + + return true; +} + +/** + * Trigger OAuth flow for provider + * Opens browser for user authentication + */ +export async function triggerOAuth( + provider: CLIProxyProvider, + options: { verbose?: boolean; headless?: boolean } = {} +): Promise { + const oauthConfig = getOAuthConfig(provider); + const { verbose = false, headless = false } = options; + + const log = (msg: string) => { + if (verbose) { + console.error(`[auth] ${msg}`); + } + }; + + // Ensure binary exists + let binaryPath: string; + try { + binaryPath = await ensureCLIProxyBinary(verbose); + } catch (error) { + console.error('[X] Failed to prepare CLIProxy binary'); + throw error; + } + + // Ensure auth directory exists + const tokenDir = getProviderTokenDir(provider); + fs.mkdirSync(tokenDir, { recursive: true, mode: 0o700 }); + + // Headless mode: display manual instructions + if (headless) { + console.log(''); + console.log(`[i] Headless mode: Manual authentication required for ${oauthConfig.displayName}`); + console.log(''); + console.log('Instructions:'); + console.log(` 1. Run CLIProxyAPI binary with auth flag on a machine with browser:`); + console.log(` ${binaryPath} ${oauthConfig.authFlag}`); + console.log(''); + console.log(` 2. Complete OAuth in browser`); + console.log(''); + console.log(` 3. Copy token files from CLIProxyAPI auth directory to:`); + console.log(` ${tokenDir}`); + console.log(''); + return false; + } + + // Standard mode: spawn binary with auth flag + const spinner = new ProgressIndicator(`Authenticating with ${oauthConfig.displayName}`); + spinner.start(); + + console.log(''); + console.log(`[i] Opening browser for ${oauthConfig.displayName} authentication...`); + console.log('[i] Complete the login in your browser.'); + console.log(''); + + return new Promise((resolve) => { + // Spawn CLIProxyAPI with auth flag + // Note: CLIProxyAPI handles the OAuth flow internally + const authProcess = spawn(binaryPath, [oauthConfig.authFlag], { + stdio: verbose ? 'inherit' : ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + // Set auth directory for CLIProxyAPI + CLI_PROXY_AUTH_DIR: tokenDir, + }, + }); + + let stderrData = ''; + + if (!verbose) { + authProcess.stdout?.on('data', (data: Buffer) => { + log(`stdout: ${data.toString().trim()}`); + }); + + authProcess.stderr?.on('data', (data: Buffer) => { + stderrData += data.toString(); + log(`stderr: ${data.toString().trim()}`); + }); + } + + // Timeout after 2 minutes + const timeout = setTimeout(() => { + spinner.fail('Authentication timeout'); + authProcess.kill(); + console.error('[X] OAuth timed out after 2 minutes'); + console.error(''); + console.error('Troubleshooting:'); + console.error(' - Make sure a browser is available'); + console.error(' - Try running with --verbose for details'); + console.error(` - For headless systems, use: ccs ${provider} --auth --headless`); + resolve(false); + }, 120000); + + authProcess.on('exit', (code) => { + clearTimeout(timeout); + + if (code === 0) { + spinner.succeed(`Authenticated with ${oauthConfig.displayName}`); + + // Verify token was created + if (isAuthenticated(provider)) { + console.log('[OK] Authentication successful'); + resolve(true); + } else { + spinner.fail('Authentication incomplete'); + console.error('[X] Token not found after authentication'); + console.error(' The OAuth flow may have been cancelled'); + resolve(false); + } + } else { + spinner.fail('Authentication failed'); + console.error(`[X] CLIProxyAPI auth exited with code ${code}`); + if (stderrData) { + console.error(` ${stderrData.trim()}`); + } + resolve(false); + } + }); + + authProcess.on('error', (error) => { + clearTimeout(timeout); + spinner.fail('Authentication error'); + console.error(`[X] Failed to start auth process: ${error.message}`); + resolve(false); + }); + }); +} + +/** + * Ensure provider is authenticated + * Triggers OAuth flow if not authenticated + */ +export async function ensureAuth( + provider: CLIProxyProvider, + options: { verbose?: boolean; headless?: boolean } = {} +): Promise { + // Check if already authenticated + if (isAuthenticated(provider)) { + if (options.verbose) { + console.error(`[auth] ${provider} already authenticated`); + } + return true; + } + + // Not authenticated - trigger OAuth + const oauthConfig = getOAuthConfig(provider); + console.log(`[i] ${oauthConfig.displayName} authentication required`); + + return triggerOAuth(provider, options); +} + +/** + * Display auth status for all providers + */ +export function displayAuthStatus(): void { + console.log('CLIProxy Authentication Status:'); + console.log(''); + + const statuses = getAllAuthStatus(); + + for (const status of statuses) { + const oauthConfig = getOAuthConfig(status.provider); + const icon = status.authenticated ? '[OK]' : '[!]'; + const authStatus = status.authenticated ? 'Authenticated' : 'Not authenticated'; + const lastAuthStr = status.lastAuth ? ` (last: ${status.lastAuth.toLocaleDateString()})` : ''; + + console.log(`${icon} ${oauthConfig.displayName}: ${authStatus}${lastAuthStr}`); + } + + console.log(''); + console.log('To authenticate: ccs --auth'); + console.log('To logout: ccs --logout'); +} diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts new file mode 100644 index 00000000..304da600 --- /dev/null +++ b/src/cliproxy/binary-manager.ts @@ -0,0 +1,642 @@ +/** + * Binary Manager for CLIProxyAPI + * + * Download-on-demand binary manager: + * - Downloads platform-specific binary from GitHub releases + * - Verifies SHA256 checksum + * - Extracts and caches binary locally + * - Supports retry logic with exponential backoff + * + * Pattern: Mirrors npm install behavior (fast check, download only when needed) + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as https from 'https'; +import * as http from 'http'; +import * as crypto from 'crypto'; +import * as zlib from 'zlib'; +import { getCcsDir } from '../utils/config-manager'; +import { ProgressIndicator } from '../utils/progress-indicator'; +import { + BinaryInfo, + BinaryManagerConfig, + ChecksumResult, + DownloadResult, + ProgressCallback, +} from './types'; +import { + detectPlatform, + getDownloadUrl, + getChecksumsUrl, + getExecutableName, + CLIPROXY_VERSION, +} from './platform-detector'; + +/** Default configuration */ +const DEFAULT_CONFIG: BinaryManagerConfig = { + version: CLIPROXY_VERSION, + releaseUrl: 'https://github.com/router-for-me/CLIProxyAPI/releases/download', + binPath: path.join(getCcsDir(), 'bin'), + maxRetries: 3, + verbose: false, +}; + +/** + * Binary Manager class for CLIProxyAPI binary lifecycle + */ +export class BinaryManager { + private config: BinaryManagerConfig; + private verbose: boolean; + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + this.verbose = this.config.verbose; + } + + /** + * Ensure binary is available (download if missing) + * @returns Path to executable binary + */ + async ensureBinary(): Promise { + const binaryPath = this.getBinaryPath(); + + // Check if binary already exists + if (fs.existsSync(binaryPath)) { + this.log(`Binary exists: ${binaryPath}`); + return binaryPath; + } + + // Download, verify, extract + this.log('Binary not found, downloading...'); + await this.downloadAndInstall(); + + return binaryPath; + } + + /** + * Get full path to binary executable + */ + getBinaryPath(): string { + const execName = getExecutableName(); + return path.join(this.config.binPath, execName); + } + + /** + * Check if binary exists + */ + isBinaryInstalled(): boolean { + return fs.existsSync(this.getBinaryPath()); + } + + /** + * Get binary info if installed + */ + async getBinaryInfo(): Promise { + const binaryPath = this.getBinaryPath(); + if (!fs.existsSync(binaryPath)) { + return null; + } + + const platform = detectPlatform(); + const checksum = await this.computeChecksum(binaryPath); + + return { + path: binaryPath, + version: this.config.version, + platform, + checksum, + }; + } + + /** + * Download and install binary + */ + private async downloadAndInstall(): Promise { + const platform = detectPlatform(); + const downloadUrl = getDownloadUrl(this.config.version); + const checksumsUrl = getChecksumsUrl(this.config.version); + + // Ensure bin directory exists + fs.mkdirSync(this.config.binPath, { recursive: true }); + + // Download archive + const archivePath = path.join(this.config.binPath, `cliproxy-archive.${platform.extension}`); + + const spinner = new ProgressIndicator(`Downloading CLIProxyAPI v${this.config.version}`); + spinner.start(); + + try { + // Download with retry + const result = await this.downloadWithRetry(downloadUrl, archivePath); + if (!result.success) { + spinner.fail('Download failed'); + throw new Error(result.error || 'Download failed after retries'); + } + + spinner.succeed('Download complete'); + + // Verify checksum + const verifySpinner = new ProgressIndicator('Verifying checksum'); + verifySpinner.start(); + + const checksumResult = await this.verifyChecksum( + archivePath, + platform.binaryName, + checksumsUrl + ); + + if (!checksumResult.valid) { + verifySpinner.fail('Checksum mismatch'); + fs.unlinkSync(archivePath); + throw new Error( + `Checksum mismatch for ${platform.binaryName}\n` + + `Expected: ${checksumResult.expected}\n` + + `Actual: ${checksumResult.actual}\n\n` + + `Manual download: ${downloadUrl}` + ); + } + + verifySpinner.succeed('Checksum verified'); + + // Extract archive + const extractSpinner = new ProgressIndicator('Extracting binary'); + extractSpinner.start(); + + await this.extractArchive(archivePath, platform.extension); + + extractSpinner.succeed('Extraction complete'); + + // Cleanup archive + fs.unlinkSync(archivePath); + + // Make executable (Unix only) + const binaryPath = this.getBinaryPath(); + if (platform.os !== 'windows' && fs.existsSync(binaryPath)) { + fs.chmodSync(binaryPath, 0o755); + this.log(`Set executable permissions: ${binaryPath}`); + } + + console.log(`[OK] CLIProxyAPI v${this.config.version} installed successfully`); + } catch (error) { + spinner.fail('Installation failed'); + throw error; + } + } + + /** + * Download file with retry logic and exponential backoff + */ + private async downloadWithRetry(url: string, destPath: string): Promise { + let lastError = ''; + let retries = 0; + + while (retries < this.config.maxRetries) { + try { + await this.downloadFile(url, destPath); + return { success: true, filePath: destPath, retries }; + } catch (error) { + const err = error as Error; + lastError = err.message; + retries++; + + if (retries < this.config.maxRetries) { + // Exponential backoff: 1s, 2s, 4s + const delay = Math.pow(2, retries - 1) * 1000; + this.log(`Retry ${retries}/${this.config.maxRetries} after ${delay}ms: ${lastError}`); + await this.sleep(delay); + } + } + } + + return { + success: false, + error: `Download failed after ${retries} attempts: ${lastError}`, + retries, + }; + } + + /** + * Download file from URL with progress tracking + */ + private downloadFile( + url: string, + destPath: string, + onProgress?: ProgressCallback + ): Promise { + return new Promise((resolve, reject) => { + const handleResponse = (res: http.IncomingMessage) => { + // Handle redirects (GitHub releases use 302) + if (res.statusCode === 301 || res.statusCode === 302) { + const redirectUrl = res.headers.location; + if (!redirectUrl) { + reject(new Error('Redirect without location header')); + return; + } + this.log(`Following redirect: ${redirectUrl}`); + this.downloadFile(redirectUrl, destPath, onProgress).then(resolve).catch(reject); + return; + } + + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`)); + return; + } + + const totalBytes = parseInt(res.headers['content-length'] || '0', 10); + let downloadedBytes = 0; + + const fileStream = fs.createWriteStream(destPath); + + res.on('data', (chunk: Buffer) => { + downloadedBytes += chunk.length; + if (onProgress && totalBytes > 0) { + onProgress({ + total: totalBytes, + downloaded: downloadedBytes, + percentage: Math.round((downloadedBytes / totalBytes) * 100), + }); + } + }); + + res.pipe(fileStream); + + fileStream.on('finish', () => { + fileStream.close(); + resolve(); + }); + + fileStream.on('error', (err) => { + fs.unlink(destPath, () => {}); // Cleanup partial file + reject(err); + }); + + res.on('error', (err) => { + fs.unlink(destPath, () => {}); + reject(err); + }); + }; + + const protocol = url.startsWith('https') ? https : http; + const req = protocol.get(url, handleResponse); + + req.on('error', reject); + req.setTimeout(60000, () => { + req.destroy(); + reject(new Error('Download timeout (60s)')); + }); + }); + } + + /** + * Verify file checksum against checksums.txt + */ + private async verifyChecksum( + filePath: string, + binaryName: string, + checksumsUrl: string + ): Promise { + // Download checksums.txt + const checksumsContent = await this.fetchText(checksumsUrl); + + // Parse expected checksum + const expectedHash = this.parseChecksum(checksumsContent, binaryName); + if (!expectedHash) { + throw new Error(`Checksum not found for ${binaryName} in checksums.txt`); + } + + // Compute actual checksum + const actualHash = await this.computeChecksum(filePath); + + return { + valid: actualHash === expectedHash, + expected: expectedHash, + actual: actualHash, + }; + } + + /** + * Parse checksum from checksums.txt content + */ + private parseChecksum(content: string, binaryName: string): string | null { + const lines = content.split('\n'); + for (const line of lines) { + // Format: "hash filename" or "hash filename" + const parts = line.trim().split(/\s+/); + if (parts.length >= 2 && parts[1] === binaryName) { + return parts[0].toLowerCase(); + } + } + return null; + } + + /** + * Compute SHA256 checksum of file + */ + private computeChecksum(filePath: string): Promise { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(filePath); + + stream.on('data', (data) => hash.update(data)); + stream.on('end', () => resolve(hash.digest('hex'))); + stream.on('error', reject); + }); + } + + /** + * Fetch text content from URL + */ + private fetchText(url: string): Promise { + return new Promise((resolve, reject) => { + const handleResponse = (res: http.IncomingMessage) => { + // Handle redirects + if (res.statusCode === 301 || res.statusCode === 302) { + const redirectUrl = res.headers.location; + if (!redirectUrl) { + reject(new Error('Redirect without location header')); + return; + } + this.fetchText(redirectUrl).then(resolve).catch(reject); + return; + } + + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`)); + return; + } + + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => resolve(data)); + res.on('error', reject); + }; + + const protocol = url.startsWith('https') ? https : http; + const req = protocol.get(url, handleResponse); + req.on('error', reject); + req.setTimeout(30000, () => { + req.destroy(); + reject(new Error('Request timeout (30s)')); + }); + }); + } + + /** + * Extract archive (tar.gz or zip) + */ + private async extractArchive(archivePath: string, extension: 'tar.gz' | 'zip'): Promise { + if (extension === 'tar.gz') { + await this.extractTarGz(archivePath); + } else { + await this.extractZip(archivePath); + } + } + + /** + * Extract tar.gz archive using Node.js built-in modules + */ + private extractTarGz(archivePath: string): Promise { + return new Promise((resolve, reject) => { + const destDir = this.config.binPath; + const execName = getExecutableName(); + + // Read and decompress + const gunzip = zlib.createGunzip(); + const input = fs.createReadStream(archivePath); + + let headerBuffer = Buffer.alloc(0); + let currentFile: { name: string; size: number } | null = null; + let bytesRead = 0; + let fileBuffer = Buffer.alloc(0); + + const processData = (data: Buffer) => { + headerBuffer = Buffer.concat([headerBuffer, data]); + + while (headerBuffer.length >= 512) { + if (!currentFile) { + // Parse tar header + const header = headerBuffer.subarray(0, 512); + headerBuffer = headerBuffer.subarray(512); + + // Check for empty header (end of archive) + if (header.every((b) => b === 0)) { + return; + } + + // Extract filename (bytes 0-99) + let name = ''; + for (let i = 0; i < 100 && header[i] !== 0; i++) { + name += String.fromCharCode(header[i]); + } + + // Extract size (bytes 124-135, octal) + let sizeStr = ''; + for (let i = 124; i < 136 && header[i] !== 0; i++) { + sizeStr += String.fromCharCode(header[i]); + } + const size = parseInt(sizeStr.trim(), 8) || 0; + + if (name && size > 0) { + // Extract just the filename (handle directories) + const baseName = path.basename(name); + if (baseName === execName || baseName === 'CLIProxyAPI') { + currentFile = { name: baseName, size }; + fileBuffer = Buffer.alloc(0); + bytesRead = 0; + } else { + // Skip this file's data + const paddedSize = Math.ceil(size / 512) * 512; + if (headerBuffer.length >= paddedSize) { + headerBuffer = headerBuffer.subarray(paddedSize); + } else { + // Need to skip data in chunks + currentFile = { name: '', size: paddedSize }; + bytesRead = 0; + } + } + } + } else { + // Read file data + const remaining = currentFile.size - bytesRead; + const chunk = headerBuffer.subarray(0, Math.min(remaining, headerBuffer.length)); + headerBuffer = headerBuffer.subarray(chunk.length); + + if (currentFile.name) { + fileBuffer = Buffer.concat([fileBuffer, chunk]); + } + bytesRead += chunk.length; + + if (bytesRead >= currentFile.size) { + // File complete + if (currentFile.name) { + const destPath = path.join(destDir, execName); + fs.writeFileSync(destPath, fileBuffer); + this.log(`Extracted: ${currentFile.name} -> ${destPath}`); + } + + // Skip padding to 512-byte boundary + const paddedSize = Math.ceil(currentFile.size / 512) * 512; + const padding = paddedSize - currentFile.size; + if (headerBuffer.length >= padding) { + headerBuffer = headerBuffer.subarray(padding); + } + + currentFile = null; + fileBuffer = Buffer.alloc(0); + } + } + } + }; + + input.pipe(gunzip); + gunzip.on('data', processData); + gunzip.on('end', resolve); + gunzip.on('error', reject); + input.on('error', reject); + }); + } + + /** + * Extract zip archive using Node.js (simple implementation) + */ + private extractZip(archivePath: string): Promise { + return new Promise((resolve, reject) => { + const destDir = this.config.binPath; + const execName = getExecutableName(); + const buffer = fs.readFileSync(archivePath); + + // Find End of Central Directory record (EOCD) + let eocdOffset = buffer.length - 22; + while (eocdOffset >= 0) { + if (buffer.readUInt32LE(eocdOffset) === 0x06054b50) { + break; + } + eocdOffset--; + } + + if (eocdOffset < 0) { + reject(new Error('Invalid ZIP file: EOCD not found')); + return; + } + + const centralDirOffset = buffer.readUInt32LE(eocdOffset + 16); + let offset = centralDirOffset; + + // Parse central directory + while (offset < eocdOffset) { + const sig = buffer.readUInt32LE(offset); + if (sig !== 0x02014b50) break; + + const compressionMethod = buffer.readUInt16LE(offset + 10); + const compressedSize = buffer.readUInt32LE(offset + 20); + const uncompressedSize = buffer.readUInt32LE(offset + 24); + const fileNameLength = buffer.readUInt16LE(offset + 28); + const extraFieldLength = buffer.readUInt16LE(offset + 30); + const commentLength = buffer.readUInt16LE(offset + 32); + const localHeaderOffset = buffer.readUInt32LE(offset + 42); + + const fileName = buffer.toString('utf8', offset + 46, offset + 46 + fileNameLength); + const baseName = path.basename(fileName); + + // Check if this is the executable we want + if (baseName === execName || baseName === 'CLIProxyAPI.exe') { + // Read from local file header + const localOffset = localHeaderOffset; + const localSig = buffer.readUInt32LE(localOffset); + + if (localSig !== 0x04034b50) { + reject(new Error('Invalid local file header')); + return; + } + + const localFileNameLength = buffer.readUInt16LE(localOffset + 26); + const localExtraLength = buffer.readUInt16LE(localOffset + 28); + const dataOffset = localOffset + 30 + localFileNameLength + localExtraLength; + + let fileData: Buffer; + if (compressionMethod === 0) { + // Stored (no compression) + fileData = buffer.subarray(dataOffset, dataOffset + compressedSize); + } else if (compressionMethod === 8) { + // Deflate + const compressed = buffer.subarray(dataOffset, dataOffset + compressedSize); + fileData = zlib.inflateRawSync(compressed); + } else { + reject(new Error(`Unsupported compression method: ${compressionMethod}`)); + return; + } + + if (fileData.length !== uncompressedSize) { + reject(new Error('Decompression size mismatch')); + return; + } + + const destPath = path.join(destDir, execName); + fs.writeFileSync(destPath, fileData); + this.log(`Extracted: ${fileName} -> ${destPath}`); + resolve(); + return; + } + + offset += 46 + fileNameLength + extraFieldLength + commentLength; + } + + reject(new Error(`Executable not found in archive: ${execName}`)); + }); + } + + /** + * Delete binary (for cleanup or reinstall) + */ + deleteBinary(): void { + const binaryPath = this.getBinaryPath(); + if (fs.existsSync(binaryPath)) { + fs.unlinkSync(binaryPath); + this.log(`Deleted: ${binaryPath}`); + } + } + + /** + * Sleep helper + */ + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + /** + * Log message if verbose + */ + private log(message: string): void { + if (this.verbose) { + console.error(`[cliproxy] ${message}`); + } + } +} + +/** + * Convenience function to ensure binary is available + * @returns Path to CLIProxyAPI executable + */ +export async function ensureCLIProxyBinary(verbose = false): Promise { + const manager = new BinaryManager({ verbose }); + return manager.ensureBinary(); +} + +/** + * Check if CLIProxyAPI binary is installed + */ +export function isCLIProxyInstalled(): boolean { + const manager = new BinaryManager(); + return manager.isBinaryInstalled(); +} + +/** + * Get CLIProxyAPI binary path (may not exist) + */ +export function getCLIProxyPath(): string { + const manager = new BinaryManager(); + return manager.getBinaryPath(); +} + +export default BinaryManager; diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts new file mode 100644 index 00000000..afa95907 --- /dev/null +++ b/src/cliproxy/cliproxy-executor.ts @@ -0,0 +1,294 @@ +/** + * CLIProxy Executor - Spawn/Kill Pattern for CLIProxyAPI + * + * Mirrors GLMT architecture: + * 1. Ensure binary exists (Phase 1) + * 2. Generate config for provider + * 3. Spawn CLIProxyAPI binary + * 4. Poll port for readiness (no stdout signal) + * 5. Execute Claude CLI with proxied environment + * 6. Kill proxy on Claude exit + * + * Key difference from GLMT: Uses TCP port polling instead of PROXY_READY signal + */ + +import { spawn, ChildProcess } from 'child_process'; +import * as net from 'net'; +import { ProgressIndicator } from '../utils/progress-indicator'; +import { escapeShellArg } from '../utils/shell-executor'; +import { ensureCLIProxyBinary } from './binary-manager'; +import { + generateConfig, + getClaudeEnvVars, + getProviderConfig, + CLIPROXY_DEFAULT_PORT, +} from './config-generator'; +import { ensureAuth, isAuthenticated } from './auth-handler'; +import { CLIProxyProvider, ExecutorConfig } from './types'; + +/** Default executor configuration */ +const DEFAULT_CONFIG: ExecutorConfig = { + port: CLIPROXY_DEFAULT_PORT, + timeout: 5000, + verbose: false, + pollInterval: 100, +}; + +/** + * Wait for TCP port to become available + * Uses polling since CLIProxyAPI doesn't emit PROXY_READY signal + */ +async function waitForProxyReady( + port: number, + timeout: number = 5000, + pollInterval: number = 100 +): Promise { + const start = Date.now(); + + while (Date.now() - start < timeout) { + try { + await new Promise((resolve, reject) => { + const socket = net.createConnection({ port, host: '127.0.0.1' }, () => { + socket.destroy(); + resolve(); + }); + + socket.on('error', (err) => { + socket.destroy(); + reject(err); + }); + + // Individual connection timeout + socket.setTimeout(1000, () => { + socket.destroy(); + reject(new Error('Connection timeout')); + }); + }); + + return; // Connection successful - proxy is ready + } catch { + // Connection failed, wait and retry + await new Promise((r) => setTimeout(r, pollInterval)); + } + } + + throw new Error(`CLIProxy not ready after ${timeout}ms on port ${port}`); +} + +/** + * Execute Claude CLI with CLIProxy (main entry point) + * + * @param claudeCli Path to Claude CLI executable + * @param provider CLIProxy provider (gemini, chatgpt, qwen) + * @param args Arguments to pass to Claude CLI + * @param config Optional executor configuration + */ +export async function execClaudeWithCLIProxy( + claudeCli: string, + provider: CLIProxyProvider, + args: string[], + config: Partial = {} +): Promise { + const cfg = { ...DEFAULT_CONFIG, ...config }; + const verbose = cfg.verbose || args.includes('--verbose') || args.includes('-v'); + + const log = (msg: string) => { + if (verbose) { + console.error(`[cliproxy] ${msg}`); + } + }; + + // Validate provider + const providerConfig = getProviderConfig(provider); + log(`Provider: ${providerConfig.displayName}`); + + // 1. Ensure binary exists (downloads if needed) + const spinner = new ProgressIndicator('Preparing CLIProxy'); + spinner.start(); + + let binaryPath: string; + try { + binaryPath = await ensureCLIProxyBinary(verbose); + spinner.succeed('CLIProxy binary ready'); + } catch (error) { + spinner.fail('Failed to prepare CLIProxy'); + throw error; + } + + // 2. Ensure OAuth completed (if provider requires it) + if (providerConfig.requiresOAuth) { + log(`Checking authentication for ${provider}`); + + // Check for --auth flag to force re-auth + const forceAuth = args.includes('--auth'); + const headless = args.includes('--headless'); + + if (forceAuth || !isAuthenticated(provider)) { + const authSuccess = await ensureAuth(provider, { verbose, headless }); + if (!authSuccess) { + throw new Error(`Authentication required for ${providerConfig.displayName}`); + } + } else { + log(`${provider} already authenticated`); + } + } + + // 3. Generate config file + log(`Generating config for ${provider}`); + const configPath = generateConfig(provider, cfg.port); + log(`Config written: ${configPath}`); + + // 4. Spawn CLIProxyAPI binary + const proxyArgs = ['--config', configPath]; + + log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`); + + const proxy = spawn(binaryPath, proxyArgs, { + stdio: ['ignore', verbose ? 'pipe' : 'ignore', verbose ? 'pipe' : 'ignore'], + detached: false, + }); + + // Forward proxy output in verbose mode + if (verbose) { + proxy.stdout?.on('data', (data: Buffer) => { + process.stderr.write(`[cliproxy-out] ${data.toString()}`); + }); + proxy.stderr?.on('data', (data: Buffer) => { + process.stderr.write(`[cliproxy-err] ${data.toString()}`); + }); + } + + // Handle proxy errors + proxy.on('error', (error) => { + console.error(`[X] CLIProxy spawn error: ${error.message}`); + }); + + // 5. Wait for proxy readiness via TCP polling + const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${cfg.port}`); + readySpinner.start(); + + try { + await waitForProxyReady(cfg.port, cfg.timeout, cfg.pollInterval); + readySpinner.succeed(`CLIProxy ready on port ${cfg.port}`); + } catch (error) { + readySpinner.fail('CLIProxy startup failed'); + proxy.kill('SIGTERM'); + + const err = error as Error; + console.error(''); + console.error('[X] CLIProxy failed to start'); + console.error(''); + console.error('Possible causes:'); + console.error(` 1. Port ${cfg.port} already in use`); + console.error(' 2. Binary crashed on startup'); + console.error(' 3. Invalid configuration'); + console.error(''); + console.error('Troubleshooting:'); + console.error(` - Check if port ${cfg.port} is in use: lsof -i :${cfg.port}`); + console.error(' - Run with --verbose for detailed logs'); + console.error(` - Check config: cat ${configPath}`); + console.error(''); + + throw new Error(`CLIProxy startup failed: ${err.message}`); + } + + // 6. Execute Claude CLI with proxied environment + const envVars = getClaudeEnvVars(provider, cfg.port); + const env = { ...process.env, ...envVars }; + + log(`Claude env: ANTHROPIC_BASE_URL=${envVars.ANTHROPIC_BASE_URL}`); + log(`Claude env: ANTHROPIC_MODEL=${envVars.ANTHROPIC_MODEL}`); + + const isWindows = process.platform === 'win32'; + const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli); + + let claude: ChildProcess; + if (needsShell) { + const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' '); + claude = spawn(cmdString, { + stdio: 'inherit', + windowsHide: true, + shell: true, + env, + }); + } else { + claude = spawn(claudeCli, args, { + stdio: 'inherit', + windowsHide: true, + env, + }); + } + + // 7. Cleanup: kill proxy when Claude exits + claude.on('exit', (code, signal) => { + log(`Claude exited: code=${code}, signal=${signal}`); + proxy.kill('SIGTERM'); + + if (signal) { + process.kill(process.pid, signal as NodeJS.Signals); + } else { + process.exit(code || 0); + } + }); + + claude.on('error', (error) => { + console.error('[X] Claude CLI error:', error); + proxy.kill('SIGTERM'); + process.exit(1); + }); + + // Handle parent process termination (SIGTERM, SIGINT) + const cleanup = () => { + log('Parent signal received, cleaning up'); + proxy.kill('SIGTERM'); + claude.kill('SIGTERM'); + }; + + process.once('SIGTERM', cleanup); + process.once('SIGINT', cleanup); + + // Handle proxy crash + proxy.on('exit', (code, signal) => { + if (code !== 0 && code !== null) { + log(`Proxy exited unexpectedly: code=${code}, signal=${signal}`); + // Don't kill Claude - it may have already exited + } + }); +} + +/** + * Check if a port is available + */ +export async function isPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = net.createServer(); + + server.once('error', () => { + resolve(false); + }); + + server.once('listening', () => { + server.close(); + resolve(true); + }); + + server.listen(port, '127.0.0.1'); + }); +} + +/** + * Find an available port in range + */ +export async function findAvailablePort( + startPort: number = CLIPROXY_DEFAULT_PORT, + range: number = 10 +): Promise { + for (let port = startPort; port < startPort + range; port++) { + if (await isPortAvailable(port)) { + return port; + } + } + throw new Error(`No available port found in range ${startPort}-${startPort + range - 1}`); +} + +export default execClaudeWithCLIProxy; diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts new file mode 100644 index 00000000..85907846 --- /dev/null +++ b/src/cliproxy/config-generator.ts @@ -0,0 +1,208 @@ +/** + * Config Generator for CLIProxyAPI + * + * Generates config.yaml for CLIProxyAPI based on provider. + * Handles OAuth token paths and provider-specific settings. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir } from '../utils/config-manager'; +import { CLIProxyProvider, ProviderConfig, ProviderModelMapping } from './types'; + +/** Default CLIProxy port */ +export const CLIPROXY_DEFAULT_PORT = 8317; + +/** Internal API key for CCS-managed requests */ +const CCS_INTERNAL_API_KEY = 'ccs-internal-managed'; + +/** + * Provider configurations with model mappings + */ +export const PROVIDER_CONFIGS: Record = { + gemini: { + name: 'gemini', + displayName: 'Gemini', + models: { + defaultModel: 'gemini-2.0-flash', + claudeModel: 'gemini-2.0-flash', + opusModel: 'gemini-2.0-flash-thinking-exp', + sonnetModel: 'gemini-2.0-flash', + haikuModel: 'gemini-2.0-flash-lite', + }, + requiresOAuth: true, + }, + chatgpt: { + name: 'chatgpt', + displayName: 'ChatGPT', + models: { + defaultModel: 'gpt-4o', + claudeModel: 'gpt-4o', + opusModel: 'o1', + sonnetModel: 'gpt-4o', + haikuModel: 'gpt-4o-mini', + }, + requiresOAuth: true, + }, + qwen: { + name: 'qwen', + displayName: 'Qwen', + models: { + defaultModel: 'qwen-max', + claudeModel: 'qwen-max', + opusModel: 'qwen-max', + sonnetModel: 'qwen-plus', + haikuModel: 'qwen-turbo', + }, + requiresOAuth: true, + }, +}; + +/** + * Get provider configuration + */ +export function getProviderConfig(provider: CLIProxyProvider): ProviderConfig { + const config = PROVIDER_CONFIGS[provider]; + if (!config) { + throw new Error(`Unknown provider: ${provider}`); + } + return config; +} + +/** + * Get model mapping for provider + */ +export function getModelMapping(provider: CLIProxyProvider): ProviderModelMapping { + return getProviderConfig(provider).models; +} + +/** + * Get auth directory for CLIProxyAPI + */ +export function getAuthDir(): string { + return path.join(getCcsDir(), 'cliproxy-auth'); +} + +/** + * Get config file path + */ +export function getConfigPath(): string { + return path.join(getCcsDir(), 'cliproxy.config.yaml'); +} + +/** + * Generate config.yaml content for provider + */ +function generateConfigContent( + provider: CLIProxyProvider, + port: number = CLIPROXY_DEFAULT_PORT +): string { + const authDir = getAuthDir(); + + // Base config (always present) + let config = `# CLIProxyAPI config generated by CCS +# Provider: ${provider} +# Generated: ${new Date().toISOString()} + +port: ${port} +debug: false +logging-to-file: false +usage-statistics-enabled: false + +# CCS internal authentication +api-keys: + - "${CCS_INTERNAL_API_KEY}" + +# OAuth tokens stored here +auth-dir: "${authDir}" +`; + + // Provider-specific config + switch (provider) { + case 'gemini': + config += ` +# Gemini configuration (OAuth-managed) +# API keys will be loaded from auth-dir after OAuth +gemini-api-key: [] +`; + break; + + case 'chatgpt': + config += ` +# ChatGPT/Codex configuration (OAuth-managed) +# API keys will be loaded from auth-dir after OAuth +codex-api-key: [] +`; + break; + + case 'qwen': + config += ` +# Qwen configuration (API key required) +openai-compatibility: + - name: "qwen" + base-url: "https://dashscope.aliyuncs.com/compatible-mode/v1" + api-key-entries: [] +`; + break; + } + + return config; +} + +/** + * Generate config.yaml file for provider + * @returns Path to generated config file + */ +export function generateConfig( + provider: CLIProxyProvider, + port: number = CLIPROXY_DEFAULT_PORT +): string { + const configPath = getConfigPath(); + const configContent = generateConfigContent(provider, port); + + // Ensure directories exist + const authDir = getAuthDir(); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.mkdirSync(authDir, { recursive: true }); + + // Write config with secure permissions (0600) + fs.writeFileSync(configPath, configContent, { mode: 0o600 }); + + return configPath; +} + +/** + * Check if config exists for provider + */ +export function configExists(): boolean { + return fs.existsSync(getConfigPath()); +} + +/** + * Delete config file + */ +export function deleteConfig(): void { + const configPath = getConfigPath(); + if (fs.existsSync(configPath)) { + fs.unlinkSync(configPath); + } +} + +/** + * Get environment variables for Claude CLI + */ +export function getClaudeEnvVars( + provider: CLIProxyProvider, + port: number = CLIPROXY_DEFAULT_PORT +): NodeJS.ProcessEnv { + const models = getModelMapping(provider); + + return { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`, + ANTHROPIC_AUTH_TOKEN: CCS_INTERNAL_API_KEY, + ANTHROPIC_MODEL: models.claudeModel, + ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel, + ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel, + ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel, + }; +} diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts new file mode 100644 index 00000000..7531e49e --- /dev/null +++ b/src/cliproxy/index.ts @@ -0,0 +1,73 @@ +/** + * CLIProxy Module Exports + * Central export point for CLIProxyAPI binary management and execution + */ + +// Types +export type { + PlatformInfo, + SupportedOS, + SupportedArch, + ArchiveExtension, + BinaryManagerConfig, + BinaryInfo, + DownloadProgress, + ProgressCallback, + ChecksumResult, + DownloadResult, + CLIProxyProvider, + CLIProxyConfig, + ExecutorConfig, + ProviderConfig, + ProviderModelMapping, +} from './types'; + +// Platform detection +export { + detectPlatform, + getDownloadUrl, + getChecksumsUrl, + getExecutableName, + isPlatformSupported, + getPlatformDescription, + CLIPROXY_VERSION, +} from './platform-detector'; + +// Binary management +export { + BinaryManager, + ensureCLIProxyBinary, + isCLIProxyInstalled, + getCLIProxyPath, +} from './binary-manager'; + +// Config generation +export { + generateConfig, + getClaudeEnvVars, + getProviderConfig, + getModelMapping, + getAuthDir, + getConfigPath, + configExists, + deleteConfig, + PROVIDER_CONFIGS, + CLIPROXY_DEFAULT_PORT, +} from './config-generator'; + +// Executor +export { execClaudeWithCLIProxy, isPortAvailable, findAvailablePort } from './cliproxy-executor'; + +// Authentication +export type { AuthStatus } from './auth-handler'; +export { + isAuthenticated, + getAuthStatus, + getAllAuthStatus, + clearAuth, + triggerOAuth, + ensureAuth, + getOAuthConfig, + getProviderTokenDir, + displayAuthStatus, +} from './auth-handler'; diff --git a/src/cliproxy/platform-detector.ts b/src/cliproxy/platform-detector.ts new file mode 100644 index 00000000..197efe65 --- /dev/null +++ b/src/cliproxy/platform-detector.ts @@ -0,0 +1,117 @@ +/** + * Platform Detector for CLIProxyAPI Binary Downloads + * + * Detects OS and architecture to determine correct binary asset. + * Supports 6 platforms: darwin/linux/windows x amd64/arm64 + */ + +import { PlatformInfo, SupportedOS, SupportedArch, ArchiveExtension } from './types'; + +/** CLIProxyAPI version pinned for stability */ +export const CLIPROXY_VERSION = '6.5.27'; + +/** + * Platform mapping from Node.js values to CLIProxyAPI naming + */ +const OS_MAP: Record = { + darwin: 'darwin', + linux: 'linux', + win32: 'windows', +}; + +const ARCH_MAP: Record = { + x64: 'amd64', + arm64: 'arm64', +}; + +/** + * Detect current platform and return binary info + * @throws Error if platform is unsupported + */ +export function detectPlatform(): PlatformInfo { + const nodePlatform = process.platform; + const nodeArch = process.arch; + + const os = OS_MAP[nodePlatform]; + const arch = ARCH_MAP[nodeArch]; + + if (!os) { + throw new Error( + `Unsupported operating system: ${nodePlatform}\n` + + `Supported: macOS (darwin), Linux, Windows` + ); + } + + if (!arch) { + throw new Error( + `Unsupported CPU architecture: ${nodeArch}\n` + `Supported: x64 (amd64), arm64` + ); + } + + const extension: ArchiveExtension = os === 'windows' ? 'zip' : 'tar.gz'; + const binaryName = `CLIProxyAPI_${CLIPROXY_VERSION}_${os}_${arch}.${extension}`; + + return { + os, + arch, + binaryName, + extension, + }; +} + +/** + * Get executable name based on platform + * @returns Binary executable name (with .exe on Windows) + */ +export function getExecutableName(): string { + const platform = detectPlatform(); + return platform.os === 'windows' ? 'CLIProxyAPI.exe' : 'CLIProxyAPI'; +} + +/** + * Get download URL for current platform + * @param version Optional version override (defaults to CLIPROXY_VERSION) + * @returns Full GitHub release download URL + */ +export function getDownloadUrl(version: string = CLIPROXY_VERSION): string { + const platform = detectPlatform(); + const baseUrl = `https://github.com/router-for-me/CLIProxyAPI/releases/download/v${version}`; + return `${baseUrl}/${platform.binaryName}`; +} + +/** + * Get checksums.txt URL for version + * @param version Optional version override + * @returns Full URL to checksums.txt + */ +export function getChecksumsUrl(version: string = CLIPROXY_VERSION): string { + return `https://github.com/router-for-me/CLIProxyAPI/releases/download/v${version}/checksums.txt`; +} + +/** + * Check if platform is supported + * @returns true if current platform is supported + */ +export function isPlatformSupported(): boolean { + try { + detectPlatform(); + return true; + } catch { + return false; + } +} + +/** + * Get human-readable platform description + * @returns Description string (e.g., "macOS arm64") + */ +export function getPlatformDescription(): string { + try { + const platform = detectPlatform(); + const osName = + platform.os === 'darwin' ? 'macOS' : platform.os === 'linux' ? 'Linux' : 'Windows'; + return `${osName} ${platform.arch}`; + } catch { + return `${process.platform} ${process.arch} (unsupported)`; + } +} diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts new file mode 100644 index 00000000..3bd16abf --- /dev/null +++ b/src/cliproxy/types.ts @@ -0,0 +1,180 @@ +/** + * CLIProxy Type Definitions + * Types for CLIProxyAPI binary management and execution + */ + +/** + * Supported operating systems + */ +export type SupportedOS = 'darwin' | 'linux' | 'windows'; + +/** + * Supported CPU architectures + */ +export type SupportedArch = 'amd64' | 'arm64'; + +/** + * Archive extension based on platform + */ +export type ArchiveExtension = 'tar.gz' | 'zip'; + +/** + * Platform detection result + */ +export interface PlatformInfo { + /** Operating system (darwin, linux, windows) */ + os: SupportedOS; + /** CPU architecture (amd64, arm64) */ + arch: SupportedArch; + /** Full binary archive name (e.g., CLIProxyAPI_6.5.27_linux_amd64.tar.gz) */ + binaryName: string; + /** Archive extension (tar.gz for Unix, zip for Windows) */ + extension: ArchiveExtension; +} + +/** + * Binary manager configuration + */ +export interface BinaryManagerConfig { + /** CLIProxyAPI version to download */ + version: string; + /** GitHub releases base URL */ + releaseUrl: string; + /** Local binary storage path (~/.ccs/bin/) */ + binPath: string; + /** Maximum download retry attempts */ + maxRetries: number; + /** Enable verbose logging */ + verbose: boolean; +} + +/** + * Download progress callback + */ +export interface DownloadProgress { + /** Total bytes to download */ + total: number; + /** Bytes downloaded so far */ + downloaded: number; + /** Download percentage (0-100) */ + percentage: number; +} + +/** + * Download progress callback function type + */ +export type ProgressCallback = (progress: DownloadProgress) => void; + +/** + * Binary info after successful download/verification + */ +export interface BinaryInfo { + /** Full path to executable */ + path: string; + /** CLIProxyAPI version */ + version: string; + /** Platform info */ + platform: PlatformInfo; + /** SHA256 checksum (verified) */ + checksum: string; +} + +/** + * Checksum verification result + */ +export interface ChecksumResult { + /** Whether checksum matched */ + valid: boolean; + /** Expected checksum from checksums.txt */ + expected: string; + /** Actual computed checksum */ + actual: string; +} + +/** + * Download result + */ +export interface DownloadResult { + /** Whether download succeeded */ + success: boolean; + /** Path to downloaded file */ + filePath?: string; + /** Error message if failed */ + error?: string; + /** Number of retries attempted */ + retries: number; +} + +/** + * Supported CLIProxy providers + */ +export type CLIProxyProvider = 'gemini' | 'chatgpt' | 'qwen'; + +/** + * CLIProxy config.yaml structure (minimal) + */ +export interface CLIProxyConfig { + port: number; + 'api-keys': string[]; + 'auth-dir': string; + debug: boolean; + 'gemini-api-key'?: Array<{ + 'api-key': string; + 'base-url'?: string; + }>; + 'codex-api-key'?: Array<{ + 'api-key': string; + 'base-url'?: string; + }>; + 'openai-compatibility'?: Array<{ + name: string; + 'base-url': string; + 'api-key-entries': Array<{ + 'api-key': string; + }>; + }>; +} + +/** + * Executor configuration + */ +export interface ExecutorConfig { + /** Port for CLIProxyAPI (default: 8317) */ + port: number; + /** Timeout for proxy readiness in ms (default: 5000) */ + timeout: number; + /** Enable verbose logging */ + verbose: boolean; + /** Poll interval for port check in ms (default: 100) */ + pollInterval: number; +} + +/** + * Model mapping for each provider + */ +export interface ProviderModelMapping { + /** Default model for requests */ + defaultModel: string; + /** Model for Claude CLI's ANTHROPIC_MODEL */ + claudeModel: string; + /** Model for Claude CLI's ANTHROPIC_DEFAULT_OPUS_MODEL */ + opusModel?: string; + /** Model for Claude CLI's ANTHROPIC_DEFAULT_SONNET_MODEL */ + sonnetModel?: string; + /** Model for Claude CLI's ANTHROPIC_DEFAULT_HAIKU_MODEL */ + haikuModel?: string; +} + +/** + * Provider configuration + */ +export interface ProviderConfig { + /** Provider name */ + name: CLIProxyProvider; + /** Display name for UI */ + displayName: string; + /** Model configuration */ + models: ProviderModelMapping; + /** Whether OAuth is required */ + requiresOAuth: boolean; +} diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index b90b4136..4c0d087b 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -27,13 +27,19 @@ export function handleHelpCommand(): void { console.log(colored('Model Switching:', 'cyan')); console.log(` ${colored('ccs', 'yellow')} Use default Claude account`); - console.log(` ${colored('ccs glm', 'yellow')} Switch to GLM 4.6 model`); console.log( - ` ${colored('ccs glmt', 'yellow')} Switch to GLM with thinking mode` + ` ${colored('ccs chatgpt', 'yellow')} ChatGPT via OAuth (zero config)` ); - console.log(` ${colored('ccs glmt --verbose', 'yellow')} Enable debug logging`); - console.log(` ${colored('ccs kimi', 'yellow')} Switch to Kimi for Coding`); - console.log(` ${colored('ccs glm', 'yellow')} "debug this code" Use GLM and run command`); + console.log( + ` ${colored('ccs gemini', 'yellow')} Gemini via OAuth (zero config)` + ); + console.log(` ${colored('ccs glm', 'yellow')} GLM 4.6 (API key required)`); + console.log(` ${colored('ccs glmt', 'yellow')} GLM with thinking mode`); + console.log(` ${colored('ccs kimi', 'yellow')} Kimi for Coding (API key)`); + console.log( + ` ${colored('ccs qwen', 'yellow')} Qwen via OAuth (zero config)` + ); + console.log(` ${colored('ccs gemini', 'yellow')} "explain this" Use Gemini with prompt`); console.log(''); console.log(colored('Account Management:', 'cyan')); @@ -94,6 +100,7 @@ export function handleHelpCommand(): void { console.log(colored('Examples:', 'cyan')); console.log(` ${colored('$ ccs', 'yellow')} # Use default account`); + console.log(` ${colored('$ ccs gemini "explain code"', 'yellow')} # Zero-config OAuth`); console.log(` ${colored('$ ccs glm "implement API"', 'yellow')} # Cost-optimized model`); console.log(''); console.log( diff --git a/src/management/doctor.ts b/src/management/doctor.ts index 952e7b40..e353c7fd 100644 --- a/src/management/doctor.ts +++ b/src/management/doctor.ts @@ -9,6 +9,14 @@ import { spawn } from 'child_process'; import { colored } from '../utils/helpers'; import { detectClaudeCli } from '../utils/claude-detector'; import packageJson from '../../package.json'; +import { + isCLIProxyInstalled, + getCLIProxyPath, + isPortAvailable, + getAllAuthStatus, + CLIPROXY_VERSION, + CLIPROXY_DEFAULT_PORT, +} from '../cliproxy'; // Make ora optional (might not be available during npm install postinstall) // ora v9+ is an ES module, need to use .default for CommonJS @@ -156,6 +164,11 @@ class Doctor { this.checkSettingsSymlinks(); console.log(''); + // Group 5: CLIProxy (OAuth profiles) + console.log(colored('CLIProxy (OAuth Profiles):', 'bold')); + await this.checkCLIProxy(); + console.log(''); + this.showReport(); return this.results; } @@ -739,6 +752,112 @@ class Doctor { } } + /** + * Check 11: CLIProxy health (OAuth profiles: gemini, chatgpt, qwen) + */ + private async checkCLIProxy(): Promise { + // 1. Binary installed? + const binarySpinner = ora('Checking CLIProxy binary').start(); + + if (isCLIProxyInstalled()) { + const binaryPath = getCLIProxyPath(); + binarySpinner.succeed( + ` ${'CLIProxy Binary'.padEnd(26)}${colored('[OK]', 'green')} v${CLIPROXY_VERSION}` + ); + this.results.addCheck('CLIProxy Binary', 'success', undefined, undefined, { + status: 'OK', + info: `v${CLIPROXY_VERSION} (${binaryPath})`, + }); + } else { + binarySpinner.info( + ` ${'CLIProxy Binary'.padEnd(26)}${colored('[i]', 'cyan')} Not installed (downloads on first use)` + ); + this.results.addCheck( + 'CLIProxy Binary', + 'success', + 'Not installed yet', + 'Run: ccs gemini "test" (will download automatically)', + { status: 'OK', info: 'Not installed (downloads on first use)' } + ); + } + + // 2. Config file exists? + const configSpinner = ora('Checking CLIProxy config').start(); + const configPath = path.join(this.ccsDir, 'cliproxy.config.yaml'); + + if (fs.existsSync(configPath)) { + configSpinner.succeed( + ` ${'CLIProxy Config'.padEnd(26)}${colored('[OK]', 'green')} cliproxy.config.yaml` + ); + this.results.addCheck('CLIProxy Config', 'success', undefined, undefined, { + status: 'OK', + info: 'cliproxy.config.yaml', + }); + } else { + configSpinner.info( + ` ${'CLIProxy Config'.padEnd(26)}${colored('[i]', 'cyan')} Not created (generated on first use)` + ); + this.results.addCheck('CLIProxy Config', 'success', 'Not created yet', undefined, { + status: 'OK', + info: 'Generated on first use', + }); + } + + // 3. OAuth status for each provider + const authStatuses = getAllAuthStatus(); + for (const status of authStatuses) { + const authSpinner = ora(`Checking ${status.provider} auth`).start(); + const providerName = status.provider.charAt(0).toUpperCase() + status.provider.slice(1); + + if (status.authenticated) { + const lastAuth = status.lastAuth ? ` (${status.lastAuth.toLocaleDateString()})` : ''; + authSpinner.succeed( + ` ${`${providerName} Auth`.padEnd(26)}${colored('[OK]', 'green')} Authenticated${lastAuth}` + ); + this.results.addCheck(`${providerName} Auth`, 'success', undefined, undefined, { + status: 'OK', + info: `Authenticated${lastAuth}`, + }); + } else { + authSpinner.info( + ` ${`${providerName} Auth`.padEnd(26)}${colored('[i]', 'cyan')} Not authenticated` + ); + this.results.addCheck( + `${providerName} Auth`, + 'success', + 'Not authenticated', + `Run: ccs ${status.provider} --auth`, + { status: 'OK', info: 'Not authenticated (run ccs to login)' } + ); + } + } + + // 4. Port availability + const portSpinner = ora(`Checking port ${CLIPROXY_DEFAULT_PORT}`).start(); + const portAvailable = await isPortAvailable(CLIPROXY_DEFAULT_PORT); + + if (portAvailable) { + portSpinner.succeed( + ` ${'CLIProxy Port'.padEnd(26)}${colored('[OK]', 'green')} ${CLIPROXY_DEFAULT_PORT} available` + ); + this.results.addCheck('CLIProxy Port', 'success', undefined, undefined, { + status: 'OK', + info: `Port ${CLIPROXY_DEFAULT_PORT} available`, + }); + } else { + portSpinner.warn( + ` ${'CLIProxy Port'.padEnd(26)}${colored('[!]', 'yellow')} ${CLIPROXY_DEFAULT_PORT} in use` + ); + this.results.addCheck( + 'CLIProxy Port', + 'warning', + `Port ${CLIPROXY_DEFAULT_PORT} is in use`, + `Check: lsof -i :${CLIPROXY_DEFAULT_PORT}`, + { status: 'WARN', info: `Port ${CLIPROXY_DEFAULT_PORT} in use` } + ); + } + } + /** * Show health check report */ diff --git a/src/utils/error-manager.ts b/src/utils/error-manager.ts index d26572b3..d4a7fbe2 100644 --- a/src/utils/error-manager.ts +++ b/src/utils/error-manager.ts @@ -157,4 +157,89 @@ export class ErrorManager { console.error(''); this.showErrorCode(ERROR_CODES.FS_CANNOT_WRITE_FILE); } + + /** + * Show CLIProxy OAuth timeout error + */ + static showOAuthTimeout(provider: string): void { + console.error(''); + console.error(colored('+---------------------------------------------------------+', 'red')); + console.error(colored('| OAuth Timeout |', 'red')); + console.error(colored('+---------------------------------------------------------+', 'red')); + console.error(''); + console.error('Authentication did not complete within 2 minutes.'); + console.error(''); + console.error(colored('Troubleshooting:', 'yellow')); + console.error(' 1. Check if browser opened (popup blocker?)'); + console.error(' 2. Complete login in browser, then return here'); + console.error(' 3. Try different browser'); + console.error(' 4. Disable browser extensions temporarily'); + console.error(''); + console.error(colored('For headless/SSH environments:', 'cyan')); + console.error(` ccs ${provider} --auth --headless`); + console.error(''); + console.error('This displays manual authentication steps.'); + console.error(''); + } + + /** + * Show CLIProxy port conflict error + */ + static showPortConflict(port: number): void { + console.error(''); + console.error(colored('+---------------------------------------------------------+', 'red')); + console.error(colored('| Port Conflict |', 'red')); + console.error(colored('+---------------------------------------------------------+', 'red')); + console.error(''); + console.error(`CLIProxy port ${port} is already in use.`); + console.error(''); + console.error(colored('Solutions:', 'yellow')); + console.error(' 1. Find process using port:'); + console.error(` lsof -i :${port} (macOS/Linux)`); + console.error(` netstat -ano | findstr ${port} (Windows)`); + console.error(''); + console.error(' 2. Kill the process:'); + console.error(` lsof -ti:${port} | xargs kill -9`); + console.error(''); + console.error(' 3. Wait and retry (process may exit on its own)'); + console.error(''); + } + + /** + * Show CLIProxy binary download failure error + */ + static showBinaryDownloadFailed(url: string, error: string): void { + console.error(''); + console.error(colored('+---------------------------------------------------------+', 'red')); + console.error(colored('| Binary Download Failed |', 'red')); + console.error(colored('+---------------------------------------------------------+', 'red')); + console.error(''); + console.error(`Error: ${error}`); + console.error(''); + console.error(colored('Troubleshooting:', 'yellow')); + console.error(' 1. Check internet connection'); + console.error(' 2. Check firewall/proxy settings'); + console.error(' 3. Try again in a few minutes'); + console.error(''); + console.error(colored('Manual download:', 'cyan')); + console.error(` URL: ${url}`); + console.error(' Save to: ~/.ccs/bin/cliproxyapi'); + console.error(' chmod +x ~/.ccs/bin/cliproxyapi'); + console.error(''); + } + + /** + * Show CLIProxy authentication required error + */ + static showAuthRequired(provider: string): void { + console.error(''); + console.error(colored(`[X] ${provider} authentication required`, 'red')); + console.error(''); + console.error(colored('To authenticate:', 'yellow')); + console.error(` ccs ${provider} --auth`); + console.error(''); + console.error('This will open a browser for OAuth login.'); + console.error('After login, you can use the profile normally.'); + console.error(''); + } }