mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
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:
+45
-950
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+12
-1175
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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('');
|
||||
}
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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('');
|
||||
}
|
||||
Reference in New Issue
Block a user