refactor(cliproxy): modularize top 4 giant files

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.
This commit is contained in:
kaitranntt
2026-02-05 15:00:02 -05:00
parent 237f91ff69
commit b149e252eb
29 changed files with 5001 additions and 4392 deletions
File diff suppressed because it is too large Load Diff
+104
View File
@@ -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<CLIProxyProvider, Promise<unknown>>();
/**
* 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<SoloOperationResult | null> {
// 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);
}
}
+57
View File
@@ -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';
+109
View File
@@ -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<CLIProxyProvider, AccountInfo[]> {
const providers: CLIProxyProvider[] = [...CLIPROXY_PROFILES];
const summary: Record<CLIProxyProvider, AccountInfo[]> = {} as Record<
CLIProxyProvider,
AccountInfo[]
>;
for (const provider of providers) {
summary[provider] = getProviderAccounts(provider);
}
return summary;
}
+565
View File
@@ -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 <name> 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<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
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<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
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);
}
+182
View File
@@ -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: <provider>-<oauth>-<profile_id>.json
* Extracts: <oauth>-<profile_id> 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: <provider>-<oauth>-<profile_id>.json → extract <oauth>-<profile_id>
// 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;
}
+70
View File
@@ -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<string, Omit<AccountInfo, 'id' | 'provider' | 'isDefault'>>;
}
/** Accounts registry structure */
export interface AccountsRegistry {
/** Version for future migrations */
version: number;
/** Accounts organized by provider */
providers: Partial<Record<CLIProxyProvider, ProviderAccounts>>;
}
/** 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[];
}
+5 -1
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
+10 -975
View File
@@ -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<CLIProxyProvider, string> = {
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<string, string> {
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<string, string> {
// 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<string, string> = {};
// 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<string, string>;
}
} 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<string, string>;
}
} 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<string, string> = {
...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';
+363
View File
@@ -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<string, string> {
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<string, string> {
// 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<string, string> = {};
// 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<string, string>;
}
} 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<string, string>;
}
} 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<string, string> = {
...globalEnv,
...userEnvVars,
// Always override URL and auth token with remote config
ANTHROPIC_BASE_URL: baseUrl,
ANTHROPIC_AUTH_TOKEN: remoteConfig.authToken || getEffectiveApiKey(),
};
return env;
}
+345
View File
@@ -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<CLIProxyProvider, string> = {
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);
}
+10
View File
@@ -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';
+79
View File
@@ -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`);
}
+59
View File
@@ -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';
}
+163
View File
@@ -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;
}
+170
View File
@@ -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<string, string> {
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<string, string>;
const effectiveEnvVarsFiltered = Object.fromEntries(
Object.entries(effectiveEnvVars).filter(([, v]) => v !== undefined)
) as Record<string, string>;
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<string, string>,
webSearchEnv: Record<string, string>,
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)`);
}
}
+762
View File
@@ -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<ExecutorConfig> = {}
): Promise<void> {
// Filter out undefined values to prevent overwriting defaults
const filteredConfig = Object.fromEntries(
Object.entries(config).filter(([, v]) => v !== undefined)
) as Partial<ExecutorConfig>;
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<string>((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 <nickname>" 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;
+160
View File
@@ -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<void> {
const start = Date.now();
while (Date.now() - start < timeout) {
try {
await new Promise<void>((resolve, reject) => {
const socket = net.createConnection({ port, host: '127.0.0.1' }, () => {
socket.destroy();
resolve();
});
socket.on('error', (err) => {
socket.destroy();
reject(err);
});
// Individual connection timeout
socket.setTimeout(1000, () => {
socket.destroy();
reject(new Error('Connection timeout'));
});
});
return; // Connection successful - proxy is ready
} catch {
// Connection failed, wait and retry
await new Promise((r) => setTimeout(r, pollInterval));
}
}
throw new Error(`CLIProxy not ready after ${timeout}ms on port ${port}`);
}
/**
* 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<void> {
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<boolean> {
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', () => {
resolve(false);
});
server.once('listening', () => {
server.close();
resolve(true);
});
server.listen(port, '127.0.0.1');
});
}
/**
* Find an available port in range
*/
export async function findAvailablePort(startPort: number, range: number = 10): Promise<number> {
for (let port = startPort; port < startPort + range; port++) {
if (await isPortAvailable(port)) {
return port;
}
}
throw new Error(`No available port found in range ${startPort}-${startPort + range - 1}`);
}
+90
View File
@@ -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<void> {
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<void> {
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)`);
}
}
}
+284
View File
@@ -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<ProxySessionResult> {
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);
}
File diff suppressed because it is too large Load Diff
+60
View File
@@ -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<void> {
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 <provider> --auth'));
console.log(dim(' To logout: ccs <provider> --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('');
}
+93
View File
@@ -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<void> {
await initUI();
console.log('');
console.log(header('CLIProxy Management'));
console.log('');
console.log(subheader('Usage:'));
console.log(` ${color('ccs cliproxy', 'command')} <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 <name>', '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 <account>', 'Set default account for rotation'],
['pause <account>', 'Pause account (skip in rotation)'],
['resume <account>', 'Resume paused account'],
['quota', 'Show quota status for all providers'],
['quota --provider <name>', '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 <version>', 'Install and pin a specific version'],
['--latest', 'Install the latest version (no pin)'],
['--update', 'Unpin and update to latest version'],
],
],
[
'Options:',
[
['--backend <type>', '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('');
}
+237
View File
@@ -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<void> {
// 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 <version>');
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);
}
+138
View File
@@ -0,0 +1,138 @@
/**
* CLIProxy Binary Installation
*
* Handles:
* - ccs cliproxy (show status)
* - ccs cliproxy --install <version>
* - 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<void> {
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<void> {
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<void> {
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('');
}
@@ -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<void> {
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<void> {
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('');
}
+467
View File
@@ -0,0 +1,467 @@
/**
* CLIProxy Quota Management
*
* Handles:
* - ccs cliproxy quota [--provider <name>]
* - ccs cliproxy default <account>
* - ccs cliproxy pause <account>
* - ccs cliproxy resume <account>
* - 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<ReturnType<typeof fetchAllProviderQuotas>>
): 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<void> {
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<void> {
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<void> {
await initUI();
const parsed = parseProfileArgs(args);
if (!parsed.name) {
console.log(fail('Usage: ccs cliproxy default <account> [--provider <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<void> {
await initUI();
const parsed = parseProfileArgs(args);
if (!parsed.name) {
console.log(fail('Usage: ccs cliproxy pause <account> [--provider <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<void> {
await initUI();
const parsed = parseProfileArgs(args);
if (!parsed.name) {
console.log(fail('Usage: ccs cliproxy resume <account> [--provider <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);
}
}
+307
View File
@@ -0,0 +1,307 @@
/**
* CLIProxy Variant Management
*
* Handles:
* - ccs cliproxy create [name]
* - ccs cliproxy remove <name>
*/
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<void> {
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<void> {
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('');
}