mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 18:18:43 +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,
|
provider: CLIProxyProvider,
|
||||||
accountId: string
|
accountId: string
|
||||||
): TokenExpiryInfo | null {
|
): 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)) {
|
if (!tokenPath || !fs.existsSync(tokenPath)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1175
File diff suppressed because it is too large
Load Diff
@@ -1,981 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Config Generator for CLIProxyAPI
|
* Config Generator for CLIProxyAPI
|
||||||
*
|
*
|
||||||
* Generates config.yaml for CLIProxyAPI based on provider.
|
* @deprecated This file is a re-export shim for backwards compatibility.
|
||||||
* Handles OAuth token paths and provider-specific settings.
|
* New code should import from './config/' module instead.
|
||||||
*
|
*
|
||||||
* Model mappings are loaded from config/base-{provider}.settings.json files
|
* Refactored into modular structure:
|
||||||
* to allow easy updates without code changes.
|
* - 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';
|
// Re-export all modules for backwards compatibility
|
||||||
import * as path from 'path';
|
export * from './config';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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