Files
ccs/src/cliproxy/cliproxy-executor.ts
T
kaitranntt d5652de634 fix(cliproxy): improve thinking flag validation and warnings
- U1: reject --thinking with no value (show usage hint)

- U2: warn when provider doesn't support thinking budget
2026-01-08 16:55:14 -05:00

983 lines
35 KiB
TypeScript

/**
* CLIProxy Executor - Spawn/Kill Pattern for CLIProxyAPI
*
* Mirrors GLMT architecture:
* 1. Ensure binary exists (Phase 1)
* 2. Generate config for provider
* 3. Spawn CLIProxyAPI binary
* 4. Poll port for readiness (no stdout signal)
* 5. Execute Claude CLI with proxied environment
* 6. Kill proxy on Claude exit
*
* Key difference from GLMT: Uses TCP port polling instead of PROXY_READY signal
*/
import { spawn, ChildProcess } from 'child_process';
import * as net from 'net';
import { ProgressIndicator } from '../utils/progress-indicator';
import { ok, fail, info, warn } from '../utils/ui';
import { escapeShellArg } from '../utils/shell-executor';
import { ensureCLIProxyBinary, getInstalledCliproxyVersion } from './binary-manager';
import {
generateConfig,
getEffectiveEnvVars,
getRemoteEnvVars,
getProviderConfig,
ensureProviderSettings,
CLIPROXY_DEFAULT_PORT,
getCliproxyWritablePath,
validatePort,
applyThinkingConfig,
} from './config-generator';
import { checkRemoteProxy } from './remote-proxy-client';
import { isAuthenticated } from './auth-handler';
import { CLIProxyProvider, ExecutorConfig } from './types';
import { configureProviderModel, getCurrentModel } from './model-config';
import { resolveProxyConfig, PROXY_CLI_FLAGS } from './proxy-config-resolver';
import { getWebSearchHookEnv } from '../utils/websearch-manager';
import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog';
import { CodexReasoningProxy } from './codex-reasoning-proxy';
import {
findAccountByQuery,
getProviderAccounts,
setDefaultAccount,
touchAccount,
renameAccount,
getDefaultAccount,
} from './account-manager';
import { getPortCheckCommand, getCatCommand, killProcessOnPort } from '../utils/platform-commands';
import {
ensureMcpWebSearch,
installWebSearchHook,
displayWebSearchStatus,
} from '../utils/websearch-manager';
import {
registerSession,
unregisterSession,
cleanupOrphanedSessions,
stopProxy,
} from './session-tracker';
import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from './proxy-detector';
import { withStartupLock } from './startup-lock';
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
import { preflightCheck } from './quota-manager';
/** Default executor configuration */
const DEFAULT_CONFIG: ExecutorConfig = {
port: CLIPROXY_DEFAULT_PORT,
timeout: 5000,
verbose: false,
pollInterval: 100,
};
/**
* Wait for TCP port to become available
* Uses polling since CLIProxyAPI doesn't emit PROXY_READY signal
*/
async function waitForProxyReady(
port: number,
timeout: number = 5000,
pollInterval: number = 100
): Promise<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}`);
}
/**
* 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');
const log = (msg: string) => {
if (verbose) {
console.error(`[cliproxy] ${msg}`);
}
};
// 0. Resolve proxy configuration (CLI > ENV > config.yaml > defaults)
// This filters proxy flags from args and returns resolved config
const unifiedConfig = loadOrCreateUnifiedConfig();
const cliproxyServerConfig = unifiedConfig.cliproxy_server;
const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args, {
remote: cliproxyServerConfig?.remote
? {
enabled: cliproxyServerConfig.remote.enabled,
host: cliproxyServerConfig.remote.host,
port: cliproxyServerConfig.remote.port,
protocol: cliproxyServerConfig.remote.protocol,
auth_token: cliproxyServerConfig.remote.auth_token,
timeout: cliproxyServerConfig.remote.timeout,
}
: undefined,
local: cliproxyServerConfig?.local
? {
port: cliproxyServerConfig.local.port,
auto_start: cliproxyServerConfig.local.auto_start,
}
: undefined,
});
// Use resolved port from proxy config (overrides ExecutorConfig)
// Priority: CLI flags > Variant port (cfg.port) > config.yaml > default
// cfg.port is set if variant has dedicated port; proxyConfig.port comes from CLI/ENV/config.yaml
if (cfg.port && cfg.port !== CLIPROXY_DEFAULT_PORT) {
// Variant port already set via config param - keep it (highest priority after CLI flags)
// CLI flags would have been parsed in resolveProxyConfig and set proxyConfig.port
// So if proxyConfig.port differs from default AND cfg.port differs, CLI wins
if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) {
cfg.port = proxyConfig.port; // CLI/ENV override variant port
}
// else: keep cfg.port (variant port)
} else if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) {
cfg.port = proxyConfig.port;
}
// Final port validation - ensure valid port after all resolution
cfg.port = validatePort(cfg.port);
log(`Proxy mode: ${proxyConfig.mode}`);
if (proxyConfig.mode === 'remote') {
log(`Remote host: ${proxyConfig.host}:${proxyConfig.port} (${proxyConfig.protocol})`);
}
// Note: proxyConfig is available for Phase 4 (remote mode integration)
// Ensure MCP web-search is configured for third-party profiles
// WebSearch is a server-side tool executed by Anthropic's API
// Third-party providers don't have access, so we use MCP fallback
ensureMcpWebSearch();
// Install WebSearch hook for Gemini CLI + MCP fallback
// Hook intercepts WebSearch, tries Gemini CLI first, falls back to MCP
installWebSearchHook();
// Display WebSearch status (single line, equilibrium UX)
displayWebSearchStatus();
// Validate provider
const providerConfig = getProviderConfig(provider);
log(`Provider: ${providerConfig.displayName}`);
// Check remote proxy if configured (before binary download)
let useRemoteProxy = false;
if (proxyConfig.mode === 'remote' && proxyConfig.host) {
const status = await checkRemoteProxy({
host: proxyConfig.host,
port: proxyConfig.port,
protocol: proxyConfig.protocol,
authToken: proxyConfig.authToken,
timeout: proxyConfig.timeout ?? 2000,
allowSelfSigned: proxyConfig.allowSelfSigned ?? false,
});
if (status.reachable) {
useRemoteProxy = true;
console.log(
ok(
`Connected to remote proxy at ${proxyConfig.host}:${proxyConfig.port} (${status.latencyMs}ms)`
)
);
} else {
console.error(warn(`Remote proxy unreachable: ${status.error}`));
if (proxyConfig.remoteOnly) {
throw new Error('Remote proxy unreachable and --remote-only specified');
}
if (proxyConfig.fallbackEnabled) {
if (proxyConfig.autoStartLocal) {
console.log(info('Falling back to local proxy...'));
} else {
// Prompt user for fallback (only in TTY)
if (process.stdin.isTTY) {
const readline = await import('readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise<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');
throw error;
}
}
// 2. Handle special flags (use argsWithoutProxy - proxy flags already stripped)
const forceAuth = argsWithoutProxy.includes('--auth');
const forceHeadless = argsWithoutProxy.includes('--headless');
const forceLogout = argsWithoutProxy.includes('--logout');
const forceConfig = argsWithoutProxy.includes('--config');
const addAccount = argsWithoutProxy.includes('--add');
const showAccounts = argsWithoutProxy.includes('--accounts');
// Kiro-specific: --import to import token from Kiro IDE directly
const forceImport = argsWithoutProxy.includes('--import');
// Kiro-specific: browser mode for OAuth
// Default to normal browser (noIncognito=true) for reliability - incognito often fails on Linux
// --incognito flag opts into incognito mode, --no-incognito is legacy (now default)
const incognitoFlag = argsWithoutProxy.includes('--incognito');
const noIncognitoFlag = argsWithoutProxy.includes('--no-incognito');
// Config setting (defaults to true = normal browser)
const kiroNoIncognitoConfig =
provider === 'kiro' ? (unifiedConfig.cliproxy?.kiro_no_incognito ?? true) : false;
// --incognito flag overrides everything to use incognito
const noIncognito = incognitoFlag ? false : noIncognitoFlag || kiroNoIncognitoConfig;
// Parse --use <account> 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 <name> 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 <value> flag for thinking budget control
// Supports: level names (low, medium, high, xhigh, auto), numeric budget, or 'off'/'none'
// Accepts both --thinking=value and --thinking value formats
let thinkingOverride: string | number | undefined;
// Check for --thinking=value format first
const thinkingEqArg = argsWithoutProxy.find((arg) => arg.startsWith('--thinking='));
if (thinkingEqArg) {
const val = thinkingEqArg.substring('--thinking='.length);
// Parse as number if numeric, otherwise keep as string (level name)
const numVal = parseInt(val, 10);
thinkingOverride = !isNaN(numVal) ? numVal : val;
// W2: Warn if multiple --thinking flags found (check both formats)
const allThinkingFlags = argsWithoutProxy.filter(
(arg) => arg === '--thinking' || arg.startsWith('--thinking=')
);
if (allThinkingFlags.length > 1) {
console.warn(
`[!] Multiple --thinking flags detected. Using first occurrence: ${thinkingEqArg}`
);
}
} else {
// Fall back to --thinking value format
const thinkingIdx = argsWithoutProxy.indexOf('--thinking');
if (thinkingIdx !== -1) {
const nextArg = argsWithoutProxy[thinkingIdx + 1];
// U1: Check if --thinking has a value (not missing or another flag)
if (!nextArg || nextArg.startsWith('-')) {
console.error(fail('--thinking requires a value'));
console.error(' Examples: --thinking low, --thinking 8192, --thinking off');
console.error(' Levels: minimal, low, medium, high, xhigh, auto');
process.exit(1);
}
const val = nextArg;
// Parse as number if numeric, otherwise keep as string (level name)
const numVal = parseInt(val, 10);
thinkingOverride = !isNaN(numVal) ? numVal : val;
// W2: Warn if multiple --thinking flags found (check both formats)
const allThinkingFlags = argsWithoutProxy.filter(
(arg) => arg === '--thinking' || arg.startsWith('--thinking=')
);
if (allThinkingFlags.length > 1) {
console.warn(
`[!] Multiple --thinking flags detected. Using first occurrence: --thinking ${val}`
);
}
}
}
// Handle --accounts: list accounts and exit
if (showAccounts) {
const accounts = getProviderAccounts(provider);
if (accounts.length === 0) {
console.log(info(`No accounts registered for ${providerConfig.displayName}`));
console.log(` Run "ccs ${provider} --auth" to add an account`);
} else {
console.log(`\n${providerConfig.displayName} Accounts:\n`);
for (const acct of accounts) {
const defaultMark = acct.isDefault ? ' (default)' : '';
const nickname = acct.nickname ? `[${acct.nickname}]` : '';
console.log(` ${nickname.padEnd(12)} ${acct.email || acct.id}${defaultMark}`);
}
console.log(`\n Use "ccs ${provider} --use <nickname>" to switch accounts`);
}
process.exit(0);
}
// Handle --use: switch to specified account
if (useAccount) {
const account = findAccountByQuery(provider, useAccount);
if (!account) {
console.error(fail(`Account not found: "${useAccount}"`));
const accounts = getProviderAccounts(provider);
if (accounts.length > 0) {
console.error(` Available accounts:`);
for (const acct of accounts) {
console.error(` - ${acct.nickname || acct.id} (${acct.email || 'no email'})`);
}
}
process.exit(1);
}
// Set as default for this and future sessions
setDefaultAccount(provider, account.id);
touchAccount(provider, account.id);
console.log(ok(`Switched to account: ${account.nickname || account.email || account.id}`));
}
// Handle --nickname: rename account and exit (unless used with --auth --add)
if (setNickname && !addAccount) {
const defaultAccount = getDefaultAccount(provider);
if (!defaultAccount) {
console.error(fail(`No account found for ${providerConfig.displayName}`));
console.error(` Run "ccs ${provider} --auth" to add an account first`);
process.exit(1);
}
try {
const success = renameAccount(provider, defaultAccount.id, setNickname);
if (success) {
console.log(ok(`Renamed account to: ${setNickname}`));
} else {
console.error(fail('Failed to rename account'));
process.exit(1);
}
} catch (err) {
console.error(fail(err instanceof Error ? err.message : 'Failed to rename account'));
process.exit(1);
}
process.exit(0);
}
// Handle --config: configure model selection and exit
// Pass customSettingsPath for CLIProxy variants to save to correct file
if (forceConfig && supportsModelConfig(provider)) {
await configureProviderModel(provider, true, cfg.customSettingsPath);
process.exit(0);
}
// Handle --logout: clear auth and exit
if (forceLogout) {
const { clearAuth } = await import('./auth-handler');
if (clearAuth(provider)) {
console.log(ok(`Logged out from ${providerConfig.displayName}`));
} else {
console.log(info(`No authentication found for ${providerConfig.displayName}`));
}
process.exit(0);
}
// Handle --import: import token from Kiro IDE directly (Kiro only)
if (forceImport) {
if (provider !== 'kiro') {
console.error(fail('--import is only available for Kiro'));
console.error(` Run "ccs ${provider} --auth" to authenticate`);
process.exit(1);
}
// Validate flag conflicts
if (forceAuth) {
console.error(fail('Cannot use --import with --auth'));
console.error(' --import: Import existing token from Kiro IDE');
console.error(' --auth: Trigger new OAuth flow in browser');
process.exit(1);
}
if (forceLogout) {
console.error(fail('Cannot use --import with --logout'));
process.exit(1);
}
const { triggerOAuth } = await import('./auth-handler');
const authSuccess = await triggerOAuth(provider, {
verbose,
import: true,
...(setNickname ? { nickname: setNickname } : {}),
});
if (!authSuccess) {
console.error(fail('Failed to import Kiro token from IDE'));
console.error(' Make sure you are logged into Kiro IDE first');
process.exit(1);
}
process.exit(0);
}
// 3. Ensure OAuth completed (if provider requires it)
if (providerConfig.requiresOAuth) {
log(`Checking authentication for ${provider}`);
if (forceAuth || !isAuthenticated(provider)) {
// Pass headless only if explicitly set; otherwise let auth-handler auto-detect
const { triggerOAuth } = await import('./auth-handler');
const authSuccess = await triggerOAuth(provider, {
verbose,
add: addAccount,
...(forceHeadless ? { headless: true } : {}),
...(setNickname ? { nickname: setNickname } : {}),
...(noIncognito ? { noIncognito: true } : {}),
});
if (!authSuccess) {
throw new Error(`Authentication required for ${providerConfig.displayName}`);
}
// If --auth was explicitly passed, exit after auth (don't start Claude)
if (forceAuth) {
process.exit(0);
}
} else {
log(`${provider} already authenticated`);
}
// 3a. Proactive token refresh - prevent UND_ERR_SOCKET from expired tokens
// CLIProxyAPI may fail mid-request if token expires during use
const { ensureTokenValid } = await import('./auth/token-manager');
const tokenResult = await ensureTokenValid(provider, verbose);
if (!tokenResult.valid) {
// Token expired and refresh failed - trigger re-auth
console.error(warn('OAuth token expired and refresh failed'));
if (tokenResult.error) {
console.error(` ${tokenResult.error}`);
}
console.error(` Run "ccs ${provider} --auth" to re-authenticate`);
process.exit(1);
}
if (tokenResult.refreshed) {
log('Token was refreshed proactively');
}
// 3a-1. Update lastUsedAt for the account being used
// This ensures dashboard shows accurate "Last used" timestamps
const usedAccount = getDefaultAccount(provider);
if (usedAccount) {
touchAccount(provider, usedAccount.id);
}
}
// 3b. Preflight quota check - auto-switch to account with quota before launch
// Uses quota-manager for caching, tier priority, and cooldown support
if (provider === 'agy') {
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) {
console.log(` New account quota: ${preflight.quotaPercent.toFixed(1)}%`);
}
}
}
// 4. First-run model configuration (interactive)
// For supported providers, prompt user to select model on first run
// Pass customSettingsPath for CLIProxy variants
if (supportsModelConfig(provider)) {
await configureProviderModel(provider, false, cfg.customSettingsPath); // false = only if not configured
}
// 5. Check for known broken models and warn user
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}`);
}
console.error(` Run "ccs ${provider} --config" to change model.`);
console.error('');
}
// 6. Ensure user settings file exists (creates from defaults if not)
ensureProviderSettings(provider);
// Local proxy mode: generate config, spawn proxy, track session
let proxy: ChildProcess | null = null;
if (!useRemoteProxy) {
// 6. Generate config file
log(`Generating config for ${provider}`);
const configPath = generateConfig(provider, cfg.port);
log(`Config written: ${configPath}`);
// 6a. Pre-flight check using unified detection with startup lock
// This prevents race conditions when multiple CCS processes start simultaneously
cleanupOrphanedSessions(cfg.port);
// Use startup lock to coordinate with other CCS processes
await withStartupLock(async () => {
// Detect running proxy using multiple methods (HTTP, session-lock, port-process)
let proxyStatus = await detectRunningProxy(cfg.port);
log(`Proxy detection: ${JSON.stringify(proxyStatus)}`);
// Check for version mismatch - restart proxy if installed version differs from running
if (proxyStatus.running && proxyStatus.verified && proxyStatus.version) {
const installedVersion = getInstalledCliproxyVersion();
if (installedVersion !== proxyStatus.version) {
console.log(
warn(
`Version mismatch: running v${proxyStatus.version}, installed v${installedVersion}. Restarting proxy...`
)
);
log(`Stopping outdated proxy (PID: ${proxyStatus.pid ?? 'unknown'})...`);
const stopResult = await stopProxy(cfg.port);
if (stopResult.stopped) {
log(`Stopped outdated proxy successfully`);
} else {
log(`Stop proxy result: ${stopResult.error ?? 'unknown error'}`);
}
// Wait for port to be released
await new Promise((r) => setTimeout(r, 500));
// Re-detect proxy status (should now be not running)
proxyStatus = await detectRunningProxy(cfg.port);
log(`Re-detection after version mismatch restart: ${JSON.stringify(proxyStatus)}`);
}
}
if (proxyStatus.running && proxyStatus.verified) {
// Healthy proxy found - join it
if (proxyStatus.pid) {
sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid, verbose) ?? undefined;
}
if (sessionId) {
console.log(info(`Joined existing CLIProxy on port ${cfg.port} (${proxyStatus.method})`));
} else {
// Failed to register session - proxy is running but we can't track it
// This happens when port-process detection fails (permissions, platform issues)
console.log(
info(`Using existing CLIProxy on port ${cfg.port} (session tracking unavailable)`)
);
log(`PID=${proxyStatus.pid ?? 'unknown'}, session registration skipped`);
}
return; // Exit lock early, skip spawning
}
if (proxyStatus.running && !proxyStatus.verified) {
// Proxy detected but not ready yet (another process is starting it)
log(`Proxy starting up (detected via ${proxyStatus.method}), waiting...`);
const becameHealthy = await waitForProxyHealthy(cfg.port, cfg.timeout);
if (becameHealthy) {
if (proxyStatus.pid) {
sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid, verbose) ?? undefined;
}
console.log(info(`Joined CLIProxy after startup wait`));
return; // Exit lock early
}
// Proxy didn't become healthy - kill and respawn
if (proxyStatus.pid) {
log(`Proxy PID ${proxyStatus.pid} not responding, killing...`);
killProcessOnPort(cfg.port, verbose);
await new Promise((r) => setTimeout(r, 500));
}
}
if (proxyStatus.blocked && proxyStatus.blocker) {
// Port blocked by non-CLIProxy process
// Last resort: try HTTP health check (handles Windows PID-XXXXX case)
const isActuallyOurs = await waitForProxyHealthy(cfg.port, 1000);
if (isActuallyOurs) {
sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.blocker.pid, verbose) ?? undefined;
console.log(info(`Reclaimed CLIProxy with unrecognized process name`));
return;
}
// Truly blocked by another application
console.error('');
console.error(
warn(
`Port ${cfg.port} is blocked by ${proxyStatus.blocker.processName} (PID ${proxyStatus.blocker.pid})`
)
);
console.error('');
console.error('To fix this, close the blocking application or run:');
console.error(` ${getPortCheckCommand(cfg.port)}`);
console.error('');
throw new Error(`Port ${cfg.port} is in use by another application`);
}
// 6b. Spawn CLIProxyAPI binary
const proxyArgs = ['--config', configPath];
log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`);
proxy = spawn(binaryPath as string, proxyArgs, {
stdio: ['ignore', 'ignore', 'ignore'],
detached: true,
env: {
...process.env,
WRITABLE_PATH: getCliproxyWritablePath(),
},
});
proxy.unref();
proxy.on('error', (error) => {
console.error(fail(`CLIProxy spawn error: ${error.message}`));
});
// 7. Wait for proxy readiness via TCP polling
const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${cfg.port}`);
readySpinner.start();
try {
await waitForProxyReady(cfg.port, cfg.timeout, cfg.pollInterval);
readySpinner.succeed(`CLIProxy ready on port ${cfg.port}`);
} catch (error) {
readySpinner.fail('CLIProxy Plus startup failed');
proxy.kill('SIGTERM');
const err = error as Error;
console.error('');
console.error(fail('CLIProxy Plus failed to start'));
console.error('');
console.error('Possible causes:');
console.error(` 1. Port ${cfg.port} already in use`);
console.error(' 2. Binary crashed on startup');
console.error(' 3. Invalid configuration');
console.error('');
console.error('Troubleshooting:');
console.error(` - Check port: ${getPortCheckCommand(cfg.port)}`);
console.error(' - Run with --verbose for detailed logs');
console.error(` - View config: ${getCatCommand(configPath)}`);
console.error(' - Try: ccs doctor --fix');
console.error('');
throw new Error(`CLIProxy startup failed: ${err.message}`);
}
// Register this session with the new proxy, including the installed version
const installedVersion = getInstalledCliproxyVersion();
sessionId = registerSession(cfg.port, proxy.pid as number, installedVersion);
log(
`Registered session ${sessionId} with new proxy (PID ${proxy.pid}, version ${installedVersion})`
);
});
}
// 7. Execute Claude CLI with proxied environment
// Use remote or local env vars based on mode
// When remote is configured (even if using local), pass config for URL rewriting
const remoteRewriteConfig =
proxyConfig.mode === 'remote' && proxyConfig.host
? {
host: proxyConfig.host,
port: proxyConfig.port,
protocol: proxyConfig.protocol,
authToken: proxyConfig.authToken,
}
: undefined;
const envVars = useRemoteProxy
? getRemoteEnvVars(
provider,
{
host: proxyConfig.host ?? 'localhost',
port: proxyConfig.port,
protocol: proxyConfig.protocol,
authToken: proxyConfig.authToken,
},
cfg.customSettingsPath
)
: getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath, remoteRewriteConfig);
// Apply thinking configuration to model (auto tier-based or manual override)
// This adds thinking suffix like model(high) or model(8192) for CLIProxyAPIPlus
applyThinkingConfig(envVars, provider, thinkingOverride);
// Codex-only: inject OpenAI reasoning effort based on tier model mapping.
// Maps by request.model:
// - OPUS/default model → xhigh
// - SONNET model → high
// - HAIKU model → medium
// - Unknown → medium
let codexReasoningProxy: CodexReasoningProxy | null = null;
let codexReasoningPort: number | null = null;
if (provider === 'codex') {
if (!envVars.ANTHROPIC_BASE_URL) {
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';
codexReasoningProxy = new CodexReasoningProxy({
upstreamBaseUrl: envVars.ANTHROPIC_BASE_URL,
verbose,
defaultEffort: 'medium',
traceFilePath: traceEnabled
? `${process.env.HOME || process.cwd()}/.ccs/codex-reasoning-proxy.log`
: '',
modelMap: {
defaultModel: envVars.ANTHROPIC_MODEL,
opusModel: envVars.ANTHROPIC_DEFAULT_OPUS_MODEL,
sonnetModel: envVars.ANTHROPIC_DEFAULT_SONNET_MODEL,
haikuModel: envVars.ANTHROPIC_DEFAULT_HAIKU_MODEL,
},
});
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}`));
}
}
}
}
const effectiveEnvVars =
codexReasoningProxy && codexReasoningPort
? {
...envVars,
ANTHROPIC_BASE_URL: `http://127.0.0.1:${codexReasoningPort}/api/provider/codex`,
}
: envVars;
const webSearchEnv = getWebSearchHookEnv();
const env = {
...process.env,
...effectiveEnvVars,
...webSearchEnv,
CCS_PROFILE_TYPE: 'cliproxy', // Signal to WebSearch hook this is a third-party provider
};
log(`Claude env: ANTHROPIC_BASE_URL=${effectiveEnvVars.ANTHROPIC_BASE_URL}`);
log(`Claude env: ANTHROPIC_MODEL=${effectiveEnvVars.ANTHROPIC_MODEL}`);
if (Object.keys(webSearchEnv).length > 0) {
log(`Claude env: WebSearch config=${JSON.stringify(webSearchEnv)}`);
}
// Log global env vars for visibility
if (
effectiveEnvVars.DISABLE_TELEMETRY ||
effectiveEnvVars.DISABLE_ERROR_REPORTING ||
effectiveEnvVars.DISABLE_BUG_COMMAND
) {
log(`Claude env: Global env applied (telemetry/reporting disabled)`);
}
// Filter out CCS-specific flags before passing to Claude CLI
// Note: Proxy flags (--proxy-host, etc.) already stripped by resolveProxyConfig()
const ccsFlags = [
'--auth',
'--headless',
'--logout',
'--config',
'--add',
'--accounts',
'--use',
'--nickname',
'--thinking',
'--incognito',
'--no-incognito',
'--import',
// Proxy flags are handled by resolveProxyConfig, but list for documentation
...PROXY_CLI_FLAGS,
];
const claudeArgs = argsWithoutProxy.filter((arg, idx) => {
// Filter out CCS flags
if (ccsFlags.includes(arg)) return false;
// Filter out --thinking=value format
if (arg.startsWith('--thinking=')) return false;
// Filter out value after --use, --nickname, or --thinking
if (
argsWithoutProxy[idx - 1] === '--use' ||
argsWithoutProxy[idx - 1] === '--nickname' ||
argsWithoutProxy[idx - 1] === '--thinking'
)
return false;
return true;
});
const isWindows = process.platform === 'win32';
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
let claude: ChildProcess;
if (needsShell) {
const cmdString = [claudeCli, ...claudeArgs].map(escapeShellArg).join(' ');
claude = spawn(cmdString, {
stdio: 'inherit',
windowsHide: true,
shell: true,
env,
});
} else {
claude = spawn(claudeCli, claudeArgs, {
stdio: 'inherit',
windowsHide: true,
env,
});
}
// 8. Cleanup: unregister session when Claude exits (local mode only)
// Proxy persists by default - use 'ccs cliproxy stop' to kill manually
// Capture port for cleanup (avoids closure issues)
const sessionPort = cfg.port;
claude.on('exit', (code, signal) => {
log(`Claude exited: code=${code}, signal=${signal}`);
if (codexReasoningProxy) {
codexReasoningProxy.stop();
}
// Unregister this session (proxy keeps running for persistence) - only for local mode
if (sessionId) {
unregisterSession(sessionId, sessionPort);
log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`);
}
if (signal) {
process.kill(process.pid, signal as NodeJS.Signals);
} else {
process.exit(code || 0);
}
});
claude.on('error', (error) => {
console.error(fail(`Claude CLI error: ${error}`));
if (codexReasoningProxy) {
codexReasoningProxy.stop();
}
// Unregister session, proxy keeps running (local mode only)
if (sessionId) {
unregisterSession(sessionId, sessionPort);
}
process.exit(1);
});
// Handle parent process termination (SIGTERM, SIGINT)
const cleanup = () => {
log('Parent signal received, cleaning up');
if (codexReasoningProxy) {
codexReasoningProxy.stop();
}
// Unregister session, proxy keeps running (local mode only)
if (sessionId) {
unregisterSession(sessionId, sessionPort);
}
claude.kill('SIGTERM');
};
process.once('SIGTERM', cleanup);
process.once('SIGINT', cleanup);
}
/**
* Check if a port is available
*/
export async function isPortAvailable(port: number): Promise<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 = CLIPROXY_DEFAULT_PORT,
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}`);
}
export default execClaudeWithCLIProxy;