From b49b7d17b20e6f470e0315ad777f61e0f266b246 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 19 Dec 2025 11:48:45 -0500 Subject: [PATCH] refactor(cliproxy): extract service layer from cliproxy-command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - create cliproxy/services/ with 6 modules (1179 → 634 lines) - variant-service, variant-settings, variant-config-adapter - proxy-lifecycle-service, binary-service - separate CLI parsing from business logic Phase 5: Command Refactor (cliproxy-command.ts) --- src/cliproxy/services/binary-service.ts | 176 +++ src/cliproxy/services/index.ts | 40 + .../services/proxy-lifecycle-service.ts | 58 + .../services/variant-config-adapter.ts | 182 +++ src/cliproxy/services/variant-service.ts | 139 +++ src/cliproxy/services/variant-settings.ts | 136 +++ src/commands/cliproxy-command.ts | 1071 ++++------------- src/config/unified-config-loader.ts | 10 + 8 files changed, 1000 insertions(+), 812 deletions(-) create mode 100644 src/cliproxy/services/binary-service.ts create mode 100644 src/cliproxy/services/index.ts create mode 100644 src/cliproxy/services/proxy-lifecycle-service.ts create mode 100644 src/cliproxy/services/variant-config-adapter.ts create mode 100644 src/cliproxy/services/variant-service.ts create mode 100644 src/cliproxy/services/variant-settings.ts diff --git a/src/cliproxy/services/binary-service.ts b/src/cliproxy/services/binary-service.ts new file mode 100644 index 00000000..c044aca4 --- /dev/null +++ b/src/cliproxy/services/binary-service.ts @@ -0,0 +1,176 @@ +/** + * CLIProxy Binary Service + * + * Handles CLIProxyAPI binary version management: + * - Get current version status + * - Install specific versions + * - Install latest version + * - Version pinning + */ + +import { + getInstalledCliproxyVersion, + installCliproxyVersion, + fetchLatestCliproxyVersion, + isCLIProxyInstalled, + getCLIProxyPath, + getPinnedVersion, + savePinnedVersion, + clearPinnedVersion, + isVersionPinned, +} from '../binary-manager'; +import { CLIPROXY_FALLBACK_VERSION } from '../platform-detector'; + +/** Binary status result */ +export interface BinaryStatusResult { + installed: boolean; + currentVersion: string | null; + pinnedVersion: string | null; + binaryPath: string; + fallbackVersion: string; +} + +/** Install result */ +export interface InstallResult { + success: boolean; + version: string; + error?: string; + wasPinned?: boolean; +} + +/** Latest version check result */ +export interface LatestVersionResult { + success: boolean; + latestVersion?: string; + currentVersion?: string; + updateAvailable?: boolean; + error?: string; +} + +/** + * Get current binary status + */ +export function getBinaryStatus(): BinaryStatusResult { + return { + installed: isCLIProxyInstalled(), + currentVersion: getInstalledCliproxyVersion(), + pinnedVersion: getPinnedVersion(), + binaryPath: getCLIProxyPath(), + fallbackVersion: CLIPROXY_FALLBACK_VERSION, + }; +} + +/** + * Check for latest version + */ +export async function checkLatestVersion(): Promise { + try { + const latestVersion = await fetchLatestCliproxyVersion(); + const currentVersion = getInstalledCliproxyVersion(); + const updateAvailable = latestVersion !== currentVersion; + + return { + success: true, + latestVersion, + currentVersion: currentVersion || undefined, + updateAvailable, + }; + } catch (error) { + return { + success: false, + error: (error as Error).message, + }; + } +} + +/** + * Validate version format + */ +export function isValidVersionFormat(version: string): boolean { + return /^\d+\.\d+\.\d+$/.test(version); +} + +/** + * Install a specific version and pin it + */ +export async function installVersion(version: string, verbose = false): Promise { + if (!isValidVersionFormat(version)) { + return { + success: false, + version, + error: 'Invalid version format. Expected format: X.Y.Z (e.g., 6.5.53)', + }; + } + + try { + await installCliproxyVersion(version, verbose); + savePinnedVersion(version); + + return { + success: true, + version, + wasPinned: true, + }; + } catch (error) { + return { + success: false, + version, + error: (error as Error).message, + }; + } +} + +/** + * Install latest version and clear any pin + */ +export async function installLatest(verbose = false): Promise { + try { + const latestVersion = await fetchLatestCliproxyVersion(); + const currentVersion = getInstalledCliproxyVersion(); + const wasPinned = isVersionPinned(); + + if (isCLIProxyInstalled() && latestVersion === currentVersion && !wasPinned) { + return { + success: true, + version: latestVersion, + error: `Already running latest version: v${latestVersion}`, + }; + } + + await installCliproxyVersion(latestVersion, verbose); + clearPinnedVersion(); + + return { + success: true, + version: latestVersion, + wasPinned, + }; + } catch (error) { + return { + success: false, + version: '', + error: (error as Error).message, + }; + } +} + +/** + * Check if a version is pinned + */ +export function isPinned(): boolean { + return isVersionPinned(); +} + +/** + * Get pinned version if any + */ +export function getPinned(): string | null { + return getPinnedVersion(); +} + +/** + * Clear version pin + */ +export function clearPin(): void { + clearPinnedVersion(); +} diff --git a/src/cliproxy/services/index.ts b/src/cliproxy/services/index.ts new file mode 100644 index 00000000..c4ece9c5 --- /dev/null +++ b/src/cliproxy/services/index.ts @@ -0,0 +1,40 @@ +/** + * CLIProxy Services + * Central export point for cliproxy service layer + */ + +// Variant management +export { + validateProfileName, + variantExists, + listVariants, + createVariant, + removeVariant, + type VariantConfig, + type VariantOperationResult, +} from './variant-service'; + +// Proxy lifecycle +export { + getProxyStatus, + stopProxy, + isProxyRunning, + getActiveSessionCount, + type ProxyStatusResult, + type StopProxyResult, +} from './proxy-lifecycle-service'; + +// Binary management +export { + getBinaryStatus, + checkLatestVersion, + isValidVersionFormat, + installVersion, + installLatest, + isPinned, + getPinned, + clearPin, + type BinaryStatusResult, + type InstallResult, + type LatestVersionResult, +} from './binary-service'; diff --git a/src/cliproxy/services/proxy-lifecycle-service.ts b/src/cliproxy/services/proxy-lifecycle-service.ts new file mode 100644 index 00000000..7ea4c124 --- /dev/null +++ b/src/cliproxy/services/proxy-lifecycle-service.ts @@ -0,0 +1,58 @@ +/** + * CLIProxy Proxy Lifecycle Service + * + * Handles start/stop/status operations for CLIProxy instances. + * Delegates to session-tracker for actual process management. + */ + +import { + stopProxy as stopProxySession, + getProxyStatus as getProxyStatusSession, +} from '../session-tracker'; + +/** Proxy status result */ +export interface ProxyStatusResult { + running: boolean; + port?: number; + pid?: number; + sessionCount?: number; + startedAt?: string; +} + +/** Stop proxy result */ +export interface StopProxyResult { + stopped: boolean; + pid?: number; + sessionCount?: number; + error?: string; +} + +/** + * Get current proxy status + */ +export function getProxyStatus(): ProxyStatusResult { + return getProxyStatusSession(); +} + +/** + * Stop the running CLIProxy instance + */ +export async function stopProxy(): Promise { + return stopProxySession(); +} + +/** + * Check if proxy is currently running + */ +export function isProxyRunning(): boolean { + const status = getProxyStatusSession(); + return status.running; +} + +/** + * Get active session count + */ +export function getActiveSessionCount(): number { + const status = getProxyStatusSession(); + return status.sessionCount ?? 0; +} diff --git a/src/cliproxy/services/variant-config-adapter.ts b/src/cliproxy/services/variant-config-adapter.ts new file mode 100644 index 00000000..a4b09058 --- /dev/null +++ b/src/cliproxy/services/variant-config-adapter.ts @@ -0,0 +1,182 @@ +/** + * CLIProxy Variant Config Adapters + * + * Handles reading/writing variant config in both unified and legacy formats. + */ + +import * as fs from 'fs'; +import { getConfigPath, loadConfig } from '../../utils/config-manager'; +import { CLIProxyProvider } from '../types'; +import { + loadOrCreateUnifiedConfig, + saveUnifiedConfig, + isUnifiedMode, +} from '../../config/unified-config-loader'; + +/** Variant configuration structure */ +export interface VariantConfig { + provider: string; + settings?: string; + account?: string; + model?: string; +} + +/** + * Check if variant exists in config + */ +export function variantExistsInConfig(name: string): boolean { + try { + if (isUnifiedMode()) { + const config = loadOrCreateUnifiedConfig(); + return !!(config.cliproxy?.variants && name in config.cliproxy.variants); + } + const config = loadConfig(); + return !!(config.cliproxy && name in config.cliproxy); + } catch { + return false; + } +} + +/** + * List variants from config + */ +export function listVariantsFromConfig(): Record { + try { + if (isUnifiedMode()) { + const unifiedConfig = loadOrCreateUnifiedConfig(); + const variants = unifiedConfig.cliproxy?.variants || {}; + const result: Record = {}; + for (const name of Object.keys(variants)) { + const v = variants[name]; + result[name] = { provider: v.provider, settings: v.settings, account: v.account }; + } + return result; + } + + const config = loadConfig(); + const variants = config.cliproxy || {}; + const result: Record = {}; + for (const name of Object.keys(variants)) { + const v = variants[name] as { provider: string; settings: string; account?: string }; + result[name] = { provider: v.provider, settings: v.settings, account: v.account }; + } + return result; + } catch { + return {}; + } +} + +/** + * Save variant to unified config + */ +export function saveVariantUnified( + name: string, + provider: CLIProxyProvider, + settingsPath: string, + account?: string +): void { + const config = loadOrCreateUnifiedConfig(); + + if (!config.cliproxy) { + config.cliproxy = { + oauth_accounts: {}, + providers: ['gemini', 'codex', 'agy', 'qwen', 'iflow'], + variants: {}, + }; + } + if (!config.cliproxy.variants) { + config.cliproxy.variants = {}; + } + + config.cliproxy.variants[name] = { + provider, + account, + settings: settingsPath, + }; + + saveUnifiedConfig(config); +} + +/** + * Save variant to legacy JSON config + */ +export function saveVariantLegacy( + name: string, + provider: string, + settingsPath: string, + account?: string +): void { + const configPath = getConfigPath(); + + let config: { profiles: Record; cliproxy?: Record }; + try { + config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + } catch { + config = { profiles: {} }; + } + + if (!config.cliproxy) { + config.cliproxy = {}; + } + + const variantConfig: { provider: string; settings: string; account?: string } = { + provider, + settings: settingsPath, + }; + if (account) { + variantConfig.account = account; + } + config.cliproxy[name] = variantConfig; + + const tempPath = configPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); + fs.renameSync(tempPath, configPath); +} + +/** + * Remove variant from unified config + */ +export function removeVariantFromUnifiedConfig(name: string): VariantConfig | null { + const config = loadOrCreateUnifiedConfig(); + + if (!config.cliproxy?.variants || !(name in config.cliproxy.variants)) { + return null; + } + + const variant = config.cliproxy.variants[name]; + delete config.cliproxy.variants[name]; + saveUnifiedConfig(config); + + return { provider: variant.provider, settings: variant.settings }; +} + +/** + * Remove variant from legacy JSON config + */ +export function removeVariantFromLegacyConfig(name: string): VariantConfig | null { + const configPath = getConfigPath(); + + let config: { profiles: Record; cliproxy?: Record }; + try { + config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + } catch { + return null; + } + + if (!config.cliproxy || !(name in config.cliproxy)) { + return null; + } + + const variant = config.cliproxy[name] as { provider: string; settings: string }; + delete config.cliproxy[name]; + + if (Object.keys(config.cliproxy).length === 0) { + delete config.cliproxy; + } + + const tempPath = configPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); + fs.renameSync(tempPath, configPath); + + return variant; +} diff --git a/src/cliproxy/services/variant-service.ts b/src/cliproxy/services/variant-service.ts new file mode 100644 index 00000000..8429ce08 --- /dev/null +++ b/src/cliproxy/services/variant-service.ts @@ -0,0 +1,139 @@ +/** + * CLIProxy Variant Service + * + * Handles CRUD operations for CLIProxy variant profiles. + * Supports both unified config (config.yaml) and legacy JSON format. + */ + +import * as path from 'path'; +import { CLIProxyProfileName } from '../../auth/profile-detector'; +import { CLIProxyProvider } from '../types'; +import { isReservedName } from '../../config/reserved-names'; +import { isUnifiedMode } from '../../config/unified-config-loader'; +import { + createSettingsFile, + createSettingsFileUnified, + deleteSettingsFile, + getRelativeSettingsPath, +} from './variant-settings'; +import { + VariantConfig, + variantExistsInConfig, + listVariantsFromConfig, + saveVariantUnified, + saveVariantLegacy, + removeVariantFromUnifiedConfig, + removeVariantFromLegacyConfig, +} from './variant-config-adapter'; + +// Re-export VariantConfig from adapter +export type { VariantConfig } from './variant-config-adapter'; + +/** Result of variant operations */ +export interface VariantOperationResult { + success: boolean; + error?: string; + variant?: VariantConfig; + settingsPath?: string; +} + +/** + * Validate CLIProxy profile name + */ +export function validateProfileName(name: string): string | null { + if (!name) { + return 'Profile name is required'; + } + if (!/^[a-zA-Z][a-zA-Z0-9._-]*$/.test(name)) { + return 'Name must start with letter, contain only letters, numbers, dot, dash, underscore'; + } + if (name.length > 32) { + return 'Name must be 32 characters or less'; + } + if (isReservedName(name)) { + return `'${name}' is a reserved name`; + } + return null; +} + +/** + * Check if CLIProxy variant profile exists + */ +export function variantExists(name: string): boolean { + return variantExistsInConfig(name); +} + +/** + * List all CLIProxy variants + */ +export function listVariants(): Record { + return listVariantsFromConfig(); +} + +/** + * Create a new CLIProxy variant + */ +export function createVariant( + name: string, + provider: CLIProxyProfileName, + model: string, + account?: string +): VariantOperationResult { + try { + let settingsPath: string; + + if (isUnifiedMode()) { + settingsPath = createSettingsFileUnified(name, provider, model); + saveVariantUnified( + name, + provider as CLIProxyProvider, + getRelativeSettingsPath(provider, name), + account + ); + } else { + settingsPath = createSettingsFile(name, provider, model); + saveVariantLegacy(name, provider, `~/.ccs/${path.basename(settingsPath)}`, account); + } + + return { + success: true, + settingsPath, + variant: { provider, model, account }, + }; + } catch (error) { + return { + success: false, + error: (error as Error).message, + }; + } +} + +/** + * Remove a CLIProxy variant + */ +export function removeVariant(name: string): VariantOperationResult { + try { + let variant: VariantConfig | null; + + if (isUnifiedMode()) { + const unifiedVariant = removeVariantFromUnifiedConfig(name); + if (unifiedVariant?.settings) { + deleteSettingsFile(unifiedVariant.settings); + } + variant = unifiedVariant; + } else { + variant = removeVariantFromLegacyConfig(name); + if (variant?.settings) { + deleteSettingsFile(variant.settings); + } + } + + if (!variant) { + return { success: false, error: `Variant '${name}' not found` }; + } + + return { success: true, variant }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } +} diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts new file mode 100644 index 00000000..b3c4b185 --- /dev/null +++ b/src/cliproxy/services/variant-settings.ts @@ -0,0 +1,136 @@ +/** + * CLIProxy Variant Settings File Manager + * + * Handles creation of settings.json files for CLIProxy variants. + * These files contain environment variables for Claude CLI integration. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CLIProxyProfileName } from '../../auth/profile-detector'; +import { getCcsDir } from '../../utils/config-manager'; +import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config-generator'; +import { CLIProxyProvider } from '../types'; + +/** Environment settings structure */ +interface SettingsEnv { + ANTHROPIC_BASE_URL: string; + ANTHROPIC_AUTH_TOKEN: string; + ANTHROPIC_MODEL: string; + ANTHROPIC_DEFAULT_OPUS_MODEL: string; + ANTHROPIC_DEFAULT_SONNET_MODEL: string; + ANTHROPIC_DEFAULT_HAIKU_MODEL: string; +} + +interface SettingsFile { + env: SettingsEnv; +} + +/** + * Build settings env object for a variant + */ +function buildSettingsEnv(provider: CLIProxyProfileName, model: string): SettingsEnv { + const baseEnv = getClaudeEnvVars(provider as CLIProxyProvider, CLIPROXY_DEFAULT_PORT); + + return { + ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '', + ANTHROPIC_AUTH_TOKEN: baseEnv.ANTHROPIC_AUTH_TOKEN || '', + ANTHROPIC_MODEL: model, + ANTHROPIC_DEFAULT_OPUS_MODEL: model, + ANTHROPIC_DEFAULT_SONNET_MODEL: model, + ANTHROPIC_DEFAULT_HAIKU_MODEL: baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || model, + }; +} + +/** + * Ensure directory exists + */ +function ensureDir(dir: string): void { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +/** + * Write settings file atomically + */ +function writeSettings(filePath: string, settings: SettingsFile): void { + fs.writeFileSync(filePath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); +} + +/** + * Get settings file path for a variant + */ +export function getSettingsFilePath(provider: CLIProxyProfileName, name: string): string { + const ccsDir = getCcsDir(); + return path.join(ccsDir, `${provider}-${name}.settings.json`); +} + +/** + * Get settings file name (without path) + */ +export function getSettingsFileName(provider: CLIProxyProfileName, name: string): string { + return `${provider}-${name}.settings.json`; +} + +/** + * Get relative settings path with ~ prefix + */ +export function getRelativeSettingsPath(provider: CLIProxyProfileName, name: string): string { + return `~/.ccs/${getSettingsFileName(provider, name)}`; +} + +/** + * Create settings.json file for CLIProxy variant (legacy mode) + */ +export function createSettingsFile( + name: string, + provider: CLIProxyProfileName, + model: string +): string { + const ccsDir = getCcsDir(); + const settingsPath = getSettingsFilePath(provider, name); + + const settings: SettingsFile = { + env: buildSettingsEnv(provider, model), + }; + + ensureDir(ccsDir); + writeSettings(settingsPath, settings); + + return settingsPath; +} + +/** + * Create settings.json file for CLIProxy variant (unified mode) + */ +export function createSettingsFileUnified( + name: string, + provider: CLIProxyProfileName, + model: string +): string { + const ccsDir = path.join(os.homedir(), '.ccs'); + const settingsPath = path.join(ccsDir, getSettingsFileName(provider, name)); + + const settings: SettingsFile = { + env: buildSettingsEnv(provider, model), + }; + + ensureDir(ccsDir); + writeSettings(settingsPath, settings); + + return settingsPath; +} + +/** + * Delete settings file if it exists + */ +export function deleteSettingsFile(settingsPath: string): boolean { + const resolvedPath = settingsPath.replace(/^~/, os.homedir()); + if (fs.existsSync(resolvedPath)) { + fs.unlinkSync(resolvedPath); + return true; + } + return false; +} diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts index 5c7cf58e..38090a9b 100644 --- a/src/commands/cliproxy-command.ts +++ b/src/commands/cliproxy-command.ts @@ -18,27 +18,14 @@ * - Legacy JSON format (config.json) as fallback */ -import * as fs from 'fs'; import * as path from 'path'; -import { - getInstalledCliproxyVersion, - installCliproxyVersion, - fetchLatestCliproxyVersion, - isCLIProxyInstalled, - getCLIProxyPath, - getPinnedVersion, - savePinnedVersion, - clearPinnedVersion, - isVersionPinned, -} from '../cliproxy'; import { getAllAuthStatus, getOAuthConfig, triggerOAuth } from '../cliproxy/auth-handler'; import { getProviderAccounts } from '../cliproxy/account-manager'; import { CLIPROXY_FALLBACK_VERSION } from '../cliproxy/platform-detector'; import { CLIPROXY_PROFILES, CLIProxyProfileName } from '../auth/profile-detector'; -import { getCcsDir, getConfigPath, loadConfig } from '../utils/config-manager'; -import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../cliproxy/config-generator'; -import { getProviderCatalog, supportsModelConfig, ModelEntry } from '../cliproxy/model-catalog'; +import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../cliproxy/model-catalog'; import { CLIProxyProvider } from '../cliproxy/types'; +import { isUnifiedMode } from '../config/unified-config-loader'; import { initUI, header, @@ -53,17 +40,24 @@ import { infoBox, } from '../utils/ui'; import { InteractivePrompt } from '../utils/prompt'; -import { isReservedName } from '../config/reserved-names'; + +// Import services import { - hasUnifiedConfig, - loadOrCreateUnifiedConfig, - saveUnifiedConfig, -} from '../config/unified-config-loader'; -import { isUnifiedConfigEnabled } from '../config/feature-flags'; -import { stopProxy, getProxyStatus } from '../cliproxy/session-tracker'; + validateProfileName, + variantExists, + listVariants, + createVariant, + removeVariant, + getProxyStatus, + stopProxy, + getBinaryStatus, + checkLatestVersion, + installVersion, + installLatest, +} from '../cliproxy/services'; // ============================================================================ -// PROFILE MANAGEMENT +// ARGUMENT PARSING // ============================================================================ interface CliproxyProfileArgs { @@ -75,15 +69,10 @@ interface CliproxyProfileArgs { yes?: boolean; } -/** - * Parse command line arguments for profile commands - */ function parseProfileArgs(args: string[]): CliproxyProfileArgs { const result: CliproxyProfileArgs = {}; - for (let i = 0; i < args.length; i++) { const arg = args[i]; - if (arg === '--provider' && args[i + 1]) { result.provider = args[++i] as CLIProxyProfileName; } else if (arg === '--model' && args[i + 1]) { @@ -98,270 +87,21 @@ function parseProfileArgs(args: string[]): CliproxyProfileArgs { result.name = arg; } } - return result; } -/** - * Validate CLIProxy profile name - */ -function validateProfileName(name: string): string | null { - if (!name) { - return 'Profile name is required'; - } - if (!/^[a-zA-Z][a-zA-Z0-9._-]*$/.test(name)) { - return 'Name must start with letter, contain only letters, numbers, dot, dash, underscore'; - } - if (name.length > 32) { - return 'Name must be 32 characters or less'; - } - // Use centralized reserved names list (includes built-in cliproxy profiles) - if (isReservedName(name)) { - return `'${name}' is a reserved name`; - } - return null; -} - -/** - * Check if unified config mode is active - */ -function isUnifiedMode(): boolean { - return hasUnifiedConfig() || isUnifiedConfigEnabled(); -} - -/** - * Check if CLIProxy variant profile exists - */ -function cliproxyVariantExists(name: string): boolean { - try { - if (isUnifiedMode()) { - const config = loadOrCreateUnifiedConfig(); - return !!(config.cliproxy?.variants && name in config.cliproxy.variants); - } - const config = loadConfig(); - return !!(config.cliproxy && name in config.cliproxy); - } catch { - return false; - } -} - -/** - * Create settings.json file for CLIProxy variant - * Includes all 6 fields for proper Claude CLI integration - */ -function createCliproxySettingsFile( - name: string, - provider: CLIProxyProfileName, - model: string, - _account?: string -): string { - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${provider}-${name}.settings.json`); - - // Get base env vars from provider config - const baseEnv = getClaudeEnvVars(provider as CLIProxyProvider, CLIPROXY_DEFAULT_PORT); - - const settings = { - env: { - ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '', - ANTHROPIC_AUTH_TOKEN: baseEnv.ANTHROPIC_AUTH_TOKEN || '', - ANTHROPIC_MODEL: model, - ANTHROPIC_DEFAULT_OPUS_MODEL: model, - ANTHROPIC_DEFAULT_SONNET_MODEL: model, - ANTHROPIC_DEFAULT_HAIKU_MODEL: baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || model, - }, - }; - - // Ensure directory exists - if (!fs.existsSync(ccsDir)) { - fs.mkdirSync(ccsDir, { recursive: true }); - } - - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); - return settingsPath; -} - -/** - * Update config.json with new CLIProxy variant - */ -function addCliproxyVariant( - name: string, - provider: CLIProxyProfileName, - settingsPath: string, - account?: string -): void { - const configPath = getConfigPath(); - - // Read existing config or create new - let config: { profiles: Record; cliproxy?: Record }; - try { - config = JSON.parse(fs.readFileSync(configPath, 'utf8')); - } catch { - config = { profiles: {} }; - } - - // Ensure cliproxy section exists - if (!config.cliproxy) { - config.cliproxy = {}; - } - - // Use relative path with ~ for portability - const relativePath = `~/.ccs/${path.basename(settingsPath)}`; - - // Build variant config with optional account - const variantConfig: { provider: string; settings: string; account?: string } = { - provider, - settings: relativePath, - }; - if (account) { - variantConfig.account = account; - } - config.cliproxy[name] = variantConfig; - - // Write config atomically - const tempPath = configPath + '.tmp'; - fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); - fs.renameSync(tempPath, configPath); -} - -/** - * Remove CLIProxy variant from config.json - */ -function removeCliproxyVariant(name: string): { provider: string; settings: string } | null { - const configPath = getConfigPath(); - - let config: { profiles: Record; cliproxy?: Record }; - try { - config = JSON.parse(fs.readFileSync(configPath, 'utf8')); - } catch { - return null; - } - - if (!config.cliproxy || !(name in config.cliproxy)) { - return null; - } - - const variant = config.cliproxy[name] as { provider: string; settings: string }; - delete config.cliproxy[name]; - - // Clean up empty cliproxy section - if (Object.keys(config.cliproxy).length === 0) { - delete config.cliproxy; - } - - // Write config atomically - const tempPath = configPath + '.tmp'; - fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); - fs.renameSync(tempPath, configPath); - - return variant; -} - -/** - * Add CLIProxy variant to unified config (config.yaml) - * Creates *.settings.json file and stores reference in config.yaml - */ -function addCliproxyVariantUnified( - name: string, - provider: CLIProxyProfileName, - model: string, - account?: string -): void { - const ccsDir = path.join(require('os').homedir(), '.ccs'); - const settingsFile = `${provider}-${name}.settings.json`; - const settingsPath = path.join(ccsDir, settingsFile); - - // Get base env vars from provider config - const baseEnv = getClaudeEnvVars(provider as CLIProxyProvider, CLIPROXY_DEFAULT_PORT); - - // Create settings file with model override - const settings = { - env: { - ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '', - ANTHROPIC_AUTH_TOKEN: baseEnv.ANTHROPIC_AUTH_TOKEN || '', - ANTHROPIC_MODEL: model, - ANTHROPIC_DEFAULT_OPUS_MODEL: model, - ANTHROPIC_DEFAULT_SONNET_MODEL: model, - ANTHROPIC_DEFAULT_HAIKU_MODEL: baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || model, - }, - }; - - // Ensure directory exists - if (!fs.existsSync(ccsDir)) { - fs.mkdirSync(ccsDir, { recursive: true }); - } - - // Write settings file - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); - - // Update config.yaml with reference - const config = loadOrCreateUnifiedConfig(); - - // Ensure cliproxy.variants section exists - if (!config.cliproxy) { - config.cliproxy = { - oauth_accounts: {}, - providers: ['gemini', 'codex', 'agy', 'qwen', 'iflow'], - variants: {}, - }; - } - if (!config.cliproxy.variants) { - config.cliproxy.variants = {}; - } - - // Add variant with settings file reference - config.cliproxy.variants[name] = { - provider: provider as CLIProxyProvider, - account, - settings: `~/.ccs/${settingsFile}`, - }; - - saveUnifiedConfig(config); -} - -/** - * Remove CLIProxy variant from unified config - */ -function removeCliproxyVariantUnified( - name: string -): { provider: string; settings?: string } | null { - const config = loadOrCreateUnifiedConfig(); - - if (!config.cliproxy?.variants || !(name in config.cliproxy.variants)) { - return null; - } - - const variant = config.cliproxy.variants[name]; - - // Delete the settings file if it exists - if (variant.settings) { - const settingsPath = variant.settings.replace(/^~/, require('os').homedir()); - if (fs.existsSync(settingsPath)) { - fs.unlinkSync(settingsPath); - } - } - - delete config.cliproxy.variants[name]; - saveUnifiedConfig(config); - - return { provider: variant.provider, settings: variant.settings }; -} - -/** - * Format model entry for display - */ function formatModelOption(model: ModelEntry): string { const tierBadge = model.tier === 'paid' ? color(' [Paid Tier]', 'warning') : ''; return `${model.name}${tierBadge}`; } -/** - * Handle 'ccs cliproxy create' command - */ +// ============================================================================ +// COMMAND HANDLERS +// ============================================================================ + async function handleCreate(args: string[]): Promise { await initUI(); const parsedArgs = parseProfileArgs(args); - console.log(header('Create CLIProxy Variant')); console.log(''); @@ -379,8 +119,7 @@ async function handleCreate(args: string[]): Promise { } } - // Check if exists - if (cliproxyVariantExists(name) && !parsedArgs.force) { + if (variantExists(name) && !parsedArgs.force) { console.log(fail(`Variant '${name}' already exists`)); console.log(` Use ${color('--force', 'command')} to overwrite`); process.exit(1); @@ -393,7 +132,6 @@ async function handleCreate(args: string[]): Promise { id: p, label: p.charAt(0).toUpperCase() + p.slice(1), })); - provider = (await InteractivePrompt.selectFromList( 'Select provider:', providerOptions @@ -410,76 +148,57 @@ async function handleCreate(args: string[]): Promise { if (!account) { if (providerAccounts.length === 0) { - // No accounts - prompt to authenticate first console.log(''); console.log(warn(`No accounts authenticated for ${provider}`)); console.log(''); - const shouldAuth = await InteractivePrompt.confirm(`Authenticate with ${provider} now?`, { default: true, }); - if (!shouldAuth) { console.log(''); console.log(info('Run authentication first:')); console.log(` ${color(`ccs ${provider} --auth`, 'command')}`); process.exit(0); } - - // Trigger OAuth inline console.log(''); const newAccount = await triggerOAuth(provider as CLIProxyProvider, { add: true, verbose: args.includes('--verbose'), }); - if (!newAccount) { console.log(fail('Authentication failed')); process.exit(1); } - account = newAccount.id; console.log(''); console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`)); } else if (providerAccounts.length === 1) { - // Single account - auto-select account = providerAccounts[0].id; } else { - // Multiple accounts - show selector with "Add new" option const ADD_NEW_ID = '__add_new__'; - const accountOptions = [ ...providerAccounts.map((acc) => ({ id: acc.id, label: `${acc.email || acc.id}${acc.isDefault ? ' (default)' : ''}`, })), - { - id: ADD_NEW_ID, - label: color('[+ Add new account...]', 'info'), - }, + { id: ADD_NEW_ID, label: color('[+ Add new account...]', 'info') }, ]; - const defaultIdx = providerAccounts.findIndex((a) => a.isDefault); - const selectedAccount = await InteractivePrompt.selectFromList( 'Select account:', accountOptions, { defaultIndex: defaultIdx >= 0 ? defaultIdx : 0 } ); - if (selectedAccount === ADD_NEW_ID) { - // Add new account inline console.log(''); const newAccount = await triggerOAuth(provider as CLIProxyProvider, { add: true, verbose: args.includes('--verbose'), }); - if (!newAccount) { console.log(fail('Authentication failed')); process.exit(1); } - account = newAccount.id; console.log(''); console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`)); @@ -488,15 +207,14 @@ async function handleCreate(args: string[]): Promise { } } } else { - // Validate provided account exists const exists = providerAccounts.find((a) => a.id === account); if (!exists) { console.log(fail(`Account '${account}' not found for ${provider}`)); console.log(''); console.log('Available accounts:'); - providerAccounts.forEach((a) => { - console.log(` - ${a.email || a.id}${a.isDefault ? ' (default)' : ''}`); - }); + providerAccounts.forEach((a) => + console.log(` - ${a.email || a.id}${a.isDefault ? ' (default)' : ''}`) + ); process.exit(1); } } @@ -504,24 +222,16 @@ async function handleCreate(args: string[]): Promise { // Step 3: Model selection let model = parsedArgs.model; if (!model) { - // Check if provider has model catalog for interactive selection if (supportsModelConfig(provider as CLIProxyProvider)) { const catalog = getProviderCatalog(provider as CLIProxyProvider); if (catalog) { - const modelOptions = catalog.models.map((m) => ({ - id: m.id, - label: formatModelOption(m), - })); - + const modelOptions = catalog.models.map((m) => ({ id: m.id, label: formatModelOption(m) })); const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel); - model = await InteractivePrompt.selectFromList('Select model:', modelOptions, { defaultIndex: defaultIdx >= 0 ? defaultIdx : 0, }); } } - - // Fallback to manual input if no catalog if (!model) { model = await InteractivePrompt.input('Model name', { validate: (val) => (val ? null : 'Model is required'), @@ -529,189 +239,103 @@ async function handleCreate(args: string[]): Promise { } } - // Create files + // Create variant console.log(''); console.log(info('Creating CLIProxy variant...')); + const result = createVariant(name, provider, model, account); - try { - if (isUnifiedMode()) { - // Use unified config format (no settings file needed - env vars derived at runtime) - addCliproxyVariantUnified(name, provider, model, account); - - console.log(''); - console.log( - infoBox( - `Variant: ${name}\n` + - `Provider: ${provider}\n` + - `Model: ${model}\n` + - (account ? `Account: ${account}\n` : '') + - `Config: ~/.ccs/config.yaml`, - 'CLIProxy Variant Created (Unified Config)' - ) - ); - } else { - // Legacy: create settings.json file - const settingsPath = createCliproxySettingsFile(name, provider, model, account); - addCliproxyVariant(name, provider, settingsPath, account); - - console.log(''); - console.log( - infoBox( - `Variant: ${name}\n` + - `Provider: ${provider}\n` + - `Model: ${model}\n` + - (account ? `Account: ${account}\n` : '') + - `Settings: ~/.ccs/${path.basename(settingsPath)}`, - 'CLIProxy Variant Created' - ) - ); - } - console.log(''); - console.log(header('Usage')); - console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`); - console.log(''); - console.log(dim('To change model later:')); - console.log(` ${color(`ccs ${name} --config`, 'command')}`); - console.log(''); - } catch (error) { - console.log(fail(`Failed to create variant: ${(error as Error).message}`)); + if (!result.success) { + console.log(fail(`Failed to create variant: ${result.error}`)); process.exit(1); } + + console.log(''); + const configType = isUnifiedMode() + ? 'CLIProxy Variant Created (Unified Config)' + : 'CLIProxy Variant Created'; + const settingsDisplay = isUnifiedMode() + ? '~/.ccs/config.yaml' + : `~/.ccs/${path.basename(result.settingsPath || '')}`; + console.log( + infoBox( + `Variant: ${name}\nProvider: ${provider}\nModel: ${model}\n${account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`, + configType + ) + ); + console.log(''); + console.log(header('Usage')); + console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`); + console.log(''); + console.log(dim('To change model later:')); + console.log(` ${color(`ccs ${name} --config`, 'command')}`); + console.log(''); } -/** - * Handle 'ccs cliproxy list' command - */ async function handleList(): Promise { await initUI(); - console.log(header('CLIProxy Profiles')); console.log(''); - try { - // Show auth status for built-in profiles - console.log(subheader('Built-in Profiles')); - const authStatuses = getAllAuthStatus(); - - for (const status of authStatuses) { - const oauthConfig = getOAuthConfig(status.provider); - const icon = status.authenticated ? ok('') : warn(''); - const authLabel = status.authenticated - ? color('authenticated', 'success') - : dim('not authenticated'); - const lastAuthStr = status.lastAuth ? dim(` (${status.lastAuth.toLocaleDateString()})`) : ''; - - console.log( - ` ${icon} ${color(status.provider, 'command').padEnd(18)} ${oauthConfig.displayName.padEnd(16)} ${authLabel}${lastAuthStr}` - ); - } - console.log(''); - console.log(dim(' To authenticate: ccs --auth')); - console.log(dim(' To logout: ccs --logout')); - console.log(''); - - // Show custom variants if any (from unified or legacy config) - let variantNames: string[]; - let variantData: Record; - - if (isUnifiedMode()) { - const unifiedConfig = loadOrCreateUnifiedConfig(); - const variants = unifiedConfig.cliproxy?.variants || {}; - variantNames = Object.keys(variants); - variantData = {}; - for (const name of variantNames) { - const v = variants[name]; - variantData[name] = { provider: v.provider, settings: v.settings }; - } - } else { - const config = loadConfig(); - const variants = config.cliproxy || {}; - variantNames = Object.keys(variants); - variantData = {}; - for (const name of variantNames) { - const v = variants[name] as { provider: string; settings: string }; - variantData[name] = { provider: v.provider, settings: v.settings }; - } - } - - if (variantNames.length > 0) { - console.log(subheader('Custom Variants')); - - // Build table data - const rows: string[][] = variantNames.map((name) => { - const variant = variantData[name]; - return [name, variant.provider, variant.settings || '-']; - }); - - // Print table - console.log( - table(rows, { - head: ['Variant', 'Provider', 'Settings'], - colWidths: [15, 12, 35], - }) - ); - console.log(''); - console.log(dim(`Total: ${variantNames.length} custom variant(s)`)); - console.log(''); - } - - console.log(dim('To create a custom variant:')); - console.log(` ${color('ccs cliproxy create', 'command')}`); - console.log(''); - } catch (error) { - console.log(fail(`Failed to list profiles: ${(error as Error).message}`)); - process.exit(1); + // Built-in profiles + console.log(subheader('Built-in Profiles')); + const authStatuses = getAllAuthStatus(); + for (const status of authStatuses) { + const oauthConfig = getOAuthConfig(status.provider); + const icon = status.authenticated ? ok('') : warn(''); + const authLabel = status.authenticated + ? color('authenticated', 'success') + : dim('not authenticated'); + const lastAuthStr = status.lastAuth ? dim(` (${status.lastAuth.toLocaleDateString()})`) : ''; + console.log( + ` ${icon} ${color(status.provider, 'command').padEnd(18)} ${oauthConfig.displayName.padEnd(16)} ${authLabel}${lastAuthStr}` + ); } + console.log(''); + console.log(dim(' To authenticate: ccs --auth')); + console.log(dim(' To logout: ccs --logout')); + console.log(''); + + // Custom variants + const variants = listVariants(); + const variantNames = Object.keys(variants); + + if (variantNames.length > 0) { + console.log(subheader('Custom Variants')); + const rows = variantNames.map((name) => { + const variant = variants[name]; + return [name, variant.provider, variant.settings || '-']; + }); + console.log( + table(rows, { head: ['Variant', 'Provider', 'Settings'], colWidths: [15, 12, 35] }) + ); + console.log(''); + console.log(dim(`Total: ${variantNames.length} custom variant(s)`)); + console.log(''); + } + + console.log(dim('To create a custom variant:')); + console.log(` ${color('ccs cliproxy create', 'command')}`); + console.log(''); } -/** - * Handle 'ccs cliproxy remove' command - */ async function handleRemove(args: string[]): Promise { await initUI(); const parsedArgs = parseProfileArgs(args); - - // Get available variants based on config mode - let variantNames: string[]; - let variantData: Record; - - if (isUnifiedMode()) { - const unifiedConfig = loadOrCreateUnifiedConfig(); - const variants = unifiedConfig.cliproxy?.variants || {}; - variantNames = Object.keys(variants); - variantData = {}; - for (const name of variantNames) { - const v = variants[name]; - variantData[name] = { provider: v.provider, settings: v.settings }; - } - } else { - const config = loadConfig(); - const variants = config.cliproxy || {}; - variantNames = Object.keys(variants); - variantData = {}; - for (const name of variantNames) { - const v = variants[name] as { provider: string; settings: string }; - variantData[name] = { provider: v.provider, settings: v.settings }; - } - } + const variants = listVariants(); + const variantNames = Object.keys(variants); if (variantNames.length === 0) { console.log(warn('No CLIProxy variants to remove')); process.exit(0); } - // Interactive selection if not provided let name = parsedArgs.name; if (!name) { console.log(header('Remove CLIProxy Variant')); console.log(''); console.log('Available variants:'); - variantNames.forEach((n, i) => { - const v = variantData[n]; - console.log(` ${i + 1}. ${n} (${v.provider})`); - }); + variantNames.forEach((n, i) => console.log(` ${i + 1}. ${n} (${variants[n].provider})`)); console.log(''); - name = await InteractivePrompt.input('Variant name to remove', { validate: (val) => { if (!val) return 'Variant name is required'; @@ -729,9 +353,7 @@ async function handleRemove(args: string[]): Promise { process.exit(1); } - const variant = variantData[name]; - - // Confirm deletion + const variant = variants[name]; console.log(''); console.log(`Variant '${color(name, 'command')}' will be removed.`); console.log(` Provider: ${variant.provider}`); @@ -740,337 +362,27 @@ async function handleRemove(args: string[]): Promise { const confirmed = parsedArgs.yes || (await InteractivePrompt.confirm('Delete this variant?', { default: false })); - if (!confirmed) { console.log(info('Cancelled')); process.exit(0); } - try { - if (isUnifiedMode()) { - // Remove from unified config - removeCliproxyVariantUnified(name); - } else { - // Remove from legacy config - const removed = removeCliproxyVariant(name); - if (!removed) { - console.log(fail('Failed to remove variant from config')); - process.exit(1); - } - - // Remove settings file if it exists - const settingsFile = removed.settings.replace(/^~/, process.env.HOME || ''); - if (fs.existsSync(settingsFile)) { - fs.unlinkSync(settingsFile); - } - } - - console.log(ok(`Variant removed: ${name}`)); - console.log(''); - } catch (error) { - console.log(fail(`Failed to remove variant: ${(error as Error).message}`)); - process.exit(1); - } -} - -// ============================================================================ -// BINARY MANAGEMENT -// ============================================================================ - -/** - * Show cliproxy command help with styled UI - */ -async function showHelp(): Promise { - await initUI(); - - console.log(''); - console.log(header('CLIProxy Management')); - console.log(''); - - // Usage - console.log(subheader('Usage:')); - console.log(` ${color('ccs cliproxy', 'command')} [options]`); - console.log(''); - - // Profile Commands - console.log(subheader('Profile Commands:')); - const profileCmds: [string, string][] = [ - ['create [name]', 'Create new CLIProxy variant profile'], - ['list', 'List all CLIProxy variant profiles'], - ['remove ', 'Remove a CLIProxy variant profile'], - ]; - const maxProfileLen = Math.max(...profileCmds.map(([cmd]) => cmd.length)); - for (const [cmd, desc] of profileCmds) { - console.log(` ${color(cmd.padEnd(maxProfileLen + 2), 'command')} ${desc}`); - } - console.log(''); - - // Proxy Lifecycle Commands - console.log(subheader('Proxy Lifecycle:')); - const lifecycleCmds: [string, string][] = [ - ['status', 'Show running CLIProxy status'], - ['stop', 'Stop running CLIProxy instance'], - ]; - const maxLifecycleLen = Math.max(...lifecycleCmds.map(([cmd]) => cmd.length)); - for (const [cmd, desc] of lifecycleCmds) { - console.log(` ${color(cmd.padEnd(maxLifecycleLen + 2), 'command')} ${desc}`); - } - console.log(''); - console.log(dim(' Note: CLIProxy now persists by default. Use "stop" to terminate.')); - console.log(''); - - // Binary Commands - console.log(subheader('Binary Commands:')); - const binaryCmds: [string, string][] = [ - ['--install ', 'Install and pin a specific version'], - ['--latest', 'Install the latest version (no pin)'], - ['--update', 'Unpin and update to latest version'], - ]; - const maxBinaryLen = Math.max(...binaryCmds.map(([cmd]) => cmd.length)); - for (const [cmd, desc] of binaryCmds) { - console.log(` ${color(cmd.padEnd(maxBinaryLen + 2), 'command')} ${desc}`); - } - console.log(''); - - // Multi-Account Commands - console.log(subheader('Multi-Account Commands:')); - const multiAcctCmds: [string, string][] = [ - ['--auth', 'Authenticate with a provider (first account)'], - ['--auth --add', 'Add another account to a provider'], - ['--nickname ', 'Set friendly name for account'], - ['--accounts', 'List all accounts for a provider'], - ['--use ', 'Switch to account by nickname/email'], - ]; - const maxMultiLen = Math.max(...multiAcctCmds.map(([cmd]) => cmd.length)); - for (const [cmd, desc] of multiAcctCmds) { - console.log(` ${color(cmd.padEnd(maxMultiLen + 2), 'command')} ${desc}`); - } - console.log(''); - - // Create Options - console.log(subheader('Create Options:')); - const createOpts: [string, string][] = [ - ['--provider ', 'Provider (gemini, codex, agy, qwen)'], - ['--model ', 'Model name'], - ['--account ', 'Account ID (email or default)'], - ['--force', 'Overwrite existing variant'], - ['--yes, -y', 'Skip confirmation prompts'], - ]; - const maxOptLen = Math.max(...createOpts.map(([opt]) => opt.length)); - for (const [opt, desc] of createOpts) { - console.log(` ${color(opt.padEnd(maxOptLen + 2), 'command')} ${desc}`); - } - console.log(''); - - // Examples - console.log(subheader('Examples:')); - console.log( - ` $ ${color('ccs cliproxy create', 'command')} ${dim('# Interactive wizard')}` - ); - console.log(` $ ${color('ccs cliproxy create g3 --provider gemini', 'command')}`); - console.log( - ` ${color('--model gemini-3-pro-preview', 'command')} ${dim('# Non-interactive')}` - ); - console.log( - ` $ ${color('ccs cliproxy list', 'command')} ${dim('# Show all variants')}` - ); - console.log( - ` $ ${color('ccs cliproxy remove g3', 'command')} ${dim('# Remove variant')}` - ); - console.log( - ` $ ${color('ccs cliproxy --latest', 'command')} ${dim('# Update binary')}` - ); - console.log(''); - console.log(subheader('Multi-Account Examples:')); - console.log( - ` $ ${color('ccs gemini --auth', 'command')} ${dim('# First account')}` - ); - console.log( - ` $ ${color('ccs gemini --auth --add', 'command')} ${dim('# Add second account')}` - ); - console.log( - ` $ ${color('ccs gemini --auth --add --nickname work', 'command')} ${dim('# With nickname')}` - ); - console.log( - ` $ ${color('ccs agy --accounts', 'command')} ${dim('# List accounts')}` - ); - console.log( - ` $ ${color('ccs agy --use work', 'command')} ${dim('# Switch account')}` - ); - console.log(''); - - // Notes - console.log(subheader('Notes:')); - console.log(` Default fallback version: ${color(CLIPROXY_FALLBACK_VERSION, 'info')}`); - console.log( - ` Releases: ${color('https://github.com/router-for-me/CLIProxyAPI/releases', 'path')}` - ); - console.log(''); -} - -/** - * Show current cliproxy status - */ -async function showStatus(verbose: boolean): Promise { - await initUI(); - - const installed = isCLIProxyInstalled(); - const currentVersion = getInstalledCliproxyVersion(); - const binaryPath = getCLIProxyPath(); - const pinnedVersion = getPinnedVersion(); - - console.log(''); - console.log(color('CLIProxyAPI Status', 'primary')); - console.log(''); - - if (installed) { - console.log(` Installed: ${color('Yes', 'success')}`); - const versionLabel = pinnedVersion - ? `${color(`v${currentVersion}`, 'info')} ${color('(pinned)', 'warning')}` - : color(`v${currentVersion}`, 'info'); - console.log(` Version: ${versionLabel}`); - console.log(` Binary: ${dim(binaryPath)}`); - } else { - console.log(` Installed: ${color('No', 'error')}`); - console.log(` Fallback: ${color(`v${CLIPROXY_FALLBACK_VERSION}`, 'info')}`); - console.log(` ${dim('Run "ccs gemini" or any provider to auto-install')}`); - } - - // Try to fetch latest version - try { - console.log(''); - console.log(` ${dim('Checking for updates...')}`); - const latestVersion = await fetchLatestCliproxyVersion(); - - if (latestVersion !== currentVersion) { - if (pinnedVersion) { - console.log( - ` Latest: ${color(`v${latestVersion}`, 'success')} ${dim('(pinned to v' + pinnedVersion + ')')}` - ); - console.log(''); - console.log(` ${dim('Run "ccs cliproxy --update" to unpin and update')}`); - } else { - console.log( - ` Latest: ${color(`v${latestVersion}`, 'success')} ${dim('(update available)')}` - ); - console.log(''); - console.log(` ${dim('Run "ccs cliproxy --latest" to update')}`); - } - } else { - console.log(` Latest: ${color(`v${latestVersion}`, 'success')} ${dim('(up to date)')}`); - } - } catch (error) { - if (verbose) { - const err = error as Error; - console.log(` Latest: ${dim(`Could not fetch (${err.message})`)}`); - } - } - - console.log(''); - console.log(dim(`Run "ccs cliproxy --help" for all available commands`)); - console.log(''); -} - -/** - * Install a specific version (pins the version to prevent auto-update) - */ -async function installVersion(version: string, verbose: boolean): Promise { - // Validate version format (basic semver check) - if (!/^\d+\.\d+\.\d+$/.test(version)) { - console.error(fail('Invalid version format. Expected format: X.Y.Z (e.g., 6.5.53)')); + const result = removeVariant(name); + if (!result.success) { + console.log(fail(`Failed to remove variant: ${result.error}`)); process.exit(1); } - console.log(info(`Installing CLIProxyAPI v${version}...`)); + console.log(ok(`Variant removed: ${name}`)); console.log(''); - - try { - await installCliproxyVersion(version, verbose); - - // Pin the version to prevent auto-update - savePinnedVersion(version); - - console.log(''); - console.log(ok(`CLIProxyAPI v${version} installed (pinned)`)); - console.log(''); - console.log(dim('This version will be used until you run:')); - console.log( - ` ${color('ccs cliproxy --update', 'command')} ${dim('# Update to latest and unpin')}` - ); - console.log(''); - } catch (error) { - const err = error as Error; - console.error(''); - console.error(fail(`Failed to install CLIProxyAPI v${version}`)); - console.error(` ${err.message}`); - console.error(''); - console.error('Possible causes:'); - console.error(' 1. Version does not exist on GitHub'); - console.error(' 2. Network connectivity issues'); - console.error(' 3. GitHub API rate limiting'); - console.error(''); - console.error('Check available versions at:'); - console.error(' https://github.com/router-for-me/CLIProxyAPI/releases'); - process.exit(1); - } } -/** - * Install latest version (clears any version pin) - */ -async function installLatest(verbose: boolean): Promise { - console.log(info('Fetching latest CLIProxyAPI version...')); - - try { - const latestVersion = await fetchLatestCliproxyVersion(); - const currentVersion = getInstalledCliproxyVersion(); - const wasPinned = isVersionPinned(); - - if (isCLIProxyInstalled() && latestVersion === currentVersion && !wasPinned) { - console.log(ok(`Already running latest version: v${latestVersion}`)); - return; - } - - console.log(info(`Latest version: v${latestVersion}`)); - if (isCLIProxyInstalled()) { - console.log(info(`Current version: v${currentVersion}`)); - } - if (wasPinned) { - console.log(info(`Removing version pin (was v${getPinnedVersion()})`)); - } - console.log(''); - - await installCliproxyVersion(latestVersion, verbose); - - // Clear any version pin so auto-update works again - clearPinnedVersion(); - - console.log(''); - console.log(ok(`CLIProxyAPI updated to v${latestVersion}`)); - console.log(dim('Auto-update is now enabled.')); - console.log(''); - } catch (error) { - const err = error as Error; - console.error(fail(`Failed to install latest version: ${err.message}`)); - process.exit(1); - } -} - -// ============================================================================ -// PROXY LIFECYCLE COMMANDS -// ============================================================================ - -/** - * Handle 'ccs cliproxy stop' - Stop running CLIProxy instance - */ async function handleStop(): Promise { await initUI(); - console.log(header('Stop CLIProxy')); console.log(''); const result = await stopProxy(); - if (result.stopped) { console.log(ok(`CLIProxy stopped (PID ${result.pid})`)); if (result.sessionCount && result.sessionCount > 0) { @@ -1082,25 +394,19 @@ async function handleStop(): Promise { console.log(''); } -/** - * Handle 'ccs cliproxy status' - Show running proxy status - */ async function handleProxyStatus(): Promise { await initUI(); - console.log(header('CLIProxy Status')); console.log(''); const status = getProxyStatus(); - if (status.running) { console.log(` Status: ${color('Running', 'success')}`); console.log(` PID: ${status.pid}`); console.log(` Port: ${status.port}`); console.log(` Sessions: ${status.sessionCount || 0} active`); if (status.startedAt) { - const started = new Date(status.startedAt); - console.log(` Started: ${started.toLocaleString()}`); + console.log(` Started: ${new Date(status.startedAt).toLocaleString()}`); } console.log(''); console.log(dim('To stop: ccs cliproxy stop')); @@ -1112,24 +418,175 @@ async function handleProxyStatus(): Promise { console.log(''); } +async function showStatus(verbose: boolean): Promise { + await initUI(); + const status = getBinaryStatus(); + + console.log(''); + console.log(color('CLIProxyAPI Status', 'primary')); + console.log(''); + + if (status.installed) { + console.log(` Installed: ${color('Yes', 'success')}`); + const versionLabel = status.pinnedVersion + ? `${color(`v${status.currentVersion}`, 'info')} ${color('(pinned)', 'warning')}` + : color(`v${status.currentVersion}`, 'info'); + console.log(` Version: ${versionLabel}`); + console.log(` Binary: ${dim(status.binaryPath)}`); + } else { + console.log(` Installed: ${color('No', 'error')}`); + console.log(` Fallback: ${color(`v${status.fallbackVersion}`, 'info')}`); + console.log(` ${dim('Run "ccs gemini" or any provider to auto-install')}`); + } + + const latestCheck = await checkLatestVersion(); + if (latestCheck.success && latestCheck.latestVersion) { + console.log(''); + if (latestCheck.updateAvailable) { + if (status.pinnedVersion) { + console.log( + ` Latest: ${color(`v${latestCheck.latestVersion}`, 'success')} ${dim('(pinned to v' + status.pinnedVersion + ')')}` + ); + console.log(''); + console.log(` ${dim('Run "ccs cliproxy --update" to unpin and update')}`); + } else { + console.log( + ` Latest: ${color(`v${latestCheck.latestVersion}`, 'success')} ${dim('(update available)')}` + ); + console.log(''); + console.log(` ${dim('Run "ccs cliproxy --latest" to update')}`); + } + } else { + console.log( + ` Latest: ${color(`v${latestCheck.latestVersion}`, 'success')} ${dim('(up to date)')}` + ); + } + } else if (verbose && latestCheck.error) { + console.log(` Latest: ${dim(`Could not fetch (${latestCheck.error})`)}`); + } + + console.log(''); + console.log(dim('Run "ccs cliproxy --help" for all available commands')); + console.log(''); +} + +async function handleInstallVersion(version: string, verbose: boolean): Promise { + console.log(info(`Installing CLIProxyAPI v${version}...`)); + console.log(''); + + const result = await installVersion(version, verbose); + if (!result.success) { + console.error(''); + console.error(fail(`Failed to install CLIProxyAPI v${version}`)); + console.error(` ${result.error}`); + console.error(''); + console.error('Possible causes:'); + console.error(' 1. Version does not exist on GitHub'); + console.error(' 2. Network connectivity issues'); + console.error(' 3. GitHub API rate limiting'); + console.error(''); + console.error('Check available versions at:'); + console.error(' https://github.com/router-for-me/CLIProxyAPI/releases'); + process.exit(1); + } + + console.log(''); + console.log(ok(`CLIProxyAPI v${version} installed (pinned)`)); + console.log(''); + console.log(dim('This version will be used until you run:')); + console.log( + ` ${color('ccs cliproxy --update', 'command')} ${dim('# Update to latest and unpin')}` + ); + console.log(''); +} + +async function handleInstallLatest(verbose: boolean): Promise { + console.log(info('Fetching latest CLIProxyAPI version...')); + + const result = await installLatest(verbose); + if (!result.success) { + console.error(fail(`Failed to install latest version: ${result.error}`)); + process.exit(1); + } + + if (result.error?.startsWith('Already')) { + console.log(ok(result.error)); + return; + } + + console.log(''); + console.log(ok(`CLIProxyAPI updated to v${result.version}`)); + console.log(dim('Auto-update is now enabled.')); + console.log(''); +} + +async function showHelp(): Promise { + await initUI(); + console.log(''); + console.log(header('CLIProxy Management')); + console.log(''); + console.log(subheader('Usage:')); + console.log(` ${color('ccs cliproxy', 'command')} [options]`); + console.log(''); + + const sections: [string, [string, string][]][] = [ + [ + 'Profile Commands:', + [ + ['create [name]', 'Create new CLIProxy variant profile'], + ['list', 'List all CLIProxy variant profiles'], + ['remove ', 'Remove a CLIProxy variant profile'], + ], + ], + [ + 'Proxy Lifecycle:', + [ + ['status', 'Show running CLIProxy status'], + ['stop', 'Stop running CLIProxy instance'], + ], + ], + [ + 'Binary Commands:', + [ + ['--install ', 'Install and pin a specific version'], + ['--latest', 'Install the latest version (no pin)'], + ['--update', 'Unpin and update to latest version'], + ], + ], + ]; + + for (const [title, cmds] of sections) { + console.log(subheader(title)); + const maxLen = Math.max(...cmds.map(([cmd]) => cmd.length)); + for (const [cmd, desc] of cmds) { + console.log(` ${color(cmd.padEnd(maxLen + 2), 'command')} ${desc}`); + } + console.log(''); + } + + 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( + ` Releases: ${color('https://github.com/router-for-me/CLIProxyAPI/releases', 'path')}` + ); + console.log(''); +} + // ============================================================================ // MAIN ROUTER // ============================================================================ -/** - * Main cliproxy command handler - */ export async function handleCliproxyCommand(args: string[]): Promise { const verbose = args.includes('--verbose') || args.includes('-v'); const command = args[0]; - // Handle --help if (args.includes('--help') || args.includes('-h')) { await showHelp(); return; } - // Handle profile commands if (command === 'create') { await handleCreate(args.slice(1)); return; @@ -1145,7 +602,6 @@ export async function handleCliproxyCommand(args: string[]): Promise { return; } - // Handle proxy lifecycle commands if (command === 'stop') { await handleStop(); return; @@ -1156,7 +612,6 @@ export async function handleCliproxyCommand(args: string[]): Promise { return; } - // Handle --install const installIdx = args.indexOf('--install'); if (installIdx !== -1) { const version = args[installIdx + 1]; @@ -1166,22 +621,14 @@ export async function handleCliproxyCommand(args: string[]): Promise { console.error(' Example: ccs cliproxy --install 6.5.53'); process.exit(1); } - await installVersion(version, verbose); + await handleInstallVersion(version, verbose); return; } - // Handle --latest - if (args.includes('--latest')) { - await installLatest(verbose); + if (args.includes('--latest') || args.includes('--update')) { + await handleInstallLatest(verbose); return; } - // Handle --update (unpin and update to latest) - if (args.includes('--update')) { - await installLatest(verbose); - return; - } - - // Default: show status await showStatus(verbose); } diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 24a36b0b..4189a994 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -449,6 +449,16 @@ export function updateUnifiedConfig(updates: Partial): UnifiedCon return updated; } +/** + * Check if unified config mode is active. + * Returns true if config.yaml exists OR CCS_UNIFIED_CONFIG=1. + * + * Use this centralized function instead of duplicating the logic. + */ +export function isUnifiedMode(): boolean { + return hasUnifiedConfig() || isUnifiedConfigEnabled(); +} + /** * Get or set default profile name. */