mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-14 10:24:30 +00:00
feat(cliproxy): add CLIProxyAPI integration for OAuth profiles
Complete integration of CLIProxyAPI for zero-config OAuth profiles. New profiles: - ccs gemini: Gemini via OAuth - ccs chatgpt: ChatGPT via OAuth - ccs qwen: Qwen via OAuth Implementation: - Phase 1: Binary infrastructure (download-on-demand) - Phase 2: Proxy executor (spawn/kill pattern) - Phase 3: OAuth integration (browser-based auth) - Phase 4: Profile registration (routing + help text) - Phase 5: Diagnostics (doctor command + errors) Files added: - src/cliproxy/*.ts (binary-manager, platform-detector, executor, auth-handler, config-generator, types, index) Files modified: - src/ccs.ts (CLIProxy routing) - src/auth/profile-detector.ts (cliproxy profile type) - src/commands/help-command.ts (new profiles in help) - src/management/doctor.ts (CLIProxy health checks) - src/utils/error-manager.ts (OAuth/port/download errors) - CHANGELOG.md (v5.0.0 release notes) Tests: 42/42 passed
This commit is contained in:
@@ -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/<provider>/`
|
||||
- 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/<provider>/`
|
||||
- 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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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 <profile>" 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,
|
||||
};
|
||||
}
|
||||
|
||||
+5
-1
@@ -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<void> {
|
||||
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
|
||||
|
||||
@@ -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/<provider>/
|
||||
*/
|
||||
|
||||
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<CLIProxyProvider, ProviderOAuthConfig> = {
|
||||
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<boolean> {
|
||||
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<boolean>((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<boolean> {
|
||||
// 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 <provider> --auth');
|
||||
console.log('To logout: ccs <provider> --logout');
|
||||
}
|
||||
@@ -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<BinaryManagerConfig> = {}) {
|
||||
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<string> {
|
||||
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<BinaryInfo | null> {
|
||||
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<void> {
|
||||
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<DownloadResult> {
|
||||
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<void> {
|
||||
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<ChecksumResult> {
|
||||
// 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<string> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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;
|
||||
@@ -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<void> {
|
||||
const start = Date.now();
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
try {
|
||||
await new Promise<void>((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<ExecutorConfig> = {}
|
||||
): Promise<void> {
|
||||
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<boolean> {
|
||||
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<number> {
|
||||
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;
|
||||
@@ -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<CLIProxyProvider, ProviderConfig> = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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<string, SupportedOS | undefined> = {
|
||||
darwin: 'darwin',
|
||||
linux: 'linux',
|
||||
win32: 'windows',
|
||||
};
|
||||
|
||||
const ARCH_MAP: Record<string, SupportedArch | undefined> = {
|
||||
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)`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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<void> {
|
||||
// 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 <profile> 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
|
||||
*/
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user