Files
ccs/bin/ccs.js
T
kaitranntt 5fae92ac07 feat(glmt): add streaming with real-time thinking blocks
- reorganize bin/ into auth/, glmt/, management/, utils/
- add budget calculator and locale enforcer
- enhance test coverage with unit/integration separation
2025-11-11 15:39:00 -05:00

455 lines
15 KiB
JavaScript
Executable File

#!/usr/bin/env node
'use strict';
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const os = require('os');
const { error, colored } = require('./utils/helpers');
const { detectClaudeCli, showClaudeNotFoundError } = require('./utils/claude-detector');
const { getSettingsPath, getConfigPath } = require('./utils/config-manager');
const { ErrorManager } = require('./utils/error-manager');
const RecoveryManager = require('./management/recovery-manager');
// Version (sync with package.json)
const CCS_VERSION = require('../package.json').version;
// Helper: Escape arguments for shell execution
function escapeShellArg(arg) {
// Windows: escape double quotes and wrap in double quotes
return '"' + String(arg).replace(/"/g, '""') + '"';
}
// Execute Claude CLI with unified spawn logic
function execClaude(claudeCli, args, envVars = null) {
const isWindows = process.platform === 'win32';
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
// Prepare environment (merge with process.env if envVars provided)
const env = envVars ? { ...process.env, ...envVars } : process.env;
let child;
if (needsShell) {
// When shell needed: concatenate into string to avoid DEP0190 warning
const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' ');
child = spawn(cmdString, {
stdio: 'inherit',
windowsHide: true,
shell: true,
env
});
} else {
// When no shell needed: use array form (faster, no shell overhead)
child = spawn(claudeCli, args, {
stdio: 'inherit',
windowsHide: true,
env
});
}
child.on('exit', (code, signal) => {
if (signal) process.kill(process.pid, signal);
else process.exit(code || 0);
});
child.on('error', () => {
showClaudeNotFoundError();
process.exit(1);
});
}
// Special command handlers
function handleVersionCommand() {
// Title
console.log(colored(`CCS (Claude Code Switch) v${CCS_VERSION}`, 'bold'));
console.log('');
// Installation section
console.log(colored('Installation:', 'cyan'));
// Location
const installLocation = process.argv[1] || '(not found)';
console.log(` ${colored('Location:', 'cyan')} ${installLocation}`);
// Config path
const configPath = getConfigPath();
console.log(` ${colored('Config:', 'cyan')} ${configPath}`);
console.log('');
// Documentation
console.log(`${colored('Documentation:', 'cyan')} https://github.com/kaitranntt/ccs`);
console.log(`${colored('License:', 'cyan')} MIT`);
console.log('');
// Help hint
console.log(colored('Run \'ccs --help\' for usage information', 'yellow'));
process.exit(0);
}
function handleHelpCommand() {
// Title
console.log(colored('CCS (Claude Code Switch) - Instant profile switching for Claude CLI', 'bold'));
console.log('');
// Usage
console.log(colored('Usage:', 'cyan'));
console.log(` ${colored('ccs', 'yellow')} [profile] [claude-args...]`);
console.log(` ${colored('ccs', 'yellow')} [flags]`);
console.log('');
// Description
console.log(colored('Description:', 'cyan'));
console.log(' Switch between multiple Claude accounts (work, personal, team) and');
console.log(' alternative models (GLM, Kimi) instantly. Concurrent sessions with');
console.log(' auto-recovery. Zero downtime.');
console.log('');
// Model Switching
console.log(colored('Model Switching:', 'cyan'));
console.log(` ${colored('ccs', 'yellow')} Use default Claude account`);
console.log(` ${colored('ccs glm', 'yellow')} Switch to GLM 4.6 model`);
console.log(` ${colored('ccs glmt', 'yellow')} Switch to GLM with thinking mode`);
console.log(` ${colored('ccs glmt --verbose', 'yellow')} Enable debug logging`);
console.log(` ${colored('ccs kimi', 'yellow')} Switch to Kimi for Coding`);
console.log(` ${colored('ccs glm', 'yellow')} "debug this code" Use GLM and run command`);
console.log('');
// Account Management
console.log(colored('Account Management:', 'cyan'));
console.log(` ${colored('ccs auth --help', 'yellow')} Manage multiple Claude accounts`);
console.log(` ${colored('ccs work', 'yellow')} Switch to work account`);
console.log(` ${colored('ccs personal', 'yellow')} Switch to personal account`);
console.log('');
// Diagnostics
console.log(colored('Diagnostics:', 'cyan'));
console.log(` ${colored('ccs doctor', 'yellow')} Run health check and diagnostics`);
console.log('');
// Flags
console.log(colored('Flags:', 'cyan'));
console.log(` ${colored('-h, --help', 'yellow')} Show this help message`);
console.log(` ${colored('-v, --version', 'yellow')} Show version and installation info`);
console.log('');
// Configuration
console.log(colored('Configuration:', 'cyan'));
console.log(' Config File: ~/.ccs/config.json');
console.log(' Profiles: ~/.ccs/profiles.json');
console.log(' Instances: ~/.ccs/instances/');
console.log(' Settings: ~/.ccs/*.settings.json');
console.log(' Environment: CCS_CONFIG (override config path)');
console.log('');
// Shared Data
console.log(colored('Shared Data:', 'cyan'));
console.log(' Commands: ~/.ccs/shared/commands/');
console.log(' Skills: ~/.ccs/shared/skills/');
console.log(' Agents: ~/.ccs/shared/agents/');
console.log(' Note: Commands, skills, and agents are symlinked across all profiles');
console.log('');
// Uninstall
console.log(colored('Uninstall:', 'yellow'));
console.log(' npm: npm uninstall -g @kaitranntt/ccs');
console.log(' macOS/Linux: curl -fsSL ccs.kaitran.ca/uninstall | bash');
console.log(' Windows: irm ccs.kaitran.ca/uninstall | iex');
console.log('');
// Documentation
console.log(colored('Documentation:', 'cyan'));
console.log(` GitHub: ${colored('https://github.com/kaitranntt/ccs', 'cyan')}`);
console.log(' Docs: https://github.com/kaitranntt/ccs/blob/main/README.md');
console.log(' Issues: https://github.com/kaitranntt/ccs/issues');
console.log('');
// License
console.log(`${colored('License:', 'cyan')} MIT`);
process.exit(0);
}
function handleInstallCommand() {
console.log('');
console.log('Feature not available');
console.log('');
console.log('The --install flag is currently under development.');
console.log('.claude/ integration testing is not complete.');
console.log('');
console.log('For updates: https://github.com/kaitranntt/ccs/issues');
console.log('');
process.exit(0);
}
function handleUninstallCommand() {
console.log('');
console.log('Feature not available');
console.log('');
console.log('The --uninstall flag is currently under development.');
console.log('.claude/ integration testing is not complete.');
console.log('');
console.log('For updates: https://github.com/kaitranntt/ccs/issues');
console.log('');
process.exit(0);
}
async function handleDoctorCommand() {
const Doctor = require('./management/doctor');
const doctor = new Doctor();
await doctor.runAllChecks();
// Exit with error code if unhealthy
process.exit(doctor.results.isHealthy() ? 0 : 1);
}
// Smart profile detection
function detectProfile(args) {
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) };
}
}
// Execute Claude CLI with embedded proxy (for GLMT profile)
async function execClaudeWithProxy(claudeCli, profileName, args) {
const { getSettingsPath } = require('./utils/config-manager');
// 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)
let port;
try {
port = await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Proxy startup timeout (5s)'));
}, 5000);
proxy.stdout.on('data', (data) => {
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}`));
}
});
});
} catch (error) {
console.error('[X] Failed to start GLMT proxy:', error.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 = {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
ANTHROPIC_AUTH_TOKEN: apiKey,
ANTHROPIC_MODEL: 'glm-4.6'
};
// Use existing execClaude helper for consistent Windows handling
const isWindows = process.platform === 'win32';
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
const env = { ...process.env, ...envVars };
let claude;
if (needsShell) {
// When shell needed: concatenate into string to avoid DEP0190 warning
const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' ');
claude = spawn(cmdString, {
stdio: 'inherit',
windowsHide: true,
shell: true,
env
});
} else {
// When no shell needed: use array form (faster, no shell overhead)
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);
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 (use .once to avoid duplicates)
process.once('SIGTERM', () => {
proxy.kill('SIGTERM');
claude.kill('SIGTERM');
});
process.once('SIGINT', () => {
proxy.kill('SIGTERM');
claude.kill('SIGTERM');
});
}
// Main execution
async function main() {
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: doctor command
if (firstArg === 'doctor' || firstArg === '--doctor') {
await handleDoctorCommand();
return;
}
// Special case: auth command (multi-account management)
if (firstArg === 'auth') {
const AuthCommands = require('./auth/auth-commands');
const authCommands = new AuthCommands();
await authCommands.route(args.slice(1));
return;
}
// Auto-recovery for missing configuration
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 ProfileDetector = require('./auth/profile-detector');
const InstanceManager = require('./management/instance-manager');
const ProfileRegistry = require('./auth/profile-registry');
const { getSettingsPath } = require('./utils/config-manager');
const detector = new ProfileDetector();
try {
const profileInfo = detector.detectProfileType(profile);
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 = { CLAUDE_CONFIG_DIR: instancePath };
execClaude(claudeCli, remainingArgs, envVars);
} else {
// DEFAULT: No profile configured, use Claude's own defaults
execClaude(claudeCli, remainingArgs);
}
} catch (error) {
console.error(`[X] ${error.message}`);
process.exit(1);
}
}
// Run main
main().catch(error => {
console.error('Fatal error:', error.message);
process.exit(1);
});