diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 7e52e81a..9ae8088f 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -8,9 +8,10 @@ import { info, warn } from '../utils/ui'; import { getBinDir, CLIPROXY_DEFAULT_PORT } from './config-generator'; import { BinaryInfo, BinaryManagerConfig } from './types'; -import { CLIPROXY_FALLBACK_VERSION, CLIPROXY_MAX_STABLE_VERSION } from './platform-detector'; +import { BACKEND_CONFIG, DEFAULT_BACKEND, CLIPROXY_MAX_STABLE_VERSION } from './platform-detector'; import { isProxyRunning, stopProxy } from './services/proxy-lifecycle-service'; import { waitForPortFree } from '../utils/port-utils'; +import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; import { UpdateCheckResult, checkForUpdates, @@ -27,24 +28,53 @@ import { ensureBinary, } from './binary'; -/** Default configuration (uses CLIProxyAPIPlus fork with Kiro + Copilot support) */ -const DEFAULT_CONFIG: BinaryManagerConfig = { - version: CLIPROXY_FALLBACK_VERSION, - releaseUrl: 'https://github.com/router-for-me/CLIProxyAPIPlus/releases/download', - binPath: getBinDir(), - maxRetries: 3, - verbose: false, - forceVersion: false, -}; +type CLIProxyBackend = 'original' | 'plus'; + +/** + * Get backend from config or default to 'plus' + */ +function getConfiguredBackend(): CLIProxyBackend { + try { + const config = loadOrCreateUnifiedConfig(); + return config.cliproxy?.backend || DEFAULT_BACKEND; + } catch { + return DEFAULT_BACKEND; + } +} + +/** + * Get backend-specific binary directory. + * Stores binaries in separate dirs: bin/original/ and bin/plus/ + */ +function getBackendBinDir(backend: CLIProxyBackend = DEFAULT_BACKEND): string { + const baseDir = getBinDir(); + return `${baseDir}/${backend}`; +} + +/** Default configuration (uses backend from config.yaml or defaults to 'plus') */ +function createDefaultConfig(backend: CLIProxyBackend = DEFAULT_BACKEND): BinaryManagerConfig { + const backendConfig = BACKEND_CONFIG[backend]; + return { + version: backendConfig.fallbackVersion, + releaseUrl: `https://github.com/${backendConfig.repo}/releases/download`, + binPath: getBackendBinDir(backend), + maxRetries: 3, + verbose: false, + forceVersion: false, + }; +} /** * Binary Manager class for CLIProxyAPI binary lifecycle */ export class BinaryManager { private config: BinaryManagerConfig; + private backend: CLIProxyBackend; - constructor(config: Partial = {}) { - this.config = { ...DEFAULT_CONFIG, ...config }; + constructor(config: Partial = {}, backend?: CLIProxyBackend) { + this.backend = backend ?? getConfiguredBackend(); + const defaultConfig = createDefaultConfig(this.backend); + this.config = { ...defaultConfig, ...config }; } /** Ensure binary is available (download if missing, update if outdated) */ @@ -80,36 +110,44 @@ export class BinaryManager { /** Convenience function respecting version pin */ export async function ensureCLIProxyBinary(verbose = false): Promise { + const backend = getConfiguredBackend(); const pinnedVersion = getPinnedVersion(); if (pinnedVersion) { if (verbose) console.error(`[cliproxy] Using pinned version: ${pinnedVersion}`); - return new BinaryManager({ - version: pinnedVersion, - verbose, - forceVersion: true, - }).ensureBinary(); + return new BinaryManager( + { + version: pinnedVersion, + verbose, + forceVersion: true, + }, + backend + ).ensureBinary(); } - return new BinaryManager({ verbose }).ensureBinary(); + return new BinaryManager({ verbose }, backend).ensureBinary(); } /** Check if CLIProxyAPI binary is installed */ export function isCLIProxyInstalled(): boolean { - return new BinaryManager().isBinaryInstalled(); + const backend = getConfiguredBackend(); + return new BinaryManager({}, backend).isBinaryInstalled(); } /** Get CLIProxyAPI binary path (may not exist) */ export function getCLIProxyPath(): string { - return new BinaryManager().getBinaryPath(); + const backend = getConfiguredBackend(); + return new BinaryManager({}, backend).getBinaryPath(); } /** Get installed CLIProxyAPI version from .version file */ export function getInstalledCliproxyVersion(): string { - return readInstalledVersion(getBinDir(), CLIPROXY_FALLBACK_VERSION); + const backend = getConfiguredBackend(); + return readInstalledVersion(getBackendBinDir(backend), BACKEND_CONFIG[backend].fallbackVersion); } /** Install a specific version of CLIProxyAPI */ export async function installCliproxyVersion(version: string, verbose = false): Promise { - const manager = new BinaryManager({ version, verbose, forceVersion: true }); + const backend = getConfiguredBackend(); + const manager = new BinaryManager({ version, verbose, forceVersion: true }, backend); // Check if proxy is running and stop it first if (isProxyRunning()) { @@ -140,7 +178,8 @@ export async function installCliproxyVersion(version: string, verbose = false): /** Fetch the latest CLIProxyAPI version from GitHub API */ export async function fetchLatestCliproxyVersion(): Promise { - const result = await new BinaryManager().checkForUpdates(); + const backend = getConfiguredBackend(); + const result = await new BinaryManager({}, backend).checkForUpdates(); return result.latestVersion; } @@ -159,7 +198,8 @@ export interface CliproxyUpdateCheckResult { /** Check for CLIProxyAPI binary updates */ export async function checkCliproxyUpdate(): Promise { - const result = await new BinaryManager().checkForUpdates(); + const backend = getConfiguredBackend(); + const result = await new BinaryManager({}, backend).checkForUpdates(); // Import isNewerVersion for stability check const { isNewerVersion } = await import('./binary/version-checker'); diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 7e0a9ed8..329678ef 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -30,7 +30,8 @@ import { } from './config-generator'; import { checkRemoteProxy } from './remote-proxy-client'; import { isAuthenticated } from './auth-handler'; -import { CLIProxyProvider, ExecutorConfig } from './types'; +import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS, ExecutorConfig } from './types'; +import { DEFAULT_BACKEND } from './platform-detector'; import { configureProviderModel, getCurrentModel } from './model-config'; import { resolveProxyConfig, PROXY_CLI_FLAGS } from './proxy-config-resolver'; import { getWebSearchHookEnv } from '../utils/websearch-manager'; @@ -141,6 +142,20 @@ export async function execClaudeWithCLIProxy( // 0. Resolve proxy configuration (CLI > ENV > config.yaml > defaults) // This filters proxy flags from args and returns resolved config const unifiedConfig = loadOrCreateUnifiedConfig(); + + // 0a. Runtime backend/provider validation - block kiro/ghcp if backend=original + const backend: CLIProxyBackend = unifiedConfig.cliproxy?.backend ?? DEFAULT_BACKEND; + if (backend === 'original' && PLUS_ONLY_PROVIDERS.includes(provider)) { + console.error(''); + console.error(fail(`${provider} requires CLIProxyAPIPlus backend`)); + console.error(''); + console.error('To use this provider, either:'); + console.error(' 1. Set `cliproxy.backend: plus` in ~/.ccs/config.yaml'); + console.error(' 2. Use --backend=plus flag: ccs ' + provider + ' --backend=plus'); + console.error(''); + throw new Error(`Provider ${provider} requires Plus backend`); + } + const cliproxyServerConfig = unifiedConfig.cliproxy_server; const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args, { remote: cliproxyServerConfig?.remote diff --git a/src/cliproxy/platform-detector.ts b/src/cliproxy/platform-detector.ts index 68f42aed..e2a4dacb 100644 --- a/src/cliproxy/platform-detector.ts +++ b/src/cliproxy/platform-detector.ts @@ -5,14 +5,40 @@ * Supports 6 platforms: darwin/linux/windows x amd64/arm64 */ -import { PlatformInfo, SupportedOS, SupportedArch, ArchiveExtension } from './types'; +import { + PlatformInfo, + SupportedOS, + SupportedArch, + ArchiveExtension, + CLIProxyBackend, +} from './types'; + +/** Backend configuration */ +export const BACKEND_CONFIG = { + original: { + repo: 'router-for-me/CLIProxyAPI', + binaryPrefix: 'CLIProxyAPI', + executable: 'cli-proxy-api', + fallbackVersion: '6.7.8', + }, + plus: { + repo: 'router-for-me/CLIProxyAPIPlus', + binaryPrefix: 'CLIProxyAPIPlus', + executable: 'cli-proxy-api-plus', + fallbackVersion: '6.7.8-0', + }, +} as const; + +/** Default backend */ +export const DEFAULT_BACKEND: CLIProxyBackend = 'plus'; /** * CLIProxyAPIPlus fallback version (used when GitHub API unavailable) * Auto-update fetches latest from GitHub; this is only a safety net * Note: CLIProxyAPIPlus uses v6.6.X-0 suffix pattern + * @deprecated Use getFallbackVersion() or BACKEND_CONFIG instead */ -export const CLIPROXY_FALLBACK_VERSION = '6.6.40-0'; +export const CLIPROXY_FALLBACK_VERSION = BACKEND_CONFIG[DEFAULT_BACKEND].fallbackVersion; /** * Maximum stable version cap - prevents auto-update to known unstable releases @@ -48,10 +74,14 @@ const ARCH_MAP: Record = { /** * Detect current platform and return binary info - * @param version Optional version for binaryName (defaults to fallback) + * @param version Optional version for binaryName (defaults to backend fallback) + * @param backend Backend variant to use (defaults to DEFAULT_BACKEND) * @throws Error if platform is unsupported */ -export function detectPlatform(version: string = CLIPROXY_FALLBACK_VERSION): PlatformInfo { +export function detectPlatform( + version?: string, + backend: CLIProxyBackend = DEFAULT_BACKEND +): PlatformInfo { const nodePlatform = process.platform; const nodeArch = process.arch; @@ -71,8 +101,10 @@ export function detectPlatform(version: string = CLIPROXY_FALLBACK_VERSION): Pla ); } + const config = BACKEND_CONFIG[backend]; + const ver = version || config.fallbackVersion; const extension: ArchiveExtension = os === 'windows' ? 'zip' : 'tar.gz'; - const binaryName = `CLIProxyAPIPlus_${version}_${os}_${arch}.${extension}`; + const binaryName = `${config.binaryPrefix}_${ver}_${os}_${arch}.${extension}`; return { os, @@ -84,41 +116,62 @@ export function detectPlatform(version: string = CLIPROXY_FALLBACK_VERSION): Pla /** * Get executable name based on platform + * @param backend Backend variant to use (defaults to DEFAULT_BACKEND) * @returns Binary executable name (with .exe on Windows) - * Note: The actual binary inside the archive is named 'cli-proxy-api-plus' */ -export function getExecutableName(): string { - const platform = detectPlatform(); - return platform.os === 'windows' ? 'cli-proxy-api-plus.exe' : 'cli-proxy-api-plus'; +export function getExecutableName(backend: CLIProxyBackend = DEFAULT_BACKEND): string { + const config = BACKEND_CONFIG[backend]; + const platform = detectPlatform(undefined, backend); + return platform.os === 'windows' ? `${config.executable}.exe` : config.executable; } /** * Get the name of the binary inside the archive + * @param backend Backend variant to use (defaults to DEFAULT_BACKEND) * @returns Binary name as it appears in the tar.gz/zip */ -export function getArchiveBinaryName(): string { - const platform = detectPlatform(); - return platform.os === 'windows' ? 'cli-proxy-api-plus.exe' : 'cli-proxy-api-plus'; +export function getArchiveBinaryName(backend: CLIProxyBackend = DEFAULT_BACKEND): string { + return getExecutableName(backend); } /** * Get download URL for current platform - * @param version Version to download (defaults to fallback version) + * @param version Version to download (defaults to backend fallback version) + * @param backend Backend variant to use (defaults to DEFAULT_BACKEND) * @returns Full GitHub release download URL */ -export function getDownloadUrl(version: string = CLIPROXY_FALLBACK_VERSION): string { - const platform = detectPlatform(version); - const baseUrl = `https://github.com/router-for-me/CLIProxyAPIPlus/releases/download/v${version}`; - return `${baseUrl}/${platform.binaryName}`; +export function getDownloadUrl( + version?: string, + backend: CLIProxyBackend = DEFAULT_BACKEND +): string { + const config = BACKEND_CONFIG[backend]; + const ver = version || config.fallbackVersion; + const platform = detectPlatform(ver, backend); + return `https://github.com/${config.repo}/releases/download/v${ver}/${platform.binaryName}`; } /** * Get checksums.txt URL for version - * @param version Version to get checksums for (defaults to fallback version) + * @param version Version to get checksums for (defaults to backend fallback version) + * @param backend Backend variant to use (defaults to DEFAULT_BACKEND) * @returns Full URL to checksums.txt */ -export function getChecksumsUrl(version: string = CLIPROXY_FALLBACK_VERSION): string { - return `https://github.com/router-for-me/CLIProxyAPIPlus/releases/download/v${version}/checksums.txt`; +export function getChecksumsUrl( + version?: string, + backend: CLIProxyBackend = DEFAULT_BACKEND +): string { + const config = BACKEND_CONFIG[backend]; + const ver = version || config.fallbackVersion; + return `https://github.com/${config.repo}/releases/download/v${ver}/checksums.txt`; +} + +/** + * Get fallback version for backend + * @param backend Backend variant to use (defaults to DEFAULT_BACKEND) + * @returns Fallback version string + */ +export function getFallbackVersion(backend: CLIProxyBackend = DEFAULT_BACKEND): string { + return BACKEND_CONFIG[backend].fallbackVersion; } /** diff --git a/src/cliproxy/services/binary-service.ts b/src/cliproxy/services/binary-service.ts index ff769e65..7a2458f9 100644 --- a/src/cliproxy/services/binary-service.ts +++ b/src/cliproxy/services/binary-service.ts @@ -19,7 +19,9 @@ import { clearPinnedVersion, isVersionPinned, } from '../binary-manager'; -import { CLIPROXY_FALLBACK_VERSION } from '../platform-detector'; +import { BACKEND_CONFIG, DEFAULT_BACKEND } from '../platform-detector'; +import { CLIProxyBackend } from '../types'; +import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; /** Binary status result */ export interface BinaryStatusResult { @@ -28,6 +30,7 @@ export interface BinaryStatusResult { pinnedVersion: string | null; binaryPath: string; fallbackVersion: string; + backend: CLIProxyBackend; } /** Install result */ @@ -48,15 +51,19 @@ export interface LatestVersionResult { } /** - * Get current binary status + * Get current binary status for a specific backend */ -export function getBinaryStatus(): BinaryStatusResult { +export function getBinaryStatus(backend?: CLIProxyBackend): BinaryStatusResult { + const effectiveBackend = + backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; + const backendConfig = BACKEND_CONFIG[effectiveBackend]; return { installed: isCLIProxyInstalled(), currentVersion: getInstalledCliproxyVersion(), pinnedVersion: getPinnedVersion(), binaryPath: getCLIProxyPath(), - fallbackVersion: CLIPROXY_FALLBACK_VERSION, + fallbackVersion: backendConfig.fallbackVersion, + backend: effectiveBackend, }; } diff --git a/src/cliproxy/services/variant-service.ts b/src/cliproxy/services/variant-service.ts index 41a5dcfb..31ad54ce 100644 --- a/src/cliproxy/services/variant-service.ts +++ b/src/cliproxy/services/variant-service.ts @@ -8,8 +8,10 @@ import * as os from 'os'; import * as path from 'path'; import { CLIProxyProfileName } from '../../auth/profile-detector'; -import { CLIProxyProvider } from '../types'; +import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS } from '../types'; import { isReservedName, isWindowsReservedName } from '../../config/reserved-names'; +import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; +import { DEFAULT_BACKEND } from '../platform-detector'; import { isUnifiedMode } from '../../config/unified-config-loader'; import { deleteConfigForPort } from '../config-generator'; import { deleteSessionLockForPort } from '../session-tracker'; @@ -64,6 +66,22 @@ export function validateProfileName(name: string): string | null { return null; } +/** + * Validate provider/backend compatibility + * Returns error message if provider requires Plus backend but original is configured + */ +export function validateProviderBackend(provider: CLIProxyProfileName): string | null { + const config = loadOrCreateUnifiedConfig(); + const backend: CLIProxyBackend = config.cliproxy?.backend ?? DEFAULT_BACKEND; + + // Normalize provider to lowercase for case-insensitive comparison + const normalizedProvider = provider.toLowerCase() as CLIProxyProvider; + if (backend === 'original' && PLUS_ONLY_PROVIDERS.includes(normalizedProvider)) { + return `${provider} requires CLIProxyAPIPlus. Set \`cliproxy.backend: plus\` in config.yaml or use --backend=plus`; + } + return null; +} + /** * Check if CLIProxy variant profile exists */ @@ -88,6 +106,12 @@ export function createVariant( account?: string ): VariantOperationResult { try { + // Validate provider/backend compatibility (block kiro/ghcp on original backend) + const backendError = validateProviderBackend(provider); + if (backendError) { + return { success: false, error: backendError }; + } + // Allocate unique port for this variant const port = getNextAvailablePort(); @@ -188,6 +212,14 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari // Update config entry if provider or account changed if (updates.provider !== undefined || updates.account !== undefined) { const newProvider = updates.provider ?? existing.provider; + + // Validate provider/backend compatibility on provider change + if (updates.provider !== undefined) { + const backendError = validateProviderBackend(updates.provider); + if (backendError) { + return { success: false, error: backendError }; + } + } const newAccount = updates.account !== undefined ? updates.account : existing.account; if (isUnifiedMode()) { diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index 5ebb6226..06912f29 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -119,6 +119,18 @@ export interface DownloadResult { */ export type CLIProxyProvider = 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp'; +/** + * CLIProxy backend selection + * - original: CLIProxyAPI (no Kiro/ghcp support) + * - plus: CLIProxyAPIPlus (Kiro/ghcp support, default) + */ +export type CLIProxyBackend = 'original' | 'plus'; + +/** + * Providers that require CLIProxyAPIPlus backend + */ +export const PLUS_ONLY_PROVIDERS: CLIProxyProvider[] = ['kiro', 'ghcp']; + /** * CLIProxy config.yaml structure (minimal) */ diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts index 3add0ca1..30360803 100644 --- a/src/commands/cliproxy-command.ts +++ b/src/commands/cliproxy-command.ts @@ -29,11 +29,11 @@ import { } from '../cliproxy/account-manager'; import { fetchAllProviderQuotas } from '../cliproxy/quota-fetcher'; import { isOnCooldown } from '../cliproxy/quota-manager'; -import { CLIPROXY_FALLBACK_VERSION } from '../cliproxy/platform-detector'; +import { DEFAULT_BACKEND, getFallbackVersion, BACKEND_CONFIG } from '../cliproxy/platform-detector'; import { CLIPROXY_PROFILES, CLIProxyProfileName } from '../auth/profile-detector'; import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../cliproxy/model-catalog'; -import { CLIProxyProvider } from '../cliproxy/types'; -import { isUnifiedMode } from '../config/unified-config-loader'; +import { CLIProxyProvider, CLIProxyBackend } from '../cliproxy/types'; +import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; import { initUI, header, @@ -68,6 +68,49 @@ import { // ARGUMENT PARSING // ============================================================================ +/** + * Parse --backend flag from args + * Returns the backend value and remaining args without --backend flag + */ +function parseBackendArg(args: string[]): { + backend: CLIProxyBackend | undefined; + remainingArgs: string[]; +} { + const backendIdx = args.indexOf('--backend'); + if (backendIdx === -1) { + // Also check for --backend=value format + const backendEqualsIdx = args.findIndex((a) => a.startsWith('--backend=')); + if (backendEqualsIdx !== -1) { + const value = args[backendEqualsIdx].split('=')[1] as CLIProxyBackend; + if (value !== 'original' && value !== 'plus') { + warn(`Invalid backend '${value}'. Valid options: original, plus`); + return { backend: undefined, remainingArgs: args }; + } + const remainingArgs = [...args]; + remainingArgs.splice(backendEqualsIdx, 1); + return { backend: value, remainingArgs }; + } + return { backend: undefined, remainingArgs: args }; + } + const value = args[backendIdx + 1]; + if (value !== 'original' && value !== 'plus') { + warn(`Invalid backend '${value}'. Valid options: original, plus`); + return { backend: undefined, remainingArgs: args }; + } + const remainingArgs = [...args]; + remainingArgs.splice(backendIdx, 2); + return { backend: value, remainingArgs }; +} + +/** + * Get effective backend (CLI flag > config.yaml > default) + */ +function getEffectiveBackend(cliBackend?: CLIProxyBackend): CLIProxyBackend { + if (cliBackend) return cliBackend; + const config = loadOrCreateUnifiedConfig(); + return config.cliproxy?.backend ?? DEFAULT_BACKEND; +} + interface CliproxyProfileArgs { name?: string; provider?: CLIProxyProfileName; @@ -431,14 +474,18 @@ async function handleProxyStatus(): Promise { console.log(''); } -async function showStatus(verbose: boolean): Promise { +async function showStatus(verbose: boolean, backend: CLIProxyBackend): Promise { await initUI(); - const status = getBinaryStatus(); + const status = getBinaryStatus(backend); console.log(''); - console.log(color('CLIProxy Plus Status', 'primary')); + const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy (Original)'; + console.log(color(`${backendLabel} Status`, 'primary')); console.log(''); + console.log( + ` Backend: ${color(backend, 'info')}${backend === DEFAULT_BACKEND ? dim(' (default)') : ''}` + ); if (status.installed) { console.log(` Installed: ${color('Yes', 'success')}`); const versionLabel = status.pinnedVersion @@ -576,7 +623,13 @@ async function showHelp(): Promise { ['--update', 'Unpin and update to latest version'], ], ], - ['Options:', [['--verbose, -v', 'Show detailed quota fetch diagnostics']]], + [ + 'Options:', + [ + ['--backend ', 'Use specific backend: original | plus (default: from config)'], + ['--verbose, -v', 'Show detailed quota fetch diagnostics'], + ], + ], ]; for (const [title, cmds] of sections) { @@ -591,9 +644,9 @@ async function showHelp(): Promise { console.log(dim(' Note: CLIProxy now persists by default. Use "stop" to terminate.')); console.log(''); console.log(subheader('Notes:')); - console.log(` Default fallback version: ${color(CLIPROXY_FALLBACK_VERSION, 'info')}`); + console.log(` Default fallback version: ${color(getFallbackVersion(), 'info')}`); console.log( - ` Releases: ${color('https://github.com/router-for-me/CLIProxyAPIPlus/releases', 'path')}` + ` Releases: ${color(`https://github.com/${BACKEND_CONFIG[DEFAULT_BACKEND].repo}/releases`, 'path')}` ); console.log(''); } @@ -909,16 +962,20 @@ async function handleQuotaStatus(verbose = false): Promise { // ============================================================================ export async function handleCliproxyCommand(args: string[]): Promise { - const verbose = args.includes('--verbose') || args.includes('-v'); - const command = args[0]; + // Parse --backend flag first (before other processing) + const { backend: cliBackend, remainingArgs } = parseBackendArg(args); + const effectiveBackend = getEffectiveBackend(cliBackend); - if (args.includes('--help') || args.includes('-h')) { + const verbose = remainingArgs.includes('--verbose') || remainingArgs.includes('-v'); + const command = remainingArgs[0]; + + if (remainingArgs.includes('--help') || remainingArgs.includes('-h')) { await showHelp(); return; } if (command === 'create') { - await handleCreate(args.slice(1)); + await handleCreate(remainingArgs.slice(1)); return; } @@ -928,7 +985,7 @@ export async function handleCliproxyCommand(args: string[]): Promise { } if (command === 'remove' || command === 'delete' || command === 'rm') { - await handleRemove(args.slice(1)); + await handleRemove(remainingArgs.slice(1)); return; } @@ -949,17 +1006,17 @@ export async function handleCliproxyCommand(args: string[]): Promise { // Quota management commands if (command === 'default') { - await handleSetDefault(args.slice(1)); + await handleSetDefault(remainingArgs.slice(1)); return; } if (command === 'pause') { - await handlePauseAccount(args.slice(1)); + await handlePauseAccount(remainingArgs.slice(1)); return; } if (command === 'resume') { - await handleResumeAccount(args.slice(1)); + await handleResumeAccount(remainingArgs.slice(1)); return; } @@ -968,9 +1025,9 @@ export async function handleCliproxyCommand(args: string[]): Promise { return; } - const installIdx = args.indexOf('--install'); + const installIdx = remainingArgs.indexOf('--install'); if (installIdx !== -1) { - let version = args[installIdx + 1]; + let version = remainingArgs[installIdx + 1]; if (!version || version.startsWith('-')) { console.error(fail('Missing version argument for --install')); console.error(' Usage: ccs cliproxy --install '); @@ -983,10 +1040,10 @@ export async function handleCliproxyCommand(args: string[]): Promise { return; } - if (args.includes('--latest') || args.includes('--update')) { + if (remainingArgs.includes('--latest') || remainingArgs.includes('--update')) { await handleInstallLatest(verbose); return; } - await showStatus(verbose); + await showStatus(verbose, effectiveBackend); } diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 385f52a9..17f507e4 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -115,6 +115,8 @@ export interface TokenRefreshSettings { * CLIProxy configuration section. */ export interface CLIProxyConfig { + /** Backend selection: 'original' or 'plus' (default: 'plus') */ + backend?: 'original' | 'plus'; /** Nickname to email mapping for OAuth accounts */ oauth_accounts: OAuthAccounts; /** Built-in providers (read-only, for reference) */ @@ -530,6 +532,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { accounts: {}, profiles: {}, cliproxy: { + backend: 'plus', oauth_accounts: {}, providers: ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp'], variants: {}, diff --git a/src/web-server/routes/proxy-routes.ts b/src/web-server/routes/proxy-routes.ts index 51978bf1..636fe5d8 100644 --- a/src/web-server/routes/proxy-routes.ts +++ b/src/web-server/routes/proxy-routes.ts @@ -10,6 +10,7 @@ import { Router, Request, Response } from 'express'; import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader'; import { testConnection } from '../../cliproxy/remote-proxy-client'; +import { isProxyRunning } from '../../cliproxy/services/proxy-lifecycle-service'; import { DEFAULT_CLIPROXY_SERVER_CONFIG, CliproxyServerConfig, @@ -65,6 +66,67 @@ router.put('/', async (req: Request, res: Response) => { } }); +/** + * GET /api/cliproxy-server/backend - Get CLIProxy backend setting + * @returns {{ backend: 'original' | 'plus' }} Current backend configuration + */ +router.get('/backend', async (_req: Request, res: Response) => { + try { + const config = await loadOrCreateUnifiedConfig(); + res.json({ backend: config.cliproxy?.backend ?? 'plus' }); + } catch (error) { + console.error('[cliproxy-server-routes] Failed to load backend config:', error); + res.status(500).json({ error: 'Failed to load backend config' }); + } +}); + +/** + * PUT /api/cliproxy-server/backend - Update CLIProxy backend setting + * @param {Object} req.body - Request body + * @param {'original' | 'plus'} req.body.backend - Backend to switch to + * @param {boolean} [req.body.force=false] - Force change even if proxy is running + * @returns {{ backend: 'original' | 'plus' }} Updated backend configuration + * @throws {400} Invalid backend value + * @throws {409} Proxy is running (unless force=true) + */ +router.put('/backend', async (req: Request, res: Response) => { + try { + const { backend, force } = req.body; + if (backend !== 'original' && backend !== 'plus') { + res.status(400).json({ error: 'Invalid backend. Must be "original" or "plus"' }); + return; + } + + // Check if proxy is running - warn about restart requirement + const config = await loadOrCreateUnifiedConfig(); + const currentBackend = config.cliproxy?.backend ?? 'plus'; + if (currentBackend !== backend && isProxyRunning() && !force) { + res.status(409).json({ + error: 'Proxy is running. Stop proxy first or use force=true to change backend.', + proxyRunning: true, + currentBackend, + }); + return; + } + if (!config.cliproxy) { + config.cliproxy = { + backend, + oauth_accounts: {}, + providers: ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp'], + variants: {}, + }; + } else { + config.cliproxy.backend = backend; + } + + await saveUnifiedConfig(config); + res.json({ backend }); + } catch (error) { + console.error('[cliproxy-server-routes] Failed to save backend config:', error); + res.status(500).json({ error: 'Failed to save backend config' }); + } +}); + /** * POST /api/cliproxy-server/test - Test remote proxy connection */ diff --git a/tests/unit/cliproxy/backend-selection.test.js b/tests/unit/cliproxy/backend-selection.test.js new file mode 100644 index 00000000..bcccb347 --- /dev/null +++ b/tests/unit/cliproxy/backend-selection.test.js @@ -0,0 +1,132 @@ +/** + * Tests for CLIProxy Backend Selection + * Verifies backend selection feature for original vs plus CLIProxyAPI variants + */ + +const assert = require('assert'); + +describe('Backend Selection', () => { + const platformDetector = require('../../../dist/cliproxy/platform-detector'); + const types = require('../../../dist/cliproxy/types'); + + describe('BACKEND_CONFIG', () => { + it('has correct configuration for original backend', () => { + const config = platformDetector.BACKEND_CONFIG.original; + assert.strictEqual(config.repo, 'router-for-me/CLIProxyAPI'); + assert.strictEqual(config.binaryPrefix, 'CLIProxyAPI'); + assert.strictEqual(config.executable, 'cli-proxy-api'); + assert(config.fallbackVersion.match(/^\d+\.\d+\.\d+$/), 'original version has no suffix'); + }); + + it('has correct configuration for plus backend', () => { + const config = platformDetector.BACKEND_CONFIG.plus; + assert.strictEqual(config.repo, 'router-for-me/CLIProxyAPIPlus'); + assert.strictEqual(config.binaryPrefix, 'CLIProxyAPIPlus'); + assert.strictEqual(config.executable, 'cli-proxy-api-plus'); + assert(config.fallbackVersion.match(/^\d+\.\d+\.\d+-\d+$/), 'plus version has -0 suffix'); + }); + }); + + describe('DEFAULT_BACKEND', () => { + it('defaults to plus backend for backward compatibility', () => { + assert.strictEqual(platformDetector.DEFAULT_BACKEND, 'plus'); + }); + }); + + describe('detectPlatform', () => { + it('generates correct binary name for original backend', () => { + const info = platformDetector.detectPlatform('6.6.51', 'original'); + assert(info.binaryName.startsWith('CLIProxyAPI_6.6.51_')); + assert(!info.binaryName.includes('CLIProxyAPIPlus')); + }); + + it('generates correct binary name for plus backend', () => { + const info = platformDetector.detectPlatform('6.6.51-0', 'plus'); + assert(info.binaryName.startsWith('CLIProxyAPIPlus_6.6.51-0_')); + }); + + it('uses plus backend by default', () => { + const info = platformDetector.detectPlatform(); + assert(info.binaryName.includes('CLIProxyAPIPlus')); + }); + + it('uses fallback version when version not specified', () => { + const infoOriginal = platformDetector.detectPlatform(undefined, 'original'); + const fallbackOriginal = platformDetector.BACKEND_CONFIG.original.fallbackVersion; + assert(infoOriginal.binaryName.includes(fallbackOriginal)); + + const infoPlus = platformDetector.detectPlatform(undefined, 'plus'); + const fallbackPlus = platformDetector.BACKEND_CONFIG.plus.fallbackVersion; + assert(infoPlus.binaryName.includes(fallbackPlus)); + }); + }); + + describe('getExecutableName', () => { + const isWindows = process.platform === 'win32'; + + it('returns correct name for original backend', () => { + const name = platformDetector.getExecutableName('original'); + const expected = isWindows ? 'cli-proxy-api.exe' : 'cli-proxy-api'; + assert.strictEqual(name, expected); + }); + + it('returns correct name for plus backend', () => { + const name = platformDetector.getExecutableName('plus'); + const expected = isWindows ? 'cli-proxy-api-plus.exe' : 'cli-proxy-api-plus'; + assert.strictEqual(name, expected); + }); + + it('defaults to plus backend', () => { + const name = platformDetector.getExecutableName(); + assert(name.includes('cli-proxy-api-plus')); + }); + }); + + describe('getDownloadUrl', () => { + it('uses correct repo for original backend', () => { + const url = platformDetector.getDownloadUrl('6.6.51', 'original'); + assert(url.includes('router-for-me/CLIProxyAPI/releases')); + assert(!url.includes('CLIProxyAPIPlus')); + }); + + it('uses correct repo for plus backend', () => { + const url = platformDetector.getDownloadUrl('6.6.51-0', 'plus'); + assert(url.includes('router-for-me/CLIProxyAPIPlus/releases')); + }); + + it('defaults to plus backend', () => { + const url = platformDetector.getDownloadUrl(); + assert(url.includes('CLIProxyAPIPlus')); + }); + }); + + describe('getFallbackVersion', () => { + it('returns correct version for original backend', () => { + const version = platformDetector.getFallbackVersion('original'); + assert.strictEqual(version, platformDetector.BACKEND_CONFIG.original.fallbackVersion); + }); + + it('returns correct version for plus backend', () => { + const version = platformDetector.getFallbackVersion('plus'); + assert.strictEqual(version, platformDetector.BACKEND_CONFIG.plus.fallbackVersion); + }); + }); + + describe('PLUS_ONLY_PROVIDERS', () => { + it('includes kiro as plus-only provider', () => { + assert(types.PLUS_ONLY_PROVIDERS.includes('kiro')); + }); + + it('includes ghcp as plus-only provider', () => { + assert(types.PLUS_ONLY_PROVIDERS.includes('ghcp')); + }); + + it('does not include gemini as plus-only provider', () => { + assert(!types.PLUS_ONLY_PROVIDERS.includes('gemini')); + }); + + it('does not include agy as plus-only provider', () => { + assert(!types.PLUS_ONLY_PROVIDERS.includes('agy')); + }); + }); +}); diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 95ce900c..d2361016 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -495,6 +495,14 @@ export const api = { method: 'PUT', body: JSON.stringify(config), }), + /** Get backend setting */ + getBackend: () => request<{ backend: 'original' | 'plus' }>('/cliproxy-server/backend'), + /** Update backend setting */ + updateBackend: (backend: 'original' | 'plus', force = false) => + request<{ backend: 'original' | 'plus' }>('/cliproxy-server/backend', { + method: 'PUT', + body: JSON.stringify({ backend, force }), + }), /** Test remote proxy connection */ test: (params: { host: string; diff --git a/ui/src/pages/settings/sections/proxy/index.tsx b/ui/src/pages/settings/sections/proxy/index.tsx index 76dfe445..275b356f 100644 --- a/ui/src/pages/settings/sections/proxy/index.tsx +++ b/ui/src/pages/settings/sections/proxy/index.tsx @@ -3,19 +3,33 @@ * Settings section for CLIProxyAPI configuration (local/remote) */ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useCallback } from 'react'; import { Button } from '@/components/ui/button'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Switch } from '@/components/ui/switch'; -import { RefreshCw, CheckCircle2, AlertCircle, Laptop, Cloud, Bug } from 'lucide-react'; +import { + RefreshCw, + CheckCircle2, + AlertCircle, + Laptop, + Cloud, + Bug, + Box, + AlertTriangle, +} from 'lucide-react'; +import { toast } from 'sonner'; import { useProxyConfig, useRawConfig } from '../../hooks'; import { LocalProxyCard } from './local-proxy-card'; import { RemoteProxyCard } from './remote-proxy-card'; +import { api } from '@/lib/api-client'; /** LocalStorage key for debug mode preference */ const DEBUG_MODE_KEY = 'ccs_debug_mode'; +/** Providers only available on CLIProxyAPIPlus */ +const PLUS_ONLY_PROVIDERS = ['kiro', 'ghcp']; + export default function ProxySection() { const { config, @@ -60,6 +74,52 @@ export default function ProxySection() { } }; + // Backend state (loaded from API) + const [backend, setBackend] = useState<'original' | 'plus'>('plus'); + const [backendSaving, setBackendSaving] = useState(false); + const [hasKiroGhcpVariants, setHasKiroGhcpVariants] = useState(false); + + // Fetch backend setting + const fetchBackend = useCallback(async () => { + try { + const result = await api.cliproxyServer.getBackend(); + setBackend(result.backend); + } catch (err) { + console.error('[Proxy] Failed to fetch backend:', err); + } + }, []); + + // Check for Kiro/ghcp variants + const checkPlusOnlyVariants = useCallback(async () => { + try { + const result = await api.cliproxy.list(); + const hasIncompatible = result.variants.some((v) => PLUS_ONLY_PROVIDERS.includes(v.provider)); + setHasKiroGhcpVariants(hasIncompatible); + } catch (err) { + console.error('[Proxy] Failed to check variants:', err); + } + }, []); + + // Save backend setting + const handleBackendChange = async (value: 'original' | 'plus') => { + const previousValue = backend; + setBackend(value); + setBackendSaving(true); + try { + await api.cliproxyServer.updateBackend(value); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to save backend'; + // Check if error is due to proxy running (409 conflict) + if (errorMessage.includes('Proxy is running')) { + toast.error('Stop the proxy first to change backend'); + } + console.error('[Proxy] Failed to save backend:', err); + setBackend(previousValue); + } finally { + setBackendSaving(false); + } + }; + // Log when debug mode changes (sanitize sensitive fields) useEffect(() => { if (debugMode && config) { @@ -80,7 +140,9 @@ export default function ProxySection() { useEffect(() => { fetchConfig(); fetchRawConfig(); - }, [fetchConfig, fetchRawConfig]); + fetchBackend(); + checkPlusOnlyVariants(); + }, [fetchConfig, fetchRawConfig, fetchBackend, checkPlusOnlyVariants]); if (loading || !config) { return ( @@ -242,6 +304,64 @@ export default function ProxySection() { + {/* Backend Selection - Card based selection */} +
+

+ + Backend Binary +

+
+ {/* Plus Backend Card */} + + + {/* Original Backend Card */} + +
+ {/* Warning when original backend selected with Kiro/ghcp variants */} + {backend === 'original' && hasKiroGhcpVariants && ( + + + + Existing Kiro/Copilot variants will not work with CLIProxyAPI. Switch to + CLIProxyAPIPlus or remove those variants. + + + )} +
+ {/* Remote Settings - Show when remote mode is enabled */} {isRemoteMode && ( { fetchConfig(); fetchRawConfig(); + fetchBackend(); + checkPlusOnlyVariants(); }} disabled={loading || saving} className="w-full"