diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index b1044cdc..a49fd640 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -1,6 +1,9 @@ /** * Account Manager for CLIProxyAPI Multi-Account Support * + * DEPRECATED: This file is now a re-export shim for backwards compatibility. + * New code should import directly from './accounts/' module. + * * Manages multiple OAuth accounts per provider (Gemini, Codex, etc.). * Each provider can have multiple accounts, with one designated as default. * @@ -9,953 +12,45 @@ * Paused tokens: ~/.ccs/cliproxy/auth-paused/ (sibling dir, outside CLIProxyAPI scan path) */ -import * as fs from 'fs'; -import * as path from 'path'; -import { CLIProxyProvider } from './types'; -import { CLIPROXY_PROFILES } from '../auth/profile-detector'; -import { getCliproxyDir, getAuthDir } from './config-generator'; -import { PROVIDER_TYPE_VALUES } from './auth/auth-types'; - -/** Account tier for quota management: ultra > pro > free */ -export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown'; - -/** - * Providers that typically have empty email in OAuth token files. - * For these providers, nickname is used as accountId instead of email. - */ -export const PROVIDERS_WITHOUT_EMAIL: CLIProxyProvider[] = ['kiro', 'ghcp']; - -/** Account information */ -export interface AccountInfo { - /** Account identifier (email or custom name) */ - id: string; - /** Email address from OAuth (if available) */ - email?: string; - /** User-friendly nickname for quick reference (auto-generated from email prefix) */ - nickname?: string; - /** Provider this account belongs to */ - provider: CLIProxyProvider; - /** Whether this is the default account for the provider */ - isDefault: boolean; - /** Token file name in auth directory */ - tokenFile: string; - /** When account was added */ - createdAt: string; - /** Last usage time */ - lastUsedAt?: string; - /** User-paused state (skip in quota rotation) */ - paused?: boolean; - /** ISO timestamp when paused */ - pausedAt?: string; - /** Account tier: ultra, pro, or free */ - tier?: AccountTier; - /** GCP Project ID (Antigravity only) - read-only, fetched from auth token */ - projectId?: string; -} - -/** Provider accounts configuration */ -interface ProviderAccounts { - /** Default account ID for this provider */ - default: string; - /** Map of account ID to account metadata */ - accounts: Record>; -} - -/** Accounts registry structure */ -interface AccountsRegistry { - /** Version for future migrations */ - version: number; - /** Accounts organized by provider */ - providers: Partial>; -} - -/** Default registry structure */ -const DEFAULT_REGISTRY: AccountsRegistry = { - version: 1, - providers: {}, -}; - -/** - * Extract unique account ID from token filename when email is unavailable - * For Kiro/GHCP OAuth, filenames are: --.json - * Extracts: - as unique identifier - * @example kiro-github-ABC123.json → github-ABC123 - * @example ghcp-amazon-XYZ789.json → amazon-XYZ789 - * @example kiro-nomail.json → default (no OAuth structure) - */ -export function extractAccountIdFromTokenFile(filename: string, email?: string): string { - if (email) return email; - - // Pattern: --.json → extract - - // Requires at least 2 hyphens to distinguish from simple filenames like "kiro-nomail.json" - const match = filename.match(/^[^-]+-([^-]+-[^.]+)\.json$/); - if (match) return match[1]; - - return 'default'; -} - -/** - * Generate nickname from email - * Takes prefix before @ symbol, sanitizes whitespace - * Validation: 1-50 chars, any non-whitespace (permissive per user preference) - */ -export function generateNickname(email?: string): string { - if (!email) return 'default'; - const prefix = email.split('@')[0]; - // Sanitize: remove whitespace, limit to 50 chars - return prefix.replace(/\s+/g, '').slice(0, 50) || 'default'; -} - -/** - * Validate nickname - * Rules: 1-50 chars, no whitespace, URL-safe, no reserved patterns - * @returns null if valid, error message if invalid - */ -export function validateNickname(nickname: string): string | null { - if (!nickname || nickname.length === 0) { - return 'Nickname is required'; - } - if (nickname.length > 50) { - return 'Nickname must be 50 characters or less'; - } - if (/\s/.test(nickname)) { - return 'Nickname cannot contain whitespace'; - } - // Block URL-unsafe chars that break routing - if (/[%\/&?#]/.test(nickname)) { - return 'Nickname cannot contain special URL characters (%, /, &, ?, #)'; - } - // Block reserved patterns used by auto-discovery (kiro-1, ghcp-2, etc.) - if (/^(kiro|ghcp)-\d+$/i.test(nickname)) { - return 'Nickname cannot match reserved pattern (kiro-N, ghcp-N)'; - } - return null; -} - -/** - * Get path to accounts registry file - */ -export function getAccountsRegistryPath(): string { - return path.join(getCliproxyDir(), 'accounts.json'); -} - -/** - * Get path to paused tokens directory - * Paused tokens are moved here so CLIProxyAPI won't discover them - * - * Uses sibling directory (auth-paused/) instead of subdirectory (auth/paused/) - * because CLIProxyAPI's watcher uses filepath.Walk() which recursively scans - * all subdirectories of auth/. A sibling directory is completely outside - * CLIProxyAPI's scan path, preventing token refresh loops. - */ -export function getPausedDir(): string { - return path.join(getCliproxyDir(), 'auth-paused'); -} - -/** - * Load accounts registry - */ -export function loadAccountsRegistry(): AccountsRegistry { - const registryPath = getAccountsRegistryPath(); - - if (!fs.existsSync(registryPath)) { - return { ...DEFAULT_REGISTRY }; - } - - try { - const content = fs.readFileSync(registryPath, 'utf-8'); - const data = JSON.parse(content); - return { - version: data.version || 1, - providers: data.providers || {}, - }; - } catch { - return { ...DEFAULT_REGISTRY }; - } -} - -/** - * Save accounts registry - */ -export function saveAccountsRegistry(registry: AccountsRegistry): void { - const registryPath = getAccountsRegistryPath(); - const dir = path.dirname(registryPath); - - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - } - - fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n', { - mode: 0o600, - }); -} - -/** - * Sync registry with actual token files - * Removes stale entries where token file no longer exists - * For paused accounts, checks both auth/ and paused/ directories - * Called automatically when loading accounts - */ -function syncRegistryWithTokenFiles(registry: AccountsRegistry): boolean { - const authDir = getAuthDir(); - const pausedDir = getPausedDir(); - let modified = false; - - for (const [_providerName, providerAccounts] of Object.entries(registry.providers)) { - if (!providerAccounts) continue; - - const staleIds: string[] = []; - - for (const [accountId, meta] of Object.entries(providerAccounts.accounts)) { - const tokenPath = path.join(authDir, meta.tokenFile); - const pausedPath = path.join(pausedDir, meta.tokenFile); - - // For paused accounts, check paused dir; for active accounts, check auth dir - const expectedPath = meta.paused ? pausedPath : tokenPath; - // Also accept if file exists in either location (handles edge cases) - const existsAnywhere = fs.existsSync(tokenPath) || fs.existsSync(pausedPath); - - if (!fs.existsSync(expectedPath) && !existsAnywhere) { - staleIds.push(accountId); - } - } - - // Remove stale accounts - for (const id of staleIds) { - delete providerAccounts.accounts[id]; - modified = true; - - // Update default if deleted - if (providerAccounts.default === id) { - const remainingIds = Object.keys(providerAccounts.accounts); - providerAccounts.default = remainingIds[0] || 'default'; - } - } - } - - return modified; -} - -/** - * Get all accounts for a provider - */ -export function getProviderAccounts(provider: CLIProxyProvider): AccountInfo[] { - const registry = loadAccountsRegistry(); - - // Sync with actual token files (removes stale entries) - if (syncRegistryWithTokenFiles(registry)) { - saveAccountsRegistry(registry); - } - - const providerAccounts = registry.providers[provider]; - - if (!providerAccounts) { - return []; - } - - return Object.entries(providerAccounts.accounts).map(([id, meta]) => ({ - id, - provider, - isDefault: id === providerAccounts.default, - ...meta, - })); -} - -/** - * Get default account for a provider - */ -export function getDefaultAccount(provider: CLIProxyProvider): AccountInfo | null { - const accounts = getProviderAccounts(provider); - return accounts.find((a) => a.isDefault) || accounts[0] || null; -} - -/** - * Get specific account by ID - */ -export function getAccount(provider: CLIProxyProvider, accountId: string): AccountInfo | null { - const accounts = getProviderAccounts(provider); - return accounts.find((a) => a.id === accountId) || null; -} - -/** - * Find account by query (nickname, email, or id) - * Supports partial matching for convenience - */ -export function findAccountByQuery(provider: CLIProxyProvider, query: string): AccountInfo | null { - const accounts = getProviderAccounts(provider); - const lowerQuery = query.toLowerCase(); - - // Exact match first (id, email, nickname) - const exactMatch = accounts.find( - (a) => - a.id === query || - a.email?.toLowerCase() === lowerQuery || - a.nickname?.toLowerCase() === lowerQuery - ); - if (exactMatch) return exactMatch; - - // Partial match on nickname or email prefix - const partialMatch = accounts.find( - (a) => - a.nickname?.toLowerCase().startsWith(lowerQuery) || - a.email?.toLowerCase().startsWith(lowerQuery) - ); - return partialMatch || null; -} - -/** - * Register a new account - * Called after successful OAuth to record the account - * - * For providers without email (kiro, ghcp): - * - nickname is REQUIRED and used as accountId - * - Uniqueness is enforced to prevent overwriting - * - * For providers with email: - * - email is used as accountId - * - nickname is auto-generated from email if not provided - */ -export function registerAccount( - provider: CLIProxyProvider, - tokenFile: string, - email?: string, - nickname?: string, - projectId?: string -): AccountInfo { - const registry = loadAccountsRegistry(); - - // Initialize provider section if needed - if (!registry.providers[provider]) { - registry.providers[provider] = { - default: 'default', - accounts: {}, - }; - } - - const providerAccounts = registry.providers[provider]; - if (!providerAccounts) { - throw new Error('Failed to initialize provider accounts'); - } - - // Determine account ID based on provider type - let accountId: string; - let accountNickname: string; - - if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) { - // For kiro/ghcp: nickname is REQUIRED and used as accountId - if (!nickname || nickname === 'default') { - throw new Error( - `Nickname is required when adding ${provider} accounts. ` + - `Use --nickname or provide a nickname in the UI.` - ); - } - - // Validate nickname format - const validationError = validateNickname(nickname); - if (validationError) { - throw new Error(validationError); - } - - // Check uniqueness - for (const [existingId, _account] of Object.entries(providerAccounts.accounts)) { - if (existingId.toLowerCase() === nickname.toLowerCase()) { - throw new Error( - `An account with nickname "${nickname}" already exists for ${provider}. ` + - `Choose a different nickname.` - ); - } - } - - accountId = nickname; - accountNickname = nickname; - } else { - // For other providers: use email as accountId, fallback to filename extraction - accountId = extractAccountIdFromTokenFile(tokenFile, email); - accountNickname = nickname || generateNickname(email); - } - - const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0; - - // Create or update account - const accountMeta: Omit = { - email, - nickname: accountNickname, - tokenFile, - createdAt: new Date().toISOString(), - lastUsedAt: new Date().toISOString(), - }; - - // Include projectId for Antigravity accounts - if (provider === 'agy' && projectId) { - accountMeta.projectId = projectId; - } - - providerAccounts.accounts[accountId] = accountMeta; - - // Set as default if first account - if (isFirstAccount) { - providerAccounts.default = accountId; - } - - saveAccountsRegistry(registry); - - return { - id: accountId, - provider, - isDefault: accountId === providerAccounts.default, - email, - nickname: accountNickname, - tokenFile, - createdAt: providerAccounts.accounts[accountId].createdAt, - lastUsedAt: providerAccounts.accounts[accountId].lastUsedAt, - projectId: providerAccounts.accounts[accountId].projectId, - }; -} - -/** - * Set default account for a provider - */ -export function setDefaultAccount(provider: CLIProxyProvider, accountId: string): boolean { - const registry = loadAccountsRegistry(); - const providerAccounts = registry.providers[provider]; - - if (!providerAccounts || !providerAccounts.accounts[accountId]) { - return false; - } - - providerAccounts.default = accountId; - saveAccountsRegistry(registry); - return true; -} - -/** - * Pause an account (skip in quota rotation) - * Moves token file to paused/ subdir so CLIProxyAPI won't discover it - */ -export function pauseAccount(provider: CLIProxyProvider, accountId: string): boolean { - const registry = loadAccountsRegistry(); - const providerAccounts = registry.providers[provider]; - - if (!providerAccounts?.accounts[accountId]) { - return false; - } - - const accountMeta = providerAccounts.accounts[accountId]; - - // Skip if already paused (idempotent) - if (accountMeta.paused) { - return true; - } - - const authDir = getAuthDir(); - const pausedDir = getPausedDir(); - const tokenPath = path.join(authDir, accountMeta.tokenFile); - const pausedPath = path.join(pausedDir, accountMeta.tokenFile); - - // Move token file to paused directory (if it exists in auth dir) - if (fs.existsSync(tokenPath)) { - try { - // Create paused directory if it doesn't exist - if (!fs.existsSync(pausedDir)) { - fs.mkdirSync(pausedDir, { recursive: true, mode: 0o700 }); - } - fs.renameSync(tokenPath, pausedPath); - } catch { - // File operation failed, but continue with registry update - // syncRegistryWithTokenFiles() will handle recovery on next load - } - } - - providerAccounts.accounts[accountId].paused = true; - providerAccounts.accounts[accountId].pausedAt = new Date().toISOString(); - saveAccountsRegistry(registry); - return true; -} - -/** - * Resume a paused account - * Moves token file back from paused/ to auth/ so CLIProxyAPI can discover it - */ -export function resumeAccount(provider: CLIProxyProvider, accountId: string): boolean { - const registry = loadAccountsRegistry(); - const providerAccounts = registry.providers[provider]; - - if (!providerAccounts?.accounts[accountId]) { - return false; - } - - const accountMeta = providerAccounts.accounts[accountId]; - - // Skip if already active (idempotent) - if (!accountMeta.paused) { - return true; - } - - const authDir = getAuthDir(); - const pausedDir = getPausedDir(); - const tokenPath = path.join(authDir, accountMeta.tokenFile); - const pausedPath = path.join(pausedDir, accountMeta.tokenFile); - - // Move token file back from paused directory (if it exists in paused dir) - if (fs.existsSync(pausedPath)) { - try { - fs.renameSync(pausedPath, tokenPath); - } catch { - // File operation failed, but continue with registry update - // syncRegistryWithTokenFiles() will handle recovery on next load - } - } - - providerAccounts.accounts[accountId].paused = false; - providerAccounts.accounts[accountId].pausedAt = undefined; - saveAccountsRegistry(registry); - return true; -} - -/** - * Check if an account is paused - */ -export function isAccountPaused(provider: CLIProxyProvider, accountId: string): boolean { - const accounts = getProviderAccounts(provider); - const account = accounts.find((a) => a.id === accountId); - return account?.paused ?? false; -} - -/** - * Update account tier - */ -export function setAccountTier( - provider: CLIProxyProvider, - accountId: string, - tier: AccountTier -): boolean { - const registry = loadAccountsRegistry(); - const providerAccounts = registry.providers[provider]; - - if (!providerAccounts?.accounts[accountId]) { - return false; - } - - providerAccounts.accounts[accountId].tier = tier; - saveAccountsRegistry(registry); - return true; -} - -/** - * Get non-paused accounts for a provider - */ -export function getActiveAccounts(provider: CLIProxyProvider): AccountInfo[] { - return getProviderAccounts(provider).filter((a) => !a.paused); -} - -/** - * Remove an account - */ -export function removeAccount(provider: CLIProxyProvider, accountId: string): boolean { - const registry = loadAccountsRegistry(); - const providerAccounts = registry.providers[provider]; - - if (!providerAccounts || !providerAccounts.accounts[accountId]) { - return false; - } - - // Get token file to delete (check both auth and paused directories) - const tokenFile = providerAccounts.accounts[accountId].tokenFile; - const tokenPath = path.join(getAuthDir(), tokenFile); - const pausedPath = path.join(getPausedDir(), tokenFile); - - // Delete token file from auth directory - if (fs.existsSync(tokenPath)) { - try { - fs.unlinkSync(tokenPath); - } catch { - // Ignore deletion errors - } - } - - // Also delete from paused directory if it exists there - if (fs.existsSync(pausedPath)) { - try { - fs.unlinkSync(pausedPath); - } catch { - // Ignore deletion errors - } - } - - // Remove from registry - delete providerAccounts.accounts[accountId]; - - // Update default if needed - const remainingAccounts = Object.keys(providerAccounts.accounts); - if (providerAccounts.default === accountId && remainingAccounts.length > 0) { - providerAccounts.default = remainingAccounts[0]; - } - - saveAccountsRegistry(registry); - return true; -} - -/** - * Rename an account's nickname - */ -export function renameAccount( - provider: CLIProxyProvider, - accountId: string, - newNickname: string -): boolean { - const validationError = validateNickname(newNickname); - if (validationError) { - throw new Error(validationError); - } - - const registry = loadAccountsRegistry(); - const providerAccounts = registry.providers[provider]; - - if (!providerAccounts?.accounts[accountId]) { - return false; - } - - // Check if nickname is already used by another account - for (const [id, account] of Object.entries(providerAccounts.accounts)) { - if (id !== accountId && account.nickname?.toLowerCase() === newNickname.toLowerCase()) { - throw new Error(`Nickname "${newNickname}" is already used by another account`); - } - } - - providerAccounts.accounts[accountId].nickname = newNickname; - saveAccountsRegistry(registry); - return true; -} - -/** - * Update last used timestamp for an account - */ -export function touchAccount(provider: CLIProxyProvider, accountId: string): void { - const registry = loadAccountsRegistry(); - const providerAccounts = registry.providers[provider]; - - if (providerAccounts?.accounts[accountId]) { - providerAccounts.accounts[accountId].lastUsedAt = new Date().toISOString(); - saveAccountsRegistry(registry); - } -} - -/** - * Get token file path for an account - * Returns path in paused/ dir if account is paused, otherwise auth/ - */ -export function getAccountTokenPath(provider: CLIProxyProvider, accountId?: string): string | null { - const account = accountId ? getAccount(provider, accountId) : getDefaultAccount(provider); - - if (!account) { - return null; - } - - // Return path from paused directory if account is paused - const baseDir = account.paused ? getPausedDir() : getAuthDir(); - return path.join(baseDir, account.tokenFile); -} - -/** - * Auto-discover accounts from existing token files - * Called during migration or first run to populate accounts registry - * - * For kiro/ghcp providers without email, generates unique accountId from: - * 1. OAuth provider + profile ID from filename (e.g., github-ABC123) - * 2. Fallback: provider + index (e.g., kiro-1, kiro-2) - */ -export function discoverExistingAccounts(): void { - const authDir = getAuthDir(); - - if (!fs.existsSync(authDir)) { - return; - } - - const registry = loadAccountsRegistry(); - const files = fs.readdirSync(authDir); - - for (const file of files) { - if (!file.endsWith('.json')) continue; - - const filePath = path.join(authDir, file); - - try { - const content = fs.readFileSync(filePath, 'utf-8'); - const data = JSON.parse(content); - - // Skip if no type field - if (!data.type) continue; - - // Build reverse mapping from PROVIDER_TYPE_VALUES (type value -> provider) - // e.g., "antigravity" -> "agy", "kiro" -> "kiro", "codewhisperer" -> "kiro" - const typeValue = data.type.toLowerCase(); - let provider: CLIProxyProvider | undefined; - for (const [prov, typeValues] of Object.entries(PROVIDER_TYPE_VALUES)) { - if (typeValues.includes(typeValue)) { - provider = prov as CLIProxyProvider; - break; - } - } - - // Skip if unknown provider type - if (!provider) { - continue; - } - - // Extract email if available, fallback to filename-based ID - let email = data.email || undefined; - - // Fallback: extract email from filename (e.g., "kiro-google-user@example.com.json") - if (!email && file.includes('@')) { - const match = file.match(/([^-]+@[^.]+\.[^.]+)(?=\.json$)/); - if (match) { - email = match[1]; - } - } - - // Initialize provider section if needed - if (!registry.providers[provider]) { - registry.providers[provider] = { - default: 'default', - accounts: {}, - }; - } - - const providerAccounts = registry.providers[provider]; - if (!providerAccounts) continue; - - // Skip if token file already registered (under any accountId) - const existingTokenFiles = Object.values(providerAccounts.accounts).map((a) => a.tokenFile); - if (existingTokenFiles.includes(file)) { - // Token file exists - check if we need to update projectId for agy accounts - const projectIdValue = - typeof data.project_id === 'string' && data.project_id.trim() - ? data.project_id.trim() - : null; - if (provider === 'agy' && projectIdValue) { - const existingEntry = Object.entries(providerAccounts.accounts).find( - ([, meta]) => meta.tokenFile === file - ); - // Update if missing or changed - if (existingEntry && existingEntry[1].projectId !== projectIdValue) { - existingEntry[1].projectId = projectIdValue; - } - } - continue; - } - - // Determine accountId based on provider type - let accountId: string; - - if (PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email) { - // For kiro/ghcp without email: extract from filename or generate unique - // Pattern: kiro-github-ABC123.json -> github-ABC123 - const filenameId = extractAccountIdFromTokenFile(file, undefined); - - if (filenameId !== 'default') { - accountId = filenameId; - } else { - // Generate unique ID: provider + incrementing index - let index = 1; - while (providerAccounts.accounts[`${provider}-${index}`]) { - index++; - } - accountId = `${provider}-${index}`; - } - } else { - // For providers with email: use email or filename extraction - accountId = extractAccountIdFromTokenFile(file, email); - } - - // Skip if account already registered - if (providerAccounts.accounts[accountId]) { - continue; - } - - // Set as default if first account - if (Object.keys(providerAccounts.accounts).length === 0) { - providerAccounts.default = accountId; - } - - // Get file stats for creation time - const stats = fs.statSync(filePath); - - // Register account with auto-generated nickname - // Use mtime as lastUsedAt (when token was last modified = last auth/refresh) - const lastModified = stats.mtime || stats.birthtime || new Date(); - const accountMeta: Omit = { - email, - nickname: generateNickname(email), - tokenFile: file, - createdAt: stats.birthtime?.toISOString() || new Date().toISOString(), - lastUsedAt: lastModified.toISOString(), - }; - - // Read project_id for Antigravity accounts (read-only field from auth token) - const discoveredProjectId = - typeof data.project_id === 'string' && data.project_id.trim() - ? data.project_id.trim() - : null; - if (provider === 'agy' && discoveredProjectId) { - accountMeta.projectId = discoveredProjectId; - } - - providerAccounts.accounts[accountId] = accountMeta; - } catch { - // Skip invalid files - continue; - } - } - - // Reload-merge pattern: reduce race condition with concurrent OAuth registration - // Reload fresh registry and merge discovered accounts (fresh registry wins on conflicts) - const freshRegistry = loadAccountsRegistry(); - for (const [providerName, discovered] of Object.entries(registry.providers)) { - if (!discovered) continue; - const prov = providerName as CLIProxyProvider; - if (!freshRegistry.providers[prov]) { - freshRegistry.providers[prov] = discovered; - } else { - // Merge accounts, preferring fresh registry's existing entries but updating projectId - const freshProviderAccounts = freshRegistry.providers[prov]; - if (!freshProviderAccounts) continue; - for (const [id, meta] of Object.entries(discovered.accounts)) { - if (!freshProviderAccounts.accounts[id]) { - freshProviderAccounts.accounts[id] = meta; - // Set default if none exists - if (!freshProviderAccounts.default || freshProviderAccounts.default === 'default') { - freshProviderAccounts.default = id; - } - } else if (meta.projectId && !freshProviderAccounts.accounts[id].projectId) { - // Update existing account with projectId if discovered from auth file - freshProviderAccounts.accounts[id].projectId = meta.projectId; - } - } - } - } - saveAccountsRegistry(freshRegistry); -} - -/** Result of bulk pause/resume operations */ -export interface BulkOperationResult { - succeeded: string[]; - failed: Array<{ id: string; reason: string }>; -} - -/** Result of solo account operation */ -export interface SoloOperationResult { - activated: string; - paused: string[]; -} - -/** Provider-level locks for preventing race conditions in solo mode */ -const providerLocks = new Map>(); - -/** - * Bulk pause multiple accounts - * Pauses each account, collecting successes and failures (no fail-fast) - */ -export function bulkPauseAccounts( - provider: CLIProxyProvider, - accountIds: string[] -): BulkOperationResult { - const result: BulkOperationResult = { succeeded: [], failed: [] }; - - for (const id of accountIds) { - const success = pauseAccount(provider, id); - if (success) { - result.succeeded.push(id); - } else { - result.failed.push({ id, reason: 'Account not found' }); - } - } - - return result; -} - -/** - * Bulk resume multiple accounts - * Resumes each account, collecting successes and failures (no fail-fast) - */ -export function bulkResumeAccounts( - provider: CLIProxyProvider, - accountIds: string[] -): BulkOperationResult { - const result: BulkOperationResult = { succeeded: [], failed: [] }; - - for (const id of accountIds) { - const success = resumeAccount(provider, id); - if (success) { - result.succeeded.push(id); - } else { - result.failed.push({ id, reason: 'Account not found' }); - } - } - - return result; -} - -/** - * Solo mode: activate one account, pause all others in same provider - * Per validation: auto-resumes target if paused, keeps default unchanged - */ -export async function soloAccount( - provider: CLIProxyProvider, - accountId: string -): Promise { - // Wait for any pending operation on this provider - const pending = providerLocks.get(provider); - if (pending) await pending; - - const operation = (async () => { - const accounts = getProviderAccounts(provider); - const targetAccount = accounts.find((a) => a.id === accountId); - - if (!targetAccount) { - return null; - } - - const result: SoloOperationResult = { activated: accountId, paused: [] }; - - // Resume target account if paused (per validation decision) - if (targetAccount.paused) { - resumeAccount(provider, accountId); - } - - // Pause all other accounts - for (const account of accounts) { - if (account.id !== accountId && !account.paused) { - const success = pauseAccount(provider, account.id); - if (success) { - result.paused.push(account.id); - } - } - } - - return result; - })(); - - providerLocks.set(provider, operation); - try { - return await operation; - } finally { - providerLocks.delete(provider); - } -} - -/** - * Get summary of all accounts across providers - */ -export function getAllAccountsSummary(): Record { - const providers: CLIProxyProvider[] = [...CLIPROXY_PROFILES]; - const summary: Record = {} as Record< - CLIProxyProvider, - AccountInfo[] - >; - - for (const provider of providers) { - summary[provider] = getProviderAccounts(provider); - } - - return summary; -} +// Re-export everything from the accounts module +export type { + AccountInfo, + AccountTier, + AccountsRegistry, + ProviderAccounts, + BulkOperationResult, + SoloOperationResult, +} from './accounts'; + +export { + PROVIDERS_WITHOUT_EMAIL, + getAccountsRegistryPath, + getPausedDir, + getAccountTokenPath, + extractAccountIdFromTokenFile, + generateNickname, + validateNickname, + tokenFileExists, + loadAccountsRegistry, + saveAccountsRegistry, + syncRegistryWithTokenFiles, + registerAccount, + setDefaultAccount, + pauseAccount, + resumeAccount, + removeAccount, + renameAccount, + touchAccount, + setAccountTier, + discoverExistingAccounts, + getProviderAccounts, + getDefaultAccount, + getAccount, + findAccountByQuery, + getActiveAccounts, + isAccountPaused, + getAllAccountsSummary, + bulkPauseAccounts, + bulkResumeAccounts, + soloAccount, +} from './accounts'; diff --git a/src/cliproxy/accounts/bulk-ops.ts b/src/cliproxy/accounts/bulk-ops.ts new file mode 100644 index 00000000..d6695a86 --- /dev/null +++ b/src/cliproxy/accounts/bulk-ops.ts @@ -0,0 +1,104 @@ +/** + * Bulk operations on accounts + * Bulk pause/resume and solo mode functionality + */ + +import { CLIProxyProvider } from '../types'; +import { BulkOperationResult, SoloOperationResult } from './types'; +import { pauseAccount, resumeAccount } from './registry'; +import { getProviderAccounts } from './query'; + +/** Provider-level locks for preventing race conditions in solo mode */ +const providerLocks = new Map>(); + +/** + * Bulk pause multiple accounts + * Pauses each account, collecting successes and failures (no fail-fast) + */ +export function bulkPauseAccounts( + provider: CLIProxyProvider, + accountIds: string[] +): BulkOperationResult { + const result: BulkOperationResult = { succeeded: [], failed: [] }; + + for (const id of accountIds) { + const success = pauseAccount(provider, id); + if (success) { + result.succeeded.push(id); + } else { + result.failed.push({ id, reason: 'Account not found' }); + } + } + + return result; +} + +/** + * Bulk resume multiple accounts + * Resumes each account, collecting successes and failures (no fail-fast) + */ +export function bulkResumeAccounts( + provider: CLIProxyProvider, + accountIds: string[] +): BulkOperationResult { + const result: BulkOperationResult = { succeeded: [], failed: [] }; + + for (const id of accountIds) { + const success = resumeAccount(provider, id); + if (success) { + result.succeeded.push(id); + } else { + result.failed.push({ id, reason: 'Account not found' }); + } + } + + return result; +} + +/** + * Solo mode: activate one account, pause all others in same provider + * Per validation: auto-resumes target if paused, keeps default unchanged + */ +export async function soloAccount( + provider: CLIProxyProvider, + accountId: string +): Promise { + // Wait for any pending operation on this provider + const pending = providerLocks.get(provider); + if (pending) await pending; + + const operation = (async () => { + const accounts = getProviderAccounts(provider); + const targetAccount = accounts.find((a) => a.id === accountId); + + if (!targetAccount) { + return null; + } + + const result: SoloOperationResult = { activated: accountId, paused: [] }; + + // Resume target account if paused (per validation decision) + if (targetAccount.paused) { + resumeAccount(provider, accountId); + } + + // Pause all other accounts + for (const account of accounts) { + if (account.id !== accountId && !account.paused) { + const success = pauseAccount(provider, account.id); + if (success) { + result.paused.push(account.id); + } + } + } + + return result; + })(); + + providerLocks.set(provider, operation); + try { + return await operation; + } finally { + providerLocks.delete(provider); + } +} diff --git a/src/cliproxy/accounts/index.ts b/src/cliproxy/accounts/index.ts new file mode 100644 index 00000000..621d5562 --- /dev/null +++ b/src/cliproxy/accounts/index.ts @@ -0,0 +1,57 @@ +/** + * Account Manager Module + * Barrel export for all account management functionality + * + * This module manages multiple OAuth accounts per provider (Gemini, Codex, etc.). + * Each provider can have multiple accounts, with one designated as default. + * + * Account storage: ~/.ccs/cliproxy/accounts.json + * Token storage: ~/.ccs/cliproxy/auth/ (flat structure, CLIProxyAPI discovers by type field) + * Paused tokens: ~/.ccs/cliproxy/auth-paused/ (sibling dir, outside CLIProxyAPI scan path) + */ + +// Types and constants +export type { AccountInfo, AccountTier, AccountsRegistry, ProviderAccounts } from './types'; +export type { BulkOperationResult, SoloOperationResult } from './types'; +export { PROVIDERS_WITHOUT_EMAIL } from './types'; + +// Token file operations +export { + getAccountsRegistryPath, + getPausedDir, + getAccountTokenPath, + extractAccountIdFromTokenFile, + generateNickname, + validateNickname, + tokenFileExists, +} from './token-file-ops'; + +// Registry operations (CRUD) +export { + loadAccountsRegistry, + saveAccountsRegistry, + syncRegistryWithTokenFiles, + registerAccount, + setDefaultAccount, + pauseAccount, + resumeAccount, + removeAccount, + renameAccount, + touchAccount, + setAccountTier, + discoverExistingAccounts, +} from './registry'; + +// Query operations +export { + getProviderAccounts, + getDefaultAccount, + getAccount, + findAccountByQuery, + getActiveAccounts, + isAccountPaused, + getAllAccountsSummary, +} from './query'; + +// Bulk operations +export { bulkPauseAccounts, bulkResumeAccounts, soloAccount } from './bulk-ops'; diff --git a/src/cliproxy/accounts/query.ts b/src/cliproxy/accounts/query.ts new file mode 100644 index 00000000..c91f9f45 --- /dev/null +++ b/src/cliproxy/accounts/query.ts @@ -0,0 +1,109 @@ +/** + * Account query and search operations + * Finding, filtering, and retrieving account information + */ + +import { CLIProxyProvider } from '../types'; +import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; +import { AccountInfo } from './types'; +import { loadAccountsRegistry, syncRegistryWithTokenFiles, saveAccountsRegistry } from './registry'; + +/** + * Get all accounts for a provider + */ +export function getProviderAccounts(provider: CLIProxyProvider): AccountInfo[] { + const registry = loadAccountsRegistry(); + + // Sync with actual token files (removes stale entries) + if (syncRegistryWithTokenFiles(registry)) { + saveAccountsRegistry(registry); + } + + const providerAccounts = registry.providers[provider]; + + if (!providerAccounts) { + return []; + } + + return Object.entries(providerAccounts.accounts).map(([id, meta]) => ({ + id, + provider, + isDefault: id === providerAccounts.default, + ...meta, + })); +} + +/** + * Get default account for a provider + */ +export function getDefaultAccount(provider: CLIProxyProvider): AccountInfo | null { + const accounts = getProviderAccounts(provider); + return accounts.find((a) => a.isDefault) || accounts[0] || null; +} + +/** + * Get specific account by ID + */ +export function getAccount(provider: CLIProxyProvider, accountId: string): AccountInfo | null { + const accounts = getProviderAccounts(provider); + return accounts.find((a) => a.id === accountId) || null; +} + +/** + * Find account by query (nickname, email, or id) + * Supports partial matching for convenience + */ +export function findAccountByQuery(provider: CLIProxyProvider, query: string): AccountInfo | null { + const accounts = getProviderAccounts(provider); + const lowerQuery = query.toLowerCase(); + + // Exact match first (id, email, nickname) + const exactMatch = accounts.find( + (a) => + a.id === query || + a.email?.toLowerCase() === lowerQuery || + a.nickname?.toLowerCase() === lowerQuery + ); + if (exactMatch) return exactMatch; + + // Partial match on nickname or email prefix + const partialMatch = accounts.find( + (a) => + a.nickname?.toLowerCase().startsWith(lowerQuery) || + a.email?.toLowerCase().startsWith(lowerQuery) + ); + return partialMatch || null; +} + +/** + * Get non-paused accounts for a provider + */ +export function getActiveAccounts(provider: CLIProxyProvider): AccountInfo[] { + return getProviderAccounts(provider).filter((a) => !a.paused); +} + +/** + * Check if an account is paused + */ +export function isAccountPaused(provider: CLIProxyProvider, accountId: string): boolean { + const accounts = getProviderAccounts(provider); + const account = accounts.find((a) => a.id === accountId); + return account?.paused ?? false; +} + +/** + * Get summary of all accounts across providers + */ +export function getAllAccountsSummary(): Record { + const providers: CLIProxyProvider[] = [...CLIPROXY_PROFILES]; + const summary: Record = {} as Record< + CLIProxyProvider, + AccountInfo[] + >; + + for (const provider of providers) { + summary[provider] = getProviderAccounts(provider); + } + + return summary; +} diff --git a/src/cliproxy/accounts/registry.ts b/src/cliproxy/accounts/registry.ts new file mode 100644 index 00000000..2e885d5e --- /dev/null +++ b/src/cliproxy/accounts/registry.ts @@ -0,0 +1,565 @@ +/** + * Account registry CRUD operations + * Handles loading, saving, and syncing the accounts.json file + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { CLIProxyProvider } from '../types'; +import { PROVIDER_TYPE_VALUES } from '../auth/auth-types'; +import { getAuthDir } from '../config-generator'; +import { AccountsRegistry, AccountInfo, PROVIDERS_WITHOUT_EMAIL } from './types'; +import { + getAccountsRegistryPath, + getPausedDir, + extractAccountIdFromTokenFile, + generateNickname, + validateNickname, + moveTokenToPaused, + moveTokenFromPaused, + deleteTokenFile, +} from './token-file-ops'; + +/** Default registry structure */ +const DEFAULT_REGISTRY: AccountsRegistry = { + version: 1, + providers: {}, +}; + +/** + * Load accounts registry + */ +export function loadAccountsRegistry(): AccountsRegistry { + const registryPath = getAccountsRegistryPath(); + + if (!fs.existsSync(registryPath)) { + return { ...DEFAULT_REGISTRY }; + } + + try { + const content = fs.readFileSync(registryPath, 'utf-8'); + const data = JSON.parse(content); + return { + version: data.version || 1, + providers: data.providers || {}, + }; + } catch { + return { ...DEFAULT_REGISTRY }; + } +} + +/** + * Save accounts registry + */ +export function saveAccountsRegistry(registry: AccountsRegistry): void { + const registryPath = getAccountsRegistryPath(); + const dir = path.dirname(registryPath); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + + fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n', { + mode: 0o600, + }); +} + +/** + * Sync registry with actual token files + * Removes stale entries where token file no longer exists + * For paused accounts, checks both auth/ and paused/ directories + * Called automatically when loading accounts + */ +export function syncRegistryWithTokenFiles(registry: AccountsRegistry): boolean { + const authDir = getAuthDir(); + const pausedDir = getPausedDir(); + let modified = false; + + for (const [_providerName, providerAccounts] of Object.entries(registry.providers)) { + if (!providerAccounts) continue; + + const staleIds: string[] = []; + + for (const [accountId, meta] of Object.entries(providerAccounts.accounts)) { + const tokenPath = path.join(authDir, meta.tokenFile); + const pausedPath = path.join(pausedDir, meta.tokenFile); + + // For paused accounts, check paused dir; for active accounts, check auth dir + const expectedPath = meta.paused ? pausedPath : tokenPath; + // Also accept if file exists in either location (handles edge cases) + const existsAnywhere = fs.existsSync(tokenPath) || fs.existsSync(pausedPath); + + if (!fs.existsSync(expectedPath) && !existsAnywhere) { + staleIds.push(accountId); + } + } + + // Remove stale accounts + for (const id of staleIds) { + delete providerAccounts.accounts[id]; + modified = true; + + // Update default if deleted + if (providerAccounts.default === id) { + const remainingIds = Object.keys(providerAccounts.accounts); + providerAccounts.default = remainingIds[0] || 'default'; + } + } + } + + return modified; +} + +/** + * Register a new account + * Called after successful OAuth to record the account + * + * For providers without email (kiro, ghcp): + * - nickname is REQUIRED and used as accountId + * - Uniqueness is enforced to prevent overwriting + * + * For providers with email: + * - email is used as accountId + * - nickname is auto-generated from email if not provided + */ +export function registerAccount( + provider: CLIProxyProvider, + tokenFile: string, + email?: string, + nickname?: string, + projectId?: string +): AccountInfo { + const registry = loadAccountsRegistry(); + + // Initialize provider section if needed + if (!registry.providers[provider]) { + registry.providers[provider] = { + default: 'default', + accounts: {}, + }; + } + + const providerAccounts = registry.providers[provider]; + if (!providerAccounts) { + throw new Error('Failed to initialize provider accounts'); + } + + // Determine account ID based on provider type + let accountId: string; + let accountNickname: string; + + if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) { + // For kiro/ghcp: nickname is REQUIRED and used as accountId + if (!nickname || nickname === 'default') { + throw new Error( + `Nickname is required when adding ${provider} accounts. ` + + `Use --nickname or provide a nickname in the UI.` + ); + } + + // Validate nickname format + const validationError = validateNickname(nickname); + if (validationError) { + throw new Error(validationError); + } + + // Check uniqueness + for (const [existingId, _account] of Object.entries(providerAccounts.accounts)) { + if (existingId.toLowerCase() === nickname.toLowerCase()) { + throw new Error( + `An account with nickname "${nickname}" already exists for ${provider}. ` + + `Choose a different nickname.` + ); + } + } + + accountId = nickname; + accountNickname = nickname; + } else { + // For other providers: use email as accountId, fallback to filename extraction + accountId = extractAccountIdFromTokenFile(tokenFile, email); + accountNickname = nickname || generateNickname(email); + } + + const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0; + + // Create or update account + const accountMeta: Omit = { + email, + nickname: accountNickname, + tokenFile, + createdAt: new Date().toISOString(), + lastUsedAt: new Date().toISOString(), + }; + + // Include projectId for Antigravity accounts + if (provider === 'agy' && projectId) { + accountMeta.projectId = projectId; + } + + providerAccounts.accounts[accountId] = accountMeta; + + // Set as default if first account + if (isFirstAccount) { + providerAccounts.default = accountId; + } + + saveAccountsRegistry(registry); + + return { + id: accountId, + provider, + isDefault: accountId === providerAccounts.default, + email, + nickname: accountNickname, + tokenFile, + createdAt: providerAccounts.accounts[accountId].createdAt, + lastUsedAt: providerAccounts.accounts[accountId].lastUsedAt, + projectId: providerAccounts.accounts[accountId].projectId, + }; +} + +/** + * Set default account for a provider + */ +export function setDefaultAccount(provider: CLIProxyProvider, accountId: string): boolean { + const registry = loadAccountsRegistry(); + const providerAccounts = registry.providers[provider]; + + if (!providerAccounts || !providerAccounts.accounts[accountId]) { + return false; + } + + providerAccounts.default = accountId; + saveAccountsRegistry(registry); + return true; +} + +/** + * Pause an account (skip in quota rotation) + * Moves token file to paused/ subdir so CLIProxyAPI won't discover it + */ +export function pauseAccount(provider: CLIProxyProvider, accountId: string): boolean { + const registry = loadAccountsRegistry(); + const providerAccounts = registry.providers[provider]; + + if (!providerAccounts?.accounts[accountId]) { + return false; + } + + const accountMeta = providerAccounts.accounts[accountId]; + + // Skip if already paused (idempotent) + if (accountMeta.paused) { + return true; + } + + // Move token file to paused directory (if it exists in auth dir) + moveTokenToPaused(accountMeta.tokenFile); + + providerAccounts.accounts[accountId].paused = true; + providerAccounts.accounts[accountId].pausedAt = new Date().toISOString(); + saveAccountsRegistry(registry); + return true; +} + +/** + * Resume a paused account + * Moves token file back from paused/ to auth/ so CLIProxyAPI can discover it + */ +export function resumeAccount(provider: CLIProxyProvider, accountId: string): boolean { + const registry = loadAccountsRegistry(); + const providerAccounts = registry.providers[provider]; + + if (!providerAccounts?.accounts[accountId]) { + return false; + } + + const accountMeta = providerAccounts.accounts[accountId]; + + // Skip if already active (idempotent) + if (!accountMeta.paused) { + return true; + } + + // Move token file back from paused directory (if it exists in paused dir) + moveTokenFromPaused(accountMeta.tokenFile); + + providerAccounts.accounts[accountId].paused = false; + providerAccounts.accounts[accountId].pausedAt = undefined; + saveAccountsRegistry(registry); + return true; +} + +/** + * Remove an account + */ +export function removeAccount(provider: CLIProxyProvider, accountId: string): boolean { + const registry = loadAccountsRegistry(); + const providerAccounts = registry.providers[provider]; + + if (!providerAccounts || !providerAccounts.accounts[accountId]) { + return false; + } + + // Delete token file from both auth and paused directories + const tokenFile = providerAccounts.accounts[accountId].tokenFile; + deleteTokenFile(tokenFile); + + // Remove from registry + delete providerAccounts.accounts[accountId]; + + // Update default if needed + const remainingAccounts = Object.keys(providerAccounts.accounts); + if (providerAccounts.default === accountId && remainingAccounts.length > 0) { + providerAccounts.default = remainingAccounts[0]; + } + + saveAccountsRegistry(registry); + return true; +} + +/** + * Rename an account's nickname + */ +export function renameAccount( + provider: CLIProxyProvider, + accountId: string, + newNickname: string +): boolean { + const validationError = validateNickname(newNickname); + if (validationError) { + throw new Error(validationError); + } + + const registry = loadAccountsRegistry(); + const providerAccounts = registry.providers[provider]; + + if (!providerAccounts?.accounts[accountId]) { + return false; + } + + // Check if nickname is already used by another account + for (const [id, account] of Object.entries(providerAccounts.accounts)) { + if (id !== accountId && account.nickname?.toLowerCase() === newNickname.toLowerCase()) { + throw new Error(`Nickname "${newNickname}" is already used by another account`); + } + } + + providerAccounts.accounts[accountId].nickname = newNickname; + saveAccountsRegistry(registry); + return true; +} + +/** + * Update last used timestamp for an account + */ +export function touchAccount(provider: CLIProxyProvider, accountId: string): void { + const registry = loadAccountsRegistry(); + const providerAccounts = registry.providers[provider]; + + if (providerAccounts?.accounts[accountId]) { + providerAccounts.accounts[accountId].lastUsedAt = new Date().toISOString(); + saveAccountsRegistry(registry); + } +} + +/** + * Update account tier + */ +export function setAccountTier( + provider: CLIProxyProvider, + accountId: string, + tier: 'free' | 'pro' | 'ultra' | 'unknown' +): boolean { + const registry = loadAccountsRegistry(); + const providerAccounts = registry.providers[provider]; + + if (!providerAccounts?.accounts[accountId]) { + return false; + } + + providerAccounts.accounts[accountId].tier = tier; + saveAccountsRegistry(registry); + return true; +} + +/** + * Auto-discover accounts from existing token files + * Called during migration or first run to populate accounts registry + * + * For kiro/ghcp providers without email, generates unique accountId from: + * 1. OAuth provider + profile ID from filename (e.g., github-ABC123) + * 2. Fallback: provider + index (e.g., kiro-1, kiro-2) + */ +export function discoverExistingAccounts(): void { + const authDir = getAuthDir(); + + if (!fs.existsSync(authDir)) { + return; + } + + const registry = loadAccountsRegistry(); + const files = fs.readdirSync(authDir); + + for (const file of files) { + if (!file.endsWith('.json')) continue; + + const filePath = path.join(authDir, file); + + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const data = JSON.parse(content); + + // Skip if no type field + if (!data.type) continue; + + // Build reverse mapping from PROVIDER_TYPE_VALUES (type value -> provider) + // e.g., "antigravity" -> "agy", "kiro" -> "kiro", "codewhisperer" -> "kiro" + const typeValue = data.type.toLowerCase(); + let provider: CLIProxyProvider | undefined; + for (const [prov, typeValues] of Object.entries(PROVIDER_TYPE_VALUES)) { + if (typeValues.includes(typeValue)) { + provider = prov as CLIProxyProvider; + break; + } + } + + // Skip if unknown provider type + if (!provider) { + continue; + } + + // Extract email if available, fallback to filename-based ID + let email = data.email || undefined; + + // Fallback: extract email from filename (e.g., "kiro-google-user@example.com.json") + if (!email && file.includes('@')) { + const match = file.match(/([^-]+@[^.]+\.[^.]+)(?=\.json$)/); + if (match) { + email = match[1]; + } + } + + // Initialize provider section if needed + if (!registry.providers[provider]) { + registry.providers[provider] = { + default: 'default', + accounts: {}, + }; + } + + const providerAccounts = registry.providers[provider]; + if (!providerAccounts) continue; + + // Skip if token file already registered (under any accountId) + const existingTokenFiles = Object.values(providerAccounts.accounts).map((a) => a.tokenFile); + if (existingTokenFiles.includes(file)) { + // Token file exists - check if we need to update projectId for agy accounts + const projectIdValue = + typeof data.project_id === 'string' && data.project_id.trim() + ? data.project_id.trim() + : null; + if (provider === 'agy' && projectIdValue) { + const existingEntry = Object.entries(providerAccounts.accounts).find( + ([, meta]) => meta.tokenFile === file + ); + // Update if missing or changed + if (existingEntry && existingEntry[1].projectId !== projectIdValue) { + existingEntry[1].projectId = projectIdValue; + } + } + continue; + } + + // Determine accountId based on provider type + let accountId: string; + + if (PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email) { + // For kiro/ghcp without email: extract from filename or generate unique + // Pattern: kiro-github-ABC123.json -> github-ABC123 + const filenameId = extractAccountIdFromTokenFile(file, undefined); + + if (filenameId !== 'default') { + accountId = filenameId; + } else { + // Generate unique ID: provider + incrementing index + let index = 1; + while (providerAccounts.accounts[`${provider}-${index}`]) { + index++; + } + accountId = `${provider}-${index}`; + } + } else { + // For providers with email: use email or filename extraction + accountId = extractAccountIdFromTokenFile(file, email); + } + + // Skip if account already registered + if (providerAccounts.accounts[accountId]) { + continue; + } + + // Set as default if first account + if (Object.keys(providerAccounts.accounts).length === 0) { + providerAccounts.default = accountId; + } + + // Get file stats for creation time + const stats = fs.statSync(filePath); + + // Register account with auto-generated nickname + // Use mtime as lastUsedAt (when token was last modified = last auth/refresh) + const lastModified = stats.mtime || stats.birthtime || new Date(); + const accountMeta: Omit = { + email, + nickname: generateNickname(email), + tokenFile: file, + createdAt: stats.birthtime?.toISOString() || new Date().toISOString(), + lastUsedAt: lastModified.toISOString(), + }; + + // Read project_id for Antigravity accounts (read-only field from auth token) + const discoveredProjectId = + typeof data.project_id === 'string' && data.project_id.trim() + ? data.project_id.trim() + : null; + if (provider === 'agy' && discoveredProjectId) { + accountMeta.projectId = discoveredProjectId; + } + + providerAccounts.accounts[accountId] = accountMeta; + } catch { + // Skip invalid files + continue; + } + } + + // Reload-merge pattern: reduce race condition with concurrent OAuth registration + // Reload fresh registry and merge discovered accounts (fresh registry wins on conflicts) + const freshRegistry = loadAccountsRegistry(); + for (const [providerName, discovered] of Object.entries(registry.providers)) { + if (!discovered) continue; + const prov = providerName as CLIProxyProvider; + if (!freshRegistry.providers[prov]) { + freshRegistry.providers[prov] = discovered; + } else { + // Merge accounts, preferring fresh registry's existing entries but updating projectId + const freshProviderAccounts = freshRegistry.providers[prov]; + if (!freshProviderAccounts) continue; + for (const [id, meta] of Object.entries(discovered.accounts)) { + if (!freshProviderAccounts.accounts[id]) { + freshProviderAccounts.accounts[id] = meta; + // Set default if none exists + if (!freshProviderAccounts.default || freshProviderAccounts.default === 'default') { + freshProviderAccounts.default = id; + } + } else if (meta.projectId && !freshProviderAccounts.accounts[id].projectId) { + // Update existing account with projectId if discovered from auth file + freshProviderAccounts.accounts[id].projectId = meta.projectId; + } + } + } + } + saveAccountsRegistry(freshRegistry); +} diff --git a/src/cliproxy/accounts/token-file-ops.ts b/src/cliproxy/accounts/token-file-ops.ts new file mode 100644 index 00000000..d06f4df7 --- /dev/null +++ b/src/cliproxy/accounts/token-file-ops.ts @@ -0,0 +1,182 @@ +/** + * Token file operations and path management + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { getCliproxyDir, getAuthDir } from '../config-generator'; +import { AccountInfo } from './types'; + +/** + * Get path to accounts registry file + */ +export function getAccountsRegistryPath(): string { + return path.join(getCliproxyDir(), 'accounts.json'); +} + +/** + * Get path to paused tokens directory + * Paused tokens are moved here so CLIProxyAPI won't discover them + * + * Uses sibling directory (auth-paused/) instead of subdirectory (auth/paused/) + * because CLIProxyAPI's watcher uses filepath.Walk() which recursively scans + * all subdirectories of auth/. A sibling directory is completely outside + * CLIProxyAPI's scan path, preventing token refresh loops. + */ +export function getPausedDir(): string { + return path.join(getCliproxyDir(), 'auth-paused'); +} + +/** + * Get token file path for an account + * Returns path in paused/ dir if account is paused, otherwise auth/ + */ +export function getAccountTokenPath(account: AccountInfo): string { + const baseDir = account.paused ? getPausedDir() : getAuthDir(); + return path.join(baseDir, account.tokenFile); +} + +/** + * Move token file to paused directory + * Creates paused directory if needed, idempotent + */ +export function moveTokenToPaused(tokenFile: string): boolean { + const authDir = getAuthDir(); + const pausedDir = getPausedDir(); + const tokenPath = path.join(authDir, tokenFile); + const pausedPath = path.join(pausedDir, tokenFile); + + // Skip if already in paused directory + if (!fs.existsSync(tokenPath)) { + return true; + } + + try { + // Create paused directory if it doesn't exist + if (!fs.existsSync(pausedDir)) { + fs.mkdirSync(pausedDir, { recursive: true, mode: 0o700 }); + } + fs.renameSync(tokenPath, pausedPath); + return true; + } catch { + // File operation failed, caller handles recovery + return false; + } +} + +/** + * Move token file from paused directory back to auth + * Idempotent + */ +export function moveTokenFromPaused(tokenFile: string): boolean { + const authDir = getAuthDir(); + const pausedDir = getPausedDir(); + const tokenPath = path.join(authDir, tokenFile); + const pausedPath = path.join(pausedDir, tokenFile); + + // Skip if already in auth directory + if (!fs.existsSync(pausedPath)) { + return true; + } + + try { + fs.renameSync(pausedPath, tokenPath); + return true; + } catch { + // File operation failed, caller handles recovery + return false; + } +} + +/** + * Delete token file from both auth and paused directories + * Idempotent + */ +export function deleteTokenFile(tokenFile: string): void { + const tokenPath = path.join(getAuthDir(), tokenFile); + const pausedPath = path.join(getPausedDir(), tokenFile); + + // Delete from auth directory + if (fs.existsSync(tokenPath)) { + try { + fs.unlinkSync(tokenPath); + } catch { + // Ignore deletion errors + } + } + + // Also delete from paused directory if it exists there + if (fs.existsSync(pausedPath)) { + try { + fs.unlinkSync(pausedPath); + } catch { + // Ignore deletion errors + } + } +} + +/** + * Check if token file exists in either auth or paused directory + */ +export function tokenFileExists(tokenFile: string): boolean { + const tokenPath = path.join(getAuthDir(), tokenFile); + const pausedPath = path.join(getPausedDir(), tokenFile); + return fs.existsSync(tokenPath) || fs.existsSync(pausedPath); +} + +/** + * Extract unique account ID from token filename when email is unavailable + * For Kiro/GHCP OAuth, filenames are: --.json + * Extracts: - as unique identifier + * @example kiro-github-ABC123.json → github-ABC123 + * @example ghcp-amazon-XYZ789.json → amazon-XYZ789 + * @example kiro-nomail.json → default (no OAuth structure) + */ +export function extractAccountIdFromTokenFile(filename: string, email?: string): string { + if (email) return email; + + // Pattern: --.json → extract - + // Requires at least 2 hyphens to distinguish from simple filenames like "kiro-nomail.json" + const match = filename.match(/^[^-]+-([^-]+-[^.]+)\.json$/); + if (match) return match[1]; + + return 'default'; +} + +/** + * Generate nickname from email + * Takes prefix before @ symbol, sanitizes whitespace + * Validation: 1-50 chars, any non-whitespace (permissive per user preference) + */ +export function generateNickname(email?: string): string { + if (!email) return 'default'; + const prefix = email.split('@')[0]; + // Sanitize: remove whitespace, limit to 50 chars + return prefix.replace(/\s+/g, '').slice(0, 50) || 'default'; +} + +/** + * Validate nickname + * Rules: 1-50 chars, no whitespace, URL-safe, no reserved patterns + * @returns null if valid, error message if invalid + */ +export function validateNickname(nickname: string): string | null { + if (!nickname || nickname.length === 0) { + return 'Nickname is required'; + } + if (nickname.length > 50) { + return 'Nickname must be 50 characters or less'; + } + if (/\s/.test(nickname)) { + return 'Nickname cannot contain whitespace'; + } + // Block URL-unsafe chars that break routing + if (/[%\/&?#]/.test(nickname)) { + return 'Nickname cannot contain special URL characters (%, /, &, ?, #)'; + } + // Block reserved patterns used by auto-discovery (kiro-1, ghcp-2, etc.) + if (/^(kiro|ghcp)-\d+$/i.test(nickname)) { + return 'Nickname cannot match reserved pattern (kiro-N, ghcp-N)'; + } + return null; +} diff --git a/src/cliproxy/accounts/types.ts b/src/cliproxy/accounts/types.ts new file mode 100644 index 00000000..d93b20c4 --- /dev/null +++ b/src/cliproxy/accounts/types.ts @@ -0,0 +1,70 @@ +/** + * Shared types and constants for account management + */ + +import { CLIProxyProvider } from '../types'; + +/** Account tier for quota management: ultra > pro > free */ +export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown'; + +/** + * Providers that typically have empty email in OAuth token files. + * For these providers, nickname is used as accountId instead of email. + */ +export const PROVIDERS_WITHOUT_EMAIL: CLIProxyProvider[] = ['kiro', 'ghcp']; + +/** Account information */ +export interface AccountInfo { + /** Account identifier (email or custom name) */ + id: string; + /** Email address from OAuth (if available) */ + email?: string; + /** User-friendly nickname for quick reference (auto-generated from email prefix) */ + nickname?: string; + /** Provider this account belongs to */ + provider: CLIProxyProvider; + /** Whether this is the default account for the provider */ + isDefault: boolean; + /** Token file name in auth directory */ + tokenFile: string; + /** When account was added */ + createdAt: string; + /** Last usage time */ + lastUsedAt?: string; + /** User-paused state (skip in quota rotation) */ + paused?: boolean; + /** ISO timestamp when paused */ + pausedAt?: string; + /** Account tier: ultra, pro, or free */ + tier?: AccountTier; + /** GCP Project ID (Antigravity only) - read-only, fetched from auth token */ + projectId?: string; +} + +/** Provider accounts configuration */ +export interface ProviderAccounts { + /** Default account ID for this provider */ + default: string; + /** Map of account ID to account metadata */ + accounts: Record>; +} + +/** Accounts registry structure */ +export interface AccountsRegistry { + /** Version for future migrations */ + version: number; + /** Accounts organized by provider */ + providers: Partial>; +} + +/** Result of bulk pause/resume operations */ +export interface BulkOperationResult { + succeeded: string[]; + failed: Array<{ id: string; reason: string }>; +} + +/** Result of solo account operation */ +export interface SoloOperationResult { + activated: string; + paused: string[]; +} diff --git a/src/cliproxy/auth/token-expiry-checker.ts b/src/cliproxy/auth/token-expiry-checker.ts index 9a6665ee..066f1d9b 100644 --- a/src/cliproxy/auth/token-expiry-checker.ts +++ b/src/cliproxy/auth/token-expiry-checker.ts @@ -54,7 +54,11 @@ export function getTokenExpiryInfo( provider: CLIProxyProvider, accountId: string ): TokenExpiryInfo | null { - const tokenPath = getAccountTokenPath(provider, accountId); + const account = getProviderAccounts(provider).find((a) => a.id === accountId); + if (!account) { + return null; + } + const tokenPath = getAccountTokenPath(account); if (!tokenPath || !fs.existsSync(tokenPath)) { return null; } diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 6b924a35..d51e3e9a 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -1,1180 +1,17 @@ /** - * CLIProxy Executor - Spawn/Kill Pattern for CLIProxyAPI + * CLIProxy Executor - Backwards Compatibility Shim * - * Mirrors GLMT architecture: - * 1. Ensure binary exists (Phase 1) - * 2. Generate config for provider - * 3. Spawn CLIProxyAPI binary - * 4. Poll port for readiness (no stdout signal) - * 5. Execute Claude CLI with proxied environment - * 6. Kill proxy on Claude exit + * This file re-exports from the modular executor/ structure. + * All implementation has been moved to: + * - executor/index.ts - Main orchestrator + * - executor/lifecycle-manager.ts - Spawn/kill/poll operations + * - executor/env-resolver.ts - Environment variable resolution + * - executor/retry-handler.ts - Error recovery and retry logic + * - executor/session-bridge.ts - Session tracking integration * - * Key difference from GLMT: Uses TCP port polling instead of PROXY_READY signal + * @deprecated Import from './executor' instead */ +// Re-export from modular structure +export { execClaudeWithCLIProxy, isPortAvailable, findAvailablePort } from './executor'; -import { spawn, ChildProcess } from 'child_process'; -import * as net from 'net'; -import * as os from 'os'; -import { ProgressIndicator } from '../utils/progress-indicator'; -import { ok, fail, info, warn } from '../utils/ui'; -import { escapeShellArg } from '../utils/shell-executor'; -import { ensureCLIProxyBinary, getInstalledCliproxyVersion } from './binary-manager'; -import { - generateConfig, - getEffectiveEnvVars, - getRemoteEnvVars, - getProviderConfig, - ensureProviderSettings, - getProviderSettingsPath, - CLIPROXY_DEFAULT_PORT, - getCliproxyWritablePath, - validatePort, - applyThinkingConfig, -} from './config-generator'; -import { checkRemoteProxy } from './remote-proxy-client'; -import { isAuthenticated } from './auth-handler'; -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'; -import { getImageAnalysisHookEnv } from '../utils/hooks/get-image-analysis-hook-env'; -import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog'; -import { CodexReasoningProxy } from './codex-reasoning-proxy'; -import { ToolSanitizationProxy } from './tool-sanitization-proxy'; -import { - findAccountByQuery, - getProviderAccounts, - setDefaultAccount, - touchAccount, - renameAccount, - getDefaultAccount, -} from './account-manager'; -import { getPortCheckCommand, getCatCommand, killProcessOnPort } from '../utils/platform-commands'; -import { - ensureMcpWebSearch, - installWebSearchHook, - displayWebSearchStatus, -} from '../utils/websearch-manager'; -import { - registerSession, - unregisterSession, - cleanupOrphanedSessions, - stopProxy, -} from './session-tracker'; -import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from './proxy-detector'; -import { withStartupLock } from './startup-lock'; -import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; -import { preflightCheck } from './quota-manager'; -import { HttpsTunnelProxy } from './https-tunnel-proxy'; - -/** Default executor configuration */ -const DEFAULT_CONFIG: ExecutorConfig = { - port: CLIPROXY_DEFAULT_PORT, - timeout: 5000, - verbose: false, - pollInterval: 100, -}; - -/** - * Wait for TCP port to become available - * Uses polling since CLIProxyAPI doesn't emit PROXY_READY signal - */ -async function waitForProxyReady( - port: number, - timeout: number = 5000, - pollInterval: number = 100 -): Promise { - const start = Date.now(); - - while (Date.now() - start < timeout) { - try { - await new Promise((resolve, reject) => { - const socket = net.createConnection({ port, host: '127.0.0.1' }, () => { - socket.destroy(); - resolve(); - }); - - socket.on('error', (err) => { - socket.destroy(); - reject(err); - }); - - // Individual connection timeout - socket.setTimeout(1000, () => { - socket.destroy(); - reject(new Error('Connection timeout')); - }); - }); - - return; // Connection successful - proxy is ready - } catch { - // Connection failed, wait and retry - await new Promise((r) => setTimeout(r, pollInterval)); - } - } - - throw new Error(`CLIProxy not ready after ${timeout}ms on port ${port}`); -} - -/** - * Execute Claude CLI with CLIProxy (main entry point) - * - * @param claudeCli Path to Claude CLI executable - * @param provider CLIProxy provider (gemini, codex, agy, qwen) - * @param args Arguments to pass to Claude CLI - * @param config Optional executor configuration - */ -export async function execClaudeWithCLIProxy( - claudeCli: string, - provider: CLIProxyProvider, - args: string[], - config: Partial = {} -): Promise { - // Filter out undefined values to prevent overwriting defaults - const filteredConfig = Object.fromEntries( - Object.entries(config).filter(([, v]) => v !== undefined) - ) as Partial; - const cfg = { ...DEFAULT_CONFIG, ...filteredConfig }; - const verbose = cfg.verbose || args.includes('--verbose') || args.includes('-v'); - - const log = (msg: string) => { - if (verbose) { - console.error(`[cliproxy] ${msg}`); - } - }; - - // 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 - ? { - enabled: cliproxyServerConfig.remote.enabled, - host: cliproxyServerConfig.remote.host, - port: cliproxyServerConfig.remote.port, - protocol: cliproxyServerConfig.remote.protocol, - auth_token: cliproxyServerConfig.remote.auth_token, - timeout: cliproxyServerConfig.remote.timeout, - } - : undefined, - local: cliproxyServerConfig?.local - ? { - port: cliproxyServerConfig.local.port, - auto_start: cliproxyServerConfig.local.auto_start, - } - : undefined, - }); - - // Use resolved port from proxy config (overrides ExecutorConfig) - // Priority: CLI flags > Variant port (cfg.port) > config.yaml > default - // cfg.port is set if variant has dedicated port; proxyConfig.port comes from CLI/ENV/config.yaml - if (cfg.port && cfg.port !== CLIPROXY_DEFAULT_PORT) { - // Variant port already set via config param - keep it (highest priority after CLI flags) - // CLI flags would have been parsed in resolveProxyConfig and set proxyConfig.port - // So if proxyConfig.port differs from default AND cfg.port differs, CLI wins - if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) { - cfg.port = proxyConfig.port; // CLI/ENV override variant port - } - // else: keep cfg.port (variant port) - } else if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) { - cfg.port = proxyConfig.port; - } - - // Final port validation - ensure valid port after all resolution - cfg.port = validatePort(cfg.port); - - log(`Proxy mode: ${proxyConfig.mode}`); - if (proxyConfig.mode === 'remote') { - log(`Remote host: ${proxyConfig.host}:${proxyConfig.port} (${proxyConfig.protocol})`); - } - - // Note: proxyConfig is available for Phase 4 (remote mode integration) - - // Ensure MCP web-search is configured for third-party profiles - // WebSearch is a server-side tool executed by Anthropic's API - // Third-party providers don't have access, so we use MCP fallback - ensureMcpWebSearch(); - - // Install WebSearch hook for Gemini CLI + MCP fallback - // Hook intercepts WebSearch, tries Gemini CLI first, falls back to MCP - installWebSearchHook(); - - // Display WebSearch status (single line, equilibrium UX) - displayWebSearchStatus(); - - // Validate provider - const providerConfig = getProviderConfig(provider); - log(`Provider: ${providerConfig.displayName}`); - - // Check remote proxy if configured (before binary download) - let useRemoteProxy = false; - if (proxyConfig.mode === 'remote' && proxyConfig.host) { - const status = await checkRemoteProxy({ - host: proxyConfig.host, - port: proxyConfig.port, - protocol: proxyConfig.protocol, - authToken: proxyConfig.authToken, - timeout: proxyConfig.timeout ?? 2000, - allowSelfSigned: proxyConfig.allowSelfSigned ?? false, - }); - - if (status.reachable) { - useRemoteProxy = true; - console.log( - ok( - `Connected to remote proxy at ${proxyConfig.host}:${proxyConfig.port} (${status.latencyMs}ms)` - ) - ); - } else { - console.error(warn(`Remote proxy unreachable: ${status.error}`)); - - if (proxyConfig.remoteOnly) { - throw new Error('Remote proxy unreachable and --remote-only specified'); - } - - if (proxyConfig.fallbackEnabled) { - if (proxyConfig.autoStartLocal) { - console.log(info('Falling back to local proxy...')); - } else { - // Prompt user for fallback (only in TTY) - if (process.stdin.isTTY) { - const readline = await import('readline'); - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - const answer = await new Promise((resolve) => { - rl.question('Start local proxy instead? [Y/n] ', resolve); - }); - rl.close(); - if (answer.toLowerCase() === 'n') { - throw new Error('Remote proxy unreachable and user declined fallback'); - } - } - console.log(info('Starting local proxy...')); - } - } else { - throw new Error('Remote proxy unreachable and fallback disabled'); - } - } - } - - // Variables for local proxy mode - let binaryPath: string | undefined; - let sessionId: string | undefined; - - // 1. Ensure binary exists (downloads if needed) - SKIP for remote mode - if (!useRemoteProxy) { - const spinner = new ProgressIndicator('Preparing CLIProxy'); - spinner.start(); - - try { - binaryPath = await ensureCLIProxyBinary(verbose); - spinner.succeed('CLIProxy binary ready'); - } catch (error) { - spinner.fail('Failed to prepare CLIProxy'); - const err = error as Error; - - // Check if network offline (DNS, connection, or timeout failure) - const networkErrors = [ - 'getaddrinfo', - 'ENOTFOUND', - 'ETIMEDOUT', - 'ECONNREFUSED', - 'ENETUNREACH', - 'EAI_AGAIN', - ]; - const isNetworkError = networkErrors.some((errCode) => err.message.includes(errCode)); - - if (isNetworkError) { - console.error(''); - console.error(fail('No network connection detected')); - console.error(''); - console.error('CLIProxy binary download requires internet access.'); - console.error('Please check your network connection and try again.'); - console.error(''); - process.exit(1); - } - - throw error; - } - } - - // 2. Handle special flags (use argsWithoutProxy - proxy flags already stripped) - const forceAuth = argsWithoutProxy.includes('--auth'); - const pasteCallback = argsWithoutProxy.includes('--paste-callback'); - const portForward = argsWithoutProxy.includes('--port-forward'); - const forceHeadless = argsWithoutProxy.includes('--headless'); - - // Validate conflicting flags - if (pasteCallback && portForward) { - console.error(fail('Cannot use --paste-callback with --port-forward')); - console.error(' --paste-callback: Manually paste OAuth redirect URL'); - console.error(' --port-forward: Use SSH port forwarding for callback'); - process.exit(1); - } - - const forceLogout = argsWithoutProxy.includes('--logout'); - const forceConfig = argsWithoutProxy.includes('--config'); - const addAccount = argsWithoutProxy.includes('--add'); - const showAccounts = argsWithoutProxy.includes('--accounts'); - // Kiro-specific: --import to import token from Kiro IDE directly - const forceImport = argsWithoutProxy.includes('--import'); - // Kiro-specific: browser mode for OAuth - // Default to normal browser (noIncognito=true) for reliability - incognito often fails on Linux - // --incognito flag opts into incognito mode, --no-incognito is legacy (now default) - const incognitoFlag = argsWithoutProxy.includes('--incognito'); - const noIncognitoFlag = argsWithoutProxy.includes('--no-incognito'); - // Config setting (defaults to true = normal browser) - const kiroNoIncognitoConfig = - provider === 'kiro' ? (unifiedConfig.cliproxy?.kiro_no_incognito ?? true) : false; - // --incognito flag overrides everything to use incognito - const noIncognito = incognitoFlag ? false : noIncognitoFlag || kiroNoIncognitoConfig; - - // Parse --use flag - let useAccount: string | undefined; - const useIdx = argsWithoutProxy.indexOf('--use'); - if ( - useIdx !== -1 && - argsWithoutProxy[useIdx + 1] && - !argsWithoutProxy[useIdx + 1].startsWith('-') - ) { - useAccount = argsWithoutProxy[useIdx + 1]; - } - - // Parse --nickname flag - let setNickname: string | undefined; - const nicknameIdx = argsWithoutProxy.indexOf('--nickname'); - if ( - nicknameIdx !== -1 && - argsWithoutProxy[nicknameIdx + 1] && - !argsWithoutProxy[nicknameIdx + 1].startsWith('-') - ) { - setNickname = argsWithoutProxy[nicknameIdx + 1]; - } - - // Parse --thinking flag for thinking budget control - // Supports: level names (low, medium, high, xhigh, auto), numeric budget, or 'off'/'none' - // Accepts both --thinking=value and --thinking value formats - let thinkingOverride: string | number | undefined; - - // Check for --thinking=value format first - const thinkingEqArg = argsWithoutProxy.find((arg) => arg.startsWith('--thinking=')); - if (thinkingEqArg) { - const val = thinkingEqArg.substring('--thinking='.length); - // Handle empty value after equals (--thinking=) - if (!val || val.trim() === '') { - console.error(fail('--thinking requires a value')); - console.error(' Examples: --thinking=low, --thinking=8192, --thinking=off'); - console.error(' Levels: minimal, low, medium, high, xhigh, auto'); - process.exit(1); - } - // Parse as number if numeric, otherwise keep as string (level name) - const numVal = parseInt(val, 10); - thinkingOverride = !isNaN(numVal) ? numVal : val; - - // W2: Warn if multiple --thinking flags found (check both formats) - const allThinkingFlags = argsWithoutProxy.filter( - (arg) => arg === '--thinking' || arg.startsWith('--thinking=') - ); - if (allThinkingFlags.length > 1) { - console.warn( - `[!] Multiple --thinking flags detected. Using first occurrence: ${thinkingEqArg}` - ); - } - } else { - // Fall back to --thinking value format - const thinkingIdx = argsWithoutProxy.indexOf('--thinking'); - if (thinkingIdx !== -1) { - const nextArg = argsWithoutProxy[thinkingIdx + 1]; - // U1: Check if --thinking has a value (not missing or another flag) - if (!nextArg || nextArg.startsWith('-')) { - console.error(fail('--thinking requires a value')); - console.error(' Examples: --thinking low, --thinking 8192, --thinking off'); - console.error(' Levels: minimal, low, medium, high, xhigh, auto'); - process.exit(1); - } - const val = nextArg; - // Parse as number if numeric, otherwise keep as string (level name) - const numVal = parseInt(val, 10); - thinkingOverride = !isNaN(numVal) ? numVal : val; - - // W2: Warn if multiple --thinking flags found (check both formats) - const allThinkingFlags = argsWithoutProxy.filter( - (arg) => arg === '--thinking' || arg.startsWith('--thinking=') - ); - if (allThinkingFlags.length > 1) { - console.warn( - `[!] Multiple --thinking flags detected. Using first occurrence: --thinking ${val}` - ); - } - } - } - - // Handle --accounts: list accounts and exit - if (showAccounts) { - const accounts = getProviderAccounts(provider); - if (accounts.length === 0) { - console.log(info(`No accounts registered for ${providerConfig.displayName}`)); - console.log(` Run "ccs ${provider} --auth" to add an account`); - } else { - console.log(`\n${providerConfig.displayName} Accounts:\n`); - for (const acct of accounts) { - const defaultMark = acct.isDefault ? ' (default)' : ''; - const nickname = acct.nickname ? `[${acct.nickname}]` : ''; - console.log(` ${nickname.padEnd(12)} ${acct.email || acct.id}${defaultMark}`); - } - console.log(`\n Use "ccs ${provider} --use " to switch accounts`); - } - process.exit(0); - } - - // Handle --use: switch to specified account - if (useAccount) { - const account = findAccountByQuery(provider, useAccount); - if (!account) { - console.error(fail(`Account not found: "${useAccount}"`)); - const accounts = getProviderAccounts(provider); - if (accounts.length > 0) { - console.error(` Available accounts:`); - for (const acct of accounts) { - console.error(` - ${acct.nickname || acct.id} (${acct.email || 'no email'})`); - } - } - process.exit(1); - } - // Set as default for this and future sessions - setDefaultAccount(provider, account.id); - touchAccount(provider, account.id); - console.log(ok(`Switched to account: ${account.nickname || account.email || account.id}`)); - } - - // Handle --nickname: rename account and exit (unless used with --auth --add) - if (setNickname && !addAccount) { - const defaultAccount = getDefaultAccount(provider); - if (!defaultAccount) { - console.error(fail(`No account found for ${providerConfig.displayName}`)); - console.error(` Run "ccs ${provider} --auth" to add an account first`); - process.exit(1); - } - try { - const success = renameAccount(provider, defaultAccount.id, setNickname); - if (success) { - console.log(ok(`Renamed account to: ${setNickname}`)); - } else { - console.error(fail('Failed to rename account')); - process.exit(1); - } - } catch (err) { - console.error(fail(err instanceof Error ? err.message : 'Failed to rename account')); - process.exit(1); - } - process.exit(0); - } - - // Handle --config: configure model selection and exit - // Pass customSettingsPath for CLIProxy variants to save to correct file - if (forceConfig && supportsModelConfig(provider)) { - await configureProviderModel(provider, true, cfg.customSettingsPath); - process.exit(0); - } - - // Handle --logout: clear auth and exit - if (forceLogout) { - const { clearAuth } = await import('./auth-handler'); - if (clearAuth(provider)) { - console.log(ok(`Logged out from ${providerConfig.displayName}`)); - } else { - console.log(info(`No authentication found for ${providerConfig.displayName}`)); - } - process.exit(0); - } - - // Handle --import: import token from Kiro IDE directly (Kiro only) - if (forceImport) { - if (provider !== 'kiro') { - console.error(fail('--import is only available for Kiro')); - console.error(` Run "ccs ${provider} --auth" to authenticate`); - process.exit(1); - } - // Validate flag conflicts - if (forceAuth) { - console.error(fail('Cannot use --import with --auth')); - console.error(' --import: Import existing token from Kiro IDE'); - console.error(' --auth: Trigger new OAuth flow in browser'); - process.exit(1); - } - if (forceLogout) { - console.error(fail('Cannot use --import with --logout')); - process.exit(1); - } - const { triggerOAuth } = await import('./auth-handler'); - const authSuccess = await triggerOAuth(provider, { - verbose, - import: true, - ...(setNickname ? { nickname: setNickname } : {}), - }); - if (!authSuccess) { - console.error(fail('Failed to import Kiro token from IDE')); - console.error(' Make sure you are logged into Kiro IDE first'); - process.exit(1); - } - process.exit(0); - } - - // 3. Ensure OAuth completed (if provider requires it) - // Skip local OAuth check when using remote proxy with auth token - // The remote proxy has its own OAuth sessions and handles authentication - // Note: Trim authToken to reject whitespace-only values - const remoteAuthToken = proxyConfig.authToken?.trim(); - const skipLocalAuth = useRemoteProxy && !!remoteAuthToken; - if (skipLocalAuth) { - log(`Using remote proxy authentication (skipping local OAuth)`); - } - - if (providerConfig.requiresOAuth && !skipLocalAuth) { - log(`Checking authentication for ${provider}`); - - if (forceAuth || !isAuthenticated(provider)) { - // Pass headless only if explicitly set; otherwise let auth-handler auto-detect - const { triggerOAuth } = await import('./auth-handler'); - const authSuccess = await triggerOAuth(provider, { - verbose, - add: addAccount, - ...(forceHeadless ? { headless: true } : {}), - ...(setNickname ? { nickname: setNickname } : {}), - ...(noIncognito ? { noIncognito: true } : {}), - ...(pasteCallback ? { pasteCallback: true } : {}), - ...(portForward ? { portForward: true } : {}), - }); - if (!authSuccess) { - throw new Error(`Authentication required for ${providerConfig.displayName}`); - } - // If --auth was explicitly passed, exit after auth (don't start Claude) - if (forceAuth) { - process.exit(0); - } - } else { - log(`${provider} already authenticated`); - } - - // 3a. Proactive token refresh - prevent UND_ERR_SOCKET from expired tokens - // CLIProxyAPI may fail mid-request if token expires during use - const { ensureTokenValid } = await import('./auth/token-manager'); - const tokenResult = await ensureTokenValid(provider, verbose); - if (!tokenResult.valid) { - // Token expired and refresh failed - trigger re-auth - console.error(warn('OAuth token expired and refresh failed')); - if (tokenResult.error) { - console.error(` ${tokenResult.error}`); - } - console.error(` Run "ccs ${provider} --auth" to re-authenticate`); - process.exit(1); - } - if (tokenResult.refreshed) { - log('Token was refreshed proactively'); - } - - // 3a-1. Update lastUsedAt for the account being used - // This ensures dashboard shows accurate "Last used" timestamps - const usedAccount = getDefaultAccount(provider); - if (usedAccount) { - touchAccount(provider, usedAccount.id); - } - } - - // 3b. Preflight quota check - auto-switch to account with quota before launch - // Uses quota-manager for caching, tier priority, and cooldown support - // Skip for remote proxy - quota is managed on the remote server - if (provider === 'agy' && !skipLocalAuth) { - const preflight = await preflightCheck(provider); - - if (!preflight.proceed) { - console.error(fail(`Cannot start session: ${preflight.reason}`)); - process.exit(1); - } - - if (preflight.switchedFrom) { - console.log(info(`Auto-switched to ${preflight.accountId}`)); - console.log(` Reason: ${preflight.reason}`); - if (preflight.quotaPercent !== undefined && preflight.quotaPercent !== null) { - console.log(` New account quota: ${preflight.quotaPercent.toFixed(1)}%`); - } else { - console.log(` New account quota: N/A (fetch unavailable)`); - } - } - } - - // 4. First-run model configuration (interactive) - // For supported providers, prompt user to select model on first run - // Pass customSettingsPath for CLIProxy variants - // Skip for remote proxy - model is configured on the remote server - if (supportsModelConfig(provider) && !skipLocalAuth) { - await configureProviderModel(provider, false, cfg.customSettingsPath); // false = only if not configured - } - - // 5. Check for known broken models and warn user - // Show warning for both local and remote modes - user should be aware of model issues - const currentModel = getCurrentModel(provider, cfg.customSettingsPath); - if (currentModel && isModelBroken(provider, currentModel)) { - const modelEntry = findModel(provider, currentModel); - const issueUrl = getModelIssueUrl(provider, currentModel); - console.error(''); - console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`)); - console.error(' Tool calls will fail. Use "gemini-3-pro-preview" instead.'); - if (issueUrl) { - console.error(` Tracking: ${issueUrl}`); - } - if (skipLocalAuth) { - console.error(' Note: Model may be overridden by remote proxy configuration.'); - } else { - console.error(` Run "ccs ${provider} --config" to change model.`); - } - console.error(''); - } - - // 6. Ensure user settings file exists (creates from defaults if not) - ensureProviderSettings(provider); - - // Local proxy mode: generate config, spawn proxy, track session - let proxy: ChildProcess | null = null; - - if (!useRemoteProxy) { - // 6. Generate config file - log(`Generating config for ${provider}`); - const configPath = generateConfig(provider, cfg.port); - log(`Config written: ${configPath}`); - - // 6a. Pre-flight check using unified detection with startup lock - // This prevents race conditions when multiple CCS processes start simultaneously - cleanupOrphanedSessions(cfg.port); - - // Use startup lock to coordinate with other CCS processes - await withStartupLock(async () => { - // Detect running proxy using multiple methods (HTTP, session-lock, port-process) - let proxyStatus = await detectRunningProxy(cfg.port); - log(`Proxy detection: ${JSON.stringify(proxyStatus)}`); - - // Check for version mismatch - restart proxy if installed version differs from running - if (proxyStatus.running && proxyStatus.verified && proxyStatus.version) { - const installedVersion = getInstalledCliproxyVersion(); - if (installedVersion !== proxyStatus.version) { - console.log( - warn( - `Version mismatch: running v${proxyStatus.version}, installed v${installedVersion}. Restarting proxy...` - ) - ); - log(`Stopping outdated proxy (PID: ${proxyStatus.pid ?? 'unknown'})...`); - const stopResult = await stopProxy(cfg.port); - if (stopResult.stopped) { - log(`Stopped outdated proxy successfully`); - } else { - log(`Stop proxy result: ${stopResult.error ?? 'unknown error'}`); - } - // Wait for port to be released - await new Promise((r) => setTimeout(r, 500)); - // Re-detect proxy status (should now be not running) - proxyStatus = await detectRunningProxy(cfg.port); - log(`Re-detection after version mismatch restart: ${JSON.stringify(proxyStatus)}`); - } - } - - if (proxyStatus.running && proxyStatus.verified) { - // Healthy proxy found - join it - if (proxyStatus.pid) { - sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid, verbose) ?? undefined; - } - if (sessionId) { - console.log(info(`Joined existing CLIProxy on port ${cfg.port} (${proxyStatus.method})`)); - } else { - // Failed to register session - proxy is running but we can't track it - // This happens when port-process detection fails (permissions, platform issues) - console.log( - info(`Using existing CLIProxy on port ${cfg.port} (session tracking unavailable)`) - ); - log(`PID=${proxyStatus.pid ?? 'unknown'}, session registration skipped`); - } - return; // Exit lock early, skip spawning - } - - if (proxyStatus.running && !proxyStatus.verified) { - // Proxy detected but not ready yet (another process is starting it) - log(`Proxy starting up (detected via ${proxyStatus.method}), waiting...`); - const becameHealthy = await waitForProxyHealthy(cfg.port, cfg.timeout); - if (becameHealthy) { - if (proxyStatus.pid) { - sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid, verbose) ?? undefined; - } - console.log(info(`Joined CLIProxy after startup wait`)); - return; // Exit lock early - } - // Proxy didn't become healthy - kill and respawn - if (proxyStatus.pid) { - log(`Proxy PID ${proxyStatus.pid} not responding, killing...`); - killProcessOnPort(cfg.port, verbose); - await new Promise((r) => setTimeout(r, 500)); - } - } - - if (proxyStatus.blocked && proxyStatus.blocker) { - // Port blocked by non-CLIProxy process - // Last resort: try HTTP health check (handles Windows PID-XXXXX case) - const isActuallyOurs = await waitForProxyHealthy(cfg.port, 1000); - if (isActuallyOurs) { - sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.blocker.pid, verbose) ?? undefined; - console.log(info(`Reclaimed CLIProxy with unrecognized process name`)); - return; - } - - // Truly blocked by another application - console.error(''); - console.error( - warn( - `Port ${cfg.port} is blocked by ${proxyStatus.blocker.processName} (PID ${proxyStatus.blocker.pid})` - ) - ); - console.error(''); - console.error('To fix this, close the blocking application or run:'); - console.error(` ${getPortCheckCommand(cfg.port)}`); - console.error(''); - throw new Error(`Port ${cfg.port} is in use by another application`); - } - - // 6b. Spawn CLIProxyAPI binary - const proxyArgs = ['--config', configPath]; - log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`); - - proxy = spawn(binaryPath as string, proxyArgs, { - stdio: ['ignore', 'ignore', 'ignore'], - detached: true, - env: { - ...process.env, - WRITABLE_PATH: getCliproxyWritablePath(), - }, - }); - - proxy.unref(); - - proxy.on('error', (error) => { - console.error(fail(`CLIProxy spawn error: ${error.message}`)); - }); - - // 7. Wait for proxy readiness via TCP polling - const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${cfg.port}`); - readySpinner.start(); - - try { - await waitForProxyReady(cfg.port, cfg.timeout, cfg.pollInterval); - readySpinner.succeed(`CLIProxy ready on port ${cfg.port}`); - } catch (error) { - const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; - readySpinner.fail(`${backendLabel} startup failed`); - proxy.kill('SIGTERM'); - - const err = error as Error; - console.error(''); - console.error(fail(`${backendLabel} failed to start`)); - console.error(''); - console.error('Possible causes:'); - console.error(` 1. Port ${cfg.port} already in use`); - console.error(' 2. Binary crashed on startup'); - console.error(' 3. Invalid configuration'); - console.error(''); - console.error('Troubleshooting:'); - console.error(` - Check port: ${getPortCheckCommand(cfg.port)}`); - console.error(' - Run with --verbose for detailed logs'); - console.error(` - View config: ${getCatCommand(configPath)}`); - console.error(' - Try: ccs doctor --fix'); - console.error(''); - - throw new Error(`CLIProxy startup failed: ${err.message}`); - } - - // Register this session with the new proxy, including version and backend - const installedVersion = getInstalledCliproxyVersion(); - sessionId = registerSession(cfg.port, proxy.pid as number, installedVersion, backend); - log( - `Registered session ${sessionId} with new proxy (PID ${proxy.pid}, version ${installedVersion})` - ); - }); - } - - // 7. Execute Claude CLI with proxied environment - // Use remote or local env vars based on mode - // When remote is configured (even if using local), pass config for URL rewriting - const remoteRewriteConfig = - proxyConfig.mode === 'remote' && proxyConfig.host - ? { - host: proxyConfig.host, - port: proxyConfig.port, - protocol: proxyConfig.protocol, - authToken: proxyConfig.authToken, - } - : undefined; - - // For HTTPS remote, we need a local HTTP tunnel since Claude Code doesn't support - // HTTPS in ANTHROPIC_BASE_URL directly (undici limitation) - let httpsTunnel: HttpsTunnelProxy | null = null; - let tunnelPort: number | null = null; - - if (useRemoteProxy && proxyConfig.protocol === 'https' && proxyConfig.host) { - try { - httpsTunnel = new HttpsTunnelProxy({ - remoteHost: proxyConfig.host, - remotePort: proxyConfig.port, - authToken: proxyConfig.authToken, - verbose, - allowSelfSigned: proxyConfig.allowSelfSigned ?? false, - }); - tunnelPort = await httpsTunnel.start(); - log( - `HTTPS tunnel started on port ${tunnelPort} → https://${proxyConfig.host}:${proxyConfig.port}` - ); - } catch (error) { - const err = error as Error; - console.error(warn(`Failed to start HTTPS tunnel: ${err.message}`)); - throw new Error(`HTTPS tunnel startup failed: ${err.message}`); - } - } - - // Build env vars - use tunnel port for HTTPS remote, direct URL otherwise - const envVars = useRemoteProxy - ? httpsTunnel && tunnelPort - ? // HTTPS remote via local tunnel - use HTTP to tunnel - getRemoteEnvVars( - provider, - { - host: '127.0.0.1', - port: tunnelPort, - protocol: 'http', // Tunnel speaks HTTP locally - authToken: proxyConfig.authToken, - }, - cfg.customSettingsPath - ) - : // HTTP remote - direct connection - getRemoteEnvVars( - provider, - { - host: proxyConfig.host ?? 'localhost', - port: proxyConfig.port, - protocol: proxyConfig.protocol, - authToken: proxyConfig.authToken, - }, - cfg.customSettingsPath - ) - : getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath, remoteRewriteConfig); - - // Apply thinking configuration to model (auto tier-based or manual override) - // This adds thinking suffix like model(high) or model(8192) for CLIProxyAPIPlus - applyThinkingConfig(envVars, provider, thinkingOverride); - - // Tool sanitization proxy - applies to ALL CLIProxy providers. - // Sanitizes MCP tool names exceeding Gemini's 64-char limit. - // Proxy chain order (request flow): - // Claude CLI → ToolSanitizationProxy → [CodexReasoningProxy if codex] → CLIProxy → Backend - // Response flow is reversed, with each proxy transforming data back. - let toolSanitizationProxy: ToolSanitizationProxy | null = null; - let toolSanitizationPort: number | null = null; - - if (envVars.ANTHROPIC_BASE_URL) { - try { - toolSanitizationProxy = new ToolSanitizationProxy({ - upstreamBaseUrl: envVars.ANTHROPIC_BASE_URL, - verbose, - warnOnSanitize: true, - }); - toolSanitizationPort = await toolSanitizationProxy.start(); - log(`Tool sanitization proxy active on port ${toolSanitizationPort}`); - } catch (error) { - // Non-fatal: continue without sanitization - const err = error as Error; - toolSanitizationProxy = null; - toolSanitizationPort = null; - if (verbose) { - console.error(warn(`Tool sanitization proxy disabled: ${err.message}`)); - } - } - } - - // Determine the effective upstream URL after tool sanitization - const postSanitizationBaseUrl = toolSanitizationPort - ? `http://127.0.0.1:${toolSanitizationPort}` - : envVars.ANTHROPIC_BASE_URL; - - // Codex-only: inject OpenAI reasoning effort based on tier model mapping. - // Maps by request.model: - // - OPUS/default model → xhigh - // - SONNET model → high - // - HAIKU model → medium - // - Unknown → medium - let codexReasoningProxy: CodexReasoningProxy | null = null; - let codexReasoningPort: number | null = null; - if (provider === 'codex') { - if (!postSanitizationBaseUrl) { - log('ANTHROPIC_BASE_URL not set for Codex, reasoning proxy disabled'); - } else { - try { - const traceEnabled = - process.env.CCS_CODEX_REASONING_TRACE === '1' || - process.env.CCS_CODEX_REASONING_TRACE === 'true'; - // For remote proxy mode, strip /api/provider/codex prefix from paths - // because remote CLIProxyAPI uses root paths (/v1/messages), not provider-prefixed - const stripPathPrefix = useRemoteProxy ? '/api/provider/codex' : undefined; - codexReasoningProxy = new CodexReasoningProxy({ - upstreamBaseUrl: postSanitizationBaseUrl, - verbose, - defaultEffort: 'medium', - traceFilePath: traceEnabled ? `${os.homedir()}/.ccs/codex-reasoning-proxy.log` : '', - modelMap: { - defaultModel: envVars.ANTHROPIC_MODEL, - opusModel: envVars.ANTHROPIC_DEFAULT_OPUS_MODEL, - sonnetModel: envVars.ANTHROPIC_DEFAULT_SONNET_MODEL, - haikuModel: envVars.ANTHROPIC_DEFAULT_HAIKU_MODEL, - }, - stripPathPrefix, - }); - codexReasoningPort = await codexReasoningProxy.start(); - log( - `Codex reasoning proxy active: http://127.0.0.1:${codexReasoningPort}/api/provider/codex` - ); - } catch (error) { - const err = error as Error; - codexReasoningProxy = null; - codexReasoningPort = null; - if (verbose) { - console.error(warn(`Codex reasoning proxy disabled: ${err.message}`)); - } - } - } - } - - // Determine the final ANTHROPIC_BASE_URL based on active proxies - // Chain order: Claude CLI → [CodexReasoningProxy] → [ToolSanitizationProxy] → CLIProxy - // The outermost active proxy becomes the effective base URL - let finalBaseUrl = envVars.ANTHROPIC_BASE_URL; - if (toolSanitizationPort) { - finalBaseUrl = `http://127.0.0.1:${toolSanitizationPort}`; - } - if (codexReasoningPort) { - // Codex reasoning proxy is the outermost layer for codex provider - finalBaseUrl = `http://127.0.0.1:${codexReasoningPort}/api/provider/codex`; - } - - const effectiveEnvVars = { - ...envVars, - ANTHROPIC_BASE_URL: finalBaseUrl, - }; - const webSearchEnv = getWebSearchHookEnv(); - const imageAnalysisEnv = getImageAnalysisHookEnv(provider); - const env = { - ...process.env, - ...effectiveEnvVars, - ...webSearchEnv, - ...imageAnalysisEnv, - CCS_PROFILE_TYPE: 'cliproxy', // Signal to WebSearch hook this is a third-party provider - }; - - log(`Claude env: ANTHROPIC_BASE_URL=${effectiveEnvVars.ANTHROPIC_BASE_URL}`); - log(`Claude env: ANTHROPIC_MODEL=${envVars.ANTHROPIC_MODEL}`); - if (Object.keys(webSearchEnv).length > 0) { - log(`Claude env: WebSearch config=${JSON.stringify(webSearchEnv)}`); - } - // Log global env vars for visibility - if (envVars.DISABLE_TELEMETRY || envVars.DISABLE_ERROR_REPORTING || envVars.DISABLE_BUG_COMMAND) { - log(`Claude env: Global env applied (telemetry/reporting disabled)`); - } - - // Filter out CCS-specific flags before passing to Claude CLI - // Note: Proxy flags (--proxy-host, etc.) already stripped by resolveProxyConfig() - const ccsFlags = [ - '--auth', - '--paste-callback', - '--port-forward', - '--headless', - '--logout', - '--config', - '--add', - '--accounts', - '--use', - '--nickname', - '--thinking', - '--incognito', - '--no-incognito', - '--import', - '--settings', // Block user --settings to prevent duplicate flags - // Proxy flags are handled by resolveProxyConfig, but list for documentation - ...PROXY_CLI_FLAGS, - ]; - const claudeArgs = argsWithoutProxy.filter((arg, idx) => { - // Filter out CCS flags - if (ccsFlags.includes(arg)) return false; - // Filter out --thinking=value format - if (arg.startsWith('--thinking=')) return false; - // Filter out value after --use, --nickname, or --thinking - if ( - argsWithoutProxy[idx - 1] === '--use' || - argsWithoutProxy[idx - 1] === '--nickname' || - argsWithoutProxy[idx - 1] === '--thinking' - ) - return false; - return true; - }); - - const isWindows = process.platform === 'win32'; - const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli); - - // Get profile settings path for hooks (WebSearch, etc.) - // Use custom settings path for CLIProxy variants, otherwise use default provider path - const settingsPath = cfg.customSettingsPath - ? cfg.customSettingsPath.replace(/^~/, os.homedir()) - : getProviderSettingsPath(provider); - - let claude: ChildProcess; - if (needsShell) { - const cmdString = [claudeCli, '--settings', settingsPath, ...claudeArgs] - .map(escapeShellArg) - .join(' '); - claude = spawn(cmdString, { - stdio: 'inherit', - windowsHide: true, - shell: true, - env, - }); - } else { - claude = spawn(claudeCli, ['--settings', settingsPath, ...claudeArgs], { - stdio: 'inherit', - windowsHide: true, - env, - }); - } - - // 8. Cleanup: unregister session when Claude exits (local mode only) - // Proxy persists by default - use 'ccs cliproxy stop' to kill manually - // Capture port for cleanup (avoids closure issues) - const sessionPort = cfg.port; - claude.on('exit', (code, signal) => { - log(`Claude exited: code=${code}, signal=${signal}`); - - if (codexReasoningProxy) { - codexReasoningProxy.stop(); - } - - if (toolSanitizationProxy) { - toolSanitizationProxy.stop(); - } - - if (httpsTunnel) { - httpsTunnel.stop(); - } - - // Unregister this session (proxy keeps running for persistence) - only for local mode - if (sessionId) { - unregisterSession(sessionId, sessionPort); - log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`); - } - - if (signal) { - process.kill(process.pid, signal as NodeJS.Signals); - } else { - process.exit(code || 0); - } - }); - - claude.on('error', (error) => { - console.error(fail(`Claude CLI error: ${error}`)); - - if (codexReasoningProxy) { - codexReasoningProxy.stop(); - } - - if (toolSanitizationProxy) { - toolSanitizationProxy.stop(); - } - - if (httpsTunnel) { - httpsTunnel.stop(); - } - - // Unregister session, proxy keeps running (local mode only) - if (sessionId) { - unregisterSession(sessionId, sessionPort); - } - process.exit(1); - }); - - // Handle parent process termination (SIGTERM, SIGINT) - const cleanup = () => { - log('Parent signal received, cleaning up'); - - if (codexReasoningProxy) { - codexReasoningProxy.stop(); - } - - if (toolSanitizationProxy) { - toolSanitizationProxy.stop(); - } - - if (httpsTunnel) { - httpsTunnel.stop(); - } - - // Unregister session, proxy keeps running (local mode only) - if (sessionId) { - unregisterSession(sessionId, sessionPort); - } - claude.kill('SIGTERM'); - }; - - process.once('SIGTERM', cleanup); - process.once('SIGINT', cleanup); -} - -/** - * Check if a port is available - */ -export async function isPortAvailable(port: number): Promise { - return new Promise((resolve) => { - const server = net.createServer(); - - server.once('error', () => { - resolve(false); - }); - - server.once('listening', () => { - server.close(); - resolve(true); - }); - - server.listen(port, '127.0.0.1'); - }); -} - -/** - * Find an available port in range - */ -export async function findAvailablePort( - startPort: number = CLIPROXY_DEFAULT_PORT, - range: number = 10 -): Promise { - for (let port = startPort; port < startPort + range; port++) { - if (await isPortAvailable(port)) { - return port; - } - } - throw new Error(`No available port found in range ${startPort}-${startPort + range - 1}`); -} - -export default execClaudeWithCLIProxy; +export { default } from './executor'; diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index a5772bf1..9e51ed91 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -1,981 +1,16 @@ /** * Config Generator for CLIProxyAPI * - * Generates config.yaml for CLIProxyAPI based on provider. - * Handles OAuth token paths and provider-specific settings. + * @deprecated This file is a re-export shim for backwards compatibility. + * New code should import from './config/' module instead. * - * Model mappings are loaded from config/base-{provider}.settings.json files - * to allow easy updates without code changes. + * Refactored into modular structure: + * - config/generator.ts - Core config generation + * - config/port-manager.ts - Port validation + * - config/env-builder.ts - Environment variables + * - config/thinking-config.ts - Thinking suffix logic + * - config/path-resolver.ts - Path utilities */ -import * as fs from 'fs'; -import * as path from 'path'; -import { getCcsDir } from '../utils/config-manager'; -import { expandPath } from '../utils/helpers'; -import { warn } from '../utils/ui'; -import { CLIProxyProvider, ProviderConfig, ProviderModelMapping } from './types'; -import { getModelMappingFromConfig, getEnvVarsFromConfig } from './base-config-loader'; -import { - loadOrCreateUnifiedConfig, - getGlobalEnvConfig, - getThinkingConfig, -} from '../config/unified-config-loader'; -import { getEffectiveApiKey, getEffectiveManagementSecret } from './auth-token-manager'; -import { supportsThinking } from './model-catalog'; -import { ThinkingConfig } from '../config/unified-config-types'; -import { validateThinking } from './thinking-validator'; - -/** - * Check if warnings should be shown based on thinking config. - * Defaults to true if show_warnings is not explicitly false. - */ -function shouldShowWarnings(thinkingConfig: ThinkingConfig): boolean { - return thinkingConfig.show_warnings !== false; -} - -/** Settings file structure for user overrides */ -interface ProviderSettings { - env: NodeJS.ProcessEnv; -} - -/** - * Detect current tier from model name. - * Looks at ANTHROPIC_MODEL to determine if using opus/sonnet/haiku tier. - */ -export type ModelTier = 'opus' | 'sonnet' | 'haiku'; - -/** - * Detect tier from model name. - * Returns 'sonnet' as default if unclear. - */ -export function detectTierFromModel(modelName: string): ModelTier { - const lower = modelName.toLowerCase(); - if (lower.includes('opus')) return 'opus'; - if (lower.includes('haiku')) return 'haiku'; - return 'sonnet'; // Default to sonnet (most common) -} - -/** - * Apply thinking suffix to model name. - * CLIProxyAPIPlus parses suffixes like model(level) or model(budget). - * - * @param model - Base model name - * @param thinkingValue - Level name (e.g., 'high') or numeric budget - * @returns Model name with thinking suffix, e.g., "gemini-3-pro-preview(high)" - */ -export function applyThinkingSuffix(model: string, thinkingValue: string | number): string { - // Don't apply if model already ends with a parenthesized suffix (e.g., "model(high)" or "model(8192)") - // Matches: ends with "(...)" where content is non-empty - if (/\([^)]+\)$/.test(model)) { - return model; - } - return `${model}(${thinkingValue})`; -} - -/** - * Get thinking value for tier based on config. - * Respects provider-specific overrides if configured. - */ -export function getThinkingValueForTier( - tier: ModelTier, - provider: CLIProxyProvider, - thinkingConfig: ThinkingConfig -): string { - // Check provider-specific override first - const providerOverride = thinkingConfig.provider_overrides?.[provider]?.[tier]; - if (providerOverride) { - return providerOverride; - } - // Fall back to global tier default (with null guard) - return thinkingConfig.tier_defaults?.[tier] ?? 'medium'; -} - -/** - * Apply thinking configuration to env vars. - * Modifies ANTHROPIC_MODEL and tier models with thinking suffixes. - * - * @param envVars - Environment variables to modify - * @param provider - CLIProxy provider - * @param thinkingOverride - Optional CLI override (takes priority over config) - * @returns Modified env vars with thinking suffixes applied - */ -export function applyThinkingConfig( - envVars: NodeJS.ProcessEnv, - provider: CLIProxyProvider, - thinkingOverride?: string | number -): NodeJS.ProcessEnv { - const thinkingConfig = getThinkingConfig(); - const result = { ...envVars }; - - // Check if thinking is off - if (thinkingConfig.mode === 'off' && thinkingOverride === undefined) { - return result; - } - - // Get base model to check thinking support - const baseModel = result.ANTHROPIC_MODEL || ''; - if (!supportsThinking(provider, baseModel)) { - // U2: Warn user if they explicitly provided --thinking but model doesn't support it - if (thinkingOverride !== undefined && shouldShowWarnings(thinkingConfig)) { - console.warn( - warn( - `Model ${baseModel || 'unknown'} (provider: ${provider}) does not support thinking budget. --thinking flag ignored.` - ) - ); - } - return result; - } - - // Determine thinking value to use - let thinkingValue: string | number; - - if (thinkingOverride !== undefined) { - // CLI override takes priority - thinkingValue = thinkingOverride; - } else if (thinkingConfig.mode === 'manual' && thinkingConfig.override !== undefined) { - // Config manual mode with override - thinkingValue = thinkingConfig.override; - } else if (thinkingConfig.mode === 'auto') { - // Auto mode: detect tier and apply default - const tier = detectTierFromModel(baseModel); - thinkingValue = getThinkingValueForTier(tier, provider, thinkingConfig); - } else { - return result; // No thinking to apply - } - - // Validate thinking value against model capabilities - const validation = validateThinking(provider, baseModel, thinkingValue); - if (validation.warning && shouldShowWarnings(thinkingConfig)) { - console.warn(warn(validation.warning)); - } - thinkingValue = validation.value; - - // If validation says off, don't apply suffix - if (thinkingValue === 'off') { - return result; - } - - // Apply thinking suffix to main model - if (result.ANTHROPIC_MODEL) { - result.ANTHROPIC_MODEL = applyThinkingSuffix(result.ANTHROPIC_MODEL, thinkingValue); - } - - // Apply to tier models if they support thinking - const tierModels = [ - 'ANTHROPIC_DEFAULT_OPUS_MODEL', - 'ANTHROPIC_DEFAULT_SONNET_MODEL', - 'ANTHROPIC_DEFAULT_HAIKU_MODEL', - ] as const; - - for (const tierVar of tierModels) { - const model = result[tierVar]; - if (model && supportsThinking(provider, model)) { - // Get tier-specific thinking value - const tier = tierVar.includes('OPUS') - ? 'opus' - : tierVar.includes('SONNET') - ? 'sonnet' - : 'haiku'; - const tierThinkingValue = - thinkingOverride ?? getThinkingValueForTier(tier, provider, thinkingConfig); - result[tierVar] = applyThinkingSuffix(model, tierThinkingValue); - } - } - - return result; -} - -/** - * Validate port is a valid positive integer (1-65535). - * Returns default port if invalid. - * - * @param port - Port number to validate - * @returns Valid port or CLIPROXY_DEFAULT_PORT (8317) if invalid - */ -export function validatePort(port: number | undefined): number { - if (port === undefined || port === null) { - return CLIPROXY_DEFAULT_PORT; - } - if (!Number.isFinite(port) || port < 1 || port > 65535 || !Number.isInteger(port)) { - return CLIPROXY_DEFAULT_PORT; - } - return port; -} - -/** - * Ensure required CLIProxy env vars are present. - * Falls back to bundled defaults if missing from user settings. - * This prevents 404 errors when users forget to set BASE_URL/AUTH_TOKEN. - */ -function ensureRequiredEnvVars( - envVars: NodeJS.ProcessEnv, - provider: CLIProxyProvider, - port: number -): NodeJS.ProcessEnv { - const validPort = validatePort(port); - const result = { ...envVars }; - const defaults = getClaudeEnvVars(provider, validPort); - - // Fill in missing required vars from defaults - if (!result.ANTHROPIC_BASE_URL?.trim()) { - result.ANTHROPIC_BASE_URL = defaults.ANTHROPIC_BASE_URL; - } - if (!result.ANTHROPIC_AUTH_TOKEN?.trim()) { - result.ANTHROPIC_AUTH_TOKEN = defaults.ANTHROPIC_AUTH_TOKEN; - } - - return result; -} - -/** Default CLIProxy port */ -export const CLIPROXY_DEFAULT_PORT = 8317; - -/** - * Normalize protocol to lowercase and validate. - * Handles runtime case sensitivity (e.g., 'HTTPS' → 'https'). - * Defaults to 'http' for invalid values. - */ -export function normalizeProtocol(protocol: string | undefined): 'http' | 'https' { - if (!protocol) return 'http'; - const normalized = protocol.toLowerCase(); - if (normalized === 'https') return 'https'; - if (normalized === 'http') return 'http'; - // Invalid protocol (e.g., 'ftp') - default to http - return 'http'; -} - -/** - * Validate and sanitize port number for remote connections. - * Returns undefined for invalid ports (letting caller use default). - * Valid range: 1-65535, must be integer. - */ -export function validateRemotePort(port: number | undefined): number | undefined { - if (port === undefined || port === null) return undefined; - if (typeof port !== 'number' || !Number.isInteger(port)) return undefined; - if (port <= 0 || port > 65535) return undefined; - return port; -} - -/** - * Get default port for remote CLIProxyAPI based on protocol. - * - HTTP: 8317 (CLIProxyAPI default) - * - HTTPS: 443 (standard SSL port) - */ -export function getRemoteDefaultPort(protocol: 'http' | 'https'): number { - return protocol === 'https' ? 443 : CLIPROXY_DEFAULT_PORT; -} - -/** Internal API key for CCS-managed requests */ -export const CCS_INTERNAL_API_KEY = 'ccs-internal-managed'; - -/** Simple secret key for Control Panel login (user-facing) */ -export const CCS_CONTROL_PANEL_SECRET = 'ccs'; - -/** - * Get CLIProxy writable directory for logs and runtime files. - * This directory is set as WRITABLE_PATH env var when spawning CLIProxy. - * Logs will be stored in ~/.ccs/cliproxy/logs/ - */ -export function getCliproxyWritablePath(): string { - return path.join(getCcsDir(), 'cliproxy'); -} - -/** - * Config version - bump when config format changes to trigger regeneration - * v1: Initial config (port, auth-dir, api-keys only) - * v2: Full-featured config with dashboard, quota mgmt, simplified key - * v3: Logging disabled by default (user opt-in via ~/.ccs/config.yaml) - * v4: Added Kiro (AWS) and GitHub Copilot providers - * v5: Added disable-cooling: true for stability - */ -export const CLIPROXY_CONFIG_VERSION = 5; - -/** Provider display names (static metadata) */ -const PROVIDER_DISPLAY_NAMES: Record = { - gemini: 'Gemini', - codex: 'Codex', - agy: 'Antigravity', - qwen: 'Qwen Code', - iflow: 'iFlow', - kiro: 'Kiro (AWS)', - ghcp: 'GitHub Copilot (OAuth)', - claude: 'Claude (Anthropic)', -}; - -/** - * Get provider configuration - * Model mappings are loaded from config/base-{provider}.settings.json - */ -export function getProviderConfig(provider: CLIProxyProvider): ProviderConfig { - const displayName = PROVIDER_DISPLAY_NAMES[provider]; - if (!displayName) { - throw new Error(`Unknown provider: ${provider}`); - } - - // Load models from base config file - const models = getModelMappingFromConfig(provider); - - return { - name: provider, - displayName, - models, - requiresOAuth: true, // All CLIProxy providers require OAuth - }; -} - -/** - * Get model mapping for provider - * Loads from config/base-{provider}.settings.json - */ -export function getModelMapping(provider: CLIProxyProvider): ProviderModelMapping { - return getProviderConfig(provider).models; -} - -/** - * Get CLIProxy base directory - * All CLIProxy-related files are stored under ~/.ccs/cliproxy/ - */ -export function getCliproxyDir(): string { - return path.join(getCcsDir(), 'cliproxy'); -} - -/** - * Get auth directory for provider - * All providers use a FLAT auth directory structure for unified config. - * CLIProxyAPI stores OAuth tokens directly in auth/ (not subdirectories). - * This enables all providers to be discovered and used concurrently. - */ -export function getProviderAuthDir(_provider: CLIProxyProvider): string { - // Use flat structure - all auth files in same directory for unified discovery - // Provider param kept for API compatibility (CLIProxyAPI handles via auth file type field) - return path.join(getCliproxyDir(), 'auth'); -} - -/** - * Get base auth directory for CLIProxyAPI - */ -export function getAuthDir(): string { - return path.join(getCliproxyDir(), 'auth'); -} - -/** - * Get config file path for a specific port. - * Default port uses config.yaml, others use config-{port}.yaml. - */ -export function getConfigPathForPort(port: number): string { - if (port === CLIPROXY_DEFAULT_PORT) { - return path.join(getCliproxyDir(), 'config.yaml'); - } - return path.join(getCliproxyDir(), `config-${port}.yaml`); -} - -/** - * Get CLIProxy config file path (default port) - * Named distinctly from config-manager's getConfigPath to avoid confusion. - */ -export function getCliproxyConfigPath(): string { - return getConfigPathForPort(CLIPROXY_DEFAULT_PORT); -} - -/** - * Get binary directory path - */ -export function getBinDir(): string { - return path.join(getCliproxyDir(), 'bin'); -} - -/** - * Get CLIProxy logging settings from user config. - * Defaults to disabled to prevent disk bloat. - */ -function getLoggingSettings(): { loggingToFile: boolean; requestLog: boolean } { - const config = loadOrCreateUnifiedConfig(); - return { - loggingToFile: config.cliproxy.logging?.enabled ?? false, - requestLog: config.cliproxy.logging?.request_log ?? false, - }; -} - -/** - * Generate UNIFIED config.yaml content for ALL providers - * This enables concurrent usage of gemini/codex/agy without config conflicts. - * CLIProxyAPI routes requests by model name to the appropriate provider. - * - * @param port - Server port (default: 8317) - * @param userApiKeys - User-added API keys to preserve (default: []) - */ -function generateUnifiedConfigContent( - port: number = CLIPROXY_DEFAULT_PORT, - userApiKeys: string[] = [] -): string { - const authDir = getAuthDir(); // Base auth dir - CLIProxyAPI scans subdirectories - // Convert Windows backslashes to forward slashes for YAML compatibility - const authDirNormalized = authDir.split(path.sep).join('/'); - - // Get logging settings from user config (disabled by default) - const { loggingToFile, requestLog } = getLoggingSettings(); - - // Get effective auth tokens (respects user customization) - const effectiveApiKey = getEffectiveApiKey(); - const effectiveSecret = getEffectiveManagementSecret(); - - // Build api-keys section with internal key + preserved user keys - const allApiKeys = [effectiveApiKey, ...userApiKeys]; - const apiKeysYaml = allApiKeys.map((key) => ` - "${key}"`).join('\n'); - - // Unified config with enhanced CLIProxyAPI features - const config = `# CLIProxyAPI config generated by CCS v${CLIPROXY_CONFIG_VERSION} -# Supports: gemini, codex, agy, qwen, iflow (concurrent usage) -# Generated: ${new Date().toISOString()} -# -# This config is auto-managed by CCS. Manual edits may be overwritten. -# Use 'ccs doctor' to regenerate with latest settings. - -# ============================================================================= -# Server Settings -# ============================================================================= - -port: ${port} -debug: false - -# ============================================================================= -# Logging -# ============================================================================= -# WARNING: Logs can grow to several GB if enabled! -# To enable logging, edit ~/.ccs/config.yaml: -# cliproxy: -# logging: -# enabled: true -# request_log: true -# Then run 'ccs doctor --fix' to regenerate this config. -# Use 'ccs cleanup' to remove old logs. - -# Write logs to file (stored in ~/.ccs/cliproxy/logs/) -logging-to-file: ${loggingToFile} - -# Log individual API requests for debugging/analytics -request-log: ${requestLog} - -# ============================================================================= -# Dashboard & Management -# ============================================================================= - -# Enable usage statistics for CCS dashboard analytics -usage-statistics-enabled: true - -# Remote management API for CCS dashboard integration -remote-management: - allow-remote: true - secret-key: "${effectiveSecret}" - disable-control-panel: false - -# ============================================================================= -# Reliability & Quota Management -# ============================================================================= - -# Disable quota cooldown scheduling for stability -disable-cooling: true - -# Auto-retry on transient errors (403, 408, 500, 502, 503, 504) -request-retry: 0 -max-retry-interval: 0 - -# Auto-switch accounts on quota exceeded (429) -# This enables seamless multi-account rotation when rate limited -quota-exceeded: - switch-project: true - switch-preview-model: true - -# ============================================================================= -# Authentication -# ============================================================================= - -# API keys for CCS and user-added external requests -# NOTE: User-added keys are preserved across CCS updates (fix for issue #200) -api-keys: -${apiKeysYaml} - -# OAuth tokens directory (auto-discovered by CLIProxyAPI) -auth-dir: "${authDirNormalized}" -`; - - return config; -} - -/** - * Generate unified config.yaml file (supports all providers concurrently) - * Only regenerates if config doesn't exist. - * @returns Path to config file - */ -export function generateConfig( - provider: CLIProxyProvider, - port: number = CLIPROXY_DEFAULT_PORT -): string { - const configPath = getConfigPathForPort(port); - - // Ensure provider auth directory exists - const authDir = getProviderAuthDir(provider); - fs.mkdirSync(path.dirname(configPath), { recursive: true }); - fs.mkdirSync(authDir, { recursive: true, mode: 0o700 }); - - // Only generate config if it doesn't exist (unified config serves all providers) - if (!fs.existsSync(configPath)) { - const configContent = generateUnifiedConfigContent(port); - fs.writeFileSync(configPath, configContent, { mode: 0o600 }); - } - - return configPath; -} - -/** - * Parse user-added API keys from existing config content. - * Extracts all keys except the internal CCS key for preservation. - * - * @param content - Existing config.yaml content - * @returns Array of user-added API keys (excludes CCS_INTERNAL_API_KEY) - */ -export function parseUserApiKeys(content: string): string[] { - const userKeys: string[] = []; - - // Find the api-keys section by looking for lines starting with " - " after "api-keys:" - // Normalize line endings first - const normalizedContent = content.replace(/\r\n/g, '\n'); - - // Find the api-keys: line and extract all subsequent key entries - const lines = normalizedContent.split('\n'); - let inApiKeysSection = false; - - for (const line of lines) { - // Check if this is the start of api-keys section - if (line.match(/^api-keys:\s*$/)) { - inApiKeysSection = true; - continue; - } - - // If we're in the api-keys section, look for key entries - if (inApiKeysSection) { - // Key entries are indented with " - " or similar - const keyMatch = line.match(/^\s+-\s*"([^"]*)"/); - if (keyMatch) { - const key = keyMatch[1]; - // Exclude the internal CCS key and empty strings - if (key && key !== CCS_INTERNAL_API_KEY) { - userKeys.push(key); - } - } else if (line.match(/^\S/) && line.trim().length > 0) { - // Non-indented line that's not empty means we've left the api-keys section - break; - } - // Continue for blank lines or other indented content - } - } - - return userKeys; -} - -/** - * Force regenerate config.yaml with latest settings. - * Preserves user-added API keys and port settings. - * - * @param port - Default port to use if not found in existing config - * @returns Path to new config file - */ -export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string { - const configPath = getConfigPathForPort(port); - - // Preserve user settings from existing config - let effectivePort = port; - let userApiKeys: string[] = []; - - if (fs.existsSync(configPath)) { - try { - const content = fs.readFileSync(configPath, 'utf-8'); - - // Preserve port setting - const portMatch = content.match(/^port:\s*(\d+)/m); - if (portMatch) { - effectivePort = parseInt(portMatch[1], 10); - } - - // Preserve user-added API keys (fix for issue #200) - userApiKeys = parseUserApiKeys(content); - } catch { - // Use defaults if reading fails - } - // Delete existing config - fs.unlinkSync(configPath); - } - - // Ensure directories exist - fs.mkdirSync(path.dirname(configPath), { recursive: true }); - fs.mkdirSync(getAuthDir(), { recursive: true, mode: 0o700 }); - - // Generate fresh config with preserved user API keys - const configContent = generateUnifiedConfigContent(effectivePort, userApiKeys); - fs.writeFileSync(configPath, configContent, { mode: 0o600 }); - - return configPath; -} - -/** - * Check if config needs regeneration (version mismatch) - * @returns true if config should be regenerated - */ -export function configNeedsRegeneration(): boolean { - const configPath = getCliproxyConfigPath(); - if (!fs.existsSync(configPath)) { - return false; // Will be created on first use - } - - try { - const content = fs.readFileSync(configPath, 'utf-8'); - - // Check for version marker - const versionMatch = content.match(/CCS v(\d+)/); - if (!versionMatch) { - return true; // No version marker = old config - } - - const configVersion = parseInt(versionMatch[1], 10); - return configVersion < CLIPROXY_CONFIG_VERSION; - } catch { - return true; // Error reading = regenerate - } -} - -/** - * Check if config exists for port - */ -export function configExists(port: number = CLIPROXY_DEFAULT_PORT): boolean { - return fs.existsSync(getConfigPathForPort(port)); -} - -/** - * Delete config file for specific port - */ -export function deleteConfigForPort(port: number): void { - const configPath = getConfigPathForPort(port); - if (fs.existsSync(configPath)) { - fs.unlinkSync(configPath); - } -} - -/** - * Delete config file (default port) - */ -export function deleteConfig(): void { - deleteConfigForPort(CLIPROXY_DEFAULT_PORT); -} - -/** - * Get path to user settings file for provider - * Example: ~/.ccs/gemini.settings.json - */ -export function getProviderSettingsPath(provider: CLIProxyProvider): string { - return path.join(getCcsDir(), `${provider}.settings.json`); -} - -/** - * Get environment variables for Claude CLI (bundled defaults) - * Uses provider-specific endpoint (e.g., /api/provider/gemini) for explicit routing. - * This enables concurrent gemini/codex usage - each session routes to its provider via URL path. - */ -export function getClaudeEnvVars( - provider: CLIProxyProvider, - port: number = CLIPROXY_DEFAULT_PORT -): NodeJS.ProcessEnv { - const models = getModelMapping(provider); - - // Base env vars from config file (includes ANTHROPIC_MAX_TOKENS, etc.) - const baseEnvVars = getEnvVarsFromConfig(provider); - - // Core env vars that we always set dynamically - const coreEnvVars = { - // Provider-specific endpoint - routes to correct provider via URL path - ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}/api/provider/${provider}`, - ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(), - ANTHROPIC_MODEL: models.claudeModel, - ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel, - ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel, - ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel, - }; - - // Filter out core env vars from base config to avoid conflicts - const { - ANTHROPIC_BASE_URL: _baseUrl, - ANTHROPIC_AUTH_TOKEN: _authToken, - ANTHROPIC_MODEL: _model, - ANTHROPIC_DEFAULT_OPUS_MODEL: _opusModel, - ANTHROPIC_DEFAULT_SONNET_MODEL: _sonnetModel, - ANTHROPIC_DEFAULT_HAIKU_MODEL: _haikuModel, - ...additionalEnvVars - } = baseEnvVars; - - // Merge core env vars with additional env vars from base config - return { - ...coreEnvVars, - ...additionalEnvVars, // Includes ANTHROPIC_MAX_TOKENS, etc. - }; -} - -/** - * Get global env vars to inject into all third-party profiles. - * Returns empty object if disabled. - */ -function getGlobalEnvVars(): Record { - const globalEnvConfig = getGlobalEnvConfig(); - if (!globalEnvConfig.enabled) { - return {}; - } - return globalEnvConfig.env; -} - -/** Remote proxy configuration for URL rewriting */ -interface RemoteProxyRewriteConfig { - host: string; - port?: number; - protocol: 'http' | 'https'; - authToken?: string; -} - -/** - * Rewrite localhost URLs to remote server URLs. - * Handles various localhost patterns: 127.0.0.1, localhost, 0.0.0.0 - */ -function rewriteLocalhostUrls( - envVars: NodeJS.ProcessEnv, - provider: CLIProxyProvider, - remoteConfig: RemoteProxyRewriteConfig -): NodeJS.ProcessEnv { - const result = { ...envVars }; - const baseUrl = result.ANTHROPIC_BASE_URL; - - if (!baseUrl) return result; - - // Check if URL points to localhost (127.0.0.1, localhost, 0.0.0.0) - const localhostPattern = /^https?:\/\/(127\.0\.0\.1|localhost|0\.0\.0\.0)(:\d+)?/i; - if (!localhostPattern.test(baseUrl)) return result; - - // Build remote URL with smart port handling (8317 for HTTP, 443 for HTTPS) - // Validate port and normalize protocol for defensive handling - const normalizedProtocol = normalizeProtocol(remoteConfig.protocol); - const validatedPort = validateRemotePort(remoteConfig.port); - const effectivePort = validatedPort ?? getRemoteDefaultPort(normalizedProtocol); - // Omit port suffix for standard web ports (80/443) for cleaner URLs - const standardWebPort = normalizedProtocol === 'https' ? 443 : 80; - const portSuffix = effectivePort === standardWebPort ? '' : `:${effectivePort}`; - const remoteBaseUrl = `${normalizedProtocol}://${remoteConfig.host}${portSuffix}/api/provider/${provider}`; - - result.ANTHROPIC_BASE_URL = remoteBaseUrl; - - // Update auth token if provided - if (remoteConfig.authToken) { - result.ANTHROPIC_AUTH_TOKEN = remoteConfig.authToken; - } - - return result; -} - -/** - * Get effective environment variables for provider - * - * Priority order: - * 1. Custom settings path (for user-defined CLIProxy variants) - * 2. User settings file (~/.ccs/{provider}.settings.json) if exists - * 3. Bundled defaults from PROVIDER_CONFIGS - * - * All results are merged with global_env vars (telemetry/reporting disables). - * User takes full responsibility for custom settings. - * - * If remoteRewriteConfig is provided, localhost URLs are rewritten to remote server. - */ -export function getEffectiveEnvVars( - provider: CLIProxyProvider, - port: number = CLIPROXY_DEFAULT_PORT, - customSettingsPath?: string, - remoteRewriteConfig?: RemoteProxyRewriteConfig -): NodeJS.ProcessEnv { - // Get global env vars (DISABLE_TELEMETRY, etc.) - const globalEnv = getGlobalEnvVars(); - - let envVars: NodeJS.ProcessEnv; - - // Priority 1: Custom settings path (for user-defined variants) - if (customSettingsPath) { - const expandedPath = expandPath(customSettingsPath); - if (fs.existsSync(expandedPath)) { - try { - const content = fs.readFileSync(expandedPath, 'utf-8'); - const settings: ProviderSettings = JSON.parse(content); - - if (settings.env && typeof settings.env === 'object') { - // Custom variant settings found - merge with global env - envVars = { ...globalEnv, ...settings.env }; - // Ensure required vars are present (fall back to defaults if missing) - envVars = ensureRequiredEnvVars(envVars, provider, port); - // Apply remote rewrite if configured - if (remoteRewriteConfig) { - envVars = rewriteLocalhostUrls(envVars, provider, remoteRewriteConfig); - } - return envVars; - } - } catch { - // Invalid JSON - fall through to provider defaults - console.warn(warn(`Invalid settings file: ${customSettingsPath}`)); - } - } else { - console.warn(warn(`Settings file not found: ${customSettingsPath}`)); - } - } - - // Priority 2: Default provider settings file - const settingsPath = getProviderSettingsPath(provider); - - // Check for user override file - if (fs.existsSync(settingsPath)) { - try { - const content = fs.readFileSync(settingsPath, 'utf-8'); - const settings: ProviderSettings = JSON.parse(content); - - if (settings.env && typeof settings.env === 'object') { - // User override found - merge with global env - envVars = { ...globalEnv, ...settings.env }; - // Ensure required vars are present (fall back to defaults if missing) - envVars = ensureRequiredEnvVars(envVars, provider, port); - // Apply remote rewrite if configured - if (remoteRewriteConfig) { - envVars = rewriteLocalhostUrls(envVars, provider, remoteRewriteConfig); - } - return envVars; - } - } catch { - // Invalid JSON or structure - fall through to defaults - // Silent fallback: don't spam errors for broken user files - } - } - - // No override or invalid - use bundled defaults merged with global env - return { ...globalEnv, ...getClaudeEnvVars(provider, port) }; -} - -/** - * Copy bundled settings template to user directory if not exists - * Called during installation/first run - */ -export function ensureProviderSettings(provider: CLIProxyProvider): void { - const settingsPath = getProviderSettingsPath(provider); - - // Only create if doesn't exist (preserve user edits) - if (fs.existsSync(settingsPath)) { - return; - } - - // Generate default settings from PROVIDER_CONFIGS - const envVars = getClaudeEnvVars(provider); - const settings: ProviderSettings = { env: envVars }; - - // Ensure directory exists - fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); - - // Write with restricted permissions - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { - mode: 0o600, - }); -} - -/** - * Get environment variables for remote proxy mode. - * Uses the remote proxy's provider endpoint as the base URL. - * Respects user model settings from custom settings path or provider settings file. - * - * @param provider CLIProxy provider (gemini, codex, agy, qwen, iflow) - * @param remoteConfig Remote proxy connection details - * @param customSettingsPath Optional path to user's custom settings file - * @returns Environment variables for Claude CLI - */ -export function getRemoteEnvVars( - provider: CLIProxyProvider, - remoteConfig: { host: string; port?: number; protocol: 'http' | 'https'; authToken?: string }, - customSettingsPath?: string -): Record { - // Build URL with smart port handling (8317 for HTTP, 443 for HTTPS) - // Validate port and normalize protocol for defensive handling - const normalizedProtocol = normalizeProtocol(remoteConfig.protocol); - const validatedPort = validateRemotePort(remoteConfig.port); - const effectivePort = validatedPort ?? getRemoteDefaultPort(normalizedProtocol); - // Omit port suffix for standard web ports (80/443) for cleaner URLs - const standardWebPort = normalizedProtocol === 'https' ? 443 : 80; - const portSuffix = effectivePort === standardWebPort ? '' : `:${effectivePort}`; - // Remote CLIProxyAPI uses root path (e.g., /v1/messages), not /api/provider/{provider}/v1/messages - // The /api/provider/ prefix is only for local CLIProxy instances - const baseUrl = `${normalizedProtocol}://${remoteConfig.host}${portSuffix}`; - - // Get global env vars (DISABLE_TELEMETRY, etc.) - const globalEnv = getGlobalEnvVars(); - - // Load user settings with priority: custom path > user settings file > base config - let userEnvVars: Record = {}; - - // Priority 1: Custom settings path (for user-defined variants) - if (customSettingsPath) { - const expandedPath = expandPath(customSettingsPath); - if (fs.existsSync(expandedPath)) { - try { - const content = fs.readFileSync(expandedPath, 'utf-8'); - const settings: ProviderSettings = JSON.parse(content); - if (settings.env && typeof settings.env === 'object') { - userEnvVars = settings.env as Record; - } - } catch { - // Invalid JSON - fall through to provider defaults - console.warn(warn(`Invalid settings file: ${customSettingsPath}`)); - } - } - } - - // Priority 2: Default provider settings file (~/.ccs/{provider}.settings.json) - if (Object.keys(userEnvVars).length === 0) { - const settingsPath = getProviderSettingsPath(provider); - if (fs.existsSync(settingsPath)) { - try { - const content = fs.readFileSync(settingsPath, 'utf-8'); - const settings: ProviderSettings = JSON.parse(content); - if (settings.env && typeof settings.env === 'object') { - userEnvVars = settings.env as Record; - } - } catch { - // Invalid JSON - fall through to base config - } - } - } - - // Priority 3: Base config defaults - if (Object.keys(userEnvVars).length === 0) { - const models = getModelMapping(provider); - const baseEnvVars = getEnvVarsFromConfig(provider); - // Filter out URL/auth from base config (we'll set those from remote config) - const { - ANTHROPIC_BASE_URL: _baseUrl, - ANTHROPIC_AUTH_TOKEN: _authToken, - ...additionalEnvVars - } = baseEnvVars; - userEnvVars = { - ...additionalEnvVars, - ANTHROPIC_MODEL: models.claudeModel, - ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel, - ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel, - ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel, - }; - } - - // Build final env: global + user settings + remote URL/auth override - const env: Record = { - ...globalEnv, - ...userEnvVars, - // Always override URL and auth token with remote config - ANTHROPIC_BASE_URL: baseUrl, - ANTHROPIC_AUTH_TOKEN: remoteConfig.authToken || getEffectiveApiKey(), - }; - - return env; -} +// Re-export all modules for backwards compatibility +export * from './config'; diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts new file mode 100644 index 00000000..e46c08e5 --- /dev/null +++ b/src/cliproxy/config/env-builder.ts @@ -0,0 +1,363 @@ +/** + * Environment variable builder for CLIProxy + * Handles env var construction, merging, and remote URL rewriting + */ + +import * as fs from 'fs'; +import { CLIProxyProvider, ProviderModelMapping } from '../types'; +import { getModelMappingFromConfig, getEnvVarsFromConfig } from '../base-config-loader'; +import { getGlobalEnvConfig } from '../../config/unified-config-loader'; +import { getEffectiveApiKey } from '../auth-token-manager'; +import { expandPath } from '../../utils/helpers'; +import { warn } from '../../utils/ui'; +import { + validatePort, + validateRemotePort, + getRemoteDefaultPort, + normalizeProtocol, + CLIPROXY_DEFAULT_PORT, +} from './port-manager'; +import { getProviderSettingsPath } from './path-resolver'; + +/** Settings file structure for user overrides */ +interface ProviderSettings { + env: NodeJS.ProcessEnv; +} + +/** Remote proxy configuration for URL rewriting */ +export interface RemoteProxyRewriteConfig { + host: string; + port?: number; + protocol: 'http' | 'https'; + authToken?: string; +} + +/** + * Get model mapping for provider + * Loads from config/base-{provider}.settings.json + */ +export function getModelMapping(provider: CLIProxyProvider): ProviderModelMapping { + return getModelMappingFromConfig(provider); +} + +/** + * Get environment variables for Claude CLI (bundled defaults) + * Uses provider-specific endpoint (e.g., /api/provider/gemini) for explicit routing. + * This enables concurrent gemini/codex usage - each session routes to its provider via URL path. + */ +export function getClaudeEnvVars( + provider: CLIProxyProvider, + port: number = CLIPROXY_DEFAULT_PORT +): NodeJS.ProcessEnv { + const models = getModelMapping(provider); + + // Base env vars from config file (includes ANTHROPIC_MAX_TOKENS, etc.) + const baseEnvVars = getEnvVarsFromConfig(provider); + + // Core env vars that we always set dynamically + const coreEnvVars = { + // Provider-specific endpoint - routes to correct provider via URL path + ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}/api/provider/${provider}`, + ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(), + ANTHROPIC_MODEL: models.claudeModel, + ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel, + ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel, + ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel, + }; + + // Filter out core env vars from base config to avoid conflicts + const { + ANTHROPIC_BASE_URL: _baseUrl, + ANTHROPIC_AUTH_TOKEN: _authToken, + ANTHROPIC_MODEL: _model, + ANTHROPIC_DEFAULT_OPUS_MODEL: _opusModel, + ANTHROPIC_DEFAULT_SONNET_MODEL: _sonnetModel, + ANTHROPIC_DEFAULT_HAIKU_MODEL: _haikuModel, + ...additionalEnvVars + } = baseEnvVars; + + // Merge core env vars with additional env vars from base config + return { + ...coreEnvVars, + ...additionalEnvVars, // Includes ANTHROPIC_MAX_TOKENS, etc. + }; +} + +/** + * Get global env vars to inject into all third-party profiles. + * Returns empty object if disabled. + */ +function getGlobalEnvVars(): Record { + const globalEnvConfig = getGlobalEnvConfig(); + if (!globalEnvConfig.enabled) { + return {}; + } + return globalEnvConfig.env; +} + +/** + * Ensure required CLIProxy env vars are present. + * Falls back to bundled defaults if missing from user settings. + * This prevents 404 errors when users forget to set BASE_URL/AUTH_TOKEN. + */ +function ensureRequiredEnvVars( + envVars: NodeJS.ProcessEnv, + provider: CLIProxyProvider, + port: number +): NodeJS.ProcessEnv { + const validPort = validatePort(port); + const result = { ...envVars }; + const defaults = getClaudeEnvVars(provider, validPort); + + // Fill in missing required vars from defaults + if (!result.ANTHROPIC_BASE_URL?.trim()) { + result.ANTHROPIC_BASE_URL = defaults.ANTHROPIC_BASE_URL; + } + if (!result.ANTHROPIC_AUTH_TOKEN?.trim()) { + result.ANTHROPIC_AUTH_TOKEN = defaults.ANTHROPIC_AUTH_TOKEN; + } + + return result; +} + +/** + * Rewrite localhost URLs to remote server URLs. + * Handles various localhost patterns: 127.0.0.1, localhost, 0.0.0.0 + */ +function rewriteLocalhostUrls( + envVars: NodeJS.ProcessEnv, + provider: CLIProxyProvider, + remoteConfig: RemoteProxyRewriteConfig +): NodeJS.ProcessEnv { + const result = { ...envVars }; + const baseUrl = result.ANTHROPIC_BASE_URL; + + if (!baseUrl) return result; + + // Check if URL points to localhost (127.0.0.1, localhost, 0.0.0.0) + const localhostPattern = /^https?:\/\/(127\.0\.0\.1|localhost|0\.0\.0\.0)(:\d+)?/i; + if (!localhostPattern.test(baseUrl)) return result; + + // Build remote URL with smart port handling (8317 for HTTP, 443 for HTTPS) + // Validate port and normalize protocol for defensive handling + const normalizedProtocol = normalizeProtocol(remoteConfig.protocol); + const validatedPort = validateRemotePort(remoteConfig.port); + const effectivePort = validatedPort ?? getRemoteDefaultPort(normalizedProtocol); + // Omit port suffix for standard web ports (80/443) for cleaner URLs + const standardWebPort = normalizedProtocol === 'https' ? 443 : 80; + const portSuffix = effectivePort === standardWebPort ? '' : `:${effectivePort}`; + const remoteBaseUrl = `${normalizedProtocol}://${remoteConfig.host}${portSuffix}/api/provider/${provider}`; + + result.ANTHROPIC_BASE_URL = remoteBaseUrl; + + // Update auth token if provided + if (remoteConfig.authToken) { + result.ANTHROPIC_AUTH_TOKEN = remoteConfig.authToken; + } + + return result; +} + +/** + * Get effective environment variables for provider + * + * Priority order: + * 1. Custom settings path (for user-defined CLIProxy variants) + * 2. User settings file (~/.ccs/{provider}.settings.json) if exists + * 3. Bundled defaults from PROVIDER_CONFIGS + * + * All results are merged with global_env vars (telemetry/reporting disables). + * User takes full responsibility for custom settings. + * + * If remoteRewriteConfig is provided, localhost URLs are rewritten to remote server. + */ +export function getEffectiveEnvVars( + provider: CLIProxyProvider, + port: number = CLIPROXY_DEFAULT_PORT, + customSettingsPath?: string, + remoteRewriteConfig?: RemoteProxyRewriteConfig +): NodeJS.ProcessEnv { + // Get global env vars (DISABLE_TELEMETRY, etc.) + const globalEnv = getGlobalEnvVars(); + + let envVars: NodeJS.ProcessEnv; + + // Priority 1: Custom settings path (for user-defined variants) + if (customSettingsPath) { + const expandedPath = expandPath(customSettingsPath); + if (fs.existsSync(expandedPath)) { + try { + const content = fs.readFileSync(expandedPath, 'utf-8'); + const settings: ProviderSettings = JSON.parse(content); + + if (settings.env && typeof settings.env === 'object') { + // Custom variant settings found - merge with global env + envVars = { ...globalEnv, ...settings.env }; + // Ensure required vars are present (fall back to defaults if missing) + envVars = ensureRequiredEnvVars(envVars, provider, port); + // Apply remote rewrite if configured + if (remoteRewriteConfig) { + envVars = rewriteLocalhostUrls(envVars, provider, remoteRewriteConfig); + } + return envVars; + } + } catch { + // Invalid JSON - fall through to provider defaults + console.warn(warn(`Invalid settings file: ${customSettingsPath}`)); + } + } else { + console.warn(warn(`Settings file not found: ${customSettingsPath}`)); + } + } + + // Priority 2: Default provider settings file + const settingsPath = getProviderSettingsPath(provider); + + // Check for user override file + if (fs.existsSync(settingsPath)) { + try { + const content = fs.readFileSync(settingsPath, 'utf-8'); + const settings: ProviderSettings = JSON.parse(content); + + if (settings.env && typeof settings.env === 'object') { + // User override found - merge with global env + envVars = { ...globalEnv, ...settings.env }; + // Ensure required vars are present (fall back to defaults if missing) + envVars = ensureRequiredEnvVars(envVars, provider, port); + // Apply remote rewrite if configured + if (remoteRewriteConfig) { + envVars = rewriteLocalhostUrls(envVars, provider, remoteRewriteConfig); + } + return envVars; + } + } catch { + // Invalid JSON or structure - fall through to defaults + // Silent fallback: don't spam errors for broken user files + } + } + + // No override or invalid - use bundled defaults merged with global env + return { ...globalEnv, ...getClaudeEnvVars(provider, port) }; +} + +/** + * Copy bundled settings template to user directory if not exists + * Called during installation/first run + */ +export function ensureProviderSettings(provider: CLIProxyProvider): void { + const settingsPath = getProviderSettingsPath(provider); + + // Only create if doesn't exist (preserve user edits) + if (fs.existsSync(settingsPath)) { + return; + } + + // Generate default settings from PROVIDER_CONFIGS + const envVars = getClaudeEnvVars(provider); + const settings: ProviderSettings = { env: envVars }; + + // Ensure directory exists + fs.mkdirSync(require('path').dirname(settingsPath), { recursive: true }); + + // Write with restricted permissions + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { + mode: 0o600, + }); +} + +/** + * Get environment variables for remote proxy mode. + * Uses the remote proxy's provider endpoint as the base URL. + * Respects user model settings from custom settings path or provider settings file. + * + * @param provider CLIProxy provider (gemini, codex, agy, qwen, iflow) + * @param remoteConfig Remote proxy connection details + * @param customSettingsPath Optional path to user's custom settings file + * @returns Environment variables for Claude CLI + */ +export function getRemoteEnvVars( + provider: CLIProxyProvider, + remoteConfig: { host: string; port?: number; protocol: 'http' | 'https'; authToken?: string }, + customSettingsPath?: string +): Record { + // Build URL with smart port handling (8317 for HTTP, 443 for HTTPS) + // Validate port and normalize protocol for defensive handling + const normalizedProtocol = normalizeProtocol(remoteConfig.protocol); + const validatedPort = validateRemotePort(remoteConfig.port); + const effectivePort = validatedPort ?? getRemoteDefaultPort(normalizedProtocol); + // Omit port suffix for standard web ports (80/443) for cleaner URLs + const standardWebPort = normalizedProtocol === 'https' ? 443 : 80; + const portSuffix = effectivePort === standardWebPort ? '' : `:${effectivePort}`; + // Remote CLIProxyAPI uses root path (e.g., /v1/messages), not /api/provider/{provider}/v1/messages + // The /api/provider/ prefix is only for local CLIProxy instances + const baseUrl = `${normalizedProtocol}://${remoteConfig.host}${portSuffix}`; + + // Get global env vars (DISABLE_TELEMETRY, etc.) + const globalEnv = getGlobalEnvVars(); + + // Load user settings with priority: custom path > user settings file > base config + let userEnvVars: Record = {}; + + // Priority 1: Custom settings path (for user-defined variants) + if (customSettingsPath) { + const expandedPath = expandPath(customSettingsPath); + if (fs.existsSync(expandedPath)) { + try { + const content = fs.readFileSync(expandedPath, 'utf-8'); + const settings: ProviderSettings = JSON.parse(content); + if (settings.env && typeof settings.env === 'object') { + userEnvVars = settings.env as Record; + } + } catch { + // Invalid JSON - fall through to provider defaults + console.warn(warn(`Invalid settings file: ${customSettingsPath}`)); + } + } + } + + // Priority 2: Default provider settings file (~/.ccs/{provider}.settings.json) + if (Object.keys(userEnvVars).length === 0) { + const settingsPath = getProviderSettingsPath(provider); + if (fs.existsSync(settingsPath)) { + try { + const content = fs.readFileSync(settingsPath, 'utf-8'); + const settings: ProviderSettings = JSON.parse(content); + if (settings.env && typeof settings.env === 'object') { + userEnvVars = settings.env as Record; + } + } catch { + // Invalid JSON - fall through to base config + } + } + } + + // Priority 3: Base config defaults + if (Object.keys(userEnvVars).length === 0) { + const models = getModelMapping(provider); + const baseEnvVars = getEnvVarsFromConfig(provider); + // Filter out URL/auth from base config (we'll set those from remote config) + const { + ANTHROPIC_BASE_URL: _baseUrl, + ANTHROPIC_AUTH_TOKEN: _authToken, + ...additionalEnvVars + } = baseEnvVars; + userEnvVars = { + ...additionalEnvVars, + ANTHROPIC_MODEL: models.claudeModel, + ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel, + ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel, + ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel, + }; + } + + // Build final env: global + user settings + remote URL/auth override + const env: Record = { + ...globalEnv, + ...userEnvVars, + // Always override URL and auth token with remote config + ANTHROPIC_BASE_URL: baseUrl, + ANTHROPIC_AUTH_TOKEN: remoteConfig.authToken || getEffectiveApiKey(), + }; + + return env; +} diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts new file mode 100644 index 00000000..644addfc --- /dev/null +++ b/src/cliproxy/config/generator.ts @@ -0,0 +1,345 @@ +/** + * Core config generation for CLIProxyAPI + * Handles unified config.yaml generation for all providers + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { CLIProxyProvider, ProviderConfig } from '../types'; +import { getModelMappingFromConfig } from '../base-config-loader'; +import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; +import { getEffectiveApiKey, getEffectiveManagementSecret } from '../auth-token-manager'; +import { getAuthDir, getProviderAuthDir, getConfigPathForPort } from './path-resolver'; +import { CLIPROXY_DEFAULT_PORT } from './port-manager'; + +/** Internal API key for CCS-managed requests */ +export const CCS_INTERNAL_API_KEY = 'ccs-internal-managed'; + +/** Simple secret key for Control Panel login (user-facing) */ +export const CCS_CONTROL_PANEL_SECRET = 'ccs'; + +/** + * Config version - bump when config format changes to trigger regeneration + * v1: Initial config (port, auth-dir, api-keys only) + * v2: Full-featured config with dashboard, quota mgmt, simplified key + * v3: Logging disabled by default (user opt-in via ~/.ccs/config.yaml) + * v4: Added Kiro (AWS) and GitHub Copilot providers + * v5: Added disable-cooling: true for stability + */ +export const CLIPROXY_CONFIG_VERSION = 5; + +/** Provider display names (static metadata) */ +const PROVIDER_DISPLAY_NAMES: Record = { + gemini: 'Gemini', + codex: 'Codex', + agy: 'Antigravity', + qwen: 'Qwen Code', + iflow: 'iFlow', + kiro: 'Kiro (AWS)', + ghcp: 'GitHub Copilot (OAuth)', + claude: 'Claude (Anthropic)', +}; + +/** + * Get provider configuration + * Model mappings are loaded from config/base-{provider}.settings.json + */ +export function getProviderConfig(provider: CLIProxyProvider): ProviderConfig { + const displayName = PROVIDER_DISPLAY_NAMES[provider]; + if (!displayName) { + throw new Error(`Unknown provider: ${provider}`); + } + + // Load models from base config file + const models = getModelMappingFromConfig(provider); + + return { + name: provider, + displayName, + models, + requiresOAuth: true, // All CLIProxy providers require OAuth + }; +} + +/** + * Get CLIProxy logging settings from user config. + * Defaults to disabled to prevent disk bloat. + */ +function getLoggingSettings(): { loggingToFile: boolean; requestLog: boolean } { + const config = loadOrCreateUnifiedConfig(); + return { + loggingToFile: config.cliproxy.logging?.enabled ?? false, + requestLog: config.cliproxy.logging?.request_log ?? false, + }; +} + +/** + * Generate UNIFIED config.yaml content for ALL providers + * This enables concurrent usage of gemini/codex/agy without config conflicts. + * CLIProxyAPI routes requests by model name to the appropriate provider. + * + * @param port - Server port (default: 8317) + * @param userApiKeys - User-added API keys to preserve (default: []) + */ +function generateUnifiedConfigContent( + port: number = CLIPROXY_DEFAULT_PORT, + userApiKeys: string[] = [] +): string { + const authDir = getAuthDir(); // Base auth dir - CLIProxyAPI scans subdirectories + // Convert Windows backslashes to forward slashes for YAML compatibility + const authDirNormalized = authDir.split(path.sep).join('/'); + + // Get logging settings from user config (disabled by default) + const { loggingToFile, requestLog } = getLoggingSettings(); + + // Get effective auth tokens (respects user customization) + const effectiveApiKey = getEffectiveApiKey(); + const effectiveSecret = getEffectiveManagementSecret(); + + // Build api-keys section with internal key + preserved user keys + const allApiKeys = [effectiveApiKey, ...userApiKeys]; + const apiKeysYaml = allApiKeys.map((key) => ` - "${key}"`).join('\n'); + + // Unified config with enhanced CLIProxyAPI features + const config = `# CLIProxyAPI config generated by CCS v${CLIPROXY_CONFIG_VERSION} +# Supports: gemini, codex, agy, qwen, iflow (concurrent usage) +# Generated: ${new Date().toISOString()} +# +# This config is auto-managed by CCS. Manual edits may be overwritten. +# Use 'ccs doctor' to regenerate with latest settings. + +# ============================================================================= +# Server Settings +# ============================================================================= + +port: ${port} +debug: false + +# ============================================================================= +# Logging +# ============================================================================= +# WARNING: Logs can grow to several GB if enabled! +# To enable logging, edit ~/.ccs/config.yaml: +# cliproxy: +# logging: +# enabled: true +# request_log: true +# Then run 'ccs doctor --fix' to regenerate this config. +# Use 'ccs cleanup' to remove old logs. + +# Write logs to file (stored in ~/.ccs/cliproxy/logs/) +logging-to-file: ${loggingToFile} + +# Log individual API requests for debugging/analytics +request-log: ${requestLog} + +# ============================================================================= +# Dashboard & Management +# ============================================================================= + +# Enable usage statistics for CCS dashboard analytics +usage-statistics-enabled: true + +# Remote management API for CCS dashboard integration +remote-management: + allow-remote: true + secret-key: "${effectiveSecret}" + disable-control-panel: false + +# ============================================================================= +# Reliability & Quota Management +# ============================================================================= + +# Disable quota cooldown scheduling for stability +disable-cooling: true + +# Auto-retry on transient errors (403, 408, 500, 502, 503, 504) +request-retry: 0 +max-retry-interval: 0 + +# Auto-switch accounts on quota exceeded (429) +# This enables seamless multi-account rotation when rate limited +quota-exceeded: + switch-project: true + switch-preview-model: true + +# ============================================================================= +# Authentication +# ============================================================================= + +# API keys for CCS and user-added external requests +# NOTE: User-added keys are preserved across CCS updates (fix for issue #200) +api-keys: +${apiKeysYaml} + +# OAuth tokens directory (auto-discovered by CLIProxyAPI) +auth-dir: "${authDirNormalized}" +`; + + return config; +} + +/** + * Generate unified config.yaml file (supports all providers concurrently) + * Only regenerates if config doesn't exist. + * @returns Path to config file + */ +export function generateConfig( + provider: CLIProxyProvider, + port: number = CLIPROXY_DEFAULT_PORT +): string { + const configPath = getConfigPathForPort(port); + + // Ensure provider auth directory exists + const authDir = getProviderAuthDir(provider); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.mkdirSync(authDir, { recursive: true, mode: 0o700 }); + + // Only generate config if it doesn't exist (unified config serves all providers) + if (!fs.existsSync(configPath)) { + const configContent = generateUnifiedConfigContent(port); + fs.writeFileSync(configPath, configContent, { mode: 0o600 }); + } + + return configPath; +} + +/** + * Parse user-added API keys from existing config content. + * Extracts all keys except the internal CCS key for preservation. + * + * @param content - Existing config.yaml content + * @returns Array of user-added API keys (excludes CCS_INTERNAL_API_KEY) + */ +export function parseUserApiKeys(content: string): string[] { + const userKeys: string[] = []; + + // Find the api-keys section by looking for lines starting with " - " after "api-keys:" + // Normalize line endings first + const normalizedContent = content.replace(/\r\n/g, '\n'); + + // Find the api-keys: line and extract all subsequent key entries + const lines = normalizedContent.split('\n'); + let inApiKeysSection = false; + + for (const line of lines) { + // Check if this is the start of api-keys section + if (line.match(/^api-keys:\s*$/)) { + inApiKeysSection = true; + continue; + } + + // If we're in the api-keys section, look for key entries + if (inApiKeysSection) { + // Key entries are indented with " - " or similar + const keyMatch = line.match(/^\s+-\s*"([^"]*)"/); + if (keyMatch) { + const key = keyMatch[1]; + // Exclude the internal CCS key and empty strings + if (key && key !== CCS_INTERNAL_API_KEY) { + userKeys.push(key); + } + } else if (line.match(/^\S/) && line.trim().length > 0) { + // Non-indented line that's not empty means we've left the api-keys section + break; + } + // Continue for blank lines or other indented content + } + } + + return userKeys; +} + +/** + * Force regenerate config.yaml with latest settings. + * Preserves user-added API keys and port settings. + * + * @param port - Default port to use if not found in existing config + * @returns Path to new config file + */ +export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string { + const configPath = getConfigPathForPort(port); + + // Preserve user settings from existing config + let effectivePort = port; + let userApiKeys: string[] = []; + + if (fs.existsSync(configPath)) { + try { + const content = fs.readFileSync(configPath, 'utf-8'); + + // Preserve port setting + const portMatch = content.match(/^port:\s*(\d+)/m); + if (portMatch) { + effectivePort = parseInt(portMatch[1], 10); + } + + // Preserve user-added API keys (fix for issue #200) + userApiKeys = parseUserApiKeys(content); + } catch { + // Use defaults if reading fails + } + // Delete existing config + fs.unlinkSync(configPath); + } + + // Ensure directories exist + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.mkdirSync(getAuthDir(), { recursive: true, mode: 0o700 }); + + // Generate fresh config with preserved user API keys + const configContent = generateUnifiedConfigContent(effectivePort, userApiKeys); + fs.writeFileSync(configPath, configContent, { mode: 0o600 }); + + return configPath; +} + +/** + * Check if config needs regeneration (version mismatch) + * @returns true if config should be regenerated + */ +export function configNeedsRegeneration(): boolean { + const configPath = getConfigPathForPort(CLIPROXY_DEFAULT_PORT); + if (!fs.existsSync(configPath)) { + return false; // Will be created on first use + } + + try { + const content = fs.readFileSync(configPath, 'utf-8'); + + // Check for version marker + const versionMatch = content.match(/CCS v(\d+)/); + if (!versionMatch) { + return true; // No version marker = old config + } + + const configVersion = parseInt(versionMatch[1], 10); + return configVersion < CLIPROXY_CONFIG_VERSION; + } catch { + return true; // Error reading = regenerate + } +} + +/** + * Check if config exists for port + */ +export function configExists(port: number = CLIPROXY_DEFAULT_PORT): boolean { + return fs.existsSync(getConfigPathForPort(port)); +} + +/** + * Delete config file for specific port + */ +export function deleteConfigForPort(port: number): void { + const configPath = getConfigPathForPort(port); + if (fs.existsSync(configPath)) { + fs.unlinkSync(configPath); + } +} + +/** + * Delete config file (default port) + */ +export function deleteConfig(): void { + deleteConfigForPort(CLIPROXY_DEFAULT_PORT); +} diff --git a/src/cliproxy/config/index.ts b/src/cliproxy/config/index.ts new file mode 100644 index 00000000..2979ad1d --- /dev/null +++ b/src/cliproxy/config/index.ts @@ -0,0 +1,10 @@ +/** + * Config module barrel exports + * Re-exports all config generation utilities from modular structure + */ + +export * from './generator'; +export * from './port-manager'; +export * from './env-builder'; +export * from './thinking-config'; +export * from './path-resolver'; diff --git a/src/cliproxy/config/path-resolver.ts b/src/cliproxy/config/path-resolver.ts new file mode 100644 index 00000000..70615d35 --- /dev/null +++ b/src/cliproxy/config/path-resolver.ts @@ -0,0 +1,79 @@ +/** + * Path resolution utilities for CLIProxy directories and files + * Centralizes all path management for CLIProxy configuration + */ + +import * as path from 'path'; +import { getCcsDir } from '../../utils/config-manager'; +import { CLIProxyProvider } from '../types'; +import { CLIPROXY_DEFAULT_PORT } from './port-manager'; + +/** + * Get CLIProxy base directory + * All CLIProxy-related files are stored under ~/.ccs/cliproxy/ + */ +export function getCliproxyDir(): string { + return path.join(getCcsDir(), 'cliproxy'); +} + +/** + * Get CLIProxy writable directory for logs and runtime files. + * This directory is set as WRITABLE_PATH env var when spawning CLIProxy. + * Logs will be stored in ~/.ccs/cliproxy/logs/ + */ +export function getCliproxyWritablePath(): string { + return path.join(getCcsDir(), 'cliproxy'); +} + +/** + * Get base auth directory for CLIProxyAPI + */ +export function getAuthDir(): string { + return path.join(getCliproxyDir(), 'auth'); +} + +/** + * Get auth directory for provider + * All providers use a FLAT auth directory structure for unified config. + * CLIProxyAPI stores OAuth tokens directly in auth/ (not subdirectories). + * This enables all providers to be discovered and used concurrently. + */ +export function getProviderAuthDir(_provider: CLIProxyProvider): string { + // Use flat structure - all auth files in same directory for unified discovery + // Provider param kept for API compatibility (CLIProxyAPI handles via auth file type field) + return path.join(getCliproxyDir(), 'auth'); +} + +/** + * Get config file path for a specific port. + * Default port uses config.yaml, others use config-{port}.yaml. + */ +export function getConfigPathForPort(port: number): string { + if (port === CLIPROXY_DEFAULT_PORT) { + return path.join(getCliproxyDir(), 'config.yaml'); + } + return path.join(getCliproxyDir(), `config-${port}.yaml`); +} + +/** + * Get CLIProxy config file path (default port) + * Named distinctly from config-manager's getConfigPath to avoid confusion. + */ +export function getCliproxyConfigPath(): string { + return getConfigPathForPort(CLIPROXY_DEFAULT_PORT); +} + +/** + * Get binary directory path + */ +export function getBinDir(): string { + return path.join(getCliproxyDir(), 'bin'); +} + +/** + * Get path to user settings file for provider + * Example: ~/.ccs/gemini.settings.json + */ +export function getProviderSettingsPath(provider: CLIProxyProvider): string { + return path.join(getCcsDir(), `${provider}.settings.json`); +} diff --git a/src/cliproxy/config/port-manager.ts b/src/cliproxy/config/port-manager.ts new file mode 100644 index 00000000..3f2b05d2 --- /dev/null +++ b/src/cliproxy/config/port-manager.ts @@ -0,0 +1,59 @@ +/** + * Port validation and management utilities + * Handles port number validation and default port resolution + */ + +/** Default CLIProxy port */ +export const CLIPROXY_DEFAULT_PORT = 8317; + +/** + * Validate port is a valid positive integer (1-65535). + * Returns default port if invalid. + * + * @param port - Port number to validate + * @returns Valid port or CLIPROXY_DEFAULT_PORT (8317) if invalid + */ +export function validatePort(port: number | undefined): number { + if (port === undefined || port === null) { + return CLIPROXY_DEFAULT_PORT; + } + if (!Number.isFinite(port) || port < 1 || port > 65535 || !Number.isInteger(port)) { + return CLIPROXY_DEFAULT_PORT; + } + return port; +} + +/** + * Validate and sanitize port number for remote connections. + * Returns undefined for invalid ports (letting caller use default). + * Valid range: 1-65535, must be integer. + */ +export function validateRemotePort(port: number | undefined): number | undefined { + if (port === undefined || port === null) return undefined; + if (typeof port !== 'number' || !Number.isInteger(port)) return undefined; + if (port <= 0 || port > 65535) return undefined; + return port; +} + +/** + * Get default port for remote CLIProxyAPI based on protocol. + * - HTTP: 8317 (CLIProxyAPI default) + * - HTTPS: 443 (standard SSL port) + */ +export function getRemoteDefaultPort(protocol: 'http' | 'https'): number { + return protocol === 'https' ? 443 : CLIPROXY_DEFAULT_PORT; +} + +/** + * Normalize protocol to lowercase and validate. + * Handles runtime case sensitivity (e.g., 'HTTPS' → 'https'). + * Defaults to 'http' for invalid values. + */ +export function normalizeProtocol(protocol: string | undefined): 'http' | 'https' { + if (!protocol) return 'http'; + const normalized = protocol.toLowerCase(); + if (normalized === 'https') return 'https'; + if (normalized === 'http') return 'http'; + // Invalid protocol (e.g., 'ftp') - default to http + return 'http'; +} diff --git a/src/cliproxy/config/thinking-config.ts b/src/cliproxy/config/thinking-config.ts new file mode 100644 index 00000000..398e9dc5 --- /dev/null +++ b/src/cliproxy/config/thinking-config.ts @@ -0,0 +1,163 @@ +/** + * Thinking configuration and suffix handling + * Manages thinking budget suffixes for CLIProxyAPIPlus + */ + +import { CLIProxyProvider } from '../types'; +import { ThinkingConfig, DEFAULT_THINKING_TIER_DEFAULTS } from '../../config/unified-config-types'; +import { getThinkingConfig } from '../../config/unified-config-loader'; +import { supportsThinking } from '../model-catalog'; +import { validateThinking } from '../thinking-validator'; +import { warn } from '../../utils/ui'; + +/** Model tier types for thinking budget defaults */ +export type ModelTier = 'opus' | 'sonnet' | 'haiku'; + +/** + * Check if warnings should be shown based on thinking config. + * Defaults to true if show_warnings is not explicitly false. + */ +function shouldShowWarnings(thinkingConfig: ThinkingConfig): boolean { + return thinkingConfig.show_warnings !== false; +} + +/** + * Detect tier from model name. + * Returns 'sonnet' as default if unclear. + */ +export function detectTierFromModel(modelName: string): ModelTier { + const lower = modelName.toLowerCase(); + if (lower.includes('opus')) return 'opus'; + if (lower.includes('haiku')) return 'haiku'; + return 'sonnet'; // Default to sonnet (most common) +} + +/** + * Apply thinking suffix to model name. + * CLIProxyAPIPlus parses suffixes like model(level) or model(budget). + * + * @param model - Base model name + * @param thinkingValue - Level name (e.g., 'high') or numeric budget + * @returns Model name with thinking suffix, e.g., "gemini-3-pro-preview(high)" + */ +export function applyThinkingSuffix(model: string, thinkingValue: string | number): string { + // Don't apply if model already ends with a parenthesized suffix (e.g., "model(high)" or "model(8192)") + // Matches: ends with "(...)" where content is non-empty + if (/\([^)]+\)$/.test(model)) { + return model; + } + return `${model}(${thinkingValue})`; +} + +/** + * Get thinking value for tier based on config. + * Respects provider-specific overrides if configured. + */ +export function getThinkingValueForTier( + tier: ModelTier, + provider: CLIProxyProvider, + thinkingConfig: ThinkingConfig +): string { + // Check provider-specific override first + const providerOverride = thinkingConfig.provider_overrides?.[provider]?.[tier]; + if (providerOverride) { + return providerOverride; + } + // Fall back to global tier default (with null guard, uses centralized defaults) + return thinkingConfig.tier_defaults?.[tier] ?? DEFAULT_THINKING_TIER_DEFAULTS[tier]; +} + +/** + * Apply thinking configuration to env vars. + * Modifies ANTHROPIC_MODEL and tier models with thinking suffixes. + * + * @param envVars - Environment variables to modify + * @param provider - CLIProxy provider + * @param thinkingOverride - Optional CLI override (takes priority over config) + * @returns Modified env vars with thinking suffixes applied + */ +export function applyThinkingConfig( + envVars: NodeJS.ProcessEnv, + provider: CLIProxyProvider, + thinkingOverride?: string | number +): NodeJS.ProcessEnv { + const thinkingConfig = getThinkingConfig(); + const result = { ...envVars }; + + // Check if thinking is off + if (thinkingConfig.mode === 'off' && thinkingOverride === undefined) { + return result; + } + + // Get base model to check thinking support + const baseModel = result.ANTHROPIC_MODEL || ''; + if (!supportsThinking(provider, baseModel)) { + // U2: Warn user if they explicitly provided --thinking but model doesn't support it + if (thinkingOverride !== undefined && shouldShowWarnings(thinkingConfig)) { + console.warn( + warn( + `Model ${baseModel || 'unknown'} (provider: ${provider}) does not support thinking budget. --thinking flag ignored.` + ) + ); + } + return result; + } + + // Determine thinking value to use + let thinkingValue: string | number; + + if (thinkingOverride !== undefined) { + // CLI override takes priority + thinkingValue = thinkingOverride; + } else if (thinkingConfig.mode === 'manual' && thinkingConfig.override !== undefined) { + // Config manual mode with override + thinkingValue = thinkingConfig.override; + } else if (thinkingConfig.mode === 'auto') { + // Auto mode: detect tier and apply default + const tier = detectTierFromModel(baseModel); + thinkingValue = getThinkingValueForTier(tier, provider, thinkingConfig); + } else { + return result; // No thinking to apply + } + + // Validate thinking value against model capabilities + const validation = validateThinking(provider, baseModel, thinkingValue); + if (validation.warning && shouldShowWarnings(thinkingConfig)) { + console.warn(warn(validation.warning)); + } + thinkingValue = validation.value; + + // If validation says off, don't apply suffix + if (thinkingValue === 'off') { + return result; + } + + // Apply thinking suffix to main model + if (result.ANTHROPIC_MODEL) { + result.ANTHROPIC_MODEL = applyThinkingSuffix(result.ANTHROPIC_MODEL, thinkingValue); + } + + // Apply to tier models if they support thinking + const tierModels = [ + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + ] as const; + + for (const tierVar of tierModels) { + const model = result[tierVar]; + if (model && supportsThinking(provider, model)) { + // Get tier-specific thinking value + const tier = tierVar.includes('OPUS') + ? 'opus' + : tierVar.includes('SONNET') + ? 'sonnet' + : 'haiku'; + const tierThinkingValue = + thinkingOverride ?? getThinkingValueForTier(tier, provider, thinkingConfig); + result[tierVar] = applyThinkingSuffix(model, tierThinkingValue); + } + } + + return result; +} diff --git a/src/cliproxy/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts new file mode 100644 index 00000000..12f4d736 --- /dev/null +++ b/src/cliproxy/executor/env-resolver.ts @@ -0,0 +1,170 @@ +/** + * Environment Resolver - Build environment variables for Claude CLI + * + * Handles: + * - Remote proxy environment configuration + * - Local proxy environment configuration + * - HTTPS tunnel integration + * - Proxy chain configuration (CodexReasoning, ToolSanitization) + * - WebSearch and ImageAnalysis hook integration + */ + +import { getEffectiveEnvVars, getRemoteEnvVars, applyThinkingConfig } from '../config-generator'; +import { CLIProxyProvider } from '../types'; +import { getWebSearchHookEnv } from '../../utils/websearch-manager'; +import { getImageAnalysisHookEnv } from '../../utils/hooks/get-image-analysis-hook-env'; +import { CodexReasoningProxy } from '../codex-reasoning-proxy'; +import { ToolSanitizationProxy } from '../tool-sanitization-proxy'; +import { HttpsTunnelProxy } from '../https-tunnel-proxy'; + +export interface RemoteProxyConfig { + host: string; + port: number; + protocol: 'http' | 'https'; + authToken?: string; +} + +export interface ProxyChainConfig { + provider: CLIProxyProvider; + useRemoteProxy: boolean; + remoteConfig?: RemoteProxyConfig; + httpsTunnel?: HttpsTunnelProxy; + tunnelPort?: number; + codexReasoningProxy?: CodexReasoningProxy; + codexReasoningPort?: number; + toolSanitizationProxy?: ToolSanitizationProxy; + toolSanitizationPort?: number; + localPort: number; + customSettingsPath?: string; + thinkingOverride?: string | number; + verbose: boolean; +} + +/** + * Build final environment variables for Claude CLI execution + * Handles proxy chain ordering and integration with hooks + */ +export function buildClaudeEnvironment(config: ProxyChainConfig): Record { + const { + provider, + useRemoteProxy, + remoteConfig, + httpsTunnel, + tunnelPort, + localPort, + customSettingsPath, + thinkingOverride, + codexReasoningPort, + toolSanitizationPort, + } = config; + + // Build base env vars - remote or local + let envVars: NodeJS.ProcessEnv; + + if (useRemoteProxy && remoteConfig) { + if (httpsTunnel && tunnelPort) { + // HTTPS remote via local tunnel - use HTTP to tunnel + envVars = getRemoteEnvVars( + provider, + { + host: '127.0.0.1', + port: tunnelPort, + protocol: 'http', // Tunnel speaks HTTP locally + authToken: remoteConfig.authToken, + }, + customSettingsPath + ); + } else { + // HTTP remote - direct connection + envVars = getRemoteEnvVars( + provider, + { + host: remoteConfig.host, + port: remoteConfig.port, + protocol: remoteConfig.protocol, + authToken: remoteConfig.authToken, + }, + customSettingsPath + ); + } + } else { + // Local proxy mode + const remoteRewriteConfig = remoteConfig + ? { + host: remoteConfig.host, + port: remoteConfig.port, + protocol: remoteConfig.protocol, + authToken: remoteConfig.authToken, + } + : undefined; + + envVars = getEffectiveEnvVars(provider, localPort, customSettingsPath, remoteRewriteConfig); + } + + // Apply thinking configuration to model (auto tier-based or manual override) + applyThinkingConfig(envVars, provider, thinkingOverride); + + // Determine the final ANTHROPIC_BASE_URL based on active proxies + // Chain order: Claude CLI → [CodexReasoningProxy] → [ToolSanitizationProxy] → CLIProxy + let finalBaseUrl = envVars.ANTHROPIC_BASE_URL; + + if (toolSanitizationPort) { + finalBaseUrl = `http://127.0.0.1:${toolSanitizationPort}`; + } + + if (codexReasoningPort) { + // Codex reasoning proxy is the outermost layer for codex provider + finalBaseUrl = `http://127.0.0.1:${codexReasoningPort}/api/provider/codex`; + } + + const effectiveEnvVars = { + ...envVars, + ANTHROPIC_BASE_URL: finalBaseUrl, + }; + + // Add hook environment variables + const webSearchEnv = getWebSearchHookEnv(); + const imageAnalysisEnv = getImageAnalysisHookEnv(provider); + + // Merge all environment variables (filter undefined values) + const baseEnv = Object.fromEntries( + Object.entries(process.env).filter(([, v]) => v !== undefined) + ) as Record; + + const effectiveEnvVarsFiltered = Object.fromEntries( + Object.entries(effectiveEnvVars).filter(([, v]) => v !== undefined) + ) as Record; + + return { + ...baseEnv, + ...effectiveEnvVarsFiltered, + ...webSearchEnv, + ...imageAnalysisEnv, + CCS_PROFILE_TYPE: 'cliproxy', // Signal to WebSearch hook this is a third-party provider + }; +} + +/** + * Log environment configuration for debugging + */ +export function logEnvironment( + env: Record, + webSearchEnv: Record, + verbose: boolean +): void { + if (!verbose) return; + + const log = (msg: string) => console.error(`[cliproxy] ${msg}`); + + log(`Claude env: ANTHROPIC_BASE_URL=${env.ANTHROPIC_BASE_URL}`); + log(`Claude env: ANTHROPIC_MODEL=${env.ANTHROPIC_MODEL}`); + + if (Object.keys(webSearchEnv).length > 0) { + log(`Claude env: WebSearch config=${JSON.stringify(webSearchEnv)}`); + } + + // Log global env vars for visibility + if (env.DISABLE_TELEMETRY || env.DISABLE_ERROR_REPORTING || env.DISABLE_BUG_COMMAND) { + log(`Claude env: Global env applied (telemetry/reporting disabled)`); + } +} diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts new file mode 100644 index 00000000..24d709e4 --- /dev/null +++ b/src/cliproxy/executor/index.ts @@ -0,0 +1,762 @@ +/** + * CLIProxy Executor - Main Orchestrator + * + * Coordinates the full execution flow: + * 1. Configuration resolution and validation + * 2. Binary management and remote proxy checks + * 3. Authentication and account management + * 4. Proxy lifecycle (spawn/detect/join) + * 5. Environment setup and proxy chains + * 6. Claude CLI execution with cleanup handlers + */ + +import { spawn, ChildProcess } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import { ProgressIndicator } from '../../utils/progress-indicator'; +import { ok, fail, info, warn } from '../../utils/ui'; +import { escapeShellArg } from '../../utils/shell-executor'; +import { ensureCLIProxyBinary } from '../binary-manager'; +import { + generateConfig, + getProviderConfig, + ensureProviderSettings, + getProviderSettingsPath, + CLIPROXY_DEFAULT_PORT, + validatePort, +} from '../config-generator'; +import { checkRemoteProxy } from '../remote-proxy-client'; +import { isAuthenticated } from '../auth-handler'; +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 { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from '../model-catalog'; +import { CodexReasoningProxy } from '../codex-reasoning-proxy'; +import { ToolSanitizationProxy } from '../tool-sanitization-proxy'; +import { + findAccountByQuery, + getProviderAccounts, + setDefaultAccount, + touchAccount, + renameAccount, + getDefaultAccount, +} from '../account-manager'; +import { + ensureMcpWebSearch, + installWebSearchHook, + displayWebSearchStatus, +} from '../../utils/websearch-manager'; +import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; +import { HttpsTunnelProxy } from '../https-tunnel-proxy'; + +// Import modular components +import { waitForProxyReadyWithSpinner, spawnProxy } from './lifecycle-manager'; +import { buildClaudeEnvironment, logEnvironment } from './env-resolver'; +import { + isNetworkError, + handleNetworkError, + handleTokenExpiration, + handleQuotaCheck, +} from './retry-handler'; +import { checkOrJoinProxy, registerProxySession, setupCleanupHandlers } from './session-bridge'; +import { getWebSearchHookEnv } from '../../utils/websearch-manager'; + +/** Default executor configuration */ +const DEFAULT_CONFIG: ExecutorConfig = { + port: CLIPROXY_DEFAULT_PORT, + timeout: 5000, + verbose: false, + pollInterval: 100, +}; + +/** + * Execute Claude CLI with CLIProxy (main entry point) + * + * @param claudeCli Path to Claude CLI executable + * @param provider CLIProxy provider (gemini, codex, agy, qwen) + * @param args Arguments to pass to Claude CLI + * @param config Optional executor configuration + */ +export async function execClaudeWithCLIProxy( + claudeCli: string, + provider: CLIProxyProvider, + args: string[], + config: Partial = {} +): Promise { + // Filter out undefined values to prevent overwriting defaults + const filteredConfig = Object.fromEntries( + Object.entries(config).filter(([, v]) => v !== undefined) + ) as Partial; + const cfg = { ...DEFAULT_CONFIG, ...filteredConfig }; + const verbose = cfg.verbose || args.includes('--verbose') || args.includes('-v'); + + // Validate Claude CLI exists before proceeding + if (!fs.existsSync(claudeCli)) { + console.error(fail(`Claude CLI not found at: ${claudeCli}`)); + console.error(' Run "ccs doctor --fix" to reinstall or check your PATH'); + process.exit(1); + } + + const log = (msg: string) => { + if (verbose) { + console.error(`[cliproxy] ${msg}`); + } + }; + + // 0. Resolve proxy configuration (CLI > ENV > config.yaml > defaults) + const unifiedConfig = loadOrCreateUnifiedConfig(); + + // 0a. Runtime backend/provider validation + 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 + ? { + enabled: cliproxyServerConfig.remote.enabled, + host: cliproxyServerConfig.remote.host, + port: cliproxyServerConfig.remote.port, + protocol: cliproxyServerConfig.remote.protocol, + auth_token: cliproxyServerConfig.remote.auth_token, + timeout: cliproxyServerConfig.remote.timeout, + } + : undefined, + local: cliproxyServerConfig?.local + ? { + port: cliproxyServerConfig.local.port, + auto_start: cliproxyServerConfig.local.auto_start, + } + : undefined, + }); + + // Port resolution and validation + if (cfg.port && cfg.port !== CLIPROXY_DEFAULT_PORT) { + if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) { + cfg.port = proxyConfig.port; + } + } else if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) { + cfg.port = proxyConfig.port; + } + cfg.port = validatePort(cfg.port); + + log(`Proxy mode: ${proxyConfig.mode}`); + if (proxyConfig.mode === 'remote') { + log(`Remote host: ${proxyConfig.host}:${proxyConfig.port} (${proxyConfig.protocol})`); + } + + // Setup WebSearch hooks + ensureMcpWebSearch(); + installWebSearchHook(); + displayWebSearchStatus(); + + const providerConfig = getProviderConfig(provider); + log(`Provider: ${providerConfig.displayName}`); + + // Check remote proxy if configured + let useRemoteProxy = false; + if (proxyConfig.mode === 'remote' && proxyConfig.host) { + const status = await checkRemoteProxy({ + host: proxyConfig.host, + port: proxyConfig.port, + protocol: proxyConfig.protocol, + authToken: proxyConfig.authToken, + timeout: proxyConfig.timeout ?? 2000, + allowSelfSigned: proxyConfig.allowSelfSigned ?? false, + }); + + if (status.reachable) { + useRemoteProxy = true; + console.log( + ok( + `Connected to remote proxy at ${proxyConfig.host}:${proxyConfig.port} (${status.latencyMs}ms)` + ) + ); + } else { + console.error(warn(`Remote proxy unreachable: ${status.error}`)); + + if (proxyConfig.remoteOnly) { + throw new Error('Remote proxy unreachable and --remote-only specified'); + } + + if (proxyConfig.fallbackEnabled) { + if (proxyConfig.autoStartLocal) { + console.log(info('Falling back to local proxy...')); + } else { + if (process.stdin.isTTY) { + const readline = await import('readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => { + rl.question('Start local proxy instead? [Y/n] ', resolve); + }); + rl.close(); + if (answer.toLowerCase() === 'n') { + throw new Error('Remote proxy unreachable and user declined fallback'); + } + } + console.log(info('Starting local proxy...')); + } + } else { + throw new Error('Remote proxy unreachable and fallback disabled'); + } + } + } + + // Variables for local proxy mode + let binaryPath: string | undefined; + let sessionId: string | undefined; + + // 1. Ensure binary exists (downloads if needed) - SKIP for remote mode + if (!useRemoteProxy) { + const spinner = new ProgressIndicator('Preparing CLIProxy'); + spinner.start(); + + try { + binaryPath = await ensureCLIProxyBinary(verbose); + spinner.succeed('CLIProxy binary ready'); + } catch (error) { + spinner.fail('Failed to prepare CLIProxy'); + const err = error as Error; + + if (isNetworkError(err)) { + handleNetworkError(err); + } + + throw error; + } + } + + // 2. Handle special flags (simplified flag parsing - full implementation continues below) + const forceAuth = argsWithoutProxy.includes('--auth'); + const pasteCallback = argsWithoutProxy.includes('--paste-callback'); + const portForward = argsWithoutProxy.includes('--port-forward'); + const forceHeadless = argsWithoutProxy.includes('--headless'); + + if (pasteCallback && portForward) { + console.error(fail('Cannot use --paste-callback with --port-forward')); + console.error(' --paste-callback: Manually paste OAuth redirect URL'); + console.error(' --port-forward: Use SSH port forwarding for callback'); + process.exit(1); + } + + const forceLogout = argsWithoutProxy.includes('--logout'); + const forceConfig = argsWithoutProxy.includes('--config'); + const addAccount = argsWithoutProxy.includes('--add'); + const showAccounts = argsWithoutProxy.includes('--accounts'); + const forceImport = argsWithoutProxy.includes('--import'); + + const incognitoFlag = argsWithoutProxy.includes('--incognito'); + const noIncognitoFlag = argsWithoutProxy.includes('--no-incognito'); + const kiroNoIncognitoConfig = + provider === 'kiro' ? (unifiedConfig.cliproxy?.kiro_no_incognito ?? true) : false; + const noIncognito = incognitoFlag ? false : noIncognitoFlag || kiroNoIncognitoConfig; + + // Parse --use flag + let useAccount: string | undefined; + const useIdx = argsWithoutProxy.indexOf('--use'); + if ( + useIdx !== -1 && + argsWithoutProxy[useIdx + 1] && + !argsWithoutProxy[useIdx + 1].startsWith('-') + ) { + useAccount = argsWithoutProxy[useIdx + 1]; + } + + // Parse --nickname flag + let setNickname: string | undefined; + const nicknameIdx = argsWithoutProxy.indexOf('--nickname'); + if ( + nicknameIdx !== -1 && + argsWithoutProxy[nicknameIdx + 1] && + !argsWithoutProxy[nicknameIdx + 1].startsWith('-') + ) { + setNickname = argsWithoutProxy[nicknameIdx + 1]; + } + + // Parse --thinking flag + let thinkingOverride: string | number | undefined; + const thinkingEqArg = argsWithoutProxy.find((arg) => arg.startsWith('--thinking=')); + if (thinkingEqArg) { + const val = thinkingEqArg.substring('--thinking='.length); + if (!val || val.trim() === '') { + console.error(fail('--thinking requires a value')); + console.error(' Examples: --thinking=low, --thinking=8192, --thinking=off'); + console.error(' Levels: minimal, low, medium, high, xhigh, auto'); + process.exit(1); + } + const numVal = parseInt(val, 10); + thinkingOverride = !isNaN(numVal) ? numVal : val; + + const allThinkingFlags = argsWithoutProxy.filter( + (arg) => arg === '--thinking' || arg.startsWith('--thinking=') + ); + if (allThinkingFlags.length > 1) { + console.warn( + `[!] Multiple --thinking flags detected. Using first occurrence: ${thinkingEqArg}` + ); + } + } else { + const thinkingIdx = argsWithoutProxy.indexOf('--thinking'); + if (thinkingIdx !== -1) { + const nextArg = argsWithoutProxy[thinkingIdx + 1]; + if (!nextArg || nextArg.startsWith('-')) { + console.error(fail('--thinking requires a value')); + console.error(' Examples: --thinking low, --thinking 8192, --thinking off'); + console.error(' Levels: minimal, low, medium, high, xhigh, auto'); + process.exit(1); + } + const numVal = parseInt(nextArg, 10); + thinkingOverride = !isNaN(numVal) ? numVal : nextArg; + + const allThinkingFlags = argsWithoutProxy.filter( + (arg) => arg === '--thinking' || arg.startsWith('--thinking=') + ); + if (allThinkingFlags.length > 1) { + console.warn( + `[!] Multiple --thinking flags detected. Using first occurrence: --thinking ${nextArg}` + ); + } + } + } + + // Handle --accounts + if (showAccounts) { + const accounts = getProviderAccounts(provider); + if (accounts.length === 0) { + console.log(info(`No accounts registered for ${providerConfig.displayName}`)); + console.log(` Run "ccs ${provider} --auth" to add an account`); + } else { + console.log(`\n${providerConfig.displayName} Accounts:\n`); + for (const acct of accounts) { + const defaultMark = acct.isDefault ? ' (default)' : ''; + const nickname = acct.nickname ? `[${acct.nickname}]` : ''; + console.log(` ${nickname.padEnd(12)} ${acct.email || acct.id}${defaultMark}`); + } + console.log(`\n Use "ccs ${provider} --use " to switch accounts`); + } + process.exit(0); + } + + // Handle --use + if (useAccount) { + const account = findAccountByQuery(provider, useAccount); + if (!account) { + console.error(fail(`Account not found: "${useAccount}"`)); + const accounts = getProviderAccounts(provider); + if (accounts.length > 0) { + console.error(` Available accounts:`); + for (const acct of accounts) { + console.error(` - ${acct.nickname || acct.id} (${acct.email || 'no email'})`); + } + } + process.exit(1); + } + setDefaultAccount(provider, account.id); + touchAccount(provider, account.id); + console.log(ok(`Switched to account: ${account.nickname || account.email || account.id}`)); + } + + // Handle --nickname (rename account) + if (setNickname && !addAccount) { + const defaultAccount = getDefaultAccount(provider); + if (!defaultAccount) { + console.error(fail(`No account found for ${providerConfig.displayName}`)); + console.error(` Run "ccs ${provider} --auth" to add an account first`); + process.exit(1); + } + try { + const success = renameAccount(provider, defaultAccount.id, setNickname); + if (success) { + console.log(ok(`Renamed account to: ${setNickname}`)); + } else { + console.error(fail('Failed to rename account')); + process.exit(1); + } + } catch (err) { + console.error(fail(err instanceof Error ? err.message : 'Failed to rename account')); + process.exit(1); + } + process.exit(0); + } + + // Handle --config + if (forceConfig && supportsModelConfig(provider)) { + await configureProviderModel(provider, true, cfg.customSettingsPath); + process.exit(0); + } + + // Handle --logout + if (forceLogout) { + const { clearAuth } = await import('../auth-handler'); + if (clearAuth(provider)) { + console.log(ok(`Logged out from ${providerConfig.displayName}`)); + } else { + console.log(info(`No authentication found for ${providerConfig.displayName}`)); + } + process.exit(0); + } + + // Handle --import (Kiro only) + if (forceImport) { + if (provider !== 'kiro') { + console.error(fail('--import is only available for Kiro')); + console.error(` Run "ccs ${provider} --auth" to authenticate`); + process.exit(1); + } + if (forceAuth) { + console.error(fail('Cannot use --import with --auth')); + console.error(' --import: Import existing token from Kiro IDE'); + console.error(' --auth: Trigger new OAuth flow in browser'); + process.exit(1); + } + if (forceLogout) { + console.error(fail('Cannot use --import with --logout')); + process.exit(1); + } + const { triggerOAuth } = await import('../auth-handler'); + const authSuccess = await triggerOAuth(provider, { + verbose, + import: true, + ...(setNickname ? { nickname: setNickname } : {}), + }); + if (!authSuccess) { + console.error(fail('Failed to import Kiro token from IDE')); + console.error(' Make sure you are logged into Kiro IDE first'); + process.exit(1); + } + process.exit(0); + } + + // 3. Ensure OAuth completed (if provider requires it) + const remoteAuthToken = proxyConfig.authToken?.trim(); + const skipLocalAuth = useRemoteProxy && !!remoteAuthToken; + if (skipLocalAuth) { + log(`Using remote proxy authentication (skipping local OAuth)`); + } + + if (providerConfig.requiresOAuth && !skipLocalAuth) { + log(`Checking authentication for ${provider}`); + + if (forceAuth || !isAuthenticated(provider)) { + const { triggerOAuth } = await import('../auth-handler'); + const authSuccess = await triggerOAuth(provider, { + verbose, + add: addAccount, + ...(forceHeadless ? { headless: true } : {}), + ...(setNickname ? { nickname: setNickname } : {}), + ...(noIncognito ? { noIncognito: true } : {}), + ...(pasteCallback ? { pasteCallback: true } : {}), + ...(portForward ? { portForward: true } : {}), + }); + if (!authSuccess) { + throw new Error(`Authentication required for ${providerConfig.displayName}`); + } + if (forceAuth) { + process.exit(0); + } + } else { + log(`${provider} already authenticated`); + } + + // 3a. Proactive token refresh + await handleTokenExpiration(provider, verbose); + + // 3a-1. Update lastUsedAt + const usedAccount = getDefaultAccount(provider); + if (usedAccount) { + touchAccount(provider, usedAccount.id); + } + } + + // 3b. Preflight quota check (Antigravity only) + if (!skipLocalAuth) { + await handleQuotaCheck(provider); + } + + // 4. First-run model configuration + if (supportsModelConfig(provider) && !skipLocalAuth) { + await configureProviderModel(provider, false, cfg.customSettingsPath); + } + + // 5. Check for broken models + const currentModel = getCurrentModel(provider, cfg.customSettingsPath); + if (currentModel && isModelBroken(provider, currentModel)) { + const modelEntry = findModel(provider, currentModel); + const issueUrl = getModelIssueUrl(provider, currentModel); + console.error(''); + console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`)); + console.error(' Tool calls will fail. Use "gemini-3-pro-preview" instead.'); + if (issueUrl) { + console.error(` Tracking: ${issueUrl}`); + } + if (skipLocalAuth) { + console.error(' Note: Model may be overridden by remote proxy configuration.'); + } else { + console.error(` Run "ccs ${provider} --config" to change model.`); + } + console.error(''); + } + + // 6. Ensure user settings file exists + ensureProviderSettings(provider); + + // Local proxy mode: generate config, spawn/join proxy, track session + let proxy: ChildProcess | null = null; + let configPath: string | undefined; + + if (!useRemoteProxy) { + log(`Generating config for ${provider}`); + configPath = generateConfig(provider, cfg.port); + log(`Config written: ${configPath}`); + + // 6a. Check or join existing proxy + const { sessionId: existingSessionId, shouldSpawn } = await checkOrJoinProxy( + cfg.port, + cfg.timeout, + verbose + ); + + sessionId = existingSessionId; + + // 6b. Spawn new proxy if needed + if (shouldSpawn && binaryPath) { + proxy = spawnProxy(binaryPath, configPath, verbose); + + // 7. Wait for proxy readiness + await waitForProxyReadyWithSpinner( + cfg.port, + cfg.timeout, + cfg.pollInterval, + backend, + configPath + ); + + // Register session + if (proxy.pid) { + sessionId = registerProxySession(cfg.port, proxy.pid, backend, verbose); + } + } + } + + // 8. Setup HTTPS tunnel if needed + let httpsTunnel: HttpsTunnelProxy | null = null; + let tunnelPort: number | null = null; + + if (useRemoteProxy && proxyConfig.protocol === 'https' && proxyConfig.host) { + try { + httpsTunnel = new HttpsTunnelProxy({ + remoteHost: proxyConfig.host, + remotePort: proxyConfig.port, + authToken: proxyConfig.authToken, + verbose, + allowSelfSigned: proxyConfig.allowSelfSigned ?? false, + }); + tunnelPort = await httpsTunnel.start(); + log( + `HTTPS tunnel started on port ${tunnelPort} → https://${proxyConfig.host}:${proxyConfig.port}` + ); + } catch (error) { + const err = error as Error; + console.error(warn(`Failed to start HTTPS tunnel: ${err.message}`)); + throw new Error(`HTTPS tunnel startup failed: ${err.message}`); + } + } + + // 9. Setup tool sanitization proxy + let toolSanitizationProxy: ToolSanitizationProxy | null = null; + let toolSanitizationPort: number | null = null; + + // Build initial env vars to get ANTHROPIC_BASE_URL + const initialEnvVars = buildClaudeEnvironment({ + provider, + useRemoteProxy, + remoteConfig: proxyConfig.host + ? { + host: proxyConfig.host, + port: proxyConfig.port, + protocol: proxyConfig.protocol, + authToken: proxyConfig.authToken, + } + : undefined, + httpsTunnel: httpsTunnel ?? undefined, + tunnelPort: tunnelPort ?? undefined, + localPort: cfg.port, + customSettingsPath: cfg.customSettingsPath, + thinkingOverride, + verbose, + }); + + if (initialEnvVars.ANTHROPIC_BASE_URL) { + try { + toolSanitizationProxy = new ToolSanitizationProxy({ + upstreamBaseUrl: initialEnvVars.ANTHROPIC_BASE_URL, + verbose, + warnOnSanitize: true, + }); + toolSanitizationPort = await toolSanitizationProxy.start(); + log(`Tool sanitization proxy active on port ${toolSanitizationPort}`); + } catch (error) { + const err = error as Error; + toolSanitizationProxy = null; + toolSanitizationPort = null; + if (verbose) { + console.error(warn(`Tool sanitization proxy disabled: ${err.message}`)); + } + } + } + + const postSanitizationBaseUrl = toolSanitizationPort + ? `http://127.0.0.1:${toolSanitizationPort}` + : initialEnvVars.ANTHROPIC_BASE_URL; + + // 10. Setup Codex reasoning proxy (Codex only) + let codexReasoningProxy: CodexReasoningProxy | null = null; + let codexReasoningPort: number | null = null; + + if (provider === 'codex') { + if (!postSanitizationBaseUrl) { + log('ANTHROPIC_BASE_URL not set for Codex, reasoning proxy disabled'); + } else { + try { + const traceEnabled = + process.env.CCS_CODEX_REASONING_TRACE === '1' || + process.env.CCS_CODEX_REASONING_TRACE === 'true'; + const stripPathPrefix = useRemoteProxy ? '/api/provider/codex' : undefined; + codexReasoningProxy = new CodexReasoningProxy({ + upstreamBaseUrl: postSanitizationBaseUrl, + verbose, + defaultEffort: 'medium', + traceFilePath: traceEnabled ? `${os.homedir()}/.ccs/codex-reasoning-proxy.log` : '', + modelMap: { + defaultModel: initialEnvVars.ANTHROPIC_MODEL, + opusModel: initialEnvVars.ANTHROPIC_DEFAULT_OPUS_MODEL, + sonnetModel: initialEnvVars.ANTHROPIC_DEFAULT_SONNET_MODEL, + haikuModel: initialEnvVars.ANTHROPIC_DEFAULT_HAIKU_MODEL, + }, + stripPathPrefix, + }); + codexReasoningPort = await codexReasoningProxy.start(); + log( + `Codex reasoning proxy active: http://127.0.0.1:${codexReasoningPort}/api/provider/codex` + ); + } catch (error) { + const err = error as Error; + codexReasoningProxy = null; + codexReasoningPort = null; + if (verbose) { + console.error(warn(`Codex reasoning proxy disabled: ${err.message}`)); + } + } + } + } + + // 11. Build final environment with all proxy chains + const env = buildClaudeEnvironment({ + provider, + useRemoteProxy, + remoteConfig: proxyConfig.host + ? { + host: proxyConfig.host, + port: proxyConfig.port, + protocol: proxyConfig.protocol, + authToken: proxyConfig.authToken, + } + : undefined, + httpsTunnel: httpsTunnel ?? undefined, + tunnelPort: tunnelPort ?? undefined, + codexReasoningProxy: codexReasoningProxy ?? undefined, + codexReasoningPort: codexReasoningPort ?? undefined, + toolSanitizationProxy: toolSanitizationProxy ?? undefined, + toolSanitizationPort: toolSanitizationPort ?? undefined, + localPort: cfg.port, + customSettingsPath: cfg.customSettingsPath, + thinkingOverride, + verbose, + }); + + const webSearchEnv = getWebSearchHookEnv(); + logEnvironment(env, webSearchEnv, verbose); + + // 12. Filter CCS-specific flags before passing to Claude CLI + const ccsFlags = [ + '--auth', + '--paste-callback', + '--port-forward', + '--headless', + '--logout', + '--config', + '--add', + '--accounts', + '--use', + '--nickname', + '--thinking', + '--incognito', + '--no-incognito', + '--import', + '--settings', + ...PROXY_CLI_FLAGS, + ]; + const claudeArgs = argsWithoutProxy.filter((arg, idx) => { + if (ccsFlags.includes(arg)) return false; + if (arg.startsWith('--thinking=')) return false; + if ( + argsWithoutProxy[idx - 1] === '--use' || + argsWithoutProxy[idx - 1] === '--nickname' || + argsWithoutProxy[idx - 1] === '--thinking' + ) + return false; + return true; + }); + + const isWindows = process.platform === 'win32'; + const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli); + + const settingsPath = cfg.customSettingsPath + ? cfg.customSettingsPath.replace(/^~/, os.homedir()) + : getProviderSettingsPath(provider); + + let claude: ChildProcess; + if (needsShell) { + const cmdString = [claudeCli, '--settings', settingsPath, ...claudeArgs] + .map(escapeShellArg) + .join(' '); + claude = spawn(cmdString, { + stdio: 'inherit', + windowsHide: true, + shell: true, + env, + }); + } else { + claude = spawn(claudeCli, ['--settings', settingsPath, ...claudeArgs], { + stdio: 'inherit', + windowsHide: true, + env, + }); + } + + // 13. Setup cleanup handlers + setupCleanupHandlers( + claude, + sessionId, + cfg.port, + codexReasoningProxy, + toolSanitizationProxy, + httpsTunnel, + verbose + ); +} + +// Re-export utility functions for backwards compatibility +export { isPortAvailable, findAvailablePort } from './lifecycle-manager'; + +export default execClaudeWithCLIProxy; diff --git a/src/cliproxy/executor/lifecycle-manager.ts b/src/cliproxy/executor/lifecycle-manager.ts new file mode 100644 index 00000000..b5657754 --- /dev/null +++ b/src/cliproxy/executor/lifecycle-manager.ts @@ -0,0 +1,160 @@ +/** + * Lifecycle Manager - Spawn/Kill/Poll operations for CLIProxy + * + * Handles: + * - Spawning CLIProxyAPI binary + * - Waiting for proxy readiness via TCP polling + * - Killing proxy processes + */ + +import { spawn, ChildProcess } from 'child_process'; +import * as net from 'net'; +import { ProgressIndicator } from '../../utils/progress-indicator'; +import { fail } from '../../utils/ui'; +import { getCliproxyWritablePath } from '../config-generator'; +import { getPortCheckCommand, getCatCommand } from '../../utils/platform-commands'; +import { CLIProxyBackend } from '../types'; + +/** + * Wait for TCP port to become available + * Uses polling since CLIProxyAPI doesn't emit PROXY_READY signal + */ +export async function waitForProxyReady( + port: number, + timeout: number = 5000, + pollInterval: number = 100 +): Promise { + const start = Date.now(); + + while (Date.now() - start < timeout) { + try { + await new Promise((resolve, reject) => { + const socket = net.createConnection({ port, host: '127.0.0.1' }, () => { + socket.destroy(); + resolve(); + }); + + socket.on('error', (err) => { + socket.destroy(); + reject(err); + }); + + // Individual connection timeout + socket.setTimeout(1000, () => { + socket.destroy(); + reject(new Error('Connection timeout')); + }); + }); + + return; // Connection successful - proxy is ready + } catch { + // Connection failed, wait and retry + await new Promise((r) => setTimeout(r, pollInterval)); + } + } + + throw new Error(`CLIProxy not ready after ${timeout}ms on port ${port}`); +} + +/** + * Spawn CLIProxyAPI binary with given config + */ +export function spawnProxy(binaryPath: string, configPath: string, verbose: boolean): ChildProcess { + const log = (msg: string) => { + if (verbose) { + console.error(`[cliproxy] ${msg}`); + } + }; + + const proxyArgs = ['--config', configPath]; + log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`); + + const proxy = spawn(binaryPath, proxyArgs, { + stdio: ['ignore', 'ignore', 'ignore'], + detached: true, + env: { + ...process.env, + WRITABLE_PATH: getCliproxyWritablePath(), + }, + }); + + proxy.unref(); + + proxy.on('error', (error) => { + console.error(fail(`CLIProxy spawn error: ${error.message}`)); + }); + + return proxy; +} + +/** + * Wait for proxy to be ready with progress indication + */ +export async function waitForProxyReadyWithSpinner( + port: number, + timeout: number, + pollInterval: number, + backend: CLIProxyBackend, + configPath: string +): Promise { + const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${port}`); + readySpinner.start(); + + try { + await waitForProxyReady(port, timeout, pollInterval); + readySpinner.succeed(`CLIProxy ready on port ${port}`); + } catch (error) { + const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; + readySpinner.fail(`${backendLabel} startup failed`); + + const err = error as Error; + console.error(''); + console.error(fail(`${backendLabel} failed to start`)); + console.error(''); + console.error('Possible causes:'); + console.error(` 1. Port ${port} already in use`); + console.error(' 2. Binary crashed on startup'); + console.error(' 3. Invalid configuration'); + console.error(''); + console.error('Troubleshooting:'); + console.error(` - Check port: ${getPortCheckCommand(port)}`); + console.error(' - Run with --verbose for detailed logs'); + console.error(` - View config: ${getCatCommand(configPath)}`); + console.error(' - Try: ccs doctor --fix'); + console.error(''); + + throw new Error(`CLIProxy startup failed: ${err.message}`); + } +} + +/** + * Check if a port is available + */ +export async function isPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = net.createServer(); + + server.once('error', () => { + resolve(false); + }); + + server.once('listening', () => { + server.close(); + resolve(true); + }); + + server.listen(port, '127.0.0.1'); + }); +} + +/** + * Find an available port in range + */ +export async function findAvailablePort(startPort: number, range: number = 10): Promise { + for (let port = startPort; port < startPort + range; port++) { + if (await isPortAvailable(port)) { + return port; + } + } + throw new Error(`No available port found in range ${startPort}-${startPort + range - 1}`); +} diff --git a/src/cliproxy/executor/retry-handler.ts b/src/cliproxy/executor/retry-handler.ts new file mode 100644 index 00000000..83480f0a --- /dev/null +++ b/src/cliproxy/executor/retry-handler.ts @@ -0,0 +1,90 @@ +/** + * Retry Handler - Error recovery and retry logic + * + * Handles: + * - Network error detection + * - Token expiration handling + * - Quota management + * - Account switching + */ + +import { fail, warn, info } from '../../utils/ui'; +import { CLIProxyProvider } from '../types'; + +/** + * Check if error is network-related + */ +export function isNetworkError(error: Error): boolean { + const networkErrors = [ + 'getaddrinfo', + 'ENOTFOUND', + 'ETIMEDOUT', + 'ECONNREFUSED', + 'ENETUNREACH', + 'EAI_AGAIN', + ]; + return networkErrors.some((errCode) => error.message.includes(errCode)); +} + +/** + * Handle network error with user-friendly message + */ +export function handleNetworkError(_error: Error): never { + console.error(''); + console.error(fail('No network connection detected')); + console.error(''); + console.error('CLIProxy binary download requires internet access.'); + console.error('Please check your network connection and try again.'); + console.error(''); + process.exit(1); +} + +/** + * Handle token expiration + */ +export async function handleTokenExpiration( + provider: CLIProxyProvider, + verbose: boolean +): Promise { + const { ensureTokenValid } = await import('../auth/token-manager'); + const tokenResult = await ensureTokenValid(provider, verbose); + + if (!tokenResult.valid) { + // Token expired and refresh failed - trigger re-auth + console.error(warn('OAuth token expired and refresh failed')); + if (tokenResult.error) { + console.error(` ${tokenResult.error}`); + } + console.error(` Run "ccs ${provider} --auth" to re-authenticate`); + process.exit(1); + } + + if (tokenResult.refreshed && verbose) { + console.error('[cliproxy] Token was refreshed proactively'); + } +} + +/** + * Handle quota check and auto-switching for Antigravity + */ +export async function handleQuotaCheck(provider: CLIProxyProvider): Promise { + if (provider !== 'agy') return; + + const { preflightCheck } = await import('../quota-manager'); + const preflight = await preflightCheck(provider); + + if (!preflight.proceed) { + console.error(fail(`Cannot start session: ${preflight.reason}`)); + process.exit(1); + } + + if (preflight.switchedFrom) { + console.log(info(`Auto-switched to ${preflight.accountId}`)); + console.log(` Reason: ${preflight.reason}`); + if (preflight.quotaPercent !== undefined && preflight.quotaPercent !== null) { + console.log(` New account quota: ${preflight.quotaPercent.toFixed(1)}%`); + } else { + console.log(` New account quota: N/A (fetch unavailable)`); + } + } +} diff --git a/src/cliproxy/executor/session-bridge.ts b/src/cliproxy/executor/session-bridge.ts new file mode 100644 index 00000000..4cf4d853 --- /dev/null +++ b/src/cliproxy/executor/session-bridge.ts @@ -0,0 +1,284 @@ +/** + * Session Bridge - Integration with session tracking and proxy detection + * + * Handles: + * - Session registration and unregistration + * - Proxy detection and version checking + * - Orphaned proxy reclamation + * - Startup lock coordination + */ + +import { ChildProcess } from 'child_process'; +import { info, warn } from '../../utils/ui'; +import { getInstalledCliproxyVersion } from '../binary-manager'; +import { CLIProxyBackend } from '../types'; +import { + cleanupOrphanedSessions, + registerSession, + unregisterSession, + stopProxy, +} from '../session-tracker'; +import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from '../proxy-detector'; +import { withStartupLock } from '../startup-lock'; +import { killProcessOnPort } from '../../utils/platform-commands'; + +export interface ProxySessionResult { + sessionId?: string; + proxy?: ChildProcess; + shouldSpawn: boolean; +} + +/** + * Check for existing proxy and handle version mismatch, or determine if new spawn needed + */ +export async function checkOrJoinProxy( + port: number, + timeout: number, + verbose: boolean +): Promise { + const log = (msg: string) => { + if (verbose) { + console.error(`[cliproxy] ${msg}`); + } + }; + + // Cleanup orphaned sessions before detection + cleanupOrphanedSessions(port); + + let sessionId: string | undefined; + let shouldSpawn = false; + + // Use startup lock to coordinate with other CCS processes + await withStartupLock(async () => { + // Detect running proxy using multiple methods (HTTP, session-lock, port-process) + let proxyStatus = await detectRunningProxy(port); + log(`Proxy detection: ${JSON.stringify(proxyStatus)}`); + + // Check for version mismatch - restart proxy if installed version differs from running + if (proxyStatus.running && proxyStatus.verified && proxyStatus.version) { + const installedVersion = getInstalledCliproxyVersion(); + if (installedVersion !== proxyStatus.version) { + console.log( + warn( + `Version mismatch: running v${proxyStatus.version}, installed v${installedVersion}. Restarting proxy...` + ) + ); + log(`Stopping outdated proxy (PID: ${proxyStatus.pid ?? 'unknown'})...`); + const stopResult = await stopProxy(port); + if (stopResult.stopped) { + log(`Stopped outdated proxy successfully`); + } else { + log(`Stop proxy result: ${stopResult.error ?? 'unknown error'}`); + } + // Wait for port to be released + await new Promise((r) => setTimeout(r, 500)); + // Re-detect proxy status (should now be not running) + proxyStatus = await detectRunningProxy(port); + log(`Re-detection after version mismatch restart: ${JSON.stringify(proxyStatus)}`); + } + } + + if (proxyStatus.running && proxyStatus.verified) { + // Healthy proxy found - join it + if (proxyStatus.pid) { + sessionId = reclaimOrphanedProxy(port, proxyStatus.pid, verbose) ?? undefined; + } + if (sessionId) { + console.log(info(`Joined existing CLIProxy on port ${port} (${proxyStatus.method})`)); + } else { + // Failed to register session - proxy is running but we can't track it + console.log(info(`Using existing CLIProxy on port ${port} (session tracking unavailable)`)); + log(`PID=${proxyStatus.pid ?? 'unknown'}, session registration skipped`); + } + return; // Exit lock early, skip spawning + } + + if (proxyStatus.running && !proxyStatus.verified) { + // Proxy detected but not ready yet (another process is starting it) + log(`Proxy starting up (detected via ${proxyStatus.method}), waiting...`); + const becameHealthy = await waitForProxyHealthy(port, timeout); + if (becameHealthy) { + if (proxyStatus.pid) { + sessionId = reclaimOrphanedProxy(port, proxyStatus.pid, verbose) ?? undefined; + } + console.log(info(`Joined CLIProxy after startup wait`)); + return; // Exit lock early + } + // Proxy didn't become healthy - kill and respawn + if (proxyStatus.pid) { + log(`Proxy PID ${proxyStatus.pid} not responding, killing...`); + killProcessOnPort(port, verbose); + await new Promise((r) => setTimeout(r, 500)); + } + } + + if (proxyStatus.blocked && proxyStatus.blocker) { + // Port blocked by non-CLIProxy process + // Last resort: try HTTP health check (handles Windows PID-XXXXX case) + const isActuallyOurs = await waitForProxyHealthy(port, 1000); + if (isActuallyOurs) { + sessionId = reclaimOrphanedProxy(port, proxyStatus.blocker.pid, verbose) ?? undefined; + console.log(info(`Reclaimed CLIProxy with unrecognized process name`)); + return; + } + + // Truly blocked by another application + const { getPortCheckCommand } = await import('../../utils/platform-commands'); + console.error(''); + console.error( + warn( + `Port ${port} is blocked by ${proxyStatus.blocker.processName} (PID ${proxyStatus.blocker.pid})` + ) + ); + console.error(''); + console.error('To fix this, close the blocking application or run:'); + console.error(` ${getPortCheckCommand(port)}`); + console.error(''); + throw new Error(`Port ${port} is in use by another application`); + } + + // No proxy found - need to spawn + shouldSpawn = true; + }); + + return { sessionId, shouldSpawn }; +} + +/** + * Register a new proxy session after spawning + */ +export function registerProxySession( + port: number, + pid: number, + backend: CLIProxyBackend, + verbose: boolean +): string { + const installedVersion = getInstalledCliproxyVersion(); + const sessionId = registerSession(port, pid, installedVersion, backend); + + if (verbose) { + console.error( + `[cliproxy] Registered session ${sessionId} with new proxy (PID ${pid}, version ${installedVersion})` + ); + } + + return sessionId; +} + +/** + * Setup cleanup handlers for session unregistration + */ +export function setupCleanupHandlers( + claude: ChildProcess, + sessionId: string | undefined, + sessionPort: number, + codexReasoningProxy: unknown, + toolSanitizationProxy: unknown, + httpsTunnel: unknown, + verbose: boolean +): void { + const log = (msg: string) => { + if (verbose) { + console.error(`[cliproxy] ${msg}`); + } + }; + + const cleanup = () => { + log('Parent signal received, cleaning up'); + + if ( + codexReasoningProxy && + typeof codexReasoningProxy === 'object' && + 'stop' in codexReasoningProxy + ) { + (codexReasoningProxy as { stop: () => void }).stop(); + } + + if ( + toolSanitizationProxy && + typeof toolSanitizationProxy === 'object' && + 'stop' in toolSanitizationProxy + ) { + (toolSanitizationProxy as { stop: () => void }).stop(); + } + + if (httpsTunnel && typeof httpsTunnel === 'object' && 'stop' in httpsTunnel) { + (httpsTunnel as { stop: () => void }).stop(); + } + + // Unregister session, proxy keeps running (local mode only) + if (sessionId) { + unregisterSession(sessionId, sessionPort); + } + claude.kill('SIGTERM'); + }; + + claude.on('exit', (code, signal) => { + log(`Claude exited: code=${code}, signal=${signal}`); + + if ( + codexReasoningProxy && + typeof codexReasoningProxy === 'object' && + 'stop' in codexReasoningProxy + ) { + (codexReasoningProxy as { stop: () => void }).stop(); + } + + if ( + toolSanitizationProxy && + typeof toolSanitizationProxy === 'object' && + 'stop' in toolSanitizationProxy + ) { + (toolSanitizationProxy as { stop: () => void }).stop(); + } + + if (httpsTunnel && typeof httpsTunnel === 'object' && 'stop' in httpsTunnel) { + (httpsTunnel as { stop: () => void }).stop(); + } + + // Unregister this session (proxy keeps running for persistence) - only for local mode + if (sessionId) { + unregisterSession(sessionId, sessionPort); + log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`); + } + + if (signal) { + process.kill(process.pid, signal as NodeJS.Signals); + } else { + process.exit(code || 0); + } + }); + + claude.on('error', (error) => { + console.error(require('../../utils/ui').fail(`Claude CLI error: ${error}`)); + + if ( + codexReasoningProxy && + typeof codexReasoningProxy === 'object' && + 'stop' in codexReasoningProxy + ) { + (codexReasoningProxy as { stop: () => void }).stop(); + } + + if ( + toolSanitizationProxy && + typeof toolSanitizationProxy === 'object' && + 'stop' in toolSanitizationProxy + ) { + (toolSanitizationProxy as { stop: () => void }).stop(); + } + + if (httpsTunnel && typeof httpsTunnel === 'object' && 'stop' in httpsTunnel) { + (httpsTunnel as { stop: () => void }).stop(); + } + + // Unregister session, proxy keeps running (local mode only) + if (sessionId) { + unregisterSession(sessionId, sessionPort); + } + process.exit(1); + }); + + process.once('SIGTERM', cleanup); + process.once('SIGINT', cleanup); +} diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts index d9cb5670..0dd56fc2 100644 --- a/src/commands/cliproxy-command.ts +++ b/src/commands/cliproxy-command.ts @@ -1,1297 +1,10 @@ /** * CLIProxy Command Handler * - * Manages CLIProxyAPI binary installation and profile variants. + * DEPRECATED: This file is a backwards-compatible re-export shim. + * The actual implementation has been modularized into src/commands/cliproxy/ * - * Binary commands: - * ccs cliproxy Show current version - * ccs cliproxy --install Install specific version - * ccs cliproxy --latest Install latest version - * - * Profile commands: - * ccs cliproxy create Create CLIProxy variant profile - * ccs cliproxy list List all CLIProxy variant profiles - * ccs cliproxy remove Remove CLIProxy variant profile - * - * Supports dual-mode configuration: - * - Unified YAML format (config.yaml) when CCS_UNIFIED_CONFIG=1 or config.yaml exists - * - Legacy JSON format (config.json) as fallback + * See src/commands/cliproxy/index.ts for the new dispatcher. */ -import * as path from 'path'; -import { getAllAuthStatus, getOAuthConfig, triggerOAuth } from '../cliproxy/auth-handler'; -import { - getProviderAccounts, - setDefaultAccount, - pauseAccount, - resumeAccount, - findAccountByQuery, -} from '../cliproxy/account-manager'; -import { fetchAllProviderQuotas } from '../cliproxy/quota-fetcher'; -import { fetchAllCodexQuotas } from '../cliproxy/quota-fetcher-codex'; -import { fetchAllGeminiCliQuotas } from '../cliproxy/quota-fetcher-gemini-cli'; -import type { CodexQuotaResult, GeminiCliQuotaResult } from '../cliproxy/quota-types'; -import { isOnCooldown } from '../cliproxy/quota-manager'; -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, CLIProxyBackend } from '../cliproxy/types'; -import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; -import { - initUI, - header, - subheader, - color, - dim, - ok, - fail, - warn, - info, - table, - infoBox, -} from '../utils/ui'; -import { InteractivePrompt } from '../utils/prompt'; - -// Import services -import { - validateProfileName, - variantExists, - listVariants, - createVariant, - removeVariant, - getProxyStatus, - stopProxy, - getBinaryStatus, - checkLatestVersion, - installVersion, - installLatest, -} from '../cliproxy/services'; - -// Import sync handler -import { handleSync } from './cliproxy-sync-handler'; - -// ============================================================================ -// ARGUMENT PARSING -// ============================================================================ - -/** - * Parse --provider flag from args for quota command - * Returns the provider filter value and remaining args - * Accepts: agy, codex, gemini, gemini-cli, all - */ -function parseProviderArg(args: string[]): { - provider: 'agy' | 'codex' | 'gemini' | 'all'; - remainingArgs: string[]; -} { - const providerIdx = args.indexOf('--provider'); - if (providerIdx === -1) { - // Also check for --provider=value format - const providerEqualsIdx = args.findIndex((a) => a.startsWith('--provider=')); - if (providerEqualsIdx !== -1) { - const value = args[providerEqualsIdx].split('=')[1]?.toLowerCase() || ''; - const remainingArgs = [...args]; - remainingArgs.splice(providerEqualsIdx, 1); - // Handle empty value - if (!value) { - console.error( - warn('--provider requires a value. Valid options: agy, codex, gemini, gemini-cli, all') - ); - return { provider: 'all', remainingArgs }; - } - // Normalize gemini-cli to gemini - const normalized = value === 'gemini-cli' ? 'gemini' : value; - if ( - normalized !== 'agy' && - normalized !== 'codex' && - normalized !== 'gemini' && - normalized !== 'all' - ) { - console.error( - warn(`Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, all`) - ); - return { provider: 'all', remainingArgs }; - } - return { provider: normalized as 'agy' | 'codex' | 'gemini' | 'all', remainingArgs }; - } - return { provider: 'all', remainingArgs: args }; - } - const value = args[providerIdx + 1]?.toLowerCase() || 'all'; - const remainingArgs = [...args]; - remainingArgs.splice(providerIdx, 2); - // Normalize gemini-cli to gemini - const normalized = value === 'gemini-cli' ? 'gemini' : value; - if ( - normalized !== 'agy' && - normalized !== 'codex' && - normalized !== 'gemini' && - normalized !== 'all' - ) { - console.error( - warn(`Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, all`) - ); - return { provider: 'all', remainingArgs }; - } - return { provider: normalized as 'agy' | 'codex' | 'gemini' | 'all', remainingArgs }; -} - -/** - * 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; -} - -/** - * Get display label for backend - */ -function getBackendLabel(backend: CLIProxyBackend): string { - return backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; -} - -interface CliproxyProfileArgs { - name?: string; - provider?: CLIProxyProfileName; - model?: string; - account?: string; - force?: boolean; - yes?: boolean; -} - -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]) { - result.model = args[++i]; - } else if (arg === '--account' && args[i + 1]) { - result.account = args[++i]; - } else if (arg === '--force') { - result.force = true; - } else if (arg === '--yes' || arg === '-y') { - result.yes = true; - } else if (!arg.startsWith('-') && !result.name) { - result.name = arg; - } - } - return result; -} - -function formatModelOption(model: ModelEntry): string { - const tierBadge = - model.tier === 'ultra' - ? color(' [Ultra]', 'warning') - : model.tier === 'pro' - ? color(' [Pro]', 'warning') - : ''; - return `${model.name}${tierBadge}`; -} - -// ============================================================================ -// COMMAND HANDLERS -// ============================================================================ - -async function handleCreate(args: string[]): Promise { - await initUI(); - const { backend } = parseBackendArg(args); - const effectiveBackend = getEffectiveBackend(backend); - const parsedArgs = parseProfileArgs(args); - console.log(header(`Create ${getBackendLabel(effectiveBackend)} Variant`)); - console.log(''); - - // Step 1: Profile name - let name = parsedArgs.name; - if (!name) { - name = await InteractivePrompt.input('Variant name (e.g., g3, flash, pro)', { - validate: validateProfileName, - }); - } else { - const error = validateProfileName(name); - if (error) { - console.log(fail(error)); - process.exit(1); - } - } - - if (variantExists(name) && !parsedArgs.force) { - console.log(fail(`Variant '${name}' already exists`)); - console.log(` Use ${color('--force', 'command')} to overwrite`); - process.exit(1); - } - - // Step 2: Provider selection - let provider = parsedArgs.provider; - if (!provider) { - const providerOptions = CLIPROXY_PROFILES.map((p) => ({ - id: p, - label: p.charAt(0).toUpperCase() + p.slice(1), - })); - provider = (await InteractivePrompt.selectFromList( - 'Select provider:', - providerOptions - )) as CLIProxyProfileName; - } else if (!CLIPROXY_PROFILES.includes(provider)) { - console.log(fail(`Invalid provider: ${provider}`)); - console.log(` Available: ${CLIPROXY_PROFILES.join(', ')}`); - process.exit(1); - } - - // Step 2.5: Account selection - let account = parsedArgs.account; - const providerAccounts = getProviderAccounts(provider as CLIProxyProvider); - - if (!account) { - if (providerAccounts.length === 0) { - 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); - } - 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) { - account = providerAccounts[0].id; - } else { - 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') }, - ]; - 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) { - 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 { - account = selectedAccount; - } - } - } else { - 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)' : ''}`) - ); - process.exit(1); - } - } - - // Step 3: Model selection - let model = parsedArgs.model; - if (!model) { - 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 defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel); - model = await InteractivePrompt.selectFromList('Select model:', modelOptions, { - defaultIndex: defaultIdx >= 0 ? defaultIdx : 0, - }); - } - } - if (!model) { - model = await InteractivePrompt.input('Model name', { - validate: (val) => (val ? null : 'Model is required'), - }); - } - } - - // Create variant - console.log(''); - console.log(info(`Creating ${getBackendLabel(effectiveBackend)} variant...`)); - const result = createVariant(name, provider, model, account); - - 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 || '')}`; - const portInfo = result.variant?.port ? `Port: ${result.variant.port}\n` : ''; - console.log( - infoBox( - `Variant: ${name}\nProvider: ${provider}\nModel: ${model}\n${portInfo}${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(''); -} - -async function handleList(): Promise { - await initUI(); - console.log(header('CLIProxy Profiles')); - console.log(''); - - // 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]; - const portStr = variant.port ? String(variant.port) : '-'; - return [name, variant.provider, portStr, variant.settings || '-']; - }); - console.log( - table(rows, { head: ['Variant', 'Provider', 'Port', 'Settings'], colWidths: [15, 12, 8, 30] }) - ); - 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(''); -} - -async function handleRemove(args: string[]): Promise { - await initUI(); - const parsedArgs = parseProfileArgs(args); - const variants = listVariants(); - const variantNames = Object.keys(variants); - - if (variantNames.length === 0) { - console.log(warn('No CLIProxy variants to remove')); - process.exit(0); - } - - let name = parsedArgs.name; - if (!name) { - console.log(header('Remove CLIProxy Variant')); - console.log(''); - console.log('Available variants:'); - 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'; - if (!variantNames.includes(val)) return `Variant '${val}' not found`; - return null; - }, - }); - } - - if (!variantNames.includes(name)) { - console.log(fail(`Variant '${name}' not found`)); - console.log(''); - console.log('Available variants:'); - variantNames.forEach((n) => console.log(` - ${n}`)); - process.exit(1); - } - - const variant = variants[name]; - console.log(''); - console.log(`Variant '${color(name, 'command')}' will be removed.`); - console.log(` Provider: ${variant.provider}`); - if (variant.port) { - console.log(` Port: ${variant.port}`); - } - console.log(` Settings: ${variant.settings || '-'}`); - console.log(''); - - const confirmed = - parsedArgs.yes || (await InteractivePrompt.confirm('Delete this variant?', { default: false })); - if (!confirmed) { - console.log(info('Cancelled')); - process.exit(0); - } - - const result = removeVariant(name); - if (!result.success) { - console.log(fail(`Failed to remove variant: ${result.error}`)); - process.exit(1); - } - - console.log(ok(`Variant removed: ${name}`)); - console.log(''); -} - -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) { - console.log(info(`${result.sessionCount} active session(s) were disconnected`)); - } - } else { - console.log(warn(result.error || 'Failed to stop CLIProxy')); - } - console.log(''); -} - -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) { - console.log(` Started: ${new Date(status.startedAt).toLocaleString()}`); - } - console.log(''); - console.log(dim('To stop: ccs cliproxy stop')); - } else { - console.log(` Status: ${color('Not running', 'warning')}`); - console.log(''); - console.log(dim('CLIProxy starts automatically when you run ccs gemini, codex, etc.')); - } - console.log(''); -} - -async function showStatus(verbose: boolean, backend: CLIProxyBackend): Promise { - await initUI(); - const status = getBinaryStatus(backend); - - console.log(''); - const backendLabel = getBackendLabel(backend); - 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 - ? `${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, - backend: CLIProxyBackend -): Promise { - const label = getBackendLabel(backend); - console.log(info(`Installing ${label} v${version}...`)); - console.log(''); - - const result = await installVersion(version, verbose, backend); - if (!result.success) { - console.error(''); - console.error(fail(`Failed to install ${label} 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/${BACKEND_CONFIG[backend].repo}/releases`); - process.exit(1); - } - - console.log(''); - console.log(ok(`${label} 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, backend: CLIProxyBackend): Promise { - const label = getBackendLabel(backend); - console.log(info(`Fetching latest ${label} version...`)); - - const result = await installLatest(verbose, backend); - 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(`${label} 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'], - ], - ], - [ - 'Local Sync:', - [ - ['sync', 'Sync API profiles to local CLIProxy config'], - ['sync --dry-run', 'Preview sync without applying'], - ['sync --verbose', 'Show detailed sync information'], - ], - ], - [ - 'Quota Management:', - [ - ['default ', 'Set default account for rotation'], - ['pause ', 'Pause account (skip in rotation)'], - ['resume ', 'Resume paused account'], - ['quota', 'Show quota status for all providers'], - ['quota --provider ', 'Filter by provider (agy|codex|gemini)'], - ], - ], - [ - 'Proxy Lifecycle:', - [ - ['status', 'Show running CLIProxy status'], - ['stop', 'Stop running CLIProxy instance'], - ['doctor', 'Quota diagnostics and shared project detection'], - ], - ], - [ - 'Binary Commands:', - [ - ['--install ', 'Install and pin a specific version'], - ['--latest', 'Install the latest version (no pin)'], - ['--update', 'Unpin and update to latest version'], - ], - ], - [ - 'Options:', - [ - ['--backend ', 'Use specific backend: original | plus (default: from config)'], - ['--verbose, -v', 'Show detailed quota fetch diagnostics'], - ], - ], - ]; - - 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(getFallbackVersion(), 'info')}`); - console.log( - ` Releases: ${color(`https://github.com/${BACKEND_CONFIG[DEFAULT_BACKEND].repo}/releases`, 'path')}` - ); - console.log(''); -} - -// ============================================================================ -// DOCTOR COMMAND - Quota diagnostics and shared project detection -// ============================================================================ - -async function handleDoctor(verbose = false): Promise { - await initUI(); - console.log(header('CLIProxy Quota Diagnostics')); - console.log(''); - - // Check each OAuth provider (agy is the only one with quota) - const provider: CLIProxyProvider = 'agy'; - const accounts = getProviderAccounts(provider); - - if (accounts.length === 0) { - console.log(info('No Antigravity accounts configured')); - console.log(` Run: ${color('ccs agy --auth', 'command')} to authenticate`); - return; - } - - console.log(subheader(`Antigravity Accounts (${accounts.length})`)); - console.log(''); - - // Fetch quota for all accounts - console.log(dim('Fetching quotas...')); - const quotaResult = await fetchAllProviderQuotas(provider, verbose); - - // Display per-account quota status - for (const { account, quota } of quotaResult.accounts) { - const accountLabel = account.email || account.id || 'Unknown Account'; - const defaultBadge = account.isDefault ? color(' (default)', 'info') : ''; - - if (!quota.success) { - console.log(` ${fail(accountLabel)}${defaultBadge}`); - console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`); - if (quota.isUnprovisioned) { - console.log( - ` ${warn('Account not provisioned - open Gemini Code Assist in IDE first')}` - ); - } - console.log(''); - continue; - } - - // Calculate overall quota health (guard against empty models array) - const avgQuota = - quota.models.length > 0 - ? quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length - : 0; - const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail(''); - - console.log(` ${statusIcon}${accountLabel}${defaultBadge}`); - if (quota.projectId) { - console.log(` Project: ${dim(quota.projectId)}`); - } - - // Show model quotas - for (const model of quota.models) { - const bar = formatQuotaBar(model.percentage); - console.log(` ${model.name.padEnd(20)} ${bar} ${model.percentage.toFixed(0)}%`); - } - console.log(''); - } - - // Check for shared GCP projects (critical warning) - const sharedProjects = Object.entries(quotaResult.projectGroups).filter( - ([, accountIds]) => accountIds.length > 1 - ); - - if (sharedProjects.length > 0) { - console.log(''); - console.log(subheader('Shared Project Warning')); - console.log(''); - for (const [projectId, accountIds] of sharedProjects) { - console.log( - fail(`Project ${projectId.substring(0, 20)}... shared by ${accountIds.length} accounts:`) - ); - for (const accountId of accountIds) { - console.log(` - ${accountId}`); - } - console.log(''); - console.log(warn('These accounts share the same quota pool!')); - console.log(warn('Failover between them will NOT help when quota is exhausted.')); - console.log(info('Solution: Use accounts from different GCP projects.')); - } - } - - // Summary - console.log(''); - console.log(subheader('Summary')); - const healthyAccounts = quotaResult.accounts.filter( - ({ quota }) => quota.success && quota.models.some((m) => m.percentage > 5) - ); - console.log(` Accounts with quota: ${healthyAccounts.length}/${accounts.length}`); - if (sharedProjects.length > 0) { - console.log(` ${fail(`Shared projects: ${sharedProjects.length} (failover limited)`)}`); - } else if (accounts.length > 1) { - console.log(` ${ok('No shared projects (failover fully operational)')}`); - } - console.log(''); -} - -function formatQuotaBar(percentage: number): string { - const width = 20; - const clampedPct = Math.max(0, Math.min(100, percentage)); - const filled = Math.round((clampedPct / 100) * width); - const empty = width - filled; - const filledChar = clampedPct > 50 ? '█' : clampedPct > 10 ? '▓' : '░'; - return `[${filledChar.repeat(filled)}${' '.repeat(empty)}]`; -} - -// ============================================================================ -// QUOTA MANAGEMENT COMMANDS -// ============================================================================ - -async function handleSetDefault(args: string[]): Promise { - await initUI(); - const parsed = parseProfileArgs(args); - - if (!parsed.name) { - console.log(fail('Usage: ccs cliproxy default [--provider ]')); - console.log(''); - console.log('Examples:'); - console.log(' ccs cliproxy default ultra@gmail.com'); - console.log(' ccs cliproxy default john --provider agy'); - process.exit(1); - } - - const provider = (parsed.provider || 'agy') as CLIProxyProvider; - const account = findAccountByQuery(provider, parsed.name); - - if (!account) { - console.log(fail(`Account not found: ${parsed.name}`)); - console.log(''); - const accounts = getProviderAccounts(provider); - if (accounts.length > 0) { - console.log('Available accounts:'); - for (const acc of accounts) { - const badge = acc.isDefault ? color(' (current default)', 'info') : ''; - console.log(` - ${acc.email || acc.id}${badge}`); - } - } else { - console.log(`No accounts found for provider: ${provider}`); - console.log(`Run: ccs ${provider} --auth`); - } - process.exit(1); - } - - const success = setDefaultAccount(provider, account.id); - - if (success) { - console.log(ok(`Default account set to: ${account.email || account.id}`)); - console.log(info(`Provider: ${provider}`)); - } else { - console.log(fail('Failed to set default account')); - process.exit(1); - } -} - -async function handlePauseAccount(args: string[]): Promise { - await initUI(); - const parsed = parseProfileArgs(args); - - if (!parsed.name) { - console.log(fail('Usage: ccs cliproxy pause [--provider ]')); - console.log(''); - console.log('Pauses an account so it will be skipped in quota rotation.'); - process.exit(1); - } - - const provider = (parsed.provider || 'agy') as CLIProxyProvider; - const account = findAccountByQuery(provider, parsed.name); - - if (!account) { - console.log(fail(`Account not found: ${parsed.name}`)); - process.exit(1); - } - - if (account.paused) { - console.log(warn(`Account already paused: ${account.email || account.id}`)); - console.log(info(`Paused at: ${account.pausedAt || 'unknown'}`)); - return; - } - - const success = pauseAccount(provider, account.id); - - if (success) { - console.log(ok(`Account paused: ${account.email || account.id}`)); - console.log(info('Account will be skipped in quota rotation')); - } else { - console.log(fail('Failed to pause account')); - process.exit(1); - } -} - -async function handleResumeAccount(args: string[]): Promise { - await initUI(); - const parsed = parseProfileArgs(args); - - if (!parsed.name) { - console.log(fail('Usage: ccs cliproxy resume [--provider ]')); - console.log(''); - console.log('Resumes a paused account for quota rotation.'); - process.exit(1); - } - - const provider = (parsed.provider || 'agy') as CLIProxyProvider; - const account = findAccountByQuery(provider, parsed.name); - - if (!account) { - console.log(fail(`Account not found: ${parsed.name}`)); - process.exit(1); - } - - if (!account.paused) { - console.log(warn(`Account is not paused: ${account.email || account.id}`)); - return; - } - - const success = resumeAccount(provider, account.id); - - if (success) { - console.log(ok(`Account resumed: ${account.email || account.id}`)); - console.log(info('Account is now active in quota rotation')); - } else { - console.log(fail('Failed to resume account')); - process.exit(1); - } -} - -async function handleQuotaStatus( - verbose = false, - providerFilter: 'agy' | 'codex' | 'gemini' | 'all' = 'all' -): Promise { - await initUI(); - console.log(header('Quota Status')); - console.log(''); - - const shouldFetch = { - agy: providerFilter === 'all' || providerFilter === 'agy', - codex: providerFilter === 'all' || providerFilter === 'codex', - gemini: providerFilter === 'all' || providerFilter === 'gemini', - }; - - console.log(dim('Fetching quotas...')); - - // Parallel fetch from all requested providers - const [agyResults, codexResults, geminiResults] = await Promise.all([ - shouldFetch.agy ? fetchAllProviderQuotas('agy', verbose) : null, - shouldFetch.codex ? fetchAllCodexQuotas(verbose) : null, - shouldFetch.gemini ? fetchAllGeminiCliQuotas(verbose) : null, - ]); - - console.log(''); - - // Display Antigravity section - if (agyResults && agyResults.accounts.length > 0) { - displayAntigravityQuotaSection(agyResults); - } else if (shouldFetch.agy) { - console.log(subheader('Antigravity (0 accounts)')); - console.log(info('No Antigravity accounts configured')); - console.log(` Run: ${color('ccs agy --auth', 'command')} to authenticate`); - console.log(''); - } - - // Display Codex section - if (codexResults && codexResults.length > 0) { - displayCodexQuotaSection(codexResults); - } else if (shouldFetch.codex) { - console.log(subheader('Codex (0 accounts)')); - console.log(info('No Codex accounts configured')); - console.log(` Run: ${color('ccs codex --auth', 'command')} to authenticate`); - console.log(''); - } - - // Display Gemini CLI section - if (geminiResults && geminiResults.length > 0) { - displayGeminiCliQuotaSection(geminiResults); - } else if (shouldFetch.gemini) { - console.log(subheader('Gemini CLI (0 accounts)')); - console.log(info('No Gemini CLI accounts configured')); - console.log(` Run: ${color('ccs gemini --auth', 'command')} to authenticate`); - console.log(''); - } -} - -/** - * Display Antigravity quota section - */ -function displayAntigravityQuotaSection( - quotaResult: Awaited> -): void { - const provider: CLIProxyProvider = 'agy'; - const accounts = getProviderAccounts(provider); - - console.log( - subheader(`Antigravity (${accounts.length} account${accounts.length !== 1 ? 's' : ''})`) - ); - console.log(''); - - // Build table rows - const rows: string[][] = []; - for (const account of accounts) { - const quotaData = quotaResult.accounts.find((q) => q.account.id === account.id); - const quota = quotaData?.quota; - - // Calculate average quota - let avgQuota = 'N/A'; - if (quota?.success && quota.models.length > 0) { - const avg = Math.round( - quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length - ); - avgQuota = `${avg}%`; - } - - // Build status badges - const statusParts: string[] = []; - if (account.paused) statusParts.push(color('PAUSED', 'warning')); - if (isOnCooldown(provider, account.id)) statusParts.push(color('COOLDOWN', 'warning')); - - const defaultMark = account.isDefault ? color('*', 'success') : ' '; - const tier = account.tier || 'unknown'; - const status = statusParts.join(', '); - - rows.push([ - defaultMark, - account.nickname || account.email || account.id, - tier, - avgQuota, - status, - ]); - } - - console.log( - table(rows, { - head: ['', 'Account', 'Tier', 'Quota', 'Status'], - colWidths: [3, 30, 10, 10, 20], - }) - ); - console.log(''); -} - -/** - * Display Codex quota section - */ -function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaResult }[]): void { - console.log(subheader(`Codex (${results.length} account${results.length !== 1 ? 's' : ''})`)); - console.log(''); - - for (const { account, quota } of results) { - const accountInfo = findAccountByQuery('codex', account); - const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : ''; - - if (!quota.success) { - console.log(` ${fail(account)}${defaultMark}`); - console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`); - console.log(''); - continue; - } - - // Calculate overall quota health - const avgQuota = - quota.windows.length > 0 - ? quota.windows.reduce((sum, w) => sum + w.remainingPercent, 0) / quota.windows.length - : 0; - const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail(''); - const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : ''; - - console.log(` ${statusIcon}${account}${defaultMark}${planBadge}`); - - // Show windows - for (const window of quota.windows) { - const bar = formatQuotaBar(window.remainingPercent); - const resetLabel = window.resetAfterSeconds - ? dim(` Resets ${formatResetTime(window.resetAfterSeconds)}`) - : ''; - console.log( - ` ${window.label.padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${resetLabel}` - ); - } - console.log(''); - } -} - -/** - * Display Gemini CLI quota section - */ -function displayGeminiCliQuotaSection( - results: { account: string; quota: GeminiCliQuotaResult }[] -): void { - console.log( - subheader(`Gemini CLI (${results.length} account${results.length !== 1 ? 's' : ''})`) - ); - console.log(''); - - for (const { account, quota } of results) { - const accountInfo = findAccountByQuery('gemini', account); - const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : ''; - - if (!quota.success) { - console.log(` ${fail(account)}${defaultMark}`); - console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`); - console.log(''); - continue; - } - - // Calculate overall quota health - const avgQuota = - quota.buckets.length > 0 - ? quota.buckets.reduce((sum, b) => sum + b.remainingPercent, 0) / quota.buckets.length - : 0; - const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail(''); - - console.log(` ${statusIcon}${account}${defaultMark}`); - if (quota.projectId) { - console.log(` Project: ${dim(quota.projectId)}`); - } - - // Show buckets - for (const bucket of quota.buckets) { - const bar = formatQuotaBar(bucket.remainingPercent); - const tokenLabel = bucket.tokenType ? dim(` (${bucket.tokenType})`) : ''; - const resetLabel = bucket.resetTime - ? dim(` Resets ${formatResetTimeISO(bucket.resetTime)}`) - : ''; - console.log( - ` ${bucket.label.padEnd(24)} ${bar} ${bucket.remainingPercent.toFixed(0)}%${tokenLabel}${resetLabel}` - ); - } - console.log(''); - } -} - -/** - * Format reset time from seconds - */ -function formatResetTime(seconds: number): string { - if (seconds <= 0) return 'now'; - if (seconds < 60) return `in ${seconds}s`; - if (seconds < 3600) return `in ${Math.round(seconds / 60)}m`; - return `in ${Math.round(seconds / 3600)}h`; -} - -/** - * Format reset time from ISO timestamp - */ -function formatResetTimeISO(isoTime: string): string { - if (!isoTime) return 'unknown'; - const resetDate = new Date(isoTime); - if (isNaN(resetDate.getTime())) return 'unknown'; - const seconds = Math.max(0, Math.round((resetDate.getTime() - Date.now()) / 1000)); - return formatResetTime(seconds); -} - -// ============================================================================ -// MAIN ROUTER -// ============================================================================ - -export async function handleCliproxyCommand(args: string[]): Promise { - // Parse --backend flag first (before other processing) - const { backend: cliBackend, remainingArgs } = parseBackendArg(args); - const effectiveBackend = getEffectiveBackend(cliBackend); - - 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(remainingArgs.slice(1)); - return; - } - - if (command === 'list' || command === 'ls') { - await handleList(); - return; - } - - if (command === 'remove' || command === 'delete' || command === 'rm') { - await handleRemove(remainingArgs.slice(1)); - return; - } - - if (command === 'sync') { - await handleSync(remainingArgs.slice(1)); - return; - } - - if (command === 'stop') { - await handleStop(); - return; - } - - if (command === 'status') { - await handleProxyStatus(); - return; - } - - if (command === 'doctor' || command === 'diag') { - await handleDoctor(verbose); - return; - } - - // Quota management commands - if (command === 'default') { - await handleSetDefault(remainingArgs.slice(1)); - return; - } - - if (command === 'pause') { - await handlePauseAccount(remainingArgs.slice(1)); - return; - } - - if (command === 'resume') { - await handleResumeAccount(remainingArgs.slice(1)); - return; - } - - if (command === 'quota') { - const { provider: providerFilter } = parseProviderArg(remainingArgs.slice(1)); - await handleQuotaStatus(verbose, providerFilter); - return; - } - - const installIdx = remainingArgs.indexOf('--install'); - if (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 '); - console.error(' Example: ccs cliproxy --install 6.6.80-0'); - process.exit(1); - } - // Strip leading 'v' prefix and whitespace (user may type " v6.6.80-0 ") - version = version.trim().replace(/^v/, ''); - await handleInstallVersion(version, verbose, effectiveBackend); - return; - } - - if (remainingArgs.includes('--latest') || remainingArgs.includes('--update')) { - await handleInstallLatest(verbose, effectiveBackend); - return; - } - - await showStatus(verbose, effectiveBackend); -} +export { handleCliproxyCommand } from './cliproxy/index'; diff --git a/src/commands/cliproxy/auth-subcommand.ts b/src/commands/cliproxy/auth-subcommand.ts new file mode 100644 index 00000000..fbdaa353 --- /dev/null +++ b/src/commands/cliproxy/auth-subcommand.ts @@ -0,0 +1,60 @@ +/** + * CLIProxy OAuth Authentication Operations + * + * Handles: + * - ccs cliproxy list + * - OAuth status display + * - Built-in profile authentication status + */ + +import { getAllAuthStatus, getOAuthConfig } from '../../cliproxy/auth-handler'; +import { listVariants } from '../../cliproxy/services'; +import { initUI, header, subheader, color, dim, ok, warn, table } from '../../utils/ui'; + +export async function handleList(): Promise { + await initUI(); + console.log(header('CLIProxy Profiles')); + console.log(''); + + // 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]; + const portStr = variant.port ? String(variant.port) : '-'; + return [name, variant.provider, portStr, variant.settings || '-']; + }); + console.log( + table(rows, { head: ['Variant', 'Provider', 'Port', 'Settings'], colWidths: [15, 12, 8, 30] }) + ); + 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(''); +} diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts new file mode 100644 index 00000000..74e0a597 --- /dev/null +++ b/src/commands/cliproxy/help-subcommand.ts @@ -0,0 +1,93 @@ +/** + * CLIProxy Help Display + * + * Handles: + * - ccs cliproxy --help + */ + +import { initUI, header, subheader, color, dim } from '../../utils/ui'; +import { + DEFAULT_BACKEND, + getFallbackVersion, + BACKEND_CONFIG, +} from '../../cliproxy/platform-detector'; + +export 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'], + ], + ], + [ + 'Local Sync:', + [ + ['sync', 'Sync API profiles to local CLIProxy config'], + ['sync --dry-run', 'Preview sync without applying'], + ['sync --verbose', 'Show detailed sync information'], + ], + ], + [ + 'Quota Management:', + [ + ['default ', 'Set default account for rotation'], + ['pause ', 'Pause account (skip in rotation)'], + ['resume ', 'Resume paused account'], + ['quota', 'Show quota status for all providers'], + ['quota --provider ', 'Filter by provider (agy|codex|gemini)'], + ], + ], + [ + 'Proxy Lifecycle:', + [ + ['status', 'Show running CLIProxy status'], + ['stop', 'Stop running CLIProxy instance'], + ['doctor', 'Quota diagnostics and shared project detection'], + ], + ], + [ + 'Binary Commands:', + [ + ['--install ', 'Install and pin a specific version'], + ['--latest', 'Install the latest version (no pin)'], + ['--update', 'Unpin and update to latest version'], + ], + ], + [ + 'Options:', + [ + ['--backend ', 'Use specific backend: original | plus (default: from config)'], + ['--verbose, -v', 'Show detailed quota fetch diagnostics'], + ], + ], + ]; + + 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(getFallbackVersion(), 'info')}`); + console.log( + ` Releases: ${color(`https://github.com/${BACKEND_CONFIG[DEFAULT_BACKEND].repo}/releases`, 'path')}` + ); + console.log(''); +} diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts new file mode 100644 index 00000000..176901c3 --- /dev/null +++ b/src/commands/cliproxy/index.ts @@ -0,0 +1,237 @@ +/** + * CLIProxy Command Dispatcher + * + * Routes cliproxy subcommands to their respective handlers. + * This is the main entry point for all `ccs cliproxy` commands. + */ + +import { CLIProxyBackend } from '../../cliproxy/types'; +import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector'; +import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; +import { handleSync } from '../cliproxy-sync-handler'; + +// Import subcommand handlers +import { handleList } from './auth-subcommand'; +import { + handleQuotaStatus, + handleDoctor, + handleSetDefault, + handlePauseAccount, + handleResumeAccount, +} from './quota-subcommand'; +import { handleCreate, handleRemove } from './variant-subcommand'; +import { handleProxyStatus, handleStop } from './proxy-lifecycle-subcommand'; +import { showStatus, handleInstallVersion, handleInstallLatest } from './install-subcommand'; +import { showHelp } from './help-subcommand'; + +/** + * 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') { + console.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') { + console.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; +} + +/** + * Parse --provider flag from args for quota command + * Returns the provider filter value and remaining args + * Accepts: agy, codex, gemini, gemini-cli, all + */ +function parseProviderArg(args: string[]): { + provider: 'agy' | 'codex' | 'gemini' | 'all'; + remainingArgs: string[]; +} { + const providerIdx = args.indexOf('--provider'); + if (providerIdx === -1) { + // Also check for --provider=value format + const providerEqualsIdx = args.findIndex((a) => a.startsWith('--provider=')); + if (providerEqualsIdx !== -1) { + const value = args[providerEqualsIdx].split('=')[1]?.toLowerCase() || ''; + const remainingArgs = [...args]; + remainingArgs.splice(providerEqualsIdx, 1); + // Handle empty value + if (!value) { + console.error( + 'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, all' + ); + return { provider: 'all', remainingArgs }; + } + // Normalize gemini-cli to gemini + const normalized = value === 'gemini-cli' ? 'gemini' : value; + if ( + normalized !== 'agy' && + normalized !== 'codex' && + normalized !== 'gemini' && + normalized !== 'all' + ) { + console.error( + `Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, all` + ); + return { provider: 'all', remainingArgs }; + } + return { provider: normalized as 'agy' | 'codex' | 'gemini' | 'all', remainingArgs }; + } + return { provider: 'all', remainingArgs: args }; + } + const rawValue = args[providerIdx + 1]; + // Warn if no value or value looks like another flag + if (!rawValue || rawValue.startsWith('-')) { + console.error( + 'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, all' + ); + } + const value = rawValue?.toLowerCase() || 'all'; + const remainingArgs = [...args]; + remainingArgs.splice(providerIdx, 2); + // Normalize gemini-cli to gemini + const normalized = value === 'gemini-cli' ? 'gemini' : value; + if ( + normalized !== 'agy' && + normalized !== 'codex' && + normalized !== 'gemini' && + normalized !== 'all' + ) { + console.error( + `Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, all` + ); + return { provider: 'all', remainingArgs }; + } + return { provider: normalized as 'agy' | 'codex' | 'gemini' | 'all', remainingArgs }; +} + +/** + * Main router for cliproxy commands + */ +export async function handleCliproxyCommand(args: string[]): Promise { + // Parse --backend flag first (before other processing) + const { backend: cliBackend, remainingArgs } = parseBackendArg(args); + const effectiveBackend = getEffectiveBackend(cliBackend); + + const verbose = remainingArgs.includes('--verbose') || remainingArgs.includes('-v'); + const command = remainingArgs[0]; + + if (remainingArgs.includes('--help') || remainingArgs.includes('-h')) { + await showHelp(); + return; + } + + // Profile commands + if (command === 'create') { + await handleCreate(remainingArgs.slice(1), effectiveBackend); + return; + } + + if (command === 'list' || command === 'ls') { + await handleList(); + return; + } + + if (command === 'remove' || command === 'delete' || command === 'rm') { + await handleRemove(remainingArgs.slice(1)); + return; + } + + // Sync command + if (command === 'sync') { + await handleSync(remainingArgs.slice(1)); + return; + } + + // Proxy lifecycle commands + if (command === 'stop') { + await handleStop(); + return; + } + + if (command === 'status') { + await handleProxyStatus(); + return; + } + + // Diagnostics + if (command === 'doctor' || command === 'diag') { + await handleDoctor(verbose); + return; + } + + // Quota management commands + if (command === 'default') { + await handleSetDefault(remainingArgs.slice(1)); + return; + } + + if (command === 'pause') { + await handlePauseAccount(remainingArgs.slice(1)); + return; + } + + if (command === 'resume') { + await handleResumeAccount(remainingArgs.slice(1)); + return; + } + + if (command === 'quota') { + const { provider: providerFilter } = parseProviderArg(remainingArgs.slice(1)); + await handleQuotaStatus(verbose, providerFilter); + return; + } + + // Binary installation commands + const installIdx = remainingArgs.indexOf('--install'); + if (installIdx !== -1) { + let version = remainingArgs[installIdx + 1]; + if (!version || version.startsWith('-')) { + console.error('Missing version argument for --install'); + console.error(' Usage: ccs cliproxy --install '); + console.error(' Example: ccs cliproxy --install 6.6.80-0'); + process.exit(1); + } + // Strip leading 'v' prefix and whitespace (user may type " v6.6.80-0 ") + version = version.trim().replace(/^v/, ''); + await handleInstallVersion(version, verbose, effectiveBackend); + return; + } + + if (remainingArgs.includes('--latest') || remainingArgs.includes('--update')) { + await handleInstallLatest(verbose, effectiveBackend); + return; + } + + // Default: show status + await showStatus(verbose, effectiveBackend); +} diff --git a/src/commands/cliproxy/install-subcommand.ts b/src/commands/cliproxy/install-subcommand.ts new file mode 100644 index 00000000..f5f59036 --- /dev/null +++ b/src/commands/cliproxy/install-subcommand.ts @@ -0,0 +1,138 @@ +/** + * CLIProxy Binary Installation + * + * Handles: + * - ccs cliproxy (show status) + * - ccs cliproxy --install + * - ccs cliproxy --latest + * - ccs cliproxy --update + */ + +import { initUI, color, dim, ok, fail, info } from '../../utils/ui'; +import { + getBinaryStatus, + checkLatestVersion, + installVersion, + installLatest, +} from '../../cliproxy/services'; +import { DEFAULT_BACKEND, BACKEND_CONFIG } from '../../cliproxy/platform-detector'; +import { CLIProxyBackend } from '../../cliproxy/types'; + +function getBackendLabel(backend: CLIProxyBackend): string { + return backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; +} + +export async function showStatus(verbose: boolean, backend: CLIProxyBackend): Promise { + await initUI(); + const status = getBinaryStatus(backend); + + console.log(''); + const backendLabel = getBackendLabel(backend); + 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 + ? `${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(''); +} + +export async function handleInstallVersion( + version: string, + verbose: boolean, + backend: CLIProxyBackend +): Promise { + const label = getBackendLabel(backend); + console.log(info(`Installing ${label} v${version}...`)); + console.log(''); + + const result = await installVersion(version, verbose, backend); + if (!result.success) { + console.error(''); + console.error(fail(`Failed to install ${label} 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/${BACKEND_CONFIG[backend].repo}/releases`); + process.exit(1); + } + + console.log(''); + console.log(ok(`${label} 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(''); +} + +export async function handleInstallLatest( + verbose: boolean, + backend: CLIProxyBackend +): Promise { + const label = getBackendLabel(backend); + console.log(info(`Fetching latest ${label} version...`)); + + const result = await installLatest(verbose, backend); + 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(`${label} updated to v${result.version}`)); + console.log(dim('Auto-update is now enabled.')); + console.log(''); +} diff --git a/src/commands/cliproxy/proxy-lifecycle-subcommand.ts b/src/commands/cliproxy/proxy-lifecycle-subcommand.ts new file mode 100644 index 00000000..aea76afb --- /dev/null +++ b/src/commands/cliproxy/proxy-lifecycle-subcommand.ts @@ -0,0 +1,51 @@ +/** + * CLIProxy Lifecycle Management + * + * Handles: + * - ccs cliproxy status + * - ccs cliproxy stop + */ + +import { initUI, header, color, dim, ok, warn, info } from '../../utils/ui'; +import { getProxyStatus, stopProxy } from '../../cliproxy/services'; + +export 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) { + console.log(` Started: ${new Date(status.startedAt).toLocaleString()}`); + } + console.log(''); + console.log(dim('To stop: ccs cliproxy stop')); + } else { + console.log(` Status: ${color('Not running', 'warning')}`); + console.log(''); + console.log(dim('CLIProxy starts automatically when you run ccs gemini, codex, etc.')); + } + console.log(''); +} + +export 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) { + console.log(info(`${result.sessionCount} active session(s) were disconnected`)); + } + } else { + console.log(warn(result.error || 'Failed to stop CLIProxy')); + } + console.log(''); +} diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts new file mode 100644 index 00000000..c1dfa9a3 --- /dev/null +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -0,0 +1,467 @@ +/** + * CLIProxy Quota Management + * + * Handles: + * - ccs cliproxy quota [--provider ] + * - ccs cliproxy default + * - ccs cliproxy pause + * - ccs cliproxy resume + * - ccs cliproxy doctor + */ + +import { + getProviderAccounts, + setDefaultAccount, + pauseAccount, + resumeAccount, + findAccountByQuery, +} from '../../cliproxy/account-manager'; +import { fetchAllProviderQuotas } from '../../cliproxy/quota-fetcher'; +import { fetchAllCodexQuotas } from '../../cliproxy/quota-fetcher-codex'; +import { fetchAllGeminiCliQuotas } from '../../cliproxy/quota-fetcher-gemini-cli'; +import type { CodexQuotaResult, GeminiCliQuotaResult } from '../../cliproxy/quota-types'; +import { isOnCooldown } from '../../cliproxy/quota-manager'; +import { CLIProxyProvider } from '../../cliproxy/types'; +import { initUI, header, subheader, color, dim, ok, fail, warn, info, table } from '../../utils/ui'; + +interface CliproxyProfileArgs { + name?: string; + provider?: string; + model?: string; + account?: string; + force?: boolean; + yes?: boolean; +} + +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]; + } else if (arg === '--model' && args[i + 1]) { + result.model = args[++i]; + } else if (arg === '--account' && args[i + 1]) { + result.account = args[++i]; + } else if (arg === '--force') { + result.force = true; + } else if (arg === '--yes' || arg === '-y') { + result.yes = true; + } else if (!arg.startsWith('-') && !result.name) { + result.name = arg; + } + } + return result; +} + +function formatQuotaBar(percentage: number): string { + const width = 20; + const clampedPct = Math.max(0, Math.min(100, percentage)); + const filled = Math.round((clampedPct / 100) * width); + const empty = width - filled; + const filledChar = clampedPct > 50 ? '█' : clampedPct > 10 ? '▓' : '░'; + return `[${filledChar.repeat(filled)}${' '.repeat(empty)}]`; +} + +function formatResetTime(seconds: number): string { + if (seconds <= 0) return 'now'; + if (seconds < 60) return `in ${seconds}s`; + if (seconds < 3600) return `in ${Math.round(seconds / 60)}m`; + return `in ${Math.round(seconds / 3600)}h`; +} + +function formatResetTimeISO(isoTime: string): string { + if (!isoTime) return 'unknown'; + const resetDate = new Date(isoTime); + if (isNaN(resetDate.getTime())) return 'unknown'; + const seconds = Math.max(0, Math.round((resetDate.getTime() - Date.now()) / 1000)); + return formatResetTime(seconds); +} + +function displayAntigravityQuotaSection( + quotaResult: Awaited> +): void { + const provider: CLIProxyProvider = 'agy'; + const accounts = getProviderAccounts(provider); + + console.log( + subheader(`Antigravity (${accounts.length} account${accounts.length !== 1 ? 's' : ''})`) + ); + console.log(''); + + const rows: string[][] = []; + for (const account of accounts) { + const quotaData = quotaResult.accounts.find((q) => q.account.id === account.id); + const quota = quotaData?.quota; + + let avgQuota = 'N/A'; + if (quota?.success && quota.models.length > 0) { + const avg = Math.round( + quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length + ); + avgQuota = `${avg}%`; + } + + const statusParts: string[] = []; + if (account.paused) statusParts.push(color('PAUSED', 'warning')); + if (isOnCooldown(provider, account.id)) statusParts.push(color('COOLDOWN', 'warning')); + + const defaultMark = account.isDefault ? color('*', 'success') : ' '; + const tier = account.tier || 'unknown'; + const status = statusParts.join(', '); + + rows.push([ + defaultMark, + account.nickname || account.email || account.id, + tier, + avgQuota, + status, + ]); + } + + console.log( + table(rows, { + head: ['', 'Account', 'Tier', 'Quota', 'Status'], + colWidths: [3, 30, 10, 10, 20], + }) + ); + console.log(''); +} + +function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaResult }[]): void { + console.log(subheader(`Codex (${results.length} account${results.length !== 1 ? 's' : ''})`)); + console.log(''); + + for (const { account, quota } of results) { + const accountInfo = findAccountByQuery('codex', account); + const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : ''; + + if (!quota.success) { + console.log(` ${fail(account)}${defaultMark}`); + console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`); + console.log(''); + continue; + } + + const avgQuota = + quota.windows.length > 0 + ? quota.windows.reduce((sum, w) => sum + w.remainingPercent, 0) / quota.windows.length + : 0; + const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail(''); + const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : ''; + + console.log(` ${statusIcon}${account}${defaultMark}${planBadge}`); + + for (const window of quota.windows) { + const bar = formatQuotaBar(window.remainingPercent); + const resetLabel = window.resetAfterSeconds + ? dim(` Resets ${formatResetTime(window.resetAfterSeconds)}`) + : ''; + console.log( + ` ${window.label.padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${resetLabel}` + ); + } + console.log(''); + } +} + +function displayGeminiCliQuotaSection( + results: { account: string; quota: GeminiCliQuotaResult }[] +): void { + console.log( + subheader(`Gemini CLI (${results.length} account${results.length !== 1 ? 's' : ''})`) + ); + console.log(''); + + for (const { account, quota } of results) { + const accountInfo = findAccountByQuery('gemini', account); + const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : ''; + + if (!quota.success) { + console.log(` ${fail(account)}${defaultMark}`); + console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`); + console.log(''); + continue; + } + + const avgQuota = + quota.buckets.length > 0 + ? quota.buckets.reduce((sum, b) => sum + b.remainingPercent, 0) / quota.buckets.length + : 0; + const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail(''); + + console.log(` ${statusIcon}${account}${defaultMark}`); + if (quota.projectId) { + console.log(` Project: ${dim(quota.projectId)}`); + } + + for (const bucket of quota.buckets) { + const bar = formatQuotaBar(bucket.remainingPercent); + const tokenLabel = bucket.tokenType ? dim(` (${bucket.tokenType})`) : ''; + const resetLabel = bucket.resetTime + ? dim(` Resets ${formatResetTimeISO(bucket.resetTime)}`) + : ''; + console.log( + ` ${bucket.label.padEnd(24)} ${bar} ${bucket.remainingPercent.toFixed(0)}%${tokenLabel}${resetLabel}` + ); + } + console.log(''); + } +} + +export async function handleQuotaStatus( + verbose = false, + providerFilter: 'agy' | 'codex' | 'gemini' | 'all' = 'all' +): Promise { + await initUI(); + console.log(header('Quota Status')); + console.log(''); + + const shouldFetch = { + agy: providerFilter === 'all' || providerFilter === 'agy', + codex: providerFilter === 'all' || providerFilter === 'codex', + gemini: providerFilter === 'all' || providerFilter === 'gemini', + }; + + console.log(dim('Fetching quotas...')); + + const [agyResults, codexResults, geminiResults] = await Promise.all([ + shouldFetch.agy ? fetchAllProviderQuotas('agy', verbose) : null, + shouldFetch.codex ? fetchAllCodexQuotas(verbose) : null, + shouldFetch.gemini ? fetchAllGeminiCliQuotas(verbose) : null, + ]); + + console.log(''); + + if (agyResults && agyResults.accounts.length > 0) { + displayAntigravityQuotaSection(agyResults); + } else if (shouldFetch.agy) { + console.log(subheader('Antigravity (0 accounts)')); + console.log(info('No Antigravity accounts configured')); + console.log(` Run: ${color('ccs agy --auth', 'command')} to authenticate`); + console.log(''); + } + + if (codexResults && codexResults.length > 0) { + displayCodexQuotaSection(codexResults); + } else if (shouldFetch.codex) { + console.log(subheader('Codex (0 accounts)')); + console.log(info('No Codex accounts configured')); + console.log(` Run: ${color('ccs codex --auth', 'command')} to authenticate`); + console.log(''); + } + + if (geminiResults && geminiResults.length > 0) { + displayGeminiCliQuotaSection(geminiResults); + } else if (shouldFetch.gemini) { + console.log(subheader('Gemini CLI (0 accounts)')); + console.log(info('No Gemini CLI accounts configured')); + console.log(` Run: ${color('ccs gemini --auth', 'command')} to authenticate`); + console.log(''); + } +} + +export async function handleDoctor(verbose = false): Promise { + await initUI(); + console.log(header('CLIProxy Quota Diagnostics')); + console.log(''); + + const provider: CLIProxyProvider = 'agy'; + const accounts = getProviderAccounts(provider); + + if (accounts.length === 0) { + console.log(info('No Antigravity accounts configured')); + console.log(` Run: ${color('ccs agy --auth', 'command')} to authenticate`); + return; + } + + console.log(subheader(`Antigravity Accounts (${accounts.length})`)); + console.log(''); + + console.log(dim('Fetching quotas...')); + const quotaResult = await fetchAllProviderQuotas(provider, verbose); + + for (const { account, quota } of quotaResult.accounts) { + const accountLabel = account.email || account.id || 'Unknown Account'; + const defaultBadge = account.isDefault ? color(' (default)', 'info') : ''; + + if (!quota.success) { + console.log(` ${fail(accountLabel)}${defaultBadge}`); + console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`); + if (quota.isUnprovisioned) { + console.log( + ` ${warn('Account not provisioned - open Gemini Code Assist in IDE first')}` + ); + } + console.log(''); + continue; + } + + const avgQuota = + quota.models.length > 0 + ? quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length + : 0; + const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail(''); + + console.log(` ${statusIcon}${accountLabel}${defaultBadge}`); + if (quota.projectId) { + console.log(` Project: ${dim(quota.projectId)}`); + } + + for (const model of quota.models) { + const bar = formatQuotaBar(model.percentage); + console.log(` ${model.name.padEnd(20)} ${bar} ${model.percentage.toFixed(0)}%`); + } + console.log(''); + } + + const sharedProjects = Object.entries(quotaResult.projectGroups).filter( + ([, accountIds]) => accountIds.length > 1 + ); + + if (sharedProjects.length > 0) { + console.log(''); + console.log(subheader('Shared Project Warning')); + console.log(''); + for (const [projectId, accountIds] of sharedProjects) { + console.log( + fail(`Project ${projectId.substring(0, 20)}... shared by ${accountIds.length} accounts:`) + ); + for (const accountId of accountIds) { + console.log(` - ${accountId}`); + } + console.log(''); + console.log(warn('These accounts share the same quota pool!')); + console.log(warn('Failover between them will NOT help when quota is exhausted.')); + console.log(info('Solution: Use accounts from different GCP projects.')); + } + } + + console.log(''); + console.log(subheader('Summary')); + const healthyAccounts = quotaResult.accounts.filter( + ({ quota }) => quota.success && quota.models.some((m) => m.percentage > 5) + ); + console.log(` Accounts with quota: ${healthyAccounts.length}/${accounts.length}`); + if (sharedProjects.length > 0) { + console.log(` ${fail(`Shared projects: ${sharedProjects.length} (failover limited)`)}`); + } else if (accounts.length > 1) { + console.log(` ${ok('No shared projects (failover fully operational)')}`); + } + console.log(''); +} + +export async function handleSetDefault(args: string[]): Promise { + await initUI(); + const parsed = parseProfileArgs(args); + + if (!parsed.name) { + console.log(fail('Usage: ccs cliproxy default [--provider ]')); + console.log(''); + console.log('Examples:'); + console.log(' ccs cliproxy default ultra@gmail.com'); + console.log(' ccs cliproxy default john --provider agy'); + process.exit(1); + } + + const provider = (parsed.provider || 'agy') as CLIProxyProvider; + const account = findAccountByQuery(provider, parsed.name); + + if (!account) { + console.log(fail(`Account not found: ${parsed.name}`)); + console.log(''); + const accounts = getProviderAccounts(provider); + if (accounts.length > 0) { + console.log('Available accounts:'); + for (const acc of accounts) { + const badge = acc.isDefault ? color(' (current default)', 'info') : ''; + console.log(` - ${acc.email || acc.id}${badge}`); + } + } else { + console.log(`No accounts found for provider: ${provider}`); + console.log(`Run: ccs ${provider} --auth`); + } + process.exit(1); + } + + const success = setDefaultAccount(provider, account.id); + + if (success) { + console.log(ok(`Default account set to: ${account.email || account.id}`)); + console.log(info(`Provider: ${provider}`)); + } else { + console.log(fail('Failed to set default account')); + process.exit(1); + } +} + +export async function handlePauseAccount(args: string[]): Promise { + await initUI(); + const parsed = parseProfileArgs(args); + + if (!parsed.name) { + console.log(fail('Usage: ccs cliproxy pause [--provider ]')); + console.log(''); + console.log('Pauses an account so it will be skipped in quota rotation.'); + process.exit(1); + } + + const provider = (parsed.provider || 'agy') as CLIProxyProvider; + const account = findAccountByQuery(provider, parsed.name); + + if (!account) { + console.log(fail(`Account not found: ${parsed.name}`)); + process.exit(1); + } + + if (account.paused) { + console.log(warn(`Account already paused: ${account.email || account.id}`)); + console.log(info(`Paused at: ${account.pausedAt || 'unknown'}`)); + return; + } + + const success = pauseAccount(provider, account.id); + + if (success) { + console.log(ok(`Account paused: ${account.email || account.id}`)); + console.log(info('Account will be skipped in quota rotation')); + } else { + console.log(fail('Failed to pause account')); + process.exit(1); + } +} + +export async function handleResumeAccount(args: string[]): Promise { + await initUI(); + const parsed = parseProfileArgs(args); + + if (!parsed.name) { + console.log(fail('Usage: ccs cliproxy resume [--provider ]')); + console.log(''); + console.log('Resumes a paused account for quota rotation.'); + process.exit(1); + } + + const provider = (parsed.provider || 'agy') as CLIProxyProvider; + const account = findAccountByQuery(provider, parsed.name); + + if (!account) { + console.log(fail(`Account not found: ${parsed.name}`)); + process.exit(1); + } + + if (!account.paused) { + console.log(warn(`Account is not paused: ${account.email || account.id}`)); + return; + } + + const success = resumeAccount(provider, account.id); + + if (success) { + console.log(ok(`Account resumed: ${account.email || account.id}`)); + console.log(info('Account is now active in quota rotation')); + } else { + console.log(fail('Failed to resume account')); + process.exit(1); + } +} diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts new file mode 100644 index 00000000..24d7ba0c --- /dev/null +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -0,0 +1,307 @@ +/** + * CLIProxy Variant Management + * + * Handles: + * - ccs cliproxy create [name] + * - ccs cliproxy remove + */ + +import * as path from 'path'; +import { getProviderAccounts } from '../../cliproxy/account-manager'; +import { triggerOAuth } from '../../cliproxy/auth/oauth-handler'; +import { CLIProxyProfileName, CLIPROXY_PROFILES } from '../../auth/profile-detector'; +import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../../cliproxy/model-catalog'; +import { CLIProxyProvider, CLIProxyBackend } from '../../cliproxy/types'; +import { isUnifiedMode } from '../../config/unified-config-loader'; +import { initUI, header, color, ok, fail, warn, info, infoBox, dim } from '../../utils/ui'; +import { InteractivePrompt } from '../../utils/prompt'; +import { + validateProfileName, + variantExists, + listVariants, + createVariant, + removeVariant, +} from '../../cliproxy/services'; +import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector'; + +interface CliproxyProfileArgs { + name?: string; + provider?: CLIProxyProfileName; + model?: string; + account?: string; + force?: boolean; + yes?: boolean; +} + +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]) { + result.model = args[++i]; + } else if (arg === '--account' && args[i + 1]) { + result.account = args[++i]; + } else if (arg === '--force') { + result.force = true; + } else if (arg === '--yes' || arg === '-y') { + result.yes = true; + } else if (!arg.startsWith('-') && !result.name) { + result.name = arg; + } + } + return result; +} + +function formatModelOption(model: ModelEntry): string { + const tierBadge = + model.tier === 'ultra' + ? color(' [Ultra]', 'warning') + : model.tier === 'pro' + ? color(' [Pro]', 'warning') + : ''; + return `${model.name}${tierBadge}`; +} + +function getBackendLabel(backend: CLIProxyBackend): string { + return backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; +} + +export async function handleCreate( + args: string[], + backend: CLIProxyBackend = DEFAULT_BACKEND +): Promise { + await initUI(); + const parsedArgs = parseProfileArgs(args); + console.log(header(`Create ${getBackendLabel(backend)} Variant`)); + console.log(''); + + // Step 1: Profile name + let name = parsedArgs.name; + if (!name) { + name = await InteractivePrompt.input('Variant name (e.g., g3, flash, pro)', { + validate: validateProfileName, + }); + } else { + const error = validateProfileName(name); + if (error) { + console.log(fail(error)); + process.exit(1); + } + } + + if (variantExists(name) && !parsedArgs.force) { + console.log(fail(`Variant '${name}' already exists`)); + console.log(` Use ${color('--force', 'command')} to overwrite`); + process.exit(1); + } + + // Step 2: Provider selection + let provider = parsedArgs.provider; + if (!provider) { + const providerOptions = CLIPROXY_PROFILES.map((p) => ({ + id: p, + label: p.charAt(0).toUpperCase() + p.slice(1), + })); + provider = (await InteractivePrompt.selectFromList( + 'Select provider:', + providerOptions + )) as CLIProxyProfileName; + } else if (!CLIPROXY_PROFILES.includes(provider)) { + console.log(fail(`Invalid provider: ${provider}`)); + console.log(` Available: ${CLIPROXY_PROFILES.join(', ')}`); + process.exit(1); + } + + // Step 2.5: Account selection + let account = parsedArgs.account; + const providerAccounts = getProviderAccounts(provider as CLIProxyProvider); + + if (!account) { + if (providerAccounts.length === 0) { + 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); + } + 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) { + account = providerAccounts[0].id; + } else { + 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') }, + ]; + 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) { + 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 { + account = selectedAccount; + } + } + } else { + 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)' : ''}`) + ); + process.exit(1); + } + } + + // Step 3: Model selection + let model = parsedArgs.model; + if (!model) { + 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 defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel); + model = await InteractivePrompt.selectFromList('Select model:', modelOptions, { + defaultIndex: defaultIdx >= 0 ? defaultIdx : 0, + }); + } + } + if (!model) { + model = await InteractivePrompt.input('Model name', { + validate: (val) => (val ? null : 'Model is required'), + }); + } + } + + // Create variant + console.log(''); + console.log(info(`Creating ${getBackendLabel(backend)} variant...`)); + const result = createVariant(name, provider, model, account); + + 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 || '')}`; + const portInfo = result.variant?.port ? `Port: ${result.variant.port}\n` : ''; + console.log( + infoBox( + `Variant: ${name}\nProvider: ${provider}\nModel: ${model}\n${portInfo}${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(''); +} + +export async function handleRemove(args: string[]): Promise { + await initUI(); + const parsedArgs = parseProfileArgs(args); + const variants = listVariants(); + const variantNames = Object.keys(variants); + + if (variantNames.length === 0) { + console.log(warn('No CLIProxy variants to remove')); + process.exit(0); + } + + let name = parsedArgs.name; + if (!name) { + console.log(header('Remove CLIProxy Variant')); + console.log(''); + console.log('Available variants:'); + 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'; + if (!variantNames.includes(val)) return `Variant '${val}' not found`; + return null; + }, + }); + } + + if (!variantNames.includes(name)) { + console.log(fail(`Variant '${name}' not found`)); + console.log(''); + console.log('Available variants:'); + variantNames.forEach((n) => console.log(` - ${n}`)); + process.exit(1); + } + + const variant = variants[name]; + console.log(''); + console.log(`Variant '${color(name, 'command')}' will be removed.`); + console.log(` Provider: ${variant.provider}`); + if (variant.port) { + console.log(` Port: ${variant.port}`); + } + console.log(` Settings: ${variant.settings || '-'}`); + console.log(''); + + const confirmed = + parsedArgs.yes || (await InteractivePrompt.confirm('Delete this variant?', { default: false })); + if (!confirmed) { + console.log(info('Cancelled')); + process.exit(0); + } + + const result = removeVariant(name); + if (!result.success) { + console.log(fail(`Failed to remove variant: ${result.error}`)); + process.exit(1); + } + + console.log(ok(`Variant removed: ${name}`)); + console.log(''); +}