Files
ccs/src/ccs.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

350 lines
11 KiB
JavaScript

#!/usr/bin/env node
/**
* CCS (Claude Code Switch) - Entry Point
*
* Instant profile switching for Claude CLI.
* Supports multiple accounts, alternative models (GLM, Kimi),
* and cost-optimized delegation.
*/
import { spawn, ChildProcess } from 'child_process';
import * as path from 'path';
import * as fs from 'fs';
import { detectClaudeCli } from './utils/claude-detector';
import { getSettingsPath } from './utils/config-manager';
import { ErrorManager } from './utils/error-manager';
import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy';
// Import extracted command handlers
import { handleVersionCommand } from './commands/version-command';
import { handleHelpCommand } from './commands/help-command';
import { handleInstallCommand, handleUninstallCommand } from './commands/install-command';
import { handleDoctorCommand } from './commands/doctor-command';
import { handleSyncCommand } from './commands/sync-command';
import { handleShellCompletionCommand } from './commands/shell-completion-command';
import { handleUpdateCommand } from './commands/update-command';
// Import extracted utility functions
import { execClaude, escapeShellArg } from './utils/shell-executor';
// ========== Profile Detection ==========
interface DetectedProfile {
profile: string;
remainingArgs: string[];
}
/**
* Smart profile detection
*/
function detectProfile(args: string[]): DetectedProfile {
if (args.length === 0 || args[0].startsWith('-')) {
// No args or first arg is a flag → use default profile
return { profile: 'default', remainingArgs: args };
} else {
// First arg doesn't start with '-' → treat as profile name
return { profile: args[0], remainingArgs: args.slice(1) };
}
}
// ========== GLMT Proxy Execution ==========
/**
* Execute Claude CLI with embedded proxy (for GLMT profile)
*/
async function execClaudeWithProxy(
claudeCli: string,
profileName: string,
args: string[]
): Promise<void> {
// 1. Read settings to get API key
const settingsPath = getSettingsPath(profileName);
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
const apiKey = settings.env.ANTHROPIC_AUTH_TOKEN;
if (!apiKey || apiKey === 'YOUR_GLM_API_KEY_HERE') {
console.error('[X] GLMT profile requires Z.AI API key');
console.error(' Edit ~/.ccs/glmt.settings.json and set ANTHROPIC_AUTH_TOKEN');
process.exit(1);
}
// Detect verbose flag
const verbose = args.includes('--verbose') || args.includes('-v');
// 2. Spawn embedded proxy with verbose flag
const proxyPath = path.join(__dirname, 'glmt', 'glmt-proxy.js');
const proxyArgs = verbose ? ['--verbose'] : [];
// Use process.execPath for Windows compatibility (CVE-2024-27980)
const proxy = spawn(process.execPath, [proxyPath, ...proxyArgs], {
stdio: ['ignore', 'pipe', verbose ? 'pipe' : 'inherit'],
});
// 3. Wait for proxy ready signal (with timeout)
const { ProgressIndicator } = await import('./utils/progress-indicator');
const spinner = new ProgressIndicator('Starting GLMT proxy');
spinner.start();
let port: number;
try {
port = await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Proxy startup timeout (5s)'));
}, 5000);
proxy.stdout?.on('data', (data: Buffer) => {
const match = data.toString().match(/PROXY_READY:(\d+)/);
if (match) {
clearTimeout(timeout);
resolve(parseInt(match[1]));
}
});
proxy.on('error', (error) => {
clearTimeout(timeout);
reject(error);
});
proxy.on('exit', (code) => {
if (code !== 0 && code !== null) {
clearTimeout(timeout);
reject(new Error(`Proxy exited with code ${code}`));
}
});
});
spinner.succeed(`GLMT proxy ready on port ${port}`);
} catch (error) {
const err = error as Error;
spinner.fail('Failed to start GLMT proxy');
console.error('[X] Error:', err.message);
console.error('');
console.error('Possible causes:');
console.error(' 1. Port conflict (unlikely with random port)');
console.error(' 2. Node.js permission issue');
console.error(' 3. Firewall blocking localhost');
console.error('');
console.error('Workarounds:');
console.error(' - Use non-thinking mode: ccs glm "prompt"');
console.error(' - Enable verbose logging: ccs glmt --verbose "prompt"');
console.error(' - Check proxy logs in ~/.ccs/logs/ (if debug enabled)');
console.error('');
proxy.kill();
process.exit(1);
}
// 4. Spawn Claude CLI with proxy URL
const envVars: NodeJS.ProcessEnv = {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
ANTHROPIC_AUTH_TOKEN: apiKey,
ANTHROPIC_MODEL: 'glm-4.6',
};
const isWindows = process.platform === 'win32';
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
const env = { ...process.env, ...envVars };
let claude: ChildProcess;
if (needsShell) {
const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' ');
claude = spawn(cmdString, {
stdio: 'inherit',
windowsHide: true,
shell: true,
env,
});
} else {
claude = spawn(claudeCli, args, {
stdio: 'inherit',
windowsHide: true,
env,
});
}
// 5. Cleanup: kill proxy when Claude exits
claude.on('exit', (code, 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);
});
// Also handle parent process termination
process.once('SIGTERM', () => {
proxy.kill('SIGTERM');
claude.kill('SIGTERM');
});
process.once('SIGINT', () => {
proxy.kill('SIGTERM');
claude.kill('SIGTERM');
});
}
// ========== Main Execution ==========
interface ProfileError extends Error {
profileName?: string;
availableProfiles?: string;
suggestions?: string[];
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
// Special case: version command (check BEFORE profile detection)
const firstArg = args[0];
if (firstArg === 'version' || firstArg === '--version' || firstArg === '-v') {
handleVersionCommand();
}
// Special case: help command
if (firstArg === '--help' || firstArg === '-h' || firstArg === 'help') {
handleHelpCommand();
return;
}
// Special case: install command
if (firstArg === '--install') {
handleInstallCommand();
return;
}
// Special case: uninstall command
if (firstArg === '--uninstall') {
handleUninstallCommand();
return;
}
// Special case: shell completion installer
if (firstArg === '--shell-completion' || firstArg === '-sc') {
await handleShellCompletionCommand(args.slice(1));
return;
}
// Special case: doctor command
if (firstArg === 'doctor' || firstArg === '--doctor') {
await handleDoctorCommand();
return;
}
// Special case: sync command
if (firstArg === 'sync' || firstArg === '--sync') {
await handleSyncCommand();
return;
}
// Special case: update command
if (firstArg === 'update' || firstArg === '--update') {
await handleUpdateCommand();
return;
}
// Special case: auth command
if (firstArg === 'auth') {
const AuthCommandsModule = await import('./auth/auth-commands');
const AuthCommands = AuthCommandsModule.default;
const authCommands = new AuthCommands();
await authCommands.route(args.slice(1));
return;
}
// Special case: headless delegation (-p flag)
if (args.includes('-p') || args.includes('--prompt')) {
const { DelegationHandler } = await import('./delegation/delegation-handler');
const handler = new DelegationHandler();
await handler.route(args);
return;
}
// Auto-recovery for missing configuration
const RecoveryManagerModule = await import('./management/recovery-manager');
const RecoveryManager = RecoveryManagerModule.default;
const recovery = new RecoveryManager();
const recovered = recovery.recoverAll();
if (recovered) {
recovery.showRecoveryHints();
}
// Detect profile
const { profile, remainingArgs } = detectProfile(args);
// Detect Claude CLI first (needed for all paths)
const claudeCli = detectClaudeCli();
if (!claudeCli) {
ErrorManager.showClaudeNotFound();
process.exit(1);
}
// Use ProfileDetector to determine profile type
const ProfileDetectorModule = await import('./auth/profile-detector');
const ProfileDetector = ProfileDetectorModule.default;
const InstanceManagerModule = await import('./management/instance-manager');
const InstanceManager = InstanceManagerModule.default;
const ProfileRegistryModule = await import('./auth/profile-registry');
const ProfileRegistry = ProfileRegistryModule.default;
const detector = new ProfileDetector();
try {
const profileInfo = detector.detectProfileType(profile);
if (profileInfo.type === 'cliproxy') {
// CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants
const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider);
const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles
await execClaudeWithCLIProxy(claudeCli, provider, remainingArgs, { customSettingsPath });
} else if (profileInfo.type === 'settings') {
// Check if this is GLMT profile (requires proxy)
if (profileInfo.name === 'glmt') {
// GLMT FLOW: Settings-based with embedded proxy for thinking support
await execClaudeWithProxy(claudeCli, profileInfo.name, remainingArgs);
} else {
// EXISTING FLOW: Settings-based profile (glm, kimi)
// Use --settings flag (backward compatible)
const expandedSettingsPath = getSettingsPath(profileInfo.name);
execClaude(claudeCli, ['--settings', expandedSettingsPath, ...remainingArgs]);
}
} else if (profileInfo.type === 'account') {
// NEW FLOW: Account-based profile (work, personal)
// All platforms: Use instance isolation with CLAUDE_CONFIG_DIR
const registry = new ProfileRegistry();
const instanceMgr = new InstanceManager();
// Ensure instance exists (lazy init if needed)
const instancePath = instanceMgr.ensureInstance(profileInfo.name);
// Update last_used timestamp
registry.touchProfile(profileInfo.name);
// Execute Claude with instance isolation
const envVars: NodeJS.ProcessEnv = { CLAUDE_CONFIG_DIR: instancePath };
execClaude(claudeCli, remainingArgs, envVars);
} else {
// DEFAULT: No profile configured, use Claude's own defaults
execClaude(claudeCli, remainingArgs);
}
} catch (error) {
const err = error as ProfileError;
// Check if this is a profile not found error with suggestions
if (err.profileName && err.availableProfiles !== undefined) {
const allProfiles = err.availableProfiles.split('\n');
ErrorManager.showProfileNotFound(err.profileName, allProfiles, err.suggestions);
} else {
console.error(`[X] ${err.message}`);
}
process.exit(1);
}
}
// Run main
main().catch((error) => {
console.error('Fatal error:', error.message);
process.exit(1);
});