Files
ccs/src/cliproxy/cliproxy-executor.ts
T
7fc281fcec chore: promote beta to main (v5.2.0) (#32)
* fix(cliproxy): use double-dash flags for cliproxyapi auth (#24)

CLIProxyAPI updated from single-dash to double-dash CLI flags.
Changed -login to --login, -codex-login to --codex-login,
-antigravity-login to --antigravity-login, -config to --config,
and -no-browser to --no-browser.

* chore(release): 5.1.1-beta.1 [skip ci]

## [5.1.1-beta.1](https://github.com/kaitranntt/ccs/compare/v5.1.0...v5.1.1-beta.1) (2025-12-01)

### Bug Fixes

* **cliproxy:** use double-dash flags for cliproxyapi auth ([#24](https://github.com/kaitranntt/ccs/issues/24)) ([4c81f28](https://github.com/kaitranntt/ccs/commit/4c81f28f0b67ef92cf74d0f5c13a5943ff0a7f00))

* chore(release): 5.1.1-beta.2 [skip ci]

## [5.1.1-beta.2](https://github.com/kaitranntt/ccs/compare/v5.1.1-beta.1...v5.1.1-beta.2) (2025-12-01)

### Bug Fixes

* **cliproxy:** use double-dash flags for cliproxyapi auth ([9489884](https://github.com/kaitranntt/ccs/commit/94898848ea4533dcfc142e1b6c9bf939ba655537))

* fix(glmt): handle 401 errors and headers-already-sent exception (#30)

- add res.headersSent check before writing error headers
- write error as SSE event when headers already sent (streaming mode)
- add parseUpstreamError() for user-friendly error messages
- return proper HTTP status codes (401, 403, 429, 502, 504)
- clear auth error: check ANTHROPIC_AUTH_TOKEN in config

Fixes #26

* chore(release): 5.1.1-beta.3 [skip ci]

## [5.1.1-beta.3](https://github.com/kaitranntt/ccs/compare/v5.1.1-beta.2...v5.1.1-beta.3) (2025-12-01)

### Bug Fixes

* **glmt:** handle 401 errors and headers-already-sent exception ([#30](https://github.com/kaitranntt/ccs/issues/30)) ([c953382](https://github.com/kaitranntt/ccs/commit/c95338232a37981b95b785b47185ce18d6d94b7a)), closes [#26](https://github.com/kaitranntt/ccs/issues/26)

* chore(release): enable success comments and released labels in semantic-release

* feat(cliproxy): add qwen code oauth provider support (#31)

Add 'qwen' as new CLIProxy OAuth provider with --qwen-login flag.
Support qwen3-coder model with base config at config/base-qwen.settings.json.
Register qwen OAuth endpoints and token prefix handling.
Update help documentation across all CLI entry points.

Implements issue #29

* chore(release): 5.2.0-beta.1 [skip ci]

# [5.2.0-beta.1](https://github.com/kaitranntt/ccs/compare/v5.1.1-beta.3...v5.2.0-beta.1) (2025-12-01)

### Features

* **cliproxy:** add qwen code oauth provider support ([#31](https://github.com/kaitranntt/ccs/issues/31)) ([a3f1e52](https://github.com/kaitranntt/ccs/commit/a3f1e52ac68600ba0806d67aacceb6477ffa3543)), closes [#29](https://github.com/kaitranntt/ccs/issues/29)

* feat(cliproxy): auto-update cliproxyapi to latest release on startup

- add github api integration to fetch latest release version
- check for updates on every startup (cached for 1 hour)
- auto-download newer versions when available
- rename CLIPROXY_VERSION to CLIPROXY_FALLBACK_VERSION (safety net)
- add version tracking (.version file) and caching (.version-cache.json)
- non-blocking: startup continues if update check fails

* chore(release): 5.2.0-beta.2 [skip ci]

# [5.2.0-beta.2](https://github.com/kaitranntt/ccs/compare/v5.2.0-beta.1...v5.2.0-beta.2) (2025-12-01)

### Features

* **cliproxy:** auto-update cliproxyapi to latest release on startup ([8873ccd](https://github.com/kaitranntt/ccs/commit/8873ccd981679e8acff8965accdc22215c6e4aa2))

---------

Co-authored-by: semantic-release-bot <semantic-release-bot@martynus.net>
2025-12-01 04:11:14 -05:00

325 lines
9.4 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 { escapeShellArg } from '../utils/shell-executor';
import { ensureCLIProxyBinary } from './binary-manager';
import {
generateConfig,
getEffectiveEnvVars,
getProviderConfig,
ensureProviderSettings,
CLIPROXY_DEFAULT_PORT,
} from './config-generator';
import { isAuthenticated } from './auth-handler';
import { CLIProxyProvider, ExecutorConfig } from './types';
/** 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> {
const cfg = { ...DEFAULT_CONFIG, ...config };
const verbose = cfg.verbose || args.includes('--verbose') || args.includes('-v');
const log = (msg: string) => {
if (verbose) {
console.error(`[cliproxy] ${msg}`);
}
};
// Validate provider
const providerConfig = getProviderConfig(provider);
log(`Provider: ${providerConfig.displayName}`);
// 1. Ensure binary exists (downloads if needed)
const spinner = new ProgressIndicator('Preparing CLIProxy');
spinner.start();
let binaryPath: string;
try {
binaryPath = await ensureCLIProxyBinary(verbose);
spinner.succeed('CLIProxy binary ready');
} catch (error) {
spinner.fail('Failed to prepare CLIProxy');
throw error;
}
// 2. Handle authentication flags
const forceAuth = args.includes('--auth');
const forceHeadless = args.includes('--headless');
const forceLogout = args.includes('--logout');
// 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(`[i] No authentication found for ${providerConfig.displayName}`);
}
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,
...(forceHeadless ? { headless: 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`);
}
}
// 4. Ensure user settings file exists (creates from defaults if not)
ensureProviderSettings(provider);
// 5. Generate config file
log(`Generating config for ${provider}`);
const configPath = generateConfig(provider, cfg.port);
log(`Config written: ${configPath}`);
// 6. Spawn CLIProxyAPI binary
const proxyArgs = ['--config', configPath];
log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`);
const proxy = spawn(binaryPath, proxyArgs, {
stdio: ['ignore', verbose ? 'pipe' : 'ignore', verbose ? 'pipe' : 'ignore'],
detached: false,
});
// Forward proxy output in verbose mode
if (verbose) {
proxy.stdout?.on('data', (data: Buffer) => {
process.stderr.write(`[cliproxy-out] ${data.toString()}`);
});
proxy.stderr?.on('data', (data: Buffer) => {
process.stderr.write(`[cliproxy-err] ${data.toString()}`);
});
}
// Handle proxy errors
proxy.on('error', (error) => {
console.error(`[X] CLIProxy spawn error: ${error.message}`);
});
// 5. 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 startup failed');
proxy.kill('SIGTERM');
const err = error as Error;
console.error('');
console.error('[X] CLIProxy 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 if port ${cfg.port} is in use: lsof -i :${cfg.port}`);
console.error(' - Run with --verbose for detailed logs');
console.error(` - Check config: cat ${configPath}`);
console.error('');
throw new Error(`CLIProxy startup failed: ${err.message}`);
}
// 7. Execute Claude CLI with proxied environment
// Uses custom settings path (for variants), user settings, or bundled defaults
const envVars = getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath);
const env = { ...process.env, ...envVars };
log(`Claude env: ANTHROPIC_BASE_URL=${envVars.ANTHROPIC_BASE_URL}`);
log(`Claude env: ANTHROPIC_MODEL=${envVars.ANTHROPIC_MODEL}`);
// Filter out CCS-specific flags before passing to Claude CLI
const ccsFlags = ['--auth', '--headless', '--logout'];
const claudeArgs = args.filter((arg) => !ccsFlags.includes(arg));
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: kill proxy when Claude exits
claude.on('exit', (code, signal) => {
log(`Claude exited: code=${code}, signal=${signal}`);
proxy.kill('SIGTERM');
if (signal) {
process.kill(process.pid, signal as NodeJS.Signals);
} else {
process.exit(code || 0);
}
});
claude.on('error', (error) => {
console.error('[X] Claude CLI error:', error);
proxy.kill('SIGTERM');
process.exit(1);
});
// Handle parent process termination (SIGTERM, SIGINT)
const cleanup = () => {
log('Parent signal received, cleaning up');
proxy.kill('SIGTERM');
claude.kill('SIGTERM');
};
process.once('SIGTERM', cleanup);
process.once('SIGINT', cleanup);
// Handle proxy crash
proxy.on('exit', (code, signal) => {
if (code !== 0 && code !== null) {
log(`Proxy exited unexpectedly: code=${code}, signal=${signal}`);
// Don't kill Claude - it may have already exited
}
});
}
/**
* 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;