From b149e252ebe3fc355e3e47772de4820810b8000f Mon Sep 17 00:00:00 2001
From: kaitranntt
Date: Thu, 5 Feb 2026 15:00:02 -0500
Subject: [PATCH 1/6] refactor(cliproxy): modularize top 4 giant files
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Split 4 files exceeding 900 lines into focused modules:
- account-manager.ts (961 → 56 lines): 6 modules in accounts/
- registry, query, bulk-ops, token-file-ops, types, index
- config-generator.ts (981 → 16 lines): 6 modules in config/
- generator, port-manager, env-builder, thinking-config, path-resolver, index
- cliproxy-executor.ts (1,180 → 21 lines): 5 modules in executor/
- index, lifecycle-manager, env-resolver, retry-handler, session-bridge
- cliproxy-command.ts (1,297 → 10 lines): 8 modules in commands/cliproxy/
- index, auth, quota, variant, install, help, proxy-lifecycle subcommands
Total: 4,419 → 103 lines in original files (97.7% reduction)
Backward compatibility maintained via re-export shims.
Edge case fixes: CLI validation, thinking fallback, provider warning.
BREAKING CHANGES: None - all exports preserved via barrel re-exports.
---
src/cliproxy/account-manager.ts | 995 +------------
src/cliproxy/accounts/bulk-ops.ts | 104 ++
src/cliproxy/accounts/index.ts | 57 +
src/cliproxy/accounts/query.ts | 109 ++
src/cliproxy/accounts/registry.ts | 565 +++++++
src/cliproxy/accounts/token-file-ops.ts | 182 +++
src/cliproxy/accounts/types.ts | 70 +
src/cliproxy/auth/token-expiry-checker.ts | 6 +-
src/cliproxy/cliproxy-executor.ts | 1187 +--------------
src/cliproxy/config-generator.ts | 985 +------------
src/cliproxy/config/env-builder.ts | 363 +++++
src/cliproxy/config/generator.ts | 345 +++++
src/cliproxy/config/index.ts | 10 +
src/cliproxy/config/path-resolver.ts | 79 +
src/cliproxy/config/port-manager.ts | 59 +
src/cliproxy/config/thinking-config.ts | 163 +++
src/cliproxy/executor/env-resolver.ts | 170 +++
src/cliproxy/executor/index.ts | 762 ++++++++++
src/cliproxy/executor/lifecycle-manager.ts | 160 ++
src/cliproxy/executor/retry-handler.ts | 90 ++
src/cliproxy/executor/session-bridge.ts | 284 ++++
src/commands/cliproxy-command.ts | 1295 +----------------
src/commands/cliproxy/auth-subcommand.ts | 60 +
src/commands/cliproxy/help-subcommand.ts | 93 ++
src/commands/cliproxy/index.ts | 237 +++
src/commands/cliproxy/install-subcommand.ts | 138 ++
.../cliproxy/proxy-lifecycle-subcommand.ts | 51 +
src/commands/cliproxy/quota-subcommand.ts | 467 ++++++
src/commands/cliproxy/variant-subcommand.ts | 307 ++++
29 files changed, 5001 insertions(+), 4392 deletions(-)
create mode 100644 src/cliproxy/accounts/bulk-ops.ts
create mode 100644 src/cliproxy/accounts/index.ts
create mode 100644 src/cliproxy/accounts/query.ts
create mode 100644 src/cliproxy/accounts/registry.ts
create mode 100644 src/cliproxy/accounts/token-file-ops.ts
create mode 100644 src/cliproxy/accounts/types.ts
create mode 100644 src/cliproxy/config/env-builder.ts
create mode 100644 src/cliproxy/config/generator.ts
create mode 100644 src/cliproxy/config/index.ts
create mode 100644 src/cliproxy/config/path-resolver.ts
create mode 100644 src/cliproxy/config/port-manager.ts
create mode 100644 src/cliproxy/config/thinking-config.ts
create mode 100644 src/cliproxy/executor/env-resolver.ts
create mode 100644 src/cliproxy/executor/index.ts
create mode 100644 src/cliproxy/executor/lifecycle-manager.ts
create mode 100644 src/cliproxy/executor/retry-handler.ts
create mode 100644 src/cliproxy/executor/session-bridge.ts
create mode 100644 src/commands/cliproxy/auth-subcommand.ts
create mode 100644 src/commands/cliproxy/help-subcommand.ts
create mode 100644 src/commands/cliproxy/index.ts
create mode 100644 src/commands/cliproxy/install-subcommand.ts
create mode 100644 src/commands/cliproxy/proxy-lifecycle-subcommand.ts
create mode 100644 src/commands/cliproxy/quota-subcommand.ts
create mode 100644 src/commands/cliproxy/variant-subcommand.ts
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('');
+}
From 759f28911958689ff2b56f03409545b6fec81dec Mon Sep 17 00:00:00 2001
From: kaitranntt
Date: Thu, 5 Feb 2026 15:09:47 -0500
Subject: [PATCH 2/6] fix(cliproxy): fix discoverExistingAccounts test failures
- Skip saving registry when no new accounts discovered (prevents empty
accounts.json creation for invalid files)
- Skip merging empty provider sections to prevent empty provider entries
- Update test module cache clearing to include new modular submodules
(accounts/registry, accounts/index) for proper test isolation
---
src/cliproxy/accounts/registry.ts | 14 ++++++++++++++
.../cliproxy/account-manager-discover.test.js | 18 +++++++++++++++---
2 files changed, 29 insertions(+), 3 deletions(-)
diff --git a/src/cliproxy/accounts/registry.ts b/src/cliproxy/accounts/registry.ts
index 2e885d5e..553864d5 100644
--- a/src/cliproxy/accounts/registry.ts
+++ b/src/cliproxy/accounts/registry.ts
@@ -402,6 +402,9 @@ export function discoverExistingAccounts(): void {
const registry = loadAccountsRegistry();
const files = fs.readdirSync(authDir);
+ // Track whether any accounts were discovered (to avoid saving empty registry)
+ let discoveredCount = 0;
+
for (const file of files) {
if (!file.endsWith('.json')) continue;
@@ -467,6 +470,7 @@ export function discoverExistingAccounts(): void {
// Update if missing or changed
if (existingEntry && existingEntry[1].projectId !== projectIdValue) {
existingEntry[1].projectId = projectIdValue;
+ discoveredCount++; // Count projectId updates as changes
}
}
continue;
@@ -529,17 +533,27 @@ export function discoverExistingAccounts(): void {
}
providerAccounts.accounts[accountId] = accountMeta;
+ discoveredCount++;
} catch {
// Skip invalid files
continue;
}
}
+ // Only save if at least one account was discovered or updated
+ // This prevents creating accounts.json with empty provider sections
+ if (discoveredCount === 0) {
+ return;
+ }
+
// 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;
+ // Skip empty provider sections (no accounts discovered)
+ if (Object.keys(discovered.accounts).length === 0) continue;
+
const prov = providerName as CLIProxyProvider;
if (!freshRegistry.providers[prov]) {
freshRegistry.providers[prov] = discovered;
diff --git a/tests/unit/cliproxy/account-manager-discover.test.js b/tests/unit/cliproxy/account-manager-discover.test.js
index 3f48f1e1..1a93f117 100644
--- a/tests/unit/cliproxy/account-manager-discover.test.js
+++ b/tests/unit/cliproxy/account-manager-discover.test.js
@@ -28,10 +28,18 @@ describe('Account Manager - discoverExistingAccounts', () => {
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = testDir;
- // Clear cache and reload
+ // Clear cache and reload - must clear all modular submodules too
delete require.cache[require.resolve('../../../dist/cliproxy/account-manager')];
delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')];
delete require.cache[require.resolve('../../../dist/utils/config-manager')];
+ // Clear new modular structure (accounts/ and config/ submodules)
+ delete require.cache[require.resolve('../../../dist/cliproxy/accounts/index')];
+ delete require.cache[require.resolve('../../../dist/cliproxy/accounts/registry')];
+ delete require.cache[require.resolve('../../../dist/cliproxy/accounts/token-file-ops')];
+ delete require.cache[require.resolve('../../../dist/cliproxy/accounts/query')];
+ delete require.cache[require.resolve('../../../dist/cliproxy/accounts/types')];
+ delete require.cache[require.resolve('../../../dist/cliproxy/config/index')];
+ delete require.cache[require.resolve('../../../dist/cliproxy/config/path-resolver')];
accountManager = require('../../../dist/cliproxy/account-manager');
});
@@ -247,8 +255,10 @@ describe('Account Manager - discoverExistingAccounts', () => {
email: '',
});
- // Reload module to clear in-memory cache
+ // Reload module to clear in-memory cache (including all submodules)
delete require.cache[require.resolve('../../../dist/cliproxy/account-manager')];
+ delete require.cache[require.resolve('../../../dist/cliproxy/accounts/index')];
+ delete require.cache[require.resolve('../../../dist/cliproxy/accounts/registry')];
accountManager = require('../../../dist/cliproxy/account-manager');
accountManager.discoverExistingAccounts();
@@ -329,8 +339,10 @@ describe('Account Manager - discoverExistingAccounts', () => {
email: '',
});
- // Reload module to pick up pre-existing accounts.json
+ // Reload module to pick up pre-existing accounts.json (including all submodules)
delete require.cache[require.resolve('../../../dist/cliproxy/account-manager')];
+ delete require.cache[require.resolve('../../../dist/cliproxy/accounts/index')];
+ delete require.cache[require.resolve('../../../dist/cliproxy/accounts/registry')];
accountManager = require('../../../dist/cliproxy/account-manager');
accountManager.discoverExistingAccounts();
From 10b73542da8b8048803e5692094b760d546c4058 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Thu, 5 Feb 2026 20:16:10 +0000
Subject: [PATCH 3/6] chore(release): 7.37.0-dev.1 [skip ci]
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 45439b13..b0ce11d0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
- "version": "7.37.0",
+ "version": "7.37.0-dev.1",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"keywords": [
"cli",
From 13f6c3f14bd0d1c920e339b2486dcd6e37ce50f4 Mon Sep 17 00:00:00 2001
From: kaitranntt
Date: Thu, 5 Feb 2026 15:27:30 -0500
Subject: [PATCH 4/6] fix(ui): display device code for GitHub Copilot OAuth in
Dashboard
For Device Code OAuth providers (ghcp, qwen), switch from /start-url
to /start endpoint which spawns CLIProxyAPI binary and emits WebSocket
events with userCode. DeviceCodeDialog then displays the code properly.
Closes #460
---
.../components/account/add-account-dialog.tsx | 19 ++-
ui/src/hooks/use-cliproxy-auth-flow.ts | 120 +++++++++++++-----
ui/src/lib/provider-config.ts | 11 ++
3 files changed, 115 insertions(+), 35 deletions(-)
diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx
index 70f447cd..55ef8079 100644
--- a/ui/src/components/account/add-account-dialog.tsx
+++ b/ui/src/components/account/add-account-dialog.tsx
@@ -1,7 +1,8 @@
/**
* Add Account Dialog Component
* Uses /start-url to get OAuth URL + polls for completion via management API.
- * Does NOT call /start (which spawns a CLIProxy binary and kills running instances).
+ * For Device Code flows (ghcp, qwen): Uses /start endpoint which spawns CLIProxy
+ * binary and emits WebSocket events. DeviceCodeDialog handles user code display.
* Shows auth URL + callback paste field. Polling auto-closes on success.
* For Kiro: Also shows "Import from IDE" option.
*/
@@ -21,6 +22,7 @@ import { Loader2, ExternalLink, User, Download, Copy, Check } from 'lucide-react
import { useKiroImport } from '@/hooks/use-cliproxy';
import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow';
import { applyDefaultPreset } from '@/lib/preset-utils';
+import { isDeviceCodeProvider } from '@/lib/provider-config';
import { toast } from 'sonner';
interface AddAccountDialogProps {
@@ -47,6 +49,7 @@ export function AddAccountDialog({
const kiroImportMutation = useKiroImport();
const isKiro = provider === 'kiro';
+ const isDeviceCode = isDeviceCodeProvider(provider);
const isPending = authFlow.isAuthenticating || kiroImportMutation.isPending;
const resetAndClose = () => {
@@ -144,7 +147,9 @@ export function AddAccountDialog({
{isKiro
? 'Authenticate via browser or import an existing token from Kiro IDE.'
- : 'Click Authenticate to get an OAuth URL. Open it in any browser to sign in.'}
+ : isDeviceCode
+ ? 'Click Authenticate. A verification code will appear for you to enter on the provider website.'
+ : 'Click Authenticate to get an OAuth URL. Open it in any browser to sign in.'}
@@ -180,17 +185,19 @@ export function AddAccountDialog({
Waiting for authentication...
- Complete the authentication in your browser. This dialog closes automatically.
+ {authFlow.isDeviceCodeFlow
+ ? 'A verification code dialog will appear shortly. Enter the code on the provider website.'
+ : 'Complete the authentication in your browser. This dialog closes automatically.'}
- {/* Error from /start-url - fallback URL not available */}
+ {/* Error display */}
{authFlow.error && !authFlow.authUrl && (
{authFlow.error}
)}
- {/* Auth URL section - appears once /start-url returns */}
- {authFlow.authUrl && (
+ {/* Auth URL section - only for Authorization Code flows, NOT Device Code */}
+ {authFlow.authUrl && !authFlow.isDeviceCodeFlow && (