diff --git a/bin/auth/auth-commands.js b/bin/auth/auth-commands.js deleted file mode 100644 index 6547d0af..00000000 --- a/bin/auth/auth-commands.js +++ /dev/null @@ -1,499 +0,0 @@ -'use strict'; - -const { spawn } = require('child_process'); -const fs = require('fs'); -const path = require('path'); -const ProfileRegistry = require('./profile-registry'); -const InstanceManager = require('../management/instance-manager'); -const { colored } = require('../utils/helpers'); -const { detectClaudeCli } = require('../utils/claude-detector'); -const { InteractivePrompt } = require('../utils/prompt'); -const CCS_VERSION = require('../../package.json').version; - -/** - * Auth Commands (Simplified) - * - * CLI interface for CCS multi-account management. - * Commands: create, list, show, remove, default - * - * Login-per-profile model: Each profile is an isolated Claude instance. - * Users login directly in each instance (no credential copying). - */ -class AuthCommands { - constructor() { - this.registry = new ProfileRegistry(); - this.instanceMgr = new InstanceManager(); - } - - /** - * Show help for auth commands - */ - showHelp() { - console.log(colored('CCS Concurrent Account Management', 'bold')); - console.log(''); - console.log(colored('Usage:', 'cyan')); - console.log(` ${colored('ccs auth', 'yellow')} [options]`); - console.log(''); - console.log(colored('Commands:', 'cyan')); - console.log(` ${colored('create ', 'yellow')} Create new profile and login`); - console.log(` ${colored('list', 'yellow')} List all saved profiles`); - console.log(` ${colored('show ', 'yellow')} Show profile details`); - console.log(` ${colored('remove ', 'yellow')} Remove saved profile`); - console.log(` ${colored('default ', 'yellow')} Set default profile`); - console.log(''); - console.log(colored('Examples:', 'cyan')); - console.log(` ${colored('ccs auth create work', 'yellow')} # Create & login to work profile`); - console.log(` ${colored('ccs auth default work', 'yellow')} # Set work as default`); - console.log(` ${colored('ccs auth list', 'yellow')} # List all profiles`); - console.log(` ${colored('ccs work "review code"', 'yellow')} # Use work profile`); - console.log(` ${colored('ccs "review code"', 'yellow')} # Use default profile`); - console.log(''); - console.log(colored('Options:', 'cyan')); - console.log(` ${colored('--force', 'yellow')} Allow overwriting existing profile (create)`); - console.log(` ${colored('--yes, -y', 'yellow')} Skip confirmation prompts (remove)`); - console.log(` ${colored('--json', 'yellow')} Output in JSON format (list, show)`); - console.log(` ${colored('--verbose', 'yellow')} Show additional details (list)`); - console.log(''); - console.log(colored('Note:', 'cyan')); - console.log(` By default, ${colored('ccs', 'yellow')} uses Claude CLI defaults from ~/.claude/`); - console.log(` Use ${colored('ccs auth default ', 'yellow')} to change the default profile.`); - console.log(''); - } - - /** - * Create new profile and prompt for login - * @param {Array} args - Command arguments - */ - async handleCreate(args) { - const profileName = args.find(arg => !arg.startsWith('--')); - const force = args.includes('--force'); - - if (!profileName) { - console.error('[X] Profile name is required'); - console.log(''); - console.log(`Usage: ${colored('ccs auth create [--force]', 'yellow')}`); - console.log(''); - console.log('Example:'); - console.log(` ${colored('ccs auth create work', 'yellow')}`); - process.exit(1); - } - - // Check if profile already exists - if (!force && this.registry.hasProfile(profileName)) { - console.error(`[X] Profile already exists: ${profileName}`); - console.log(` Use ${colored('--force', 'yellow')} to overwrite`); - process.exit(1); - } - - try { - // Create instance directory - console.log(`[i] Creating profile: ${profileName}`); - const instancePath = this.instanceMgr.ensureInstance(profileName); - - // Create/update profile entry - if (this.registry.hasProfile(profileName)) { - this.registry.updateProfile(profileName, { - type: 'account' - }); - } else { - this.registry.createProfile(profileName, { - type: 'account' - }); - } - - console.log(`[i] Instance directory: ${instancePath}`); - console.log(''); - console.log(colored('[i] Starting Claude in isolated instance...', 'yellow')); - console.log(colored('[i] You will be prompted to login with your account.', 'yellow')); - console.log(''); - - // Detect Claude CLI - const claudeCli = detectClaudeCli(); - if (!claudeCli) { - console.error('[X] Claude CLI not found'); - console.log(''); - console.log('Please install Claude CLI first:'); - console.log(' https://claude.ai/download'); - process.exit(1); - } - - // Execute Claude in isolated instance (will auto-prompt for login if no credentials) - const child = spawn(claudeCli, [], { - stdio: 'inherit', - env: { ...process.env, CLAUDE_CONFIG_DIR: instancePath } - }); - - child.on('exit', (code) => { - if (code === 0) { - console.log(''); - console.log(colored('[OK] Profile created successfully', 'green')); - console.log(''); - console.log(` Profile: ${profileName}`); - console.log(` Instance: ${instancePath}`); - console.log(''); - console.log('Usage:'); - console.log(` ${colored(`ccs ${profileName} "your prompt here"`, 'yellow')} # Use this specific profile`); - console.log(''); - console.log('To set as default (so you can use just "ccs"):'); - console.log(` ${colored(`ccs auth default ${profileName}`, 'yellow')}`); - console.log(''); - process.exit(0); - } else { - console.log(''); - console.error('[X] Login failed or cancelled'); - console.log(''); - console.log('To retry:'); - console.log(` ${colored(`ccs auth create ${profileName} --force`, 'yellow')}`); - console.log(''); - process.exit(1); - } - }); - - child.on('error', (err) => { - console.error(`[X] Failed to execute Claude CLI: ${err.message}`); - process.exit(1); - }); - - } catch (error) { - console.error(`[X] Failed to create profile: ${error.message}`); - process.exit(1); - } - } - - /** - * List all saved profiles - * @param {Array} args - Command arguments - */ - async handleList(args) { - const verbose = args.includes('--verbose'); - const json = args.includes('--json'); - - try { - const profiles = this.registry.getAllProfiles(); - const defaultProfile = this.registry.getDefaultProfile(); - const profileNames = Object.keys(profiles); - - // JSON output mode - if (json) { - const output = { - version: CCS_VERSION, - profiles: profileNames.map(name => { - const profile = profiles[name]; - const isDefault = name === defaultProfile; - const instancePath = this.instanceMgr.getInstancePath(name); - - return { - name: name, - type: profile.type || 'account', - is_default: isDefault, - created: profile.created, - last_used: profile.last_used || null, - instance_path: instancePath - }; - }) - }; - console.log(JSON.stringify(output, null, 2)); - return; - } - - // Human-readable output - if (profileNames.length === 0) { - console.log(colored('No account profiles found', 'yellow')); - console.log(''); - console.log('To create your first profile:'); - console.log(` ${colored('ccs auth create ', 'yellow')} # Create and login to profile`); - console.log(''); - console.log('Example:'); - console.log(` ${colored('ccs auth create work', 'yellow')}`); - console.log(''); - return; - } - - console.log(colored('Saved Account Profiles:', 'bold')); - console.log(''); - - // Sort by last_used (descending), then alphabetically - const sorted = profileNames.sort((a, b) => { - const aProfile = profiles[a]; - const bProfile = profiles[b]; - - // Default first - if (a === defaultProfile) return -1; - if (b === defaultProfile) return 1; - - // Then by last_used - if (aProfile.last_used && bProfile.last_used) { - return new Date(bProfile.last_used) - new Date(aProfile.last_used); - } - if (aProfile.last_used) return -1; - if (bProfile.last_used) return 1; - - // Then alphabetically - return a.localeCompare(b); - }); - - sorted.forEach(name => { - const profile = profiles[name]; - const isDefault = name === defaultProfile; - const indicator = isDefault ? colored('[*]', 'green') : '[ ]'; - - console.log(`${indicator} ${colored(name, 'cyan')}${isDefault ? colored(' (default)', 'green') : ''}`); - - console.log(` Type: ${profile.type || 'account'}`); - - if (verbose) { - console.log(` Created: ${new Date(profile.created).toLocaleString()}`); - if (profile.last_used) { - console.log(` Last used: ${new Date(profile.last_used).toLocaleString()}`); - } - } - - console.log(''); - }); - - console.log(`Total profiles: ${profileNames.length}`); - console.log(''); - - } catch (error) { - console.error(`[X] Failed to list profiles: ${error.message}`); - process.exit(1); - } - } - - /** - * Show details for a specific profile - * @param {Array} args - Command arguments - */ - async handleShow(args) { - const profileName = args.find(arg => !arg.startsWith('--')); - const json = args.includes('--json'); - - if (!profileName) { - console.error('[X] Profile name is required'); - console.log(''); - console.log(`Usage: ${colored('ccs auth show [--json]', 'yellow')}`); - process.exit(1); - } - - try { - const profile = this.registry.getProfile(profileName); - const defaultProfile = this.registry.getDefaultProfile(); - const isDefault = profileName === defaultProfile; - const instancePath = this.instanceMgr.getInstancePath(profileName); - - // Count sessions - let sessionCount = 0; - try { - const sessionsDir = path.join(instancePath, 'session-env'); - if (fs.existsSync(sessionsDir)) { - const files = fs.readdirSync(sessionsDir); - sessionCount = files.filter(f => f.endsWith('.json')).length; - } - } catch (e) { - // Ignore errors counting sessions - } - - // JSON output mode - if (json) { - const output = { - name: profileName, - type: profile.type || 'account', - is_default: isDefault, - created: profile.created, - last_used: profile.last_used || null, - instance_path: instancePath, - session_count: sessionCount - }; - console.log(JSON.stringify(output, null, 2)); - return; - } - - // Human-readable output - console.log(colored(`Profile: ${profileName}`, 'bold')); - console.log(''); - console.log(` Type: ${profile.type || 'account'}`); - console.log(` Default: ${isDefault ? 'Yes' : 'No'}`); - console.log(` Instance: ${instancePath}`); - console.log(` Created: ${new Date(profile.created).toLocaleString()}`); - - if (profile.last_used) { - console.log(` Last used: ${new Date(profile.last_used).toLocaleString()}`); - } else { - console.log(` Last used: Never`); - } - - console.log(''); - - } catch (error) { - console.error(`[X] ${error.message}`); - process.exit(1); - } - } - - /** - * Remove a saved profile - * @param {Array} args - Command arguments - */ - async handleRemove(args) { - const profileName = args.find(arg => !arg.startsWith('--') && !arg.startsWith('-')); - - if (!profileName) { - console.error('[X] Profile name is required'); - console.log(''); - console.log(`Usage: ${colored('ccs auth remove [--yes]', 'yellow')}`); - process.exit(1); - } - - if (!this.registry.hasProfile(profileName)) { - console.error(`[X] Profile not found: ${profileName}`); - process.exit(1); - } - - try { - // Get instance path and session count for impact display - const instancePath = this.instanceMgr.getInstancePath(profileName); - let sessionCount = 0; - - try { - const sessionsDir = path.join(instancePath, 'session-env'); - if (fs.existsSync(sessionsDir)) { - const files = fs.readdirSync(sessionsDir); - sessionCount = files.filter(f => f.endsWith('.json')).length; - } - } catch (e) { - // Ignore errors counting sessions - } - - // Display impact - console.log(''); - console.log(`Profile '${colored(profileName, 'cyan')}' will be permanently deleted.`); - console.log(` Instance path: ${instancePath}`); - console.log(` Sessions: ${sessionCount} conversation${sessionCount !== 1 ? 's' : ''}`); - console.log(''); - - // Interactive confirmation (or --yes flag) - const confirmed = await InteractivePrompt.confirm( - 'Delete this profile?', - { default: false } // Default to NO (safe) - ); - - if (!confirmed) { - console.log('[i] Cancelled'); - process.exit(0); - } - - // Delete instance - this.instanceMgr.deleteInstance(profileName); - - // Delete profile - this.registry.deleteProfile(profileName); - - console.log(colored('[OK] Profile removed successfully', 'green')); - console.log(` Profile: ${profileName}`); - console.log(''); - - } catch (error) { - console.error(`[X] Failed to remove profile: ${error.message}`); - process.exit(1); - } - } - - /** - * Set default profile - * @param {Array} args - Command arguments - */ - async handleDefault(args) { - const profileName = args.find(arg => !arg.startsWith('--')); - - if (!profileName) { - console.error('[X] Profile name is required'); - console.log(''); - console.log(`Usage: ${colored('ccs auth default ', 'yellow')}`); - process.exit(1); - } - - try { - this.registry.setDefaultProfile(profileName); - - console.log(colored('[OK] Default profile set', 'green')); - console.log(` Profile: ${profileName}`); - console.log(''); - console.log('Now you can use:'); - console.log(` ${colored('ccs "your prompt"', 'yellow')} # Uses ${profileName} profile`); - console.log(''); - - } catch (error) { - console.error(`[X] ${error.message}`); - process.exit(1); - } - } - - /** - * Route auth command to appropriate handler - * @param {Array} args - Command arguments - */ - async route(args) { - if (args.length === 0 || args[0] === '--help' || args[0] === '-h' || args[0] === 'help') { - this.showHelp(); - return; - } - - const command = args[0]; - const commandArgs = args.slice(1); - - switch (command) { - case 'create': - await this.handleCreate(commandArgs); - break; - - case 'save': - // Deprecated - redirect to create - console.log(colored('[!] Command "save" is deprecated', 'yellow')); - console.log(` Use: ${colored('ccs auth create ', 'yellow')} instead`); - console.log(''); - await this.handleCreate(commandArgs); - break; - - case 'list': - await this.handleList(commandArgs); - break; - - case 'show': - await this.handleShow(commandArgs); - break; - - case 'remove': - await this.handleRemove(commandArgs); - break; - - case 'default': - await this.handleDefault(commandArgs); - break; - - case 'current': - console.log(colored('[!] Command "current" has been removed', 'yellow')); - console.log(''); - console.log('Each profile has its own login in an isolated instance.'); - console.log('Use "ccs auth list" to see all profiles.'); - console.log(''); - break; - - case 'cleanup': - console.log(colored('[!] Command "cleanup" has been removed', 'yellow')); - console.log(''); - console.log('No cleanup needed - no separate vault files.'); - console.log('Use "ccs auth list" to see all profiles.'); - console.log(''); - break; - - default: - console.error(`[X] Unknown command: ${command}`); - console.log(''); - console.log('Run for help:'); - console.log(` ${colored('ccs auth --help', 'yellow')}`); - process.exit(1); - } - } -} - -module.exports = AuthCommands; diff --git a/bin/auth/profile-detector.js b/bin/auth/profile-detector.js deleted file mode 100644 index 844e2c07..00000000 --- a/bin/auth/profile-detector.js +++ /dev/null @@ -1,204 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { findSimilarStrings } = require('../utils/helpers'); - -/** - * Profile Detector - * - * Determines profile type (settings-based vs account-based) for routing. - * Priority: settings-based profiles (glm/kimi) checked FIRST for backward compatibility. - */ -class ProfileDetector { - constructor() { - this.configPath = path.join(os.homedir(), '.ccs', 'config.json'); - this.profilesPath = path.join(os.homedir(), '.ccs', 'profiles.json'); - } - - /** - * Read settings-based config (config.json) - * @returns {Object} Config data - */ - _readConfig() { - if (!fs.existsSync(this.configPath)) { - return { profiles: {} }; - } - - try { - const data = fs.readFileSync(this.configPath, 'utf8'); - return JSON.parse(data); - } catch (error) { - console.warn(`[!] Warning: Could not read config.json: ${error.message}`); - return { profiles: {} }; - } - } - - /** - * Read account-based profiles (profiles.json) - * @returns {Object} Profiles data - */ - _readProfiles() { - if (!fs.existsSync(this.profilesPath)) { - return { profiles: {}, default: null }; - } - - try { - const data = fs.readFileSync(this.profilesPath, 'utf8'); - return JSON.parse(data); - } catch (error) { - console.warn(`[!] Warning: Could not read profiles.json: ${error.message}`); - return { profiles: {}, default: null }; - } - } - - /** - * Detect profile type and return routing information - * @param {string} profileName - Profile name to detect - * @returns {Object} {type: 'settings'|'account'|'default', ...info} - */ - detectProfileType(profileName) { - // Special case: 'default' means use default profile - if (profileName === 'default' || profileName === null || profileName === undefined) { - return this._resolveDefaultProfile(); - } - - // Priority 1: Check settings-based profiles (glm, kimi) - BACKWARD COMPATIBILITY - const config = this._readConfig(); - - if (config.profiles && config.profiles[profileName]) { - return { - type: 'settings', - name: profileName, - settingsPath: config.profiles[profileName] - }; - } - - // Priority 2: Check account-based profiles (work, personal) - const profiles = this._readProfiles(); - - if (profiles.profiles && profiles.profiles[profileName]) { - return { - type: 'account', - name: profileName, - profile: profiles.profiles[profileName] - }; - } - - // Not found - generate suggestions - const allProfiles = this.getAllProfiles(); - const allProfileNames = [...allProfiles.settings, ...allProfiles.accounts]; - const suggestions = findSimilarStrings(profileName, allProfileNames); - - const error = new Error(`Profile not found: ${profileName}`); - error.profileName = profileName; - error.suggestions = suggestions; - error.availableProfiles = this._listAvailableProfiles(); - throw error; - } - - /** - * Resolve default profile - * @returns {Object} Default profile info - */ - _resolveDefaultProfile() { - // Check if account-based default exists - const profiles = this._readProfiles(); - - if (profiles.default && profiles.profiles[profiles.default]) { - return { - type: 'account', - name: profiles.default, - profile: profiles.profiles[profiles.default] - }; - } - - // Check if settings-based default exists - const config = this._readConfig(); - - if (config.profiles && config.profiles['default']) { - return { - type: 'settings', - name: 'default', - settingsPath: config.profiles['default'] - }; - } - - // No default profile configured, use Claude's own defaults - return { - type: 'default', - name: 'default', - message: 'No profile configured. Using Claude CLI defaults from ~/.claude/' - }; - } - - /** - * List available profiles (for error messages) - * @returns {string} Formatted list - */ - _listAvailableProfiles() { - const lines = []; - - // Settings-based profiles - const config = this._readConfig(); - const settingsProfiles = Object.keys(config.profiles || {}); - - if (settingsProfiles.length > 0) { - lines.push('Settings-based profiles (GLM, Kimi, etc.):'); - settingsProfiles.forEach(name => { - lines.push(` - ${name}`); - }); - } - - // Account-based profiles - const profiles = this._readProfiles(); - const accountProfiles = Object.keys(profiles.profiles || {}); - - if (accountProfiles.length > 0) { - lines.push('Account-based profiles:'); - accountProfiles.forEach(name => { - const isDefault = name === profiles.default; - lines.push(` - ${name}${isDefault ? ' [DEFAULT]' : ''}`); - }); - } - - if (lines.length === 0) { - return ' (no profiles configured)\n' + - ' Run "ccs auth save " to create your first account profile.'; - } - - return lines.join('\n'); - } - - /** - * Check if profile exists (any type) - * @param {string} profileName - Profile name - * @returns {boolean} True if exists - */ - hasProfile(profileName) { - try { - this.detectProfileType(profileName); - return true; - } catch { - return false; - } - } - - /** - * Get all available profile names - * @returns {Object} {settings: [...], accounts: [...]} - */ - getAllProfiles() { - const config = this._readConfig(); - const profiles = this._readProfiles(); - - return { - settings: Object.keys(config.profiles || {}), - accounts: Object.keys(profiles.profiles || {}), - default: profiles.default - }; - } -} - -module.exports = ProfileDetector; diff --git a/bin/auth/profile-registry.js b/bin/auth/profile-registry.js deleted file mode 100644 index aaebc610..00000000 --- a/bin/auth/profile-registry.js +++ /dev/null @@ -1,225 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -/** - * Profile Registry (Simplified) - * - * Manages account profile metadata in ~/.ccs/profiles.json - * Each profile represents an isolated Claude instance with login credentials. - * - * Profile Schema (v3.0 - Minimal): - * { - * type: 'account', // Profile type - * created: , // Creation time - * last_used: // Last usage time - * } - * - * Removed fields from v2.x: - * - vault: No encrypted vault (credentials in instance) - * - subscription: No credential reading - * - email: No credential reading - */ -class ProfileRegistry { - constructor() { - this.profilesPath = path.join(os.homedir(), '.ccs', 'profiles.json'); - } - - /** - * Read profiles from disk - * @returns {Object} Profiles data - */ - _read() { - if (!fs.existsSync(this.profilesPath)) { - return { - version: '2.0.0', - profiles: {}, - default: null - }; - } - - try { - const data = fs.readFileSync(this.profilesPath, 'utf8'); - return JSON.parse(data); - } catch (error) { - throw new Error(`Failed to read profiles: ${error.message}`); - } - } - - /** - * Write profiles to disk atomically - * @param {Object} data - Profiles data - */ - _write(data) { - const dir = path.dirname(this.profilesPath); - - // Ensure directory exists - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - } - - // Atomic write: temp file + rename - const tempPath = `${this.profilesPath}.tmp`; - - try { - fs.writeFileSync(tempPath, JSON.stringify(data, null, 2), { mode: 0o600 }); - fs.renameSync(tempPath, this.profilesPath); - } catch (error) { - // Cleanup temp file on error - if (fs.existsSync(tempPath)) { - fs.unlinkSync(tempPath); - } - throw new Error(`Failed to write profiles: ${error.message}`); - } - } - - /** - * Create a new profile - * @param {string} name - Profile name - * @param {Object} metadata - Profile metadata (type, created, last_used) - */ - createProfile(name, metadata = {}) { - const data = this._read(); - - if (data.profiles[name]) { - throw new Error(`Profile already exists: ${name}`); - } - - // v3.0 minimal schema: only essential fields - data.profiles[name] = { - type: metadata.type || 'account', - created: new Date().toISOString(), - last_used: null - }; - - // Note: No longer auto-set as default - // Users must explicitly run: ccs auth default - // Default always stays on implicit 'default' profile (uses ~/.claude/) - - this._write(data); - } - - /** - * Get profile metadata - * @param {string} name - Profile name - * @returns {Object} Profile metadata - */ - getProfile(name) { - const data = this._read(); - - if (!data.profiles[name]) { - throw new Error(`Profile not found: ${name}`); - } - - return data.profiles[name]; - } - - /** - * Update profile metadata - * @param {string} name - Profile name - * @param {Object} updates - Fields to update - */ - updateProfile(name, updates) { - const data = this._read(); - - if (!data.profiles[name]) { - throw new Error(`Profile not found: ${name}`); - } - - data.profiles[name] = { - ...data.profiles[name], - ...updates - }; - - this._write(data); - } - - /** - * Delete a profile - * @param {string} name - Profile name - */ - deleteProfile(name) { - const data = this._read(); - - if (!data.profiles[name]) { - throw new Error(`Profile not found: ${name}`); - } - - delete data.profiles[name]; - - // Clear default if it was the deleted profile - if (data.default === name) { - // Set to first remaining profile or null - const remaining = Object.keys(data.profiles); - data.default = remaining.length > 0 ? remaining[0] : null; - } - - this._write(data); - } - - /** - * List all profiles - * @returns {Array} Array of profile names - */ - listProfiles() { - const data = this._read(); - return Object.keys(data.profiles); - } - - /** - * Get all profiles with metadata - * @returns {Object} All profiles - */ - getAllProfiles() { - const data = this._read(); - return data.profiles; - } - - /** - * Get default profile name - * @returns {string|null} Default profile name - */ - getDefaultProfile() { - const data = this._read(); - return data.default; - } - - /** - * Set default profile - * @param {string} name - Profile name - */ - setDefaultProfile(name) { - const data = this._read(); - - if (!data.profiles[name]) { - throw new Error(`Profile not found: ${name}`); - } - - data.default = name; - this._write(data); - } - - /** - * Check if profile exists - * @param {string} name - Profile name - * @returns {boolean} - */ - hasProfile(name) { - const data = this._read(); - return !!data.profiles[name]; - } - - /** - * Update last used timestamp - * @param {string} name - Profile name - */ - touchProfile(name) { - this.updateProfile(name, { - last_used: new Date().toISOString() - }); - } -} - -module.exports = ProfileRegistry; diff --git a/bin/ccs.js b/bin/ccs.js deleted file mode 100755 index 0b1bf855..00000000 --- a/bin/ccs.js +++ /dev/null @@ -1,1034 +0,0 @@ -#!/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 with table-like formatting - console.log(colored('Installation:', 'cyan')); - - // Location - const installLocation = process.argv[1] || '(not found)'; - console.log(` ${colored('Location:'.padEnd(17), 'cyan')} ${installLocation}`); - - // .ccs/ directory location - const ccsDir = path.join(os.homedir(), '.ccs'); - console.log(` ${colored('CCS Directory:'.padEnd(17), 'cyan')} ${ccsDir}`); - - // Config path - const configPath = getConfigPath(); - console.log(` ${colored('Config:'.padEnd(17), 'cyan')} ${configPath}`); - - // Profiles.json location - const profilesJson = path.join(os.homedir(), '.ccs', 'profiles.json'); - console.log(` ${colored('Profiles:'.padEnd(17), 'cyan')} ${profilesJson}`); - - // Delegation status - check multiple indicators - const delegationSessionsPath = path.join(os.homedir(), '.ccs', 'delegation-sessions.json'); - const delegationConfigured = fs.existsSync(delegationSessionsPath); - - let readyProfiles = []; - - // Check for profiles with valid API keys - for (const profile of ['glm', 'kimi']) { - const settingsPath = path.join(os.homedir(), '.ccs', `${profile}.settings.json`); - if (fs.existsSync(settingsPath)) { - try { - const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); - const apiKey = settings.env?.ANTHROPIC_AUTH_TOKEN; - if (apiKey && !apiKey.match(/YOUR_.*_API_KEY_HERE/) && !apiKey.match(/sk-test.*/)) { - readyProfiles.push(profile); - } - } catch (error) { - // Invalid JSON, skip - } - } - } - - const hasValidApiKeys = readyProfiles.length > 0; - const delegationEnabled = delegationConfigured || hasValidApiKeys; - - if (delegationEnabled) { - console.log(` ${colored('Delegation:'.padEnd(17), 'cyan')} Enabled`); - } else { - console.log(` ${colored('Delegation:'.padEnd(17), 'cyan')} Not configured`); - } - - console.log(''); - - // Ready Profiles section - make it more prominent - if (readyProfiles.length > 0) { - console.log(colored('Delegation Ready:', 'cyan')); - console.log(` ${colored('✓', 'yellow')} ${readyProfiles.join(', ')} profiles are ready for delegation`); - console.log(''); - } else if (delegationEnabled) { - console.log(colored('Delegation Ready:', 'cyan')); - console.log(` ${colored('!', 'yellow')} Delegation configured but no valid API keys found`); - 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 and alternative models'); - console.log(' (GLM, Kimi) instantly. Run different Claude CLI sessions concurrently'); - console.log(' with 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')} Run multiple Claude accounts concurrently`); - console.log(''); - - // Delegation (inside Claude Code CLI) - console.log(colored('Delegation (inside Claude Code CLI):', 'cyan')); - console.log(` ${colored('/ccs "task"', 'yellow')} Delegate task (auto-selects best profile)`); - console.log(` ${colored('/ccs --glm "task"', 'yellow')} Force GLM-4.6 for simple tasks`); - console.log(` ${colored('/ccs --kimi "task"', 'yellow')} Force Kimi for long context`); - console.log(` ${colored('/ccs:continue "follow-up"', 'yellow')} Continue last delegation session`); - console.log(' Save tokens by delegating simple tasks to cost-optimized models'); - console.log(''); - - // Diagnostics - console.log(colored('Diagnostics:', 'cyan')); - console.log(` ${colored('ccs doctor', 'yellow')} Run health check and diagnostics`); - console.log(` ${colored('ccs sync', 'yellow')} Sync delegation commands and skills`); - console.log(` ${colored('ccs update', 'yellow')} Update CCS to latest version`); - 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(` ${colored('-sc, --shell-completion', 'yellow')} Install shell auto-completion`); - 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(' Plugins: ~/.ccs/shared/plugins/'); - console.log(' Note: Commands, skills, agents, and plugins are symlinked across all profiles'); - console.log(''); - - // Examples - console.log(colored('Examples:', 'cyan')); - console.log(` ${colored('$ ccs', 'yellow')} # Use default account`); - console.log(` ${colored('$ ccs glm "implement API"', 'yellow')} # Cost-optimized model`); - console.log(''); - console.log(` For more: ${colored('https://github.com/kaitranntt/ccs/blob/main/README.md', 'cyan')}`); - 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); -} - -async function handleSyncCommand() { - const { colored } = require('./utils/helpers'); - - console.log(''); - console.log(colored('Syncing CCS Components...', 'cyan')); - console.log(''); - - // First, copy .claude/ directory from package to ~/.ccs/.claude/ - const ClaudeDirInstaller = require('./utils/claude-dir-installer'); - const installer = new ClaudeDirInstaller(); - installer.install(); - - console.log(''); - - const cleanupResult = installer.cleanupDeprecated(); - if (cleanupResult.success && cleanupResult.cleanedFiles.length > 0) { - console.log(''); - } - - // Then, create symlinks from ~/.ccs/.claude/ to ~/.claude/ - const ClaudeSymlinkManager = require('./utils/claude-symlink-manager'); - const manager = new ClaudeSymlinkManager(); - manager.install(false); - - console.log(''); - console.log(colored('[OK] Sync complete!', 'green')); - console.log(''); - - process.exit(0); -} - -/** - * Detect installation method - * @returns {'npm'|'direct'} - Installation method - */ -function detectInstallationMethod() { - const scriptPath = process.argv[1]; - - // Method 1: Check if script is inside node_modules - if (scriptPath.includes('node_modules')) { - return 'npm'; - } - - // Method 2: Check if script is in npm global bin directory - // Common patterns for npm global installations - const npmGlobalBinPatterns = [ - /\.npm\/global\/bin\//, // ~/.npm/global/bin/ccs - /\/\.nvm\/versions\/node\/[^\/]+\/bin\//, // ~/.nvm/versions/node/v22.19.0/bin/ccs - /\/usr\/local\/bin\//, // /usr/local/bin/ccs (if npm global prefix is /usr/local) - /\/usr\/bin\// // /usr/bin/ccs (if npm global prefix is /usr) - ]; - - for (const pattern of npmGlobalBinPatterns) { - if (pattern.test(scriptPath)) { - // Verify this is actually CCS by checking the linked target - try { - const binDir = path.dirname(scriptPath); - const nodeModulesDir = path.join(binDir, '..', 'lib', 'node_modules', '@kaitranntt', 'ccs'); - const globalModulesDir = path.join(binDir, '..', 'node_modules', '@kaitranntt', 'ccs'); - - if (fs.existsSync(nodeModulesDir) || fs.existsSync(globalModulesDir)) { - return 'npm'; - } - } catch (err) { - // Continue checking other patterns - } - } - } - - // Method 3: Check if package.json exists in parent directory (development mode) - const packageJsonPath = path.join(__dirname, '..', 'package.json'); - - if (fs.existsSync(packageJsonPath)) { - try { - const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - // If package.json has name "@kaitranntt/ccs", it's npm install - if (pkg.name === '@kaitranntt/ccs') { - return 'npm'; - } - } catch (err) { - // Ignore parse errors - } - } - - // Method 4: Check if script is a symlink pointing to node_modules - try { - const stats = fs.lstatSync(scriptPath); - if (stats.isSymbolicLink()) { - const targetPath = fs.readlinkSync(scriptPath); - if (targetPath.includes('node_modules') || targetPath.includes('@kaitranntt/ccs')) { - return 'npm'; - } - } - } catch (err) { - // Continue to default - } - - // Default to direct installation - return 'direct'; -} - -/** - * Detect which package manager was used for installation - * @returns {'npm'|'yarn'|'pnpm'|'bun'|'unknown'} - */ -function detectPackageManager() { - const scriptPath = process.argv[1]; - - // Check if script path contains package manager indicators - if (scriptPath.includes('.pnpm')) return 'pnpm'; - if (scriptPath.includes('yarn')) return 'yarn'; - if (scriptPath.includes('bun')) return 'bun'; - - // Check parent directories for lock files - const binDir = path.dirname(scriptPath); - const fs = require('fs'); - - // Check global node_modules parent for lock files - let checkDir = binDir; - for (let i = 0; i < 5; i++) { - if (fs.existsSync(path.join(checkDir, 'pnpm-lock.yaml'))) return 'pnpm'; - if (fs.existsSync(path.join(checkDir, 'yarn.lock'))) return 'yarn'; - if (fs.existsSync(path.join(checkDir, 'bun.lockb'))) return 'bun'; - checkDir = path.dirname(checkDir); - } - - // Check if package managers are available on the system - const { spawnSync } = require('child_process'); - - // Try yarn global list to see if CCS is installed via yarn - try { - const yarnResult = spawnSync('yarn', ['global', 'list', '--pattern', '@kaitranntt/ccs'], { - encoding: 'utf8', - shell: true, - timeout: 5000 - }); - if (yarnResult.status === 0 && yarnResult.stdout.includes('@kaitranntt/ccs')) { - return 'yarn'; - } - } catch (err) { - // Continue to next check - } - - // Try pnpm list -g to see if CCS is installed via pnpm - try { - const pnpmResult = spawnSync('pnpm', ['list', '-g', '--pattern', '@kaitranntt/ccs'], { - encoding: 'utf8', - shell: true, - timeout: 5000 - }); - if (pnpmResult.status === 0 && pnpmResult.stdout.includes('@kaitranntt/ccs')) { - return 'pnpm'; - } - } catch (err) { - // Continue to next check - } - - // Try bun pm ls -g to see if CCS is installed via bun - try { - const bunResult = spawnSync('bun', ['pm', 'ls', '-g', '--pattern', '@kaitranntt/ccs'], { - encoding: 'utf8', - shell: true, - timeout: 5000 - }); - if (bunResult.status === 0 && bunResult.stdout.includes('@kaitranntt/ccs')) { - return 'bun'; - } - } catch (err) { - // Continue to default - } - - // Default to npm - return 'npm'; -} - -async function handleUpdateCommand() { - const { checkForUpdates } = require('./utils/update-checker'); - const { spawn } = require('child_process'); - - console.log(''); - console.log(colored('Checking for updates...', 'cyan')); - console.log(''); - - // Detect installation method for proper update source - const installMethod = detectInstallationMethod(); - const isNpmInstall = installMethod === 'npm'; - - // Check for updates (force check) - const updateResult = await checkForUpdates(CCS_VERSION, true, installMethod); - - if (updateResult.status === 'check_failed') { - console.log(colored(`[X] ${updateResult.message}`, 'red')); - console.log(''); - console.log(colored('[i] Possible causes:', 'yellow')); - console.log(' • Network connection issues'); - console.log(' • Firewall blocking requests'); - console.log(' • GitHub/npm API temporarily unavailable'); - console.log(''); - console.log('Try again later or update manually:'); - if (isNpmInstall) { - const packageManager = detectPackageManager(); - let manualCommand; - - switch (packageManager) { - case 'npm': - manualCommand = 'npm install -g @kaitranntt/ccs@latest'; - break; - case 'yarn': - manualCommand = 'yarn global add @kaitranntt/ccs@latest'; - break; - case 'pnpm': - manualCommand = 'pnpm add -g @kaitranntt/ccs@latest'; - break; - case 'bun': - manualCommand = 'bun add -g @kaitranntt/ccs@latest'; - break; - default: - manualCommand = 'npm install -g @kaitranntt/ccs@latest'; - } - - console.log(colored(` ${manualCommand}`, 'yellow')); - } else { - const isWindows = process.platform === 'win32'; - if (isWindows) { - console.log(colored(' irm ccs.kaitran.ca/install | iex', 'yellow')); - } else { - console.log(colored(' curl -fsSL ccs.kaitran.ca/install | bash', 'yellow')); - } - } - console.log(''); - process.exit(1); - } - - if (updateResult.status === 'no_update') { - let message = `You are already on the latest version (${CCS_VERSION})`; - - // Add context for why no update is shown - switch (updateResult.reason) { - case 'dismissed': - message = `Update dismissed. You are on version ${CCS_VERSION}`; - console.log(colored(`[i] ${message}`, 'yellow')); - break; - case 'cached': - message = `No updates available (cached result). You are on version ${CCS_VERSION}`; - console.log(colored(`[i] ${message}`, 'cyan')); - break; - default: - console.log(colored(`[OK] ${message}`, 'green')); - } - console.log(''); - process.exit(0); - } - - // Update available - console.log(colored(`[i] Update available: ${updateResult.current} → ${updateResult.latest}`, 'yellow')); - console.log(''); - - if (isNpmInstall) { - // npm installation - detect package manager and update - const packageManager = detectPackageManager(); - let updateCommand, updateArgs, cacheCommand, cacheArgs; - - switch (packageManager) { - case 'npm': - updateCommand = 'npm'; - updateArgs = ['install', '-g', '@kaitranntt/ccs@latest']; - cacheCommand = 'npm'; - cacheArgs = ['cache', 'clean', '--force']; - break; - case 'yarn': - updateCommand = 'yarn'; - updateArgs = ['global', 'add', '@kaitranntt/ccs@latest']; - cacheCommand = 'yarn'; - cacheArgs = ['cache', 'clean']; - break; - case 'pnpm': - updateCommand = 'pnpm'; - updateArgs = ['add', '-g', '@kaitranntt/ccs@latest']; - cacheCommand = 'pnpm'; - cacheArgs = ['store', 'prune']; - break; - case 'bun': - updateCommand = 'bun'; - updateArgs = ['add', '-g', '@kaitranntt/ccs@latest']; - cacheCommand = null; // bun doesn't need explicit cache clearing - cacheArgs = null; - break; - default: - updateCommand = 'npm'; - updateArgs = ['install', '-g', '@kaitranntt/ccs@latest']; - cacheCommand = 'npm'; - cacheArgs = ['cache', 'clean', '--force']; - } - - console.log(colored(`Updating via ${packageManager}...`, 'cyan')); - console.log(''); - - // Clear package manager cache first to ensure fresh download - if (cacheCommand) { - console.log(colored('Clearing package cache...', 'cyan')); - const cacheChild = spawn(cacheCommand, cacheArgs, { - stdio: 'inherit' - }); - - cacheChild.on('exit', (code) => { - if (code !== 0) { - console.log(colored('[!] Cache clearing failed, proceeding anyway...', 'yellow')); - } - // Continue with update regardless of cache clearing result - performUpdate(); - }); - - cacheChild.on('error', (err) => { - console.log(colored('[!] Cache clearing failed, proceeding anyway...', 'yellow')); - // Continue with update regardless of cache clearing result - performUpdate(); - }); - } else { - // No cache clearing needed, proceed directly - performUpdate(); - } - - function performUpdate() { - const child = spawn(updateCommand, updateArgs, { - stdio: 'inherit' - // No shell needed for direct commands - }); - - child.on('exit', (code) => { - if (code === 0) { - console.log(''); - console.log(colored('[OK] Update successful!', 'green')); - console.log(''); - console.log(`Run ${colored('ccs --version', 'yellow')} to verify`); - console.log(''); - } else { - console.log(''); - console.log(colored('[X] Update failed', 'red')); - console.log(''); - console.log('Try manually:'); - console.log(colored(` ${updateCommand} ${updateArgs.join(' ')}`, 'yellow')); - console.log(''); - } - process.exit(code || 0); - }); - - child.on('error', (err) => { - console.log(''); - console.log(colored(`[X] Failed to run ${packageManager} update`, 'red')); - console.log(''); - console.log('Try manually:'); - console.log(colored(` ${updateCommand} ${updateArgs.join(' ')}`, 'yellow')); - console.log(''); - process.exit(1); - }); - } - } else { - // Direct installation - re-run installer - console.log(colored('Updating via installer...', 'cyan')); - console.log(''); - - const isWindows = process.platform === 'win32'; - let command, args; - - if (isWindows) { - // PowerShell - command = 'powershell.exe'; - args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', - 'irm ccs.kaitran.ca/install | iex']; - } else { - // Unix (bash with proper shell invocation) - command = '/bin/bash'; - args = ['-c', 'curl -fsSL ccs.kaitran.ca/install | bash']; - } - - const child = spawn(command, args, { - stdio: 'inherit' - // Do NOT use shell: true - }); - - child.on('exit', (code) => { - if (code === 0) { - console.log(''); - console.log(colored('[OK] Update successful!', 'green')); - console.log(''); - console.log(`Run ${colored('ccs --version', 'yellow')} to verify`); - console.log(''); - } else { - console.log(''); - console.log(colored('[X] Update failed', 'red')); - console.log(''); - console.log('Try manually:'); - if (isWindows) { - console.log(colored(' irm ccs.kaitran.ca/install | iex', 'yellow')); - } else { - console.log(colored(' curl -fsSL ccs.kaitran.ca/install | bash', 'yellow')); - } - console.log(''); - } - process.exit(code || 0); - }); - - child.on('error', (err) => { - console.log(''); - console.log(colored('[X] Failed to run installer', 'red')); - console.log(''); - console.log('Try manually:'); - if (isWindows) { - console.log(colored(' irm ccs.kaitran.ca/install | iex', 'yellow')); - } else { - console.log(colored(' curl -fsSL ccs.kaitran.ca/install | bash', 'yellow')); - } - console.log(''); - process.exit(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) - const { ProgressIndicator } = require('./utils/progress-indicator'); - const spinner = new ProgressIndicator('Starting GLMT proxy'); - spinner.start(); - - 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}`)); - } - }); - }); - - spinner.succeed(`GLMT proxy ready on port ${port}`); - } catch (error) { - spinner.fail('Failed to start GLMT proxy'); - console.error('[X] Error:', 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'); - }); -} - -/** - * Handle shell completion installation - */ -async function handleShellCompletionCommand(args) { - const { ShellCompletionInstaller } = require('./utils/shell-completion'); - const { colored } = require('./utils/helpers'); - - console.log(colored('Shell Completion Installer', 'bold')); - console.log(''); - - // Parse flags - let targetShell = null; - if (args.includes('--bash')) targetShell = 'bash'; - else if (args.includes('--zsh')) targetShell = 'zsh'; - else if (args.includes('--fish')) targetShell = 'fish'; - else if (args.includes('--powershell')) targetShell = 'powershell'; - - try { - const installer = new ShellCompletionInstaller(); - const result = installer.install(targetShell); - - if (result.alreadyInstalled) { - console.log(colored('[OK] Shell completion already installed', 'green')); - console.log(''); - return; - } - - console.log(colored('[OK] Shell completion installed successfully!', 'green')); - console.log(''); - console.log(result.message); - console.log(''); - console.log(colored('To activate:', 'cyan')); - console.log(` ${result.reload}`); - console.log(''); - console.log(colored('Then test:', 'cyan')); - console.log(' ccs # See available profiles'); - console.log(' ccs auth # See auth subcommands'); - console.log(''); - } catch (error) { - console.error(colored('[X] Error:', 'red'), error.message); - console.error(''); - console.error(colored('Usage:', 'yellow')); - console.error(' ccs --shell-completion # Auto-detect shell'); - console.error(' ccs --shell-completion --bash # Install for bash'); - console.error(' ccs --shell-completion --zsh # Install for zsh'); - console.error(' ccs --shell-completion --fish # Install for fish'); - console.error(' ccs --shell-completion --powershell # Install for PowerShell'); - console.error(''); - process.exit(1); - } -} - -// 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: 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 (sync delegation commands and skills to ~/.claude/) - if (firstArg === 'sync' || firstArg === '--sync') { - await handleSyncCommand(); - return; - } - - // Special case: update command (update CCS to latest version) - if (firstArg === 'update' || firstArg === '--update') { - await handleUpdateCommand(); - 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; - } - - // Special case: headless delegation (-p flag) - if (args.includes('-p') || args.includes('--prompt')) { - const DelegationHandler = require('./delegation/delegation-handler'); - const handler = new DelegationHandler(); - await handler.route(args); - 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) { - // Check if this is a profile not found error with suggestions - if (error.profileName && error.availableProfiles !== undefined) { - const allProfiles = error.availableProfiles.split('\n'); - ErrorManager.showProfileNotFound(error.profileName, allProfiles, error.suggestions); - } else { - console.error(`[X] ${error.message}`); - } - process.exit(1); - } -} - -// Run main -main().catch(error => { - console.error('Fatal error:', error.message); - process.exit(1); -}); \ No newline at end of file diff --git a/bin/delegation/README.md b/bin/delegation/README.md deleted file mode 100644 index 0f32af90..00000000 --- a/bin/delegation/README.md +++ /dev/null @@ -1,191 +0,0 @@ -# CCS Delegation Module - -Enhanced Claude Code delegation system for multi-model task delegation. - -## Files - -### Core Components -- **headless-executor.js** (405 lines) - Main executor, spawns `claude -p` with enhanced features -- **session-manager.js** (156 lines) - Session persistence and cost tracking -- **settings-parser.js** (88 lines) - Parse tool restrictions from settings -- **result-formatter.js** (326 lines) - Terminal output formatting - -**Total**: 975 lines (down from 1,755 lines - 44% reduction) - -## Features - -### Enhanced Headless Execution -- Stream-JSON output parsing (`--output-format stream-json`) -- Real-time tool use visibility in TTY -- Permission mode acceptEdits (`--permission-mode acceptEdits`) -- Tool restrictions from `.claude/settings.local.json` -- Multi-turn session management (`--resume `) -- Time-based limits (10 min default timeout with graceful termination) -- Cost tracking and aggregation - -### Session Management -- Persistence: `~/.ccs/delegation-sessions.json` -- Resume via `/ccs:continue` (auto-detects profile) -- Auto-cleanup expired sessions (>30 days) -- Cost aggregation across turns - -### Settings -- Profile location: `~/.ccs/{profile}.settings.json` -- Examples: `glm.settings.json`, `kimi.settings.json`, `glmt.settings.json` -- Tool restrictions from `.claude/settings.local.json` - -## Usage - -### Basic Delegation -```javascript -const { HeadlessExecutor } = require('./headless-executor'); - -const result = await HeadlessExecutor.execute('glm', 'Refactor auth.js', { - cwd: '/path/to/project', - outputFormat: 'stream-json', - permissionMode: 'acceptEdits', - timeout: 600000 // 10 minutes -}); - -console.log(result.sessionId); // For multi-turn -console.log(result.totalCost); // Cost in USD -console.log(result.content); // Result text -``` - -### Multi-Turn Sessions -```javascript -// Start session -const result1 = await HeadlessExecutor.execute('glm', 'Implement feature'); -const sessionId = result1.sessionId; - -// Continue session -const result2 = await HeadlessExecutor.execute('glm', 'Add tests', { - resumeSession: true -}); - -// Or with specific session ID -const result3 = await HeadlessExecutor.execute('glm', 'Run tests', { - sessionId: sessionId -}); -``` - -### Tool Restrictions -Create `.claude/settings.local.json`: -```json -{ - "permissions": { - "allow": ["Bash(git:*)", "Read", "Edit"], - "deny": ["Bash(rm:*)", "Bash(sudo:*)"] - } -} -``` - -Automatically applied as CLI flags: -```bash ---allowedTools "Bash(git:*)" "Read" "Edit" \ ---disallowedTools "Bash(rm:*)" "Bash(sudo:*)" -``` - -## Slash Commands - -The delegation system is invoked via simple slash commands in `.claude/commands/ccs/`: - -### Basic Commands -- `/ccs "task"` - Delegate task (auto-selects best profile) -- `/ccs --glm "task"` - Force GLM-4.6 delegation -- `/ccs --kimi "task"` - Force Kimi delegation (long-context) - -### Session Continuation -- `/ccs:continue "follow-up"` - Resume last delegation session (auto-detect profile) -- `/ccs:continue --glm "follow-up"` - Resume with specific profile switch - -Each command directly invokes: -```bash -claude -p "$ARGUMENTS" \ - --settings ~/.ccs/{profile}.settings.json \ - --output-format stream-json \ - --permission-mode acceptEdits -``` - -## Debug Mode - -```bash -export CCS_DEBUG=1 -``` - -Enables verbose logging: -- Permission mode selection -- Session resumption details -- Tool restrictions parsing -- CLI args construction -- Session persistence events - -## Testing - -```bash -# Run all delegation tests -node tests/unit/delegation/json-output.test.js -node tests/unit/delegation/permission-mode.test.js -node tests/unit/delegation/session-manager.test.js -node tests/unit/delegation/settings-parser.test.js -node tests/unit/delegation/max-turns.test.js -node tests/unit/delegation/result-formatter.test.js -``` - -**Test Coverage:** -- JSON output parsing (6 tests) -- Permission modes (11 tests) -- Session management (7 tests) -- Settings parser (6 tests) -- Auto max-turns (14 tests) -- Result formatting (14 tests) -- **Total: 58 tests** - -## Architecture - -``` -User → SlashCommand (/ccs) - → ccs-delegation skill (auto-selects profile) - → Directly invokes: claude -p - → HeadlessExecutor (monitors execution) - → SessionManager (load last session) - → SettingsParser (tool restrictions) - → Parse JSON response - → SessionManager (store/update) - → ResultFormatter.format() - → Display to user -``` - -**Key Simplification**: Slash commands invoke `claude -p` directly. No intermediate delegation engine or rule system - just direct headless execution with enhanced features. - -## File Permissions - -All files should be `644` (rw-r--r--): -```bash -chmod 644 bin/delegation/*.js -``` - -## Dependencies - -- Node.js 14+ -- Claude CLI installed and in PATH -- Profile settings configured in `~/.ccs/{profile}.settings.json` - -## Migration from Legacy System - -**Removed components** (as of 2025-11-15): -- ~~delegation-engine.js~~ - Rule-based decision engine (unused) -- ~~cwd-resolver.js~~ - Working directory resolution (unused) -- ~~rules-schema.js~~ - Schema validation (unused) -- ~~delegation-rules.json~~ - Configuration file (not created) - -**Why removed**: Current slash commands directly invoke `claude -p` without intermediate orchestration. The delegation engine, CWD resolver, and rules schema were designed for a more complex system that was never fully integrated. - -**Result**: 44% code reduction (1,755 → 975 lines) with same functionality. - -## References - -- Official docs: https://code.claude.com/docs/en/headless.md -- Skill: `.claude/skills/ccs-delegation/` -- Commands: `.claude/commands/ccs/` -- Tests: `tests/unit/delegation/` diff --git a/bin/delegation/delegation-handler.js b/bin/delegation/delegation-handler.js deleted file mode 100644 index f4805ee4..00000000 --- a/bin/delegation/delegation-handler.js +++ /dev/null @@ -1,212 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -const { HeadlessExecutor } = require('./headless-executor'); -const { SessionManager } = require('./session-manager'); -const { ResultFormatter } = require('./result-formatter'); -const { DelegationValidator } = require('../utils/delegation-validator'); -const { SettingsParser } = require('./settings-parser'); - -/** - * Delegation command handler - * Routes -p flag commands to HeadlessExecutor with enhanced features - */ -class DelegationHandler { - /** - * Route delegation command - * @param {Array} args - Full args array from ccs.js - */ - async route(args) { - try { - // 1. Parse args into { profile, prompt, options } - const parsed = this._parseArgs(args); - - // 2. Detect special profiles (glm:continue, kimi:continue) - if (parsed.profile.includes(':continue')) { - return await this._handleContinue(parsed); - } - - // 3. Validate profile - this._validateProfile(parsed.profile); - - // 4. Execute via HeadlessExecutor - const result = await HeadlessExecutor.execute( - parsed.profile, - parsed.prompt, - parsed.options - ); - - // 5. Format and display results - const formatted = ResultFormatter.format(result); - console.log(formatted); - - // 6. Exit with proper code - process.exit(result.exitCode || 0); - } catch (error) { - console.error(`[X] Delegation error: ${error.message}`); - if (process.env.CCS_DEBUG) { - console.error(error.stack); - } - process.exit(1); - } - } - - /** - * Handle continue command (resume last session) - * @param {Object} parsed - Parsed args - */ - async _handleContinue(parsed) { - const baseProfile = parsed.profile.replace(':continue', ''); - - // Get last session from SessionManager - const sessionMgr = new SessionManager(); - const lastSession = sessionMgr.getLastSession(baseProfile); - - if (!lastSession) { - console.error(`[X] No previous session found for ${baseProfile}`); - console.error(` Start a new session first with: ccs ${baseProfile} -p "task"`); - process.exit(1); - } - - // Execute with resume flag - const result = await HeadlessExecutor.execute( - baseProfile, - parsed.prompt, - { - ...parsed.options, - resumeSession: true, - sessionId: lastSession.sessionId - } - ); - - const formatted = ResultFormatter.format(result); - console.log(formatted); - - process.exit(result.exitCode || 0); - } - - /** - * Parse args into structured format - * @param {Array} args - Raw args - * @returns {Object} { profile, prompt, options } - */ - _parseArgs(args) { - // Extract profile (first non-flag arg or 'default') - const profile = this._extractProfile(args); - - // Extract prompt from -p or --prompt - const prompt = this._extractPrompt(args); - - // Extract options (--timeout, --permission-mode, etc.) - const options = this._extractOptions(args); - - return { profile, prompt, options }; - } - - /** - * Extract profile from args (first non-flag arg) - * @param {Array} args - Args array - * @returns {string} Profile name - */ - _extractProfile(args) { - // Find first arg that doesn't start with '-' and isn't -p value - let skipNext = false; - for (let i = 0; i < args.length; i++) { - if (skipNext) { - skipNext = false; - continue; - } - - if (args[i] === '-p' || args[i] === '--prompt') { - skipNext = true; - continue; - } - - if (!args[i].startsWith('-')) { - return args[i]; - } - } - - // No profile specified, return null (will error in validation) - return null; - } - - /** - * Extract prompt from -p flag - * @param {Array} args - Args array - * @returns {string} Prompt text - */ - _extractPrompt(args) { - const pIndex = args.indexOf('-p'); - const promptIndex = args.indexOf('--prompt'); - - const index = pIndex !== -1 ? pIndex : promptIndex; - - if (index === -1 || index === args.length - 1) { - console.error('[X] Missing prompt after -p flag'); - console.error(' Usage: ccs glm -p "task description"'); - process.exit(1); - } - - return args[index + 1]; - } - - /** - * Extract options from remaining args - * @param {Array} args - Args array - * @returns {Object} Options for HeadlessExecutor - */ - _extractOptions(args) { - const cwd = process.cwd(); - - // Read default permission mode from .claude/settings.local.json - // Falls back to 'acceptEdits' if file doesn't exist - const defaultPermissionMode = SettingsParser.parseDefaultPermissionMode(cwd); - - const options = { - cwd, - outputFormat: 'stream-json', - permissionMode: defaultPermissionMode - }; - - // Parse permission-mode (CLI flag overrides settings file) - const permModeIndex = args.indexOf('--permission-mode'); - if (permModeIndex !== -1 && permModeIndex < args.length - 1) { - options.permissionMode = args[permModeIndex + 1]; - } - - // Parse timeout - const timeoutIndex = args.indexOf('--timeout'); - if (timeoutIndex !== -1 && timeoutIndex < args.length - 1) { - options.timeout = parseInt(args[timeoutIndex + 1], 10); - } - - return options; - } - - /** - * Validate profile exists and is configured - * @param {string} profile - Profile name - */ - _validateProfile(profile) { - if (!profile) { - console.error('[X] No profile specified'); - console.error(' Usage: ccs -p "task"'); - console.error(' Examples: ccs glm -p "task", ccs kimi -p "task"'); - process.exit(1); - } - - // Use DelegationValidator to check profile - const validation = DelegationValidator.validate(profile); - if (!validation.valid) { - console.error(`[X] Profile '${profile}' is not configured for delegation`); - console.error(` ${validation.error}`); - console.error(''); - console.error(' Run: ccs doctor'); - console.error(' Or configure: ~/.ccs/${profile}.settings.json'); - process.exit(1); - } - } -} - -module.exports = DelegationHandler; diff --git a/bin/delegation/headless-executor.js b/bin/delegation/headless-executor.js deleted file mode 100644 index 4c05d3a3..00000000 --- a/bin/delegation/headless-executor.js +++ /dev/null @@ -1,618 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -const { spawn } = require('child_process'); -const path = require('path'); -const os = require('os'); -const fs = require('fs'); -const { SessionManager } = require('./session-manager'); -const { SettingsParser } = require('./settings-parser'); - -/** - * Headless executor for Claude CLI delegation - * Spawns claude with -p flag for single-turn execution - */ -class HeadlessExecutor { - /** - * Execute task via headless Claude CLI - * @param {string} profile - Profile name (glm, kimi, custom) - * @param {string} enhancedPrompt - Enhanced prompt with context - * @param {Object} options - Execution options - * @param {string} options.cwd - Working directory (absolute path) - * @param {number} options.timeout - Timeout in milliseconds (default: 600000 = 10 minutes) - * @param {string} options.outputFormat - Output format: 'stream-json' or 'text' (default: 'stream-json') - * @param {string} options.permissionMode - Permission mode: 'default', 'plan', 'acceptEdits', 'bypassPermissions' (default: 'acceptEdits') - * @param {boolean} options.resumeSession - Resume last session for profile (default: false) - * @param {string} options.sessionId - Specific session ID to resume - * @returns {Promise} Execution result - */ - static async execute(profile, enhancedPrompt, options = {}) { - const { - cwd = process.cwd(), - timeout = 600000, // 10 minutes default - outputFormat = 'stream-json', // Use stream-json for real-time progress - permissionMode = 'acceptEdits', - resumeSession = false, - sessionId = null - } = options; - - // Validate permission mode - this._validatePermissionMode(permissionMode); - - // Initialize session manager - const sessionMgr = new SessionManager(); - - // Detect Claude CLI path - const claudeCli = this._detectClaudeCli(); - if (!claudeCli) { - throw new Error('Claude CLI not found in PATH. Install from: https://docs.claude.com/en/docs/claude-code/installation'); - } - - // Get settings path for profile - const settingsPath = path.join(os.homedir(), '.ccs', `${profile}.settings.json`); - - // Validate settings file exists - if (!fs.existsSync(settingsPath)) { - throw new Error(`Settings file not found: ${settingsPath}\nProfile "${profile}" may not be configured.`); - } - - // Smart slash command detection and preservation - // Detects if prompt contains slash command and restructures for proper execution - const processedPrompt = this._processSlashCommand(enhancedPrompt); - - // Prepare arguments - const args = ['-p', processedPrompt, '--settings', settingsPath]; - - // Always use stream-json for real-time progress visibility - // Note: --verbose is required when using --print with stream-json - args.push('--output-format', 'stream-json', '--verbose'); - - // Add permission mode - if (permissionMode && permissionMode !== 'default') { - if (permissionMode === 'bypassPermissions') { - args.push('--dangerously-skip-permissions'); - // Warn about dangerous mode - if (process.env.CCS_DEBUG) { - console.warn('[!] WARNING: Using --dangerously-skip-permissions mode'); - console.warn('[!] This bypasses ALL permission checks. Use only in trusted environments.'); - } - } else { - args.push('--permission-mode', permissionMode); - } - } - - // Add resume flag for multi-turn sessions - if (resumeSession) { - const lastSession = sessionMgr.getLastSession(profile); - - if (lastSession) { - args.push('--resume', lastSession.sessionId); - if (process.env.CCS_DEBUG) { - const cost = lastSession.totalCost !== undefined && lastSession.totalCost !== null ? lastSession.totalCost.toFixed(4) : '0.0000'; - console.error(`[i] Resuming session: ${lastSession.sessionId} (${lastSession.turns} turns, $${cost})`); - } - } else if (sessionId) { - args.push('--resume', sessionId); - if (process.env.CCS_DEBUG) { - console.error(`[i] Resuming specific session: ${sessionId}`); - } - } else { - console.warn('[!] No previous session found, starting new session'); - } - } else if (sessionId) { - args.push('--resume', sessionId); - if (process.env.CCS_DEBUG) { - console.error(`[i] Resuming specific session: ${sessionId}`); - } - } - - // Add tool restrictions from settings - const toolRestrictions = SettingsParser.parseToolRestrictions(cwd); - - if (toolRestrictions.allowedTools.length > 0) { - args.push('--allowedTools'); - toolRestrictions.allowedTools.forEach(tool => args.push(tool)); - } - - if (toolRestrictions.disallowedTools.length > 0) { - args.push('--disallowedTools'); - toolRestrictions.disallowedTools.forEach(tool => args.push(tool)); - } - - // Note: No max-turns limit - using time-based limits instead (default 10min timeout) - - // Debug log args - if (process.env.CCS_DEBUG) { - console.error(`[i] Claude CLI args: ${args.join(' ')}`); - } - - // Execute with spawn - return new Promise((resolve, reject) => { - const startTime = Date.now(); - - // Show progress unless explicitly disabled with CCS_QUIET - const showProgress = !process.env.CCS_QUIET; - - // Show initial progress message - if (showProgress) { - const modelName = profile === 'glm' ? 'GLM-4.6' : profile === 'kimi' ? 'Kimi' : profile.toUpperCase(); - console.error(`[i] Delegating to ${modelName}...`); - } - - const proc = spawn(claudeCli, args, { - cwd, - stdio: ['ignore', 'pipe', 'pipe'], - timeout - }); - - let stdout = ''; - let stderr = ''; - let progressInterval; - const messages = []; // Accumulate stream-json messages - let partialLine = ''; // Buffer for incomplete JSON lines - - // Handle parent process termination (Ctrl+C or Esc in Claude) - // When main Claude session is killed, cleanup spawned child process - const cleanupHandler = () => { - if (!proc.killed) { - if (process.env.CCS_DEBUG) { - console.error('[!] Parent process terminating, killing delegated session...'); - } - proc.kill('SIGTERM'); - // Force kill if not dead after 2s - setTimeout(() => { - if (!proc.killed) { - proc.kill('SIGKILL'); - } - }, 2000); - } - }; - - // Register signal handlers for parent process termination - process.once('SIGINT', cleanupHandler); - process.once('SIGTERM', cleanupHandler); - - // Cleanup signal handlers when child process exits - const removeSignalHandlers = () => { - process.removeListener('SIGINT', cleanupHandler); - process.removeListener('SIGTERM', cleanupHandler); - }; - - proc.on('close', removeSignalHandlers); - proc.on('error', removeSignalHandlers); - - // Progress indicator (show elapsed time every 5 seconds) - if (showProgress) { - progressInterval = setInterval(() => { - const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - process.stderr.write(`[i] Still running... ${elapsed}s elapsed\r`); - }, 5000); - } - - // Capture stdout (stream-json format - jsonl) - proc.stdout.on('data', (data) => { - stdout += data.toString(); - - // Parse stream-json messages (jsonl format - one JSON per line) - const chunk = partialLine + data.toString(); - const lines = chunk.split('\n'); - partialLine = lines.pop() || ''; // Save incomplete line for next chunk - - for (const line of lines) { - if (!line.trim()) continue; - - try { - const msg = JSON.parse(line); - messages.push(msg); - - // Show real-time tool use with verbose details - if (showProgress && msg.type === 'assistant') { - const toolUses = msg.message?.content?.filter(c => c.type === 'tool_use') || []; - - for (const tool of toolUses) { - process.stderr.write('\r\x1b[K'); // Clear line - - // Show verbose tool use with description/input if available - const toolInput = tool.input || {}; - let verboseMsg = `[Tool] ${tool.name}`; - - // Add context based on tool type (all Claude Code tools) - switch (tool.name) { - case 'Bash': - if (toolInput.command) { - // Truncate long commands - const cmd = toolInput.command.length > 80 - ? toolInput.command.substring(0, 77) + '...' - : toolInput.command; - verboseMsg += `: ${cmd}`; - } - break; - - case 'Edit': - case 'Write': - case 'Read': - if (toolInput.file_path) { - verboseMsg += `: ${toolInput.file_path}`; - } - break; - - case 'NotebookEdit': - case 'NotebookRead': - if (toolInput.notebook_path) { - verboseMsg += `: ${toolInput.notebook_path}`; - } - break; - - case 'Grep': - if (toolInput.pattern) { - verboseMsg += `: searching for "${toolInput.pattern}"`; - if (toolInput.path) { - verboseMsg += ` in ${toolInput.path}`; - } - } - break; - - case 'Glob': - if (toolInput.pattern) { - verboseMsg += `: ${toolInput.pattern}`; - } - break; - - case 'SlashCommand': - if (toolInput.command) { - verboseMsg += `: ${toolInput.command}`; - } - break; - - case 'Task': - if (toolInput.description) { - verboseMsg += `: ${toolInput.description}`; - } else if (toolInput.prompt) { - const prompt = toolInput.prompt.length > 60 - ? toolInput.prompt.substring(0, 57) + '...' - : toolInput.prompt; - verboseMsg += `: ${prompt}`; - } - break; - - case 'TodoWrite': - if (toolInput.todos && Array.isArray(toolInput.todos)) { - // Show in_progress task instead of just count - const inProgressTask = toolInput.todos.find(t => t.status === 'in_progress'); - if (inProgressTask && inProgressTask.activeForm) { - verboseMsg += `: ${inProgressTask.activeForm}`; - } else { - // Fallback to count if no in_progress task - verboseMsg += `: ${toolInput.todos.length} task(s)`; - } - } - break; - - case 'WebFetch': - if (toolInput.url) { - verboseMsg += `: ${toolInput.url}`; - } - break; - - case 'WebSearch': - if (toolInput.query) { - verboseMsg += `: "${toolInput.query}"`; - } - break; - - default: - // For unknown tools, show first meaningful parameter - if (Object.keys(toolInput).length > 0) { - const firstKey = Object.keys(toolInput)[0]; - const firstValue = toolInput[firstKey]; - if (typeof firstValue === 'string' && firstValue.length < 60) { - verboseMsg += `: ${firstValue}`; - } - } - } - - process.stderr.write(`${verboseMsg}\n`); - } - } - } catch (parseError) { - // Skip malformed JSON lines (shouldn't happen with stream-json) - if (process.env.CCS_DEBUG) { - console.error(`[!] Failed to parse stream-json line: ${parseError.message}`); - } - } - } - }); - - // Stream stderr in real-time (progress messages from Claude CLI) - proc.stderr.on('data', (data) => { - const stderrText = data.toString(); - stderr += stderrText; - - // Show stderr in real-time if in TTY - if (showProgress) { - // Clear progress line before showing stderr - if (progressInterval) { - process.stderr.write('\r\x1b[K'); // Clear line - } - process.stderr.write(stderrText); - } - }); - - // Handle completion - proc.on('close', (exitCode) => { - const duration = Date.now() - startTime; - - // Clear progress indicator - if (progressInterval) { - clearInterval(progressInterval); - process.stderr.write('\r\x1b[K'); // Clear line - } - - // Show completion message - if (showProgress) { - const durationSec = (duration / 1000).toFixed(1); - if (timedOut) { - console.error(`[i] Execution timed out after ${durationSec}s`); - } else { - console.error(`[i] Execution completed in ${durationSec}s`); - } - console.error(''); // Blank line before formatted output - } - - const result = { - exitCode, - stdout, - stderr, - cwd, - profile, - duration, - timedOut, - success: exitCode === 0 && !timedOut, - messages // Include all stream-json messages - }; - - // Extract metadata from final 'result' message in stream-json - const resultMessage = messages.find(m => m.type === 'result'); - if (resultMessage) { - // Add parsed fields from result message - result.sessionId = resultMessage.session_id || null; - result.totalCost = resultMessage.total_cost_usd || 0; - result.numTurns = resultMessage.num_turns || 0; - result.isError = resultMessage.is_error || false; - result.type = resultMessage.type || null; - result.subtype = resultMessage.subtype || null; - result.durationApi = resultMessage.duration_api_ms || 0; - result.permissionDenials = resultMessage.permission_denials || []; - result.errors = resultMessage.errors || []; - - // Extract content from result message - result.content = resultMessage.result || ''; - } else { - // Fallback: no result message found (shouldn't happen) - result.content = stdout; - if (process.env.CCS_DEBUG) { - console.error(`[!] No result message found in stream-json output`); - } - } - - // Store or update session if we have session ID (even on timeout, for :continue support) - if (result.sessionId) { - if (resumeSession || sessionId) { - // Update existing session - sessionMgr.updateSession(profile, result.sessionId, { - totalCost: result.totalCost - }); - } else { - // Store new session - sessionMgr.storeSession(profile, { - sessionId: result.sessionId, - totalCost: result.totalCost, - cwd: result.cwd - }); - } - - // Cleanup expired sessions periodically - if (Math.random() < 0.1) { // 10% chance - sessionMgr.cleanupExpired(); - } - } - - resolve(result); - }); - - // Handle errors - proc.on('error', (error) => { - if (progressInterval) { - clearInterval(progressInterval); - } - reject(new Error(`Failed to execute Claude CLI: ${error.message}`)); - }); - - // Handle timeout with graceful SIGTERM then forceful SIGKILL - let timedOut = false; - if (timeout > 0) { - const timeoutHandle = setTimeout(() => { - if (!proc.killed) { - timedOut = true; - - if (progressInterval) { - clearInterval(progressInterval); - process.stderr.write('\r\x1b[K'); // Clear line - } - - if (process.env.CCS_DEBUG) { - console.error(`[!] Timeout reached after ${timeout}ms, sending SIGTERM for graceful shutdown...`); - } - - // Send SIGTERM for graceful shutdown - proc.kill('SIGTERM'); - - // If process doesn't terminate within 10s, force kill - setTimeout(() => { - if (!proc.killed) { - if (process.env.CCS_DEBUG) { - console.error(`[!] Process did not terminate gracefully, sending SIGKILL...`); - } - proc.kill('SIGKILL'); - } - }, 10000); // Give 10s for graceful shutdown instead of 5s - } - }, timeout); - - // Clear timeout on successful completion - proc.on('close', () => clearTimeout(timeoutHandle)); - } - }); - } - - /** - * Validate permission mode - * @param {string} mode - Permission mode - * @throws {Error} If mode is invalid - * @private - */ - static _validatePermissionMode(mode) { - const VALID_MODES = ['default', 'plan', 'acceptEdits', 'bypassPermissions']; - if (!VALID_MODES.includes(mode)) { - throw new Error( - `Invalid permission mode: "${mode}". Valid modes: ${VALID_MODES.join(', ')}` - ); - } - } - - /** - * Detect Claude CLI executable - * @returns {string|null} Path to claude CLI or null if not found - * @private - */ - static _detectClaudeCli() { - // Check environment variable override - if (process.env.CCS_CLAUDE_PATH) { - return process.env.CCS_CLAUDE_PATH; - } - - // Try to find in PATH - const { execSync } = require('child_process'); - try { - const result = execSync('command -v claude', { encoding: 'utf8' }); - return result.trim(); - } catch (error) { - return null; - } - } - - /** - * Execute with retry logic - * @param {string} profile - Profile name - * @param {string} enhancedPrompt - Enhanced prompt - * @param {Object} options - Execution options - * @param {number} options.maxRetries - Maximum retry attempts (default: 2) - * @returns {Promise} Execution result - */ - static async executeWithRetry(profile, enhancedPrompt, options = {}) { - const { maxRetries = 2, ...execOptions } = options; - let lastError; - - for (let attempt = 0; attempt <= maxRetries; attempt++) { - try { - const result = await this.execute(profile, enhancedPrompt, execOptions); - - // If successful, return immediately - if (result.success) { - return result; - } - - // If not last attempt, retry - if (attempt < maxRetries) { - console.error(`[!] Attempt ${attempt + 1} failed, retrying...`); - await this._sleep(1000 * (attempt + 1)); // Exponential backoff - continue; - } - - // Last attempt failed, return result anyway - return result; - } catch (error) { - lastError = error; - - if (attempt < maxRetries) { - console.error(`[!] Attempt ${attempt + 1} errored: ${error.message}, retrying...`); - await this._sleep(1000 * (attempt + 1)); - } - } - } - - // All retries exhausted - throw lastError || new Error('Execution failed after all retry attempts'); - } - - /** - * Sleep utility for retry backoff - * @param {number} ms - Milliseconds to sleep - * @returns {Promise} - * @private - */ - static _sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Process prompt to detect and preserve slash commands - * Implements smart enhancement: preserves slash command at start, allows context in rest - * @param {string} prompt - Original prompt (may contain slash command) - * @returns {string} Processed prompt with slash command preserved - * @private - */ - static _processSlashCommand(prompt) { - const trimmed = prompt.trim(); - - // Case 1: Already starts with slash command - keep as-is - if (trimmed.match(/^\/[\w:-]+(\s|$)/)) { - return prompt; - } - - // Case 2: Find slash command embedded in text - // Look for /command that's NOT part of a file path - // File paths: /home/user, /path/to/file (have / before or after) - // Commands: /cook, /plan (standalone, preceded by space/colon/start) - // Strategy: Find LAST occurrence that looks like a command, not a path - const embeddedSlash = trimmed.match(/(?:^|[^\w/])(\/[\w:-]+)(\s+[\s\S]*)?$/); - - if (embeddedSlash) { - const command = embeddedSlash[1]; // e.g., "/cook" - const args = (embeddedSlash[2] || '').trim(); // Everything after command - - // Calculate where the command starts (excluding preceding char if any) - const matchStart = embeddedSlash.index + (embeddedSlash[0][0] === '/' ? 0 : 1); - const beforeCommand = trimmed.substring(0, matchStart).trim(); - - // Restructure: command first, context after - if (beforeCommand && args) { - return `${command} ${args}\n\nContext: ${beforeCommand}`; - } else if (beforeCommand) { - return `${command}\n\nContext: ${beforeCommand}`; - } - return args ? `${command} ${args}` : command; - } - - // No slash command detected, return as-is - return prompt; - } - - /** - * Test if profile is executable (quick health check) - * @param {string} profile - Profile name - * @returns {Promise} True if profile can execute - */ - static async testProfile(profile) { - try { - const result = await this.execute(profile, 'Say "test successful"', { - timeout: 10000 - }); - return result.success; - } catch (error) { - return false; - } - } -} - -module.exports = { HeadlessExecutor }; diff --git a/bin/delegation/result-formatter.js b/bin/delegation/result-formatter.js deleted file mode 100644 index 85257e8a..00000000 --- a/bin/delegation/result-formatter.js +++ /dev/null @@ -1,485 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -const path = require('path'); - -/** - * Formats delegation execution results for display - * Creates ASCII box output with file change tracking - */ -class ResultFormatter { - /** - * Format execution result with complete source-of-truth - * @param {Object} result - Execution result from HeadlessExecutor - * @param {string} result.profile - Profile used (glm, kimi, etc.) - * @param {string} result.cwd - Working directory - * @param {number} result.exitCode - Exit code - * @param {string} result.stdout - Standard output - * @param {string} result.stderr - Standard error - * @param {number} result.duration - Duration in milliseconds - * @param {boolean} result.success - Success flag - * @param {string} result.content - Parsed content (from JSON or stdout) - * @param {string} result.sessionId - Session ID (from JSON) - * @param {number} result.totalCost - Total cost USD (from JSON) - * @param {number} result.numTurns - Number of turns (from JSON) - * @returns {string} Formatted result - */ - static format(result) { - const { profile, cwd, exitCode, stdout, stderr, duration, success, content, sessionId, totalCost, numTurns, subtype, permissionDenials, errors, json, timedOut } = result; - - // Handle timeout (graceful termination) - if (timedOut) { - return this._formatTimeoutError(result); - } - - // Handle legacy max_turns error (Claude CLI might still return this) - if (subtype === 'error_max_turns') { - return this._formatTimeoutError(result); - } - - // Use content field for output (JSON result or fallback stdout) - const displayOutput = content || stdout; - - // Build formatted output - let output = ''; - - // Header - output += this._formatHeader(profile, success); - - // Info box (file detection handled by delegated session itself) - output += this._formatInfoBox(cwd, profile, duration, exitCode, sessionId, totalCost, numTurns); - - // Task output - output += '\n'; - output += this._formatOutput(displayOutput); - - // Permission denials if present - if (permissionDenials && permissionDenials.length > 0) { - output += '\n'; - output += this._formatPermissionDenials(permissionDenials); - } - - // Errors if present - if (errors && errors.length > 0) { - output += '\n'; - output += this._formatErrors(errors); - } - - // Stderr if present - if (stderr && stderr.trim()) { - output += '\n'; - output += this._formatStderr(stderr); - } - - // Footer - output += '\n'; - output += this._formatFooter(success, duration); - - return output; - } - - /** - * Extract file changes from output - * @param {string} output - Command output - * @param {string} cwd - Working directory for filesystem scanning fallback - * @returns {Object} { created: Array, modified: Array } - */ - static extractFileChanges(output, cwd) { - const created = []; - const modified = []; - - // Patterns to match file operations (case-insensitive) - const createdPatterns = [ - /created:\s*([^\n\r]+)/gi, - /create:\s*([^\n\r]+)/gi, - /wrote:\s*([^\n\r]+)/gi, - /write:\s*([^\n\r]+)/gi, - /new file:\s*([^\n\r]+)/gi, - /generated:\s*([^\n\r]+)/gi, - /added:\s*([^\n\r]+)/gi - ]; - - const modifiedPatterns = [ - /modified:\s*([^\n\r]+)/gi, - /update:\s*([^\n\r]+)/gi, - /updated:\s*([^\n\r]+)/gi, - /edit:\s*([^\n\r]+)/gi, - /edited:\s*([^\n\r]+)/gi, - /changed:\s*([^\n\r]+)/gi - ]; - - // Helper to check if file is infrastructure (should be ignored) - const isInfrastructure = (filePath) => { - return filePath.includes('/.claude/') || filePath.startsWith('.claude/'); - }; - - // Extract created files - for (const pattern of createdPatterns) { - let match; - while ((match = pattern.exec(output)) !== null) { - const filePath = match[1].trim(); - if (filePath && !created.includes(filePath) && !isInfrastructure(filePath)) { - created.push(filePath); - } - } - } - - // Extract modified files - for (const pattern of modifiedPatterns) { - let match; - while ((match = pattern.exec(output)) !== null) { - const filePath = match[1].trim(); - // Don't include if already in created list or is infrastructure - if (filePath && !modified.includes(filePath) && !created.includes(filePath) && !isInfrastructure(filePath)) { - modified.push(filePath); - } - } - } - - // Fallback: Scan filesystem for recently modified files (last 5 minutes) - if (created.length === 0 && modified.length === 0 && cwd) { - try { - const fs = require('fs'); - const childProcess = require('child_process'); - - // Use find command to get recently modified files (excluding infrastructure) - const findCmd = `find . -type f -mmin -5 -not -path "./.git/*" -not -path "./node_modules/*" -not -path "./.claude/*" 2>/dev/null | head -20`; - const result = childProcess.execSync(findCmd, { cwd, encoding: 'utf8', timeout: 5000 }); - - const files = result.split('\n').filter(f => f.trim()); - files.forEach(file => { - const fullPath = path.join(cwd, file); - - // Double-check not infrastructure - if (isInfrastructure(fullPath)) { - return; - } - - try { - const stats = fs.statSync(fullPath); - const now = Date.now(); - const mtime = stats.mtimeMs; - const ctime = stats.ctimeMs; - - // If both mtime and ctime are very recent (within 10 minutes), likely created - // ctime = inode change time, for new files this is close to creation time - const isVeryRecent = (now - mtime) < 600000 && (now - ctime) < 600000; - const timeDiff = Math.abs(mtime - ctime); - - // If mtime and ctime are very close (< 1 second apart) and both recent, it's created - if (isVeryRecent && timeDiff < 1000) { - if (!created.includes(fullPath)) { - created.push(fullPath); - } - } else { - // Otherwise, it's modified - if (!modified.includes(fullPath)) { - modified.push(fullPath); - } - } - } catch (statError) { - // If stat fails, default to created (since we're in fallback mode) - if (!created.includes(fullPath) && !modified.includes(fullPath)) { - created.push(fullPath); - } - } - }); - } catch (scanError) { - // Silently fail if filesystem scan doesn't work - if (process.env.CCS_DEBUG) { - console.error(`[!] Filesystem scan failed: ${scanError.message}`); - } - } - } - - return { created, modified }; - } - - /** - * Format header with delegation indicator - * @param {string} profile - Profile name - * @param {boolean} success - Success flag - * @returns {string} Formatted header - * @private - */ - static _formatHeader(profile, success) { - const modelName = this._getModelDisplayName(profile); - const icon = success ? '[i]' : '[X]'; - return `${icon} Delegated to ${modelName} (ccs:${profile})\n`; - } - - /** - * Format info box with delegation details - * @param {string} cwd - Working directory - * @param {string} profile - Profile name - * @param {number} duration - Duration in ms - * @param {number} exitCode - Exit code - * @param {string} sessionId - Session ID (from JSON) - * @param {number} totalCost - Total cost USD (from JSON) - * @param {number} numTurns - Number of turns (from JSON) - * @returns {string} Formatted info box - * @private - */ - static _formatInfoBox(cwd, profile, duration, exitCode, sessionId, totalCost, numTurns) { - const modelName = this._getModelDisplayName(profile); - const durationSec = (duration / 1000).toFixed(1); - - // Calculate box width (fit longest line + padding) - const maxWidth = 70; - const cwdLine = `Working Directory: ${cwd}`; - const boxWidth = Math.min(Math.max(cwdLine.length + 4, 50), maxWidth); - - const lines = [ - `Working Directory: ${this._truncate(cwd, boxWidth - 22)}`, - `Model: ${modelName}`, - `Duration: ${durationSec}s`, - `Exit Code: ${exitCode}` - ]; - - // Add JSON-specific fields if available - if (sessionId) { - // Abbreviate session ID (Git-style first 8 chars) to prevent wrapping - const shortId = sessionId.length > 8 ? sessionId.substring(0, 8) : sessionId; - lines.push(`Session ID: ${shortId}`); - } - if (totalCost !== undefined && totalCost !== null) { - lines.push(`Cost: $${totalCost.toFixed(4)}`); - } - if (numTurns) { - lines.push(`Turns: ${numTurns}`); - } - - let box = ''; - box += '╔' + '═'.repeat(boxWidth - 2) + '╗\n'; - - for (const line of lines) { - const padding = boxWidth - line.length - 4; - box += '║ ' + line + ' '.repeat(Math.max(0, padding)) + ' ║\n'; - } - - box += '╚' + '═'.repeat(boxWidth - 2) + '╝'; - - return box; - } - - /** - * Format task output - * @param {string} output - Standard output - * @returns {string} Formatted output - * @private - */ - static _formatOutput(output) { - if (!output || !output.trim()) { - return '[i] No output from delegated task\n'; - } - - return output.trim() + '\n'; - } - - /** - * Format stderr output - * @param {string} stderr - Standard error - * @returns {string} Formatted stderr - * @private - */ - static _formatStderr(stderr) { - return `[!] Stderr:\n${stderr.trim()}\n\n`; - } - - /** - * Format file list (created or modified) - * @param {string} label - Label (Created/Modified) - * @param {Array} files - File paths - * @returns {string} Formatted file list - * @private - */ - static _formatFileList(label, files) { - let output = `[i] ${label} Files:\n`; - - for (const file of files) { - output += ` - ${file}\n`; - } - - return output; - } - - /** - * Format footer with completion status - * @param {boolean} success - Success flag - * @param {number} duration - Duration in ms - * @returns {string} Formatted footer - * @private - */ - static _formatFooter(success, duration) { - const icon = success ? '[OK]' : '[X]'; - const status = success ? 'Delegation completed' : 'Delegation failed'; - return `${icon} ${status}\n`; - } - - /** - * Get display name for model profile - * @param {string} profile - Profile name - * @returns {string} Display name - * @private - */ - static _getModelDisplayName(profile) { - const displayNames = { - 'glm': 'GLM-4.6', - 'glmt': 'GLM-4.6 (Thinking)', - 'kimi': 'Kimi', - 'default': 'Claude' - }; - - return displayNames[profile] || profile.toUpperCase(); - } - - /** - * Truncate string to max length - * @param {string} str - String to truncate - * @param {number} maxLength - Maximum length - * @returns {string} Truncated string - * @private - */ - static _truncate(str, maxLength) { - if (str.length <= maxLength) { - return str; - } - return str.substring(0, maxLength - 3) + '...'; - } - - /** - * Format minimal result (for quick tasks) - * @param {Object} result - Execution result - * @returns {string} Minimal formatted result - */ - static formatMinimal(result) { - const { profile, success, duration } = result; - const modelName = this._getModelDisplayName(profile); - const icon = success ? '[OK]' : '[X]'; - const durationSec = (duration / 1000).toFixed(1); - - return `${icon} ${modelName} delegation ${success ? 'completed' : 'failed'} (${durationSec}s)\n`; - } - - /** - * Format verbose result (with full details) - * @param {Object} result - Execution result - * @returns {string} Verbose formatted result - */ - static formatVerbose(result) { - const basic = this.format(result); - - // Add additional debug info - let verbose = basic; - verbose += '\n=== Debug Information ===\n'; - verbose += `CWD: ${result.cwd}\n`; - verbose += `Profile: ${result.profile}\n`; - verbose += `Exit Code: ${result.exitCode}\n`; - verbose += `Duration: ${result.duration}ms\n`; - verbose += `Success: ${result.success}\n`; - verbose += `Stdout Length: ${result.stdout.length} chars\n`; - verbose += `Stderr Length: ${result.stderr.length} chars\n`; - - return verbose; - } - - /** - * Check if NO_COLOR environment variable is set - * @returns {boolean} True if colors should be disabled - * @private - */ - static _shouldDisableColors() { - return process.env.NO_COLOR !== undefined; - } - - /** - * Format timeout error (session exceeded time limit) - * @param {Object} result - Execution result - * @returns {string} Formatted timeout error - * @private - */ - static _formatTimeoutError(result) { - const { profile, cwd, duration, sessionId, totalCost, numTurns, permissionDenials } = result; - - let output = ''; - - // Header - output += this._formatHeader(profile, false); - - // Info box - output += this._formatInfoBox(cwd, profile, duration, 0, sessionId, totalCost, numTurns); - - // Timeout message - output += '\n'; - const timeoutMin = (duration / 60000).toFixed(1); - output += `[!] Execution timed out after ${timeoutMin} minutes\n\n`; - output += 'The delegated session exceeded its time limit before completing the task.\n'; - output += 'Session was gracefully terminated and saved for continuation.\n'; - - // Permission denials if present - if (permissionDenials && permissionDenials.length > 0) { - output += '\n'; - output += this._formatPermissionDenials(permissionDenials); - output += '\n'; - output += 'The task may require permissions that were denied.\n'; - output += 'Consider running with --permission-mode bypassPermissions or execute manually.\n'; - } - - // Suggestions - output += '\n'; - output += 'Suggestions:\n'; - output += ` - Continue session: ccs ${profile}:continue -p "finish the task"\n`; - output += ` - Increase timeout: ccs ${profile} -p "task" --timeout ${duration * 2}\n`; - output += ' - Break task into smaller steps\n'; - output += ' - Run task manually in main Claude session\n'; - - output += '\n'; - // Abbreviate session ID (Git-style first 8 chars) - const shortId = sessionId && sessionId.length > 8 ? sessionId.substring(0, 8) : sessionId; - output += `[i] Session persisted with ID: ${shortId}\n`; - if (totalCost !== undefined && totalCost !== null) { - output += `[i] Cost: $${totalCost.toFixed(4)}\n`; - } - - return output; - } - - /** - * Format permission denials - * @param {Array} denials - Permission denial objects - * @returns {string} Formatted permission denials - * @private - */ - static _formatPermissionDenials(denials) { - let output = '[!] Permission Denials:\n'; - - for (const denial of denials) { - const tool = denial.tool_name || 'Unknown'; - const input = denial.tool_input || {}; - const command = input.command || input.description || JSON.stringify(input); - - output += ` - ${tool}: ${command}\n`; - } - - return output; - } - - /** - * Format errors array - * @param {Array} errors - Error objects - * @returns {string} Formatted errors - * @private - */ - static _formatErrors(errors) { - let output = '[X] Errors:\n'; - - for (const error of errors) { - const message = error.message || error.error || JSON.stringify(error); - output += ` - ${message}\n`; - } - - return output; - } -} - -module.exports = { ResultFormatter }; diff --git a/bin/delegation/session-manager.js b/bin/delegation/session-manager.js deleted file mode 100644 index 6d2a3af6..00000000 --- a/bin/delegation/session-manager.js +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -/** - * Manages delegation session persistence for multi-turn conversations - */ -class SessionManager { - constructor() { - this.sessionsPath = path.join(os.homedir(), '.ccs', 'delegation-sessions.json'); - } - - /** - * Store new session metadata - * @param {string} profile - Profile name (glm, kimi, etc.) - * @param {Object} sessionData - Session data - * @param {string} sessionData.sessionId - Claude session ID - * @param {number} sessionData.totalCost - Initial cost - * @param {string} sessionData.cwd - Working directory - */ - storeSession(profile, sessionData) { - const sessions = this._loadSessions(); - const key = `${profile}:latest`; - - sessions[key] = { - sessionId: sessionData.sessionId, - profile, - startTime: Date.now(), - lastTurnTime: Date.now(), - totalCost: sessionData.totalCost || 0, - turns: 1, - cwd: sessionData.cwd || process.cwd() - }; - - this._saveSessions(sessions); - - if (process.env.CCS_DEBUG) { - console.error(`[i] Stored session: ${sessionData.sessionId} for ${profile}`); - } - } - - /** - * Update session after additional turn - * @param {string} profile - Profile name - * @param {string} sessionId - Session ID - * @param {Object} turnData - Turn data - * @param {number} turnData.totalCost - Turn cost - */ - updateSession(profile, sessionId, turnData) { - const sessions = this._loadSessions(); - const key = `${profile}:latest`; - - if (sessions[key]?.sessionId === sessionId) { - sessions[key].lastTurnTime = Date.now(); - sessions[key].totalCost += turnData.totalCost || 0; - sessions[key].turns += 1; - this._saveSessions(sessions); - - if (process.env.CCS_DEBUG) { - const cost = sessions[key].totalCost !== undefined && sessions[key].totalCost !== null ? sessions[key].totalCost.toFixed(4) : '0.0000'; - console.error(`[i] Updated session: ${sessionId}, total: $${cost}, turns: ${sessions[key].turns}`); - } - } - } - - /** - * Get last session for profile - * @param {string} profile - Profile name - * @returns {Object|null} Session metadata or null - */ - getLastSession(profile) { - const sessions = this._loadSessions(); - const key = `${profile}:latest`; - return sessions[key] || null; - } - - /** - * Clear all sessions for profile - * @param {string} profile - Profile name - */ - clearProfile(profile) { - const sessions = this._loadSessions(); - const key = `${profile}:latest`; - delete sessions[key]; - this._saveSessions(sessions); - } - - /** - * Clean up expired sessions (>30 days) - */ - cleanupExpired() { - const sessions = this._loadSessions(); - const now = Date.now(); - const maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days - - let cleaned = 0; - Object.keys(sessions).forEach(key => { - if (now - sessions[key].lastTurnTime > maxAge) { - delete sessions[key]; - cleaned++; - } - }); - - if (cleaned > 0) { - this._saveSessions(sessions); - if (process.env.CCS_DEBUG) { - console.error(`[i] Cleaned ${cleaned} expired sessions`); - } - } - } - - /** - * Load sessions from disk - * @returns {Object} Sessions object - * @private - */ - _loadSessions() { - try { - if (!fs.existsSync(this.sessionsPath)) { - return {}; - } - const content = fs.readFileSync(this.sessionsPath, 'utf8'); - return JSON.parse(content); - } catch (error) { - if (process.env.CCS_DEBUG) { - console.warn(`[!] Failed to load sessions: ${error.message}`); - } - return {}; - } - } - - /** - * Save sessions to disk - * @param {Object} sessions - Sessions object - * @private - */ - _saveSessions(sessions) { - try { - const dir = path.dirname(this.sessionsPath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - } - fs.writeFileSync( - this.sessionsPath, - JSON.stringify(sessions, null, 2), - { mode: 0o600 } - ); - } catch (error) { - console.error(`[!] Failed to save sessions: ${error.message}`); - } - } -} - -module.exports = { SessionManager }; diff --git a/bin/delegation/settings-parser.js b/bin/delegation/settings-parser.js deleted file mode 100644 index 939b876c..00000000 --- a/bin/delegation/settings-parser.js +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -const fs = require('fs'); -const path = require('path'); - -/** - * Parses Claude Code settings for tool restrictions - */ -class SettingsParser { - /** - * Parse default permission mode from project settings - * @param {string} projectDir - Project directory (usually cwd) - * @returns {string} Default permission mode (e.g., 'acceptEdits', 'bypassPermissions', 'plan', 'default') - */ - static parseDefaultPermissionMode(projectDir) { - const settings = this._loadSettings(projectDir); - const permissions = settings.permissions || {}; - - // Priority: local > shared > fallback to 'acceptEdits' - const defaultMode = permissions.defaultMode || 'acceptEdits'; - - if (process.env.CCS_DEBUG) { - console.error(`[i] Permission mode from settings: ${defaultMode}`); - } - - return defaultMode; - } - - /** - * Parse project settings for tool restrictions - * @param {string} projectDir - Project directory (usually cwd) - * @returns {Object} { allowedTools: string[], disallowedTools: string[] } - */ - static parseToolRestrictions(projectDir) { - const settings = this._loadSettings(projectDir); - const permissions = settings.permissions || {}; - - const allowed = permissions.allow || []; - const denied = permissions.deny || []; - - if (process.env.CCS_DEBUG) { - console.error(`[i] Tool restrictions: ${allowed.length} allowed, ${denied.length} denied`); - } - - return { - allowedTools: allowed, - disallowedTools: denied - }; - } - - /** - * Load and merge settings files (local overrides shared) - * @param {string} projectDir - Project directory - * @returns {Object} Merged settings - * @private - */ - static _loadSettings(projectDir) { - const claudeDir = path.join(projectDir, '.claude'); - const sharedPath = path.join(claudeDir, 'settings.json'); - const localPath = path.join(claudeDir, 'settings.local.json'); - - // Load shared settings - const shared = this._readJsonSafe(sharedPath) || {}; - - // Load local settings (overrides shared) - const local = this._readJsonSafe(localPath) || {}; - - // Merge permissions (local overrides shared) - return { - permissions: { - allow: [ - ...(shared.permissions?.allow || []), - ...(local.permissions?.allow || []) - ], - deny: [ - ...(shared.permissions?.deny || []), - ...(local.permissions?.deny || []) - ], - // Local defaultMode takes priority over shared - defaultMode: local.permissions?.defaultMode || shared.permissions?.defaultMode || null - } - }; - } - - /** - * Read JSON file safely (no throw) - * @param {string} filePath - Path to JSON file - * @returns {Object|null} Parsed JSON or null - * @private - */ - static _readJsonSafe(filePath) { - try { - if (!fs.existsSync(filePath)) { - return null; - } - - const content = fs.readFileSync(filePath, 'utf8'); - return JSON.parse(content); - } catch (error) { - if (process.env.CCS_DEBUG) { - console.warn(`[!] Failed to read settings: ${filePath}: ${error.message}`); - } - return null; - } - } -} - -module.exports = { SettingsParser }; diff --git a/bin/glmt/delta-accumulator.js b/bin/glmt/delta-accumulator.js deleted file mode 100644 index c39e71de..00000000 --- a/bin/glmt/delta-accumulator.js +++ /dev/null @@ -1,276 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -/** - * DeltaAccumulator - Maintain state across streaming deltas - * - * Tracks: - * - Message metadata (id, model, role) - * - Content blocks (thinking, text) - * - Current block index - * - Accumulated content - * - * Usage: - * const acc = new DeltaAccumulator(thinkingConfig); - * const events = transformer.transformDelta(openaiEvent, acc); - */ -class DeltaAccumulator { - constructor(thinkingConfig = {}, options = {}) { - this.thinkingConfig = thinkingConfig; - this.messageId = 'msg_' + Date.now() + '_' + Math.random().toString(36).substring(7); - this.model = null; - this.role = 'assistant'; - - // Content blocks - this.contentBlocks = []; - this.currentBlockIndex = -1; - - // Tool calls tracking - this.toolCalls = []; - this.toolCallsIndex = {}; - - // Buffers - this.thinkingBuffer = ''; - this.textBuffer = ''; - - // C-02 Fix: Limits to prevent unbounded accumulation - this.maxBlocks = options.maxBlocks || 100; - this.maxBufferSize = options.maxBufferSize || 10 * 1024 * 1024; // 10MB - - // Loop detection configuration - this.loopDetectionThreshold = options.loopDetectionThreshold || 3; - this.loopDetected = false; - - // State flags - this.messageStarted = false; - this.finalized = false; - this.usageReceived = false; // Track if usage data has arrived - - // Statistics - this.inputTokens = 0; - this.outputTokens = 0; - this.finishReason = null; - } - - /** - * Get current content block - * @returns {Object|null} Current block or null - */ - getCurrentBlock() { - if (this.currentBlockIndex >= 0 && this.currentBlockIndex < this.contentBlocks.length) { - return this.contentBlocks[this.currentBlockIndex]; - } - return null; - } - - /** - * Start new content block - * @param {string} type - Block type ('thinking', 'text', or 'tool_use') - * @returns {Object} New block - */ - startBlock(type) { - // C-02 Fix: Enforce max blocks limit - if (this.contentBlocks.length >= this.maxBlocks) { - throw new Error(`Maximum ${this.maxBlocks} content blocks exceeded (DoS protection)`); - } - - this.currentBlockIndex++; - const block = { - index: this.currentBlockIndex, - type: type, - content: '', - started: true, - stopped: false - }; - this.contentBlocks.push(block); - - // Reset buffer for new block (tool_use doesn't use buffers) - if (type === 'thinking') { - this.thinkingBuffer = ''; - } else if (type === 'text') { - this.textBuffer = ''; - } - - return block; - } - - /** - * Add delta to current block - * @param {string} delta - Content delta - */ - addDelta(delta) { - const block = this.getCurrentBlock(); - if (!block) { - // FIX: Guard against null block (should never happen, but defensive) - console.error('[DeltaAccumulator] ERROR: addDelta called with no current block'); - return; - } - - if (block.type === 'thinking') { - // C-02 Fix: Enforce buffer size limit - if (this.thinkingBuffer.length + delta.length > this.maxBufferSize) { - throw new Error(`Thinking buffer exceeded ${this.maxBufferSize} bytes (DoS protection)`); - } - this.thinkingBuffer += delta; - block.content = this.thinkingBuffer; - - // FIX: Verify assignment succeeded (paranoid check for race conditions) - if (block.content.length !== this.thinkingBuffer.length) { - console.error('[DeltaAccumulator] ERROR: Block content assignment failed'); - console.error(`Expected: ${this.thinkingBuffer.length}, Got: ${block.content.length}`); - } - } else if (block.type === 'text') { - // C-02 Fix: Enforce buffer size limit - if (this.textBuffer.length + delta.length > this.maxBufferSize) { - throw new Error(`Text buffer exceeded ${this.maxBufferSize} bytes (DoS protection)`); - } - this.textBuffer += delta; - block.content = this.textBuffer; - } - } - - /** - * Mark current block as stopped - */ - stopCurrentBlock() { - const block = this.getCurrentBlock(); - if (block) { - block.stopped = true; - - // FIX: Log block closure for debugging (helps diagnose timing issues) - if (block.type === 'thinking' && process.env.CCS_DEBUG === '1') { - console.error(`[DeltaAccumulator] Stopped thinking block ${block.index}: ${block.content?.length || 0} chars`); - } - } - } - - /** - * Update usage statistics - * @param {Object} usage - Usage object from OpenAI - */ - updateUsage(usage) { - if (usage) { - this.inputTokens = usage.prompt_tokens || usage.input_tokens || 0; - this.outputTokens = usage.completion_tokens || usage.output_tokens || 0; - this.usageReceived = true; // Mark that we've received usage data - } - } - - /** - * Add or update tool call delta - * @param {Object} toolCallDelta - Tool call delta from OpenAI - */ - addToolCallDelta(toolCallDelta) { - const index = toolCallDelta.index; - - // Initialize tool call if not exists - if (!this.toolCallsIndex[index]) { - const toolCall = { - index: index, - id: '', - type: 'function', - function: { - name: '', - arguments: '' - } - }; - this.toolCalls.push(toolCall); - this.toolCallsIndex[index] = toolCall; - } - - const toolCall = this.toolCallsIndex[index]; - - // Update id if present - if (toolCallDelta.id) { - toolCall.id = toolCallDelta.id; - } - - // Update type if present - if (toolCallDelta.type) { - toolCall.type = toolCallDelta.type; - } - - // Update function name if present - if (toolCallDelta.function?.name) { - toolCall.function.name += toolCallDelta.function.name; - } - - // Update function arguments if present - if (toolCallDelta.function?.arguments) { - toolCall.function.arguments += toolCallDelta.function.arguments; - } - } - - /** - * Get all tool calls - * @returns {Array} Tool calls array - */ - getToolCalls() { - return this.toolCalls; - } - - /** - * Check for planning loop pattern - * Loop = N consecutive thinking blocks with no tool calls - * @returns {boolean} True if loop detected - */ - checkForLoop() { - // Already detected loop - if (this.loopDetected) { - return true; - } - - // Need minimum blocks to detect pattern - if (this.contentBlocks.length < this.loopDetectionThreshold) { - return false; - } - - // Get last N blocks - const recentBlocks = this.contentBlocks.slice(-this.loopDetectionThreshold); - - // Check if all recent blocks are thinking blocks - const allThinking = recentBlocks.every(b => b.type === 'thinking'); - - // Check if no tool calls have been made at all - const noToolCalls = this.toolCalls.length === 0; - - // Loop detected if: all recent blocks are thinking AND no tool calls yet - if (allThinking && noToolCalls) { - this.loopDetected = true; - return true; - } - - return false; - } - - /** - * Reset loop detection state (for testing) - */ - resetLoopDetection() { - this.loopDetected = false; - } - - /** - * Get summary of accumulated state - * @returns {Object} Summary - */ - getSummary() { - return { - messageId: this.messageId, - model: this.model, - role: this.role, - blockCount: this.contentBlocks.length, - currentIndex: this.currentBlockIndex, - toolCallCount: this.toolCalls.length, - messageStarted: this.messageStarted, - finalized: this.finalized, - loopDetected: this.loopDetected, - usage: { - input_tokens: this.inputTokens, - output_tokens: this.outputTokens - } - }; - } -} - -module.exports = DeltaAccumulator; diff --git a/bin/glmt/glmt-proxy.js b/bin/glmt/glmt-proxy.js deleted file mode 100644 index 2e428f84..00000000 --- a/bin/glmt/glmt-proxy.js +++ /dev/null @@ -1,495 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -const http = require('http'); -const https = require('https'); -const GlmtTransformer = require('./glmt-transformer'); -const SSEParser = require('./sse-parser'); -const DeltaAccumulator = require('./delta-accumulator'); - -/** - * GlmtProxy - Embedded HTTP proxy for GLM thinking support - * - * Architecture: - * - Intercepts Claude CLI → Z.AI calls - * - Transforms Anthropic format → OpenAI format - * - Converts reasoning_content → thinking blocks - * - Supports both streaming and buffered modes - * - * Lifecycle: - * - Spawned by bin/ccs.js when 'glmt' profile detected - * - Binds to 127.0.0.1:random_port (security + avoid conflicts) - * - Terminates when parent process exits - * - * Debugging: - * - Verbose: Pass --verbose to see request/response logs - * - Debug: Set CCS_DEBUG=1 to write logs to ~/.ccs/logs/ - * - * Usage: - * const proxy = new GlmtProxy({ verbose: true }); - * await proxy.start(); - */ -class GlmtProxy { - constructor(config = {}) { - this.transformer = new GlmtTransformer({ - verbose: config.verbose, - debugLog: config.debugLog || process.env.CCS_DEBUG === '1' || process.env.CCS_DEBUG_LOG === '1' - }); - // Use ANTHROPIC_BASE_URL from environment (set by settings.json) or fallback to Z.AI default - this.upstreamUrl = process.env.ANTHROPIC_BASE_URL || 'https://api.z.ai/api/coding/paas/v4/chat/completions'; - this.server = null; - this.port = null; - this.verbose = config.verbose || false; - this.timeout = config.timeout || 120000; // 120s default - } - - /** - * Start HTTP server on random port - * @returns {Promise} Port number - */ - async start() { - return new Promise((resolve, reject) => { - this.server = http.createServer((req, res) => { - this.handleRequest(req, res); - }); - - // Bind to 127.0.0.1:0 (random port for security + avoid conflicts) - this.server.listen(0, '127.0.0.1', () => { - this.port = this.server.address().port; - // Signal parent process - console.log(`PROXY_READY:${this.port}`); - - // Info message (only show in verbose mode) - if (this.verbose) { - console.error(`[glmt] Proxy listening on port ${this.port} (streaming with auto-fallback)`); - } - - // Debug mode notice - if (this.transformer.debugLog) { - console.error(`[glmt] Debug logging enabled: ${this.transformer.debugLogDir}`); - console.error(`[glmt] WARNING: Debug logs contain full request/response data`); - } - - this.log(`Verbose logging enabled`); - resolve(this.port); - }); - - this.server.on('error', (error) => { - console.error('[glmt-proxy] Server error:', error); - reject(error); - }); - }); - } - - /** - * Handle incoming HTTP request - * @param {http.IncomingMessage} req - Request - * @param {http.ServerResponse} res - Response - */ - async handleRequest(req, res) { - const startTime = Date.now(); - this.log(`Request: ${req.method} ${req.url}`); - - try { - // Only accept POST requests - if (req.method !== 'POST') { - res.writeHead(405, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Method not allowed' })); - return; - } - - // Read request body - const body = await this._readBody(req); - this.log(`Request body size: ${body.length} bytes`); - - // Parse JSON with error handling - let anthropicRequest; - try { - anthropicRequest = JSON.parse(body); - } catch (jsonError) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - type: 'invalid_request_error', - message: 'Invalid JSON in request body: ' + jsonError.message - } - })); - return; - } - - // Log thinking parameter for debugging - if (anthropicRequest.thinking) { - this.log(`Request contains thinking parameter: ${JSON.stringify(anthropicRequest.thinking)}`); - } else { - this.log(`Request does NOT contain thinking parameter (will use message tags or default)`); - } - - // Try streaming first (default), fallback to buffered on error - const useStreaming = anthropicRequest.stream !== false; - - if (useStreaming) { - try { - await this._handleStreamingRequest(req, res, anthropicRequest, startTime); - } catch (streamError) { - this.log(`Streaming failed: ${streamError.message}, retrying buffered mode`); - try { - await this._handleBufferedRequest(req, res, anthropicRequest, startTime); - } catch (bufferedError) { - // Both modes failed, propagate error - throw bufferedError; - } - } - } else { - await this._handleBufferedRequest(req, res, anthropicRequest, startTime); - } - - } catch (error) { - console.error('[glmt-proxy] Request error:', error.message); - const duration = Date.now() - startTime; - this.log(`Request failed after ${duration}ms: ${error.message}`); - - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - type: 'proxy_error', - message: error.message - } - })); - } - } - - /** - * Handle buffered (non-streaming) request - * @private - */ - async _handleBufferedRequest(req, res, anthropicRequest, startTime) { - // Transform to OpenAI format - const { openaiRequest, thinkingConfig } = - this.transformer.transformRequest(anthropicRequest); - - this.log(`Transformed request, thinking: ${thinkingConfig.thinking}`); - - // Forward to Z.AI - const openaiResponse = await this._forwardToUpstream( - openaiRequest, - req.headers - ); - - this.log(`Received response from upstream`); - - // Transform back to Anthropic format - const anthropicResponse = this.transformer.transformResponse( - openaiResponse, - thinkingConfig - ); - - // Return to Claude CLI - res.writeHead(200, { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*' - }); - res.end(JSON.stringify(anthropicResponse)); - - const duration = Date.now() - startTime; - this.log(`Request completed in ${duration}ms`); - } - - /** - * Handle streaming request - * @private - */ - async _handleStreamingRequest(req, res, anthropicRequest, startTime) { - this.log('Using streaming mode'); - - // Transform request - const { openaiRequest, thinkingConfig } = - this.transformer.transformRequest(anthropicRequest); - - // Force streaming - openaiRequest.stream = true; - - // Set SSE headers - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'Access-Control-Allow-Origin': '*', - 'X-Accel-Buffering': 'no' // Disable proxy buffering - }); - - // Disable Nagle's algorithm to prevent buffering at socket level - if (res.socket) { - res.socket.setNoDelay(true); - } - - this.log('Starting SSE stream to Claude CLI (socket buffering disabled)'); - - // Forward and stream - await this._forwardAndStreamUpstream( - openaiRequest, - req.headers, - res, - thinkingConfig, - startTime - ); - } - - /** - * Read request body - * @param {http.IncomingMessage} req - Request - * @returns {Promise} Body content - * @private - */ - _readBody(req) { - return new Promise((resolve, reject) => { - const chunks = []; - const maxSize = 10 * 1024 * 1024; // 10MB limit - let totalSize = 0; - - req.on('data', chunk => { - totalSize += chunk.length; - if (totalSize > maxSize) { - reject(new Error('Request body too large (max 10MB)')); - return; - } - chunks.push(chunk); - }); - - req.on('end', () => resolve(Buffer.concat(chunks).toString())); - req.on('error', reject); - }); - } - - /** - * Forward request to Z.AI upstream - * @param {Object} openaiRequest - OpenAI format request - * @param {Object} originalHeaders - Original request headers - * @returns {Promise} OpenAI response - * @private - */ - _forwardToUpstream(openaiRequest, originalHeaders) { - return new Promise((resolve, reject) => { - const url = new URL(this.upstreamUrl); - const requestBody = JSON.stringify(openaiRequest); - - const options = { - hostname: url.hostname, - port: url.port || 443, - path: url.pathname || '/api/coding/paas/v4/chat/completions', - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(requestBody), - // Preserve auth header from original request - 'Authorization': originalHeaders['authorization'] || '', - 'User-Agent': 'CCS-GLMT-Proxy/1.0' - } - }; - - // Debug logging - this.log(`Forwarding to: ${url.hostname}${url.pathname}`); - - // Set timeout - const timeoutHandle = setTimeout(() => { - req.destroy(); - reject(new Error('Upstream request timeout')); - }, this.timeout); - - const req = https.request(options, (res) => { - clearTimeout(timeoutHandle); - - const chunks = []; - res.on('data', chunk => chunks.push(chunk)); - - res.on('end', () => { - try { - const body = Buffer.concat(chunks).toString(); - this.log(`Upstream response size: ${body.length} bytes`); - - // Check for non-200 status - if (res.statusCode !== 200) { - reject(new Error( - `Upstream error: ${res.statusCode} ${res.statusMessage}\n${body}` - )); - return; - } - - const response = JSON.parse(body); - resolve(response); - } catch (error) { - reject(new Error('Invalid JSON from upstream: ' + error.message)); - } - }); - }); - - req.on('error', (error) => { - clearTimeout(timeoutHandle); - reject(error); - }); - - req.write(requestBody); - req.end(); - }); - } - - /** - * Forward request to Z.AI and stream response - * @param {Object} openaiRequest - OpenAI format request - * @param {Object} originalHeaders - Original request headers - * @param {http.ServerResponse} clientRes - Response to Claude CLI - * @param {Object} thinkingConfig - Thinking configuration - * @param {number} startTime - Request start time - * @returns {Promise} - * @private - */ - async _forwardAndStreamUpstream(openaiRequest, originalHeaders, clientRes, thinkingConfig, startTime) { - return new Promise((resolve, reject) => { - const url = new URL(this.upstreamUrl); - const requestBody = JSON.stringify(openaiRequest); - - const options = { - hostname: url.hostname, - port: url.port || 443, - path: url.pathname || '/api/coding/paas/v4/chat/completions', - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(requestBody), - 'Authorization': originalHeaders['authorization'] || '', - 'User-Agent': 'CCS-GLMT-Proxy/1.0', - 'Accept': 'text/event-stream' - } - }; - - this.log(`Forwarding streaming request to: ${url.hostname}${url.pathname}`); - - // C-03 Fix: Apply timeout to streaming requests - const timeoutHandle = setTimeout(() => { - req.destroy(); - reject(new Error(`Streaming request timeout after ${this.timeout}ms`)); - }, this.timeout); - - const req = https.request(options, (upstreamRes) => { - clearTimeout(timeoutHandle); - if (upstreamRes.statusCode !== 200) { - let body = ''; - upstreamRes.on('data', chunk => body += chunk); - upstreamRes.on('end', () => { - reject(new Error(`Upstream error: ${upstreamRes.statusCode}\n${body}`)); - }); - return; - } - - const parser = new SSEParser(); - const accumulator = new DeltaAccumulator(thinkingConfig); - - upstreamRes.on('data', (chunk) => { - try { - const events = parser.parse(chunk); - - events.forEach(event => { - // Transform OpenAI delta → Anthropic events - const anthropicEvents = this.transformer.transformDelta(event, accumulator); - - // Forward to Claude CLI with immediate flush - anthropicEvents.forEach(evt => { - const eventLine = `event: ${evt.event}\n`; - const dataLine = `data: ${JSON.stringify(evt.data)}\n\n`; - clientRes.write(eventLine + dataLine); - - // Flush immediately if method available (HTTP/2 or custom servers) - if (typeof clientRes.flush === 'function') { - clientRes.flush(); - } - }); - }); - } catch (error) { - this.log(`Error processing chunk: ${error.message}`); - } - }); - - upstreamRes.on('end', () => { - const duration = Date.now() - startTime; - this.log(`Streaming completed in ${duration}ms`); - clientRes.end(); - resolve(); - }); - - upstreamRes.on('error', (error) => { - clearTimeout(timeoutHandle); - this.log(`Upstream stream error: ${error.message}`); - clientRes.write(`event: error\n`); - clientRes.write(`data: ${JSON.stringify({ error: error.message })}\n\n`); - clientRes.end(); - reject(error); - }); - }); - - req.on('error', (error) => { - clearTimeout(timeoutHandle); - this.log(`Request error: ${error.message}`); - clientRes.write(`event: error\n`); - clientRes.write(`data: ${JSON.stringify({ error: error.message })}\n\n`); - clientRes.end(); - reject(error); - }); - - req.write(requestBody); - req.end(); - }); - } - - /** - * Stop proxy server - */ - stop() { - if (this.server) { - this.log('Stopping proxy server'); - this.server.close(); - } - } - - /** - * Log message if verbose - * @param {string} message - Message to log - * @private - */ - log(message) { - if (this.verbose) { - console.error(`[glmt-proxy] ${message}`); - } - } -} - -// Main entry point -if (require.main === module) { - const args = process.argv.slice(2); - const verbose = args.includes('--verbose') || args.includes('-v'); - - const proxy = new GlmtProxy({ verbose }); - - proxy.start().catch(error => { - console.error('[glmt-proxy] Failed to start:', error); - process.exit(1); - }); - - // Cleanup on signals - process.on('SIGTERM', () => { - proxy.stop(); - process.exit(0); - }); - - process.on('SIGINT', () => { - proxy.stop(); - process.exit(0); - }); - - // Keep process alive - process.on('uncaughtException', (error) => { - console.error('[glmt-proxy] Uncaught exception:', error); - proxy.stop(); - process.exit(1); - }); -} - -module.exports = GlmtProxy; diff --git a/bin/glmt/glmt-transformer.js b/bin/glmt/glmt-transformer.js deleted file mode 100644 index 6878b5dc..00000000 --- a/bin/glmt/glmt-transformer.js +++ /dev/null @@ -1,999 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -const crypto = require('crypto'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const SSEParser = require('./sse-parser'); -const DeltaAccumulator = require('./delta-accumulator'); -const LocaleEnforcer = require('./locale-enforcer'); -const ReasoningEnforcer = require('./reasoning-enforcer'); - -/** - * GlmtTransformer - Convert between Anthropic and OpenAI formats with thinking and tool support - * - * Features: - * - Request: Anthropic → OpenAI (inject reasoning params, transform tools) - * - Response: OpenAI reasoning_content → Anthropic thinking blocks - * - Tool Support: Anthropic tools ↔ OpenAI function calling (bidirectional) - * - Streaming: Real-time tool calls with input_json deltas - * - Debug mode: Log raw data to ~/.ccs/logs/ (CCS_DEBUG=1) - * - Verbose mode: Console logging with timestamps - * - Validation: Self-test transformation results - * - * Usage: - * const transformer = new GlmtTransformer({ verbose: true, debugLog: true }); - * const { openaiRequest, thinkingConfig } = transformer.transformRequest(req); - * const anthropicResponse = transformer.transformResponse(resp, thinkingConfig); - * - * Control Tags (in user prompt): - * - Enable/disable reasoning - * - Control reasoning depth - */ -class GlmtTransformer { - static _warnedDeprecation = false; - - constructor(config = {}) { - this.defaultThinking = config.defaultThinking ?? true; - this.verbose = config.verbose || false; - - // CCS_DEBUG controls all debug logging (file + console) - const debugEnabled = process.env.CCS_DEBUG === '1'; - this.debugLog = config.debugLog ?? debugEnabled; - this.debugMode = config.debugMode ?? debugEnabled; - - this.debugLogDir = config.debugLogDir || path.join(os.homedir(), '.ccs', 'logs'); - this.modelMaxTokens = { - 'GLM-4.6': 128000, - 'GLM-4.5': 96000, - 'GLM-4.5-air': 16000 - }; - // Effort level thresholds (budget_tokens) - this.EFFORT_LOW_THRESHOLD = 2048; - this.EFFORT_HIGH_THRESHOLD = 8192; - - // Initialize locale enforcer (always enforce English) - this.localeEnforcer = new LocaleEnforcer(); - - // Initialize reasoning enforcer (enabled by default for all GLMT usage) - this.reasoningEnforcer = new ReasoningEnforcer({ - enabled: config.explicitReasoning ?? true - }); - } - - /** - * Transform Anthropic request to OpenAI format - * @param {Object} anthropicRequest - Anthropic Messages API request - * @returns {Object} { openaiRequest, thinkingConfig } - */ - transformRequest(anthropicRequest) { - // Log original request - this._writeDebugLog('request-anthropic', anthropicRequest); - - try { - // 1. Extract thinking control from messages (tags like ) - const thinkingConfig = this._extractThinkingControl( - anthropicRequest.messages || [] - ); - const hasControlTags = this._hasThinkingTags(anthropicRequest.messages || []); - - // 2. Detect "think" keywords in user prompts (Anthropic-style) - const keywordConfig = this._detectThinkKeywords(anthropicRequest.messages || []); - if (keywordConfig && !anthropicRequest.thinking && !hasControlTags) { - thinkingConfig.thinking = keywordConfig.thinking; - thinkingConfig.effort = keywordConfig.effort; - this.log(`Detected think keyword: ${keywordConfig.keyword}, effort=${keywordConfig.effort}`); - } - - // 3. Check anthropicRequest.thinking parameter (takes precedence) - // Claude CLI sends this when alwaysThinkingEnabled is configured - if (anthropicRequest.thinking) { - if (anthropicRequest.thinking.type === 'enabled') { - thinkingConfig.thinking = true; - this.log('Claude CLI explicitly enabled thinking (overrides budget)'); - } else if (anthropicRequest.thinking.type === 'disabled') { - thinkingConfig.thinking = false; - this.log('Claude CLI explicitly disabled thinking (overrides budget)'); - } else { - this.log(`Warning: Unknown thinking type: ${anthropicRequest.thinking.type}`); - } - } - - this.log(`Final thinking control: ${JSON.stringify(thinkingConfig)}`); - - // 3. Map model - const glmModel = this._mapModel(anthropicRequest.model); - - // 4. Inject locale instruction before sanitization - const messagesWithLocale = this.localeEnforcer.injectInstruction( - anthropicRequest.messages || [] - ); - - // 4.5. Inject reasoning instruction (if enabled or thinking requested) - const messagesWithReasoning = this.reasoningEnforcer.injectInstruction( - messagesWithLocale, - thinkingConfig - ); - - // 5. Convert to OpenAI format - const openaiRequest = { - model: glmModel, - messages: this._sanitizeMessages(messagesWithReasoning), - max_tokens: this._getMaxTokens(glmModel), - stream: anthropicRequest.stream ?? false - }; - - // 5.5. Transform tools parameter if present - if (anthropicRequest.tools && anthropicRequest.tools.length > 0) { - openaiRequest.tools = this._transformTools(anthropicRequest.tools); - // Always use "auto" as Z.AI doesn't support other modes - openaiRequest.tool_choice = "auto"; - this.log(`Transformed ${anthropicRequest.tools.length} tools for OpenAI format`); - } - - // 6. Preserve optional parameters - if (anthropicRequest.temperature !== undefined) { - openaiRequest.temperature = anthropicRequest.temperature; - } - if (anthropicRequest.top_p !== undefined) { - openaiRequest.top_p = anthropicRequest.top_p; - } - - // 7. Handle streaming - // Keep stream parameter from request - if (anthropicRequest.stream !== undefined) { - openaiRequest.stream = anthropicRequest.stream; - } - - // 8. Inject reasoning parameters - this._injectReasoningParams(openaiRequest, thinkingConfig); - - // Log transformed request - this._writeDebugLog('request-openai', openaiRequest); - - return { openaiRequest, thinkingConfig }; - } catch (error) { - console.error('[glmt-transformer] Request transformation error:', error); - // Return original request with warning - return { - openaiRequest: anthropicRequest, - thinkingConfig: { thinking: false }, - error: error.message - }; - } - } - - /** - * Transform OpenAI response to Anthropic format - * @param {Object} openaiResponse - OpenAI Chat Completions response - * @param {Object} thinkingConfig - Config from request transformation - * @returns {Object} Anthropic Messages API response - */ - transformResponse(openaiResponse, thinkingConfig = {}) { - // Log original response - this._writeDebugLog('response-openai', openaiResponse); - - try { - const choice = openaiResponse.choices?.[0]; - if (!choice) { - throw new Error('No choices in OpenAI response'); - } - - const message = choice.message; - const content = []; - - // Add thinking block if reasoning_content exists - if (message.reasoning_content) { - const length = message.reasoning_content.length; - const lineCount = message.reasoning_content.split('\n').length; - const preview = message.reasoning_content - .substring(0, 100) - .replace(/\n/g, ' ') - .trim(); - - this.log(`Detected reasoning_content:`); - this.log(` Length: ${length} characters`); - this.log(` Lines: ${lineCount}`); - this.log(` Preview: ${preview}...`); - - content.push({ - type: 'thinking', - thinking: message.reasoning_content, - signature: this._generateThinkingSignature(message.reasoning_content) - }); - } else { - this.log('No reasoning_content in OpenAI response'); - this.log('Note: This is expected if thinking not requested or model cannot reason'); - } - - // Add text content - if (message.content) { - content.push({ - type: 'text', - text: message.content - }); - } - - // Handle tool_calls if present - if (message.tool_calls && message.tool_calls.length > 0) { - message.tool_calls.forEach(toolCall => { - let parsedInput; - try { - parsedInput = JSON.parse(toolCall.function.arguments || '{}'); - } catch (parseError) { - this.log(`Warning: Invalid JSON in tool arguments: ${parseError.message}`); - parsedInput = { _error: 'Invalid JSON', _raw: toolCall.function.arguments }; - } - - content.push({ - type: 'tool_use', - id: toolCall.id, - name: toolCall.function.name, - input: parsedInput - }); - }); - } - - const anthropicResponse = { - id: openaiResponse.id || 'msg_' + Date.now(), - type: 'message', - role: 'assistant', - content: content, - model: openaiResponse.model || 'glm-4.6', - stop_reason: this._mapStopReason(choice.finish_reason), - usage: { - input_tokens: openaiResponse.usage?.prompt_tokens || 0, - output_tokens: openaiResponse.usage?.completion_tokens || 0 - } - }; - - // Validate transformation in verbose mode - if (this.verbose) { - const validation = this._validateTransformation(anthropicResponse); - this.log(`Transformation validation: ${validation.passed}/${validation.total} checks passed`); - if (!validation.valid) { - this.log(`Failed checks: ${JSON.stringify(validation.checks, null, 2)}`); - } - } - - // Log transformed response - this._writeDebugLog('response-anthropic', anthropicResponse); - - return anthropicResponse; - } catch (error) { - console.error('[glmt-transformer] Response transformation error:', error); - // Return minimal valid response - return { - id: 'msg_error_' + Date.now(), - type: 'message', - role: 'assistant', - content: [{ - type: 'text', - text: '[Transformation Error] ' + error.message - }], - stop_reason: 'end_turn', - usage: { input_tokens: 0, output_tokens: 0 } - }; - } - } - - /** - * Sanitize messages for OpenAI API compatibility - * Convert tool_result blocks to separate tool messages - * Filter out thinking blocks - * @param {Array} messages - Messages array - * @returns {Array} Sanitized messages - * @private - */ - _sanitizeMessages(messages) { - const result = []; - - for (const msg of messages) { - // If content is a string, add as-is - if (typeof msg.content === 'string') { - result.push(msg); - continue; - } - - // If content is an array, process blocks - if (Array.isArray(msg.content)) { - // Separate tool_result blocks from other content - const toolResults = msg.content.filter(block => block.type === 'tool_result'); - const textBlocks = msg.content.filter(block => block.type === 'text'); - const toolUseBlocks = msg.content.filter(block => block.type === 'tool_use'); - - // CRITICAL: Tool messages must come BEFORE user text in OpenAI API - // Convert tool_result blocks to OpenAI tool messages FIRST - for (const toolResult of toolResults) { - result.push({ - role: 'tool', - tool_call_id: toolResult.tool_use_id, - content: typeof toolResult.content === 'string' - ? toolResult.content - : JSON.stringify(toolResult.content) - }); - } - - // Add text content as user/assistant message AFTER tool messages - if (textBlocks.length > 0) { - const textContent = textBlocks.length === 1 - ? textBlocks[0].text - : textBlocks.map(b => b.text).join('\n'); - - result.push({ - role: msg.role, - content: textContent - }); - } - - // Add tool_use blocks (assistant's tool calls) - skip for now, they're in assistant messages - // OpenAI handles these differently in response, not request - - // If no content at all, add empty message (but not if we added tool messages) - if (textBlocks.length === 0 && toolResults.length === 0 && toolUseBlocks.length === 0) { - result.push({ - role: msg.role, - content: '' - }); - } - - continue; - } - - // Fallback: return message as-is - result.push(msg); - } - - return result; - } - - /** - * Transform Anthropic tools to OpenAI tools format - * @param {Array} anthropicTools - Anthropic tools array - * @returns {Array} OpenAI tools array - * @private - */ - _transformTools(anthropicTools) { - return anthropicTools.map(tool => ({ - type: 'function', - function: { - name: tool.name, - description: tool.description, - parameters: tool.input_schema || {} - } - })); - } - - /** - * Check if messages contain thinking control tags - * @param {Array} messages - Messages array - * @returns {boolean} True if tags found - * @private - */ - _hasThinkingTags(messages) { - for (const msg of messages) { - if (msg.role !== 'user') continue; - const content = msg.content; - if (typeof content !== 'string') continue; - - // Check for control tags - if (//i.test(content) || //i.test(content)) { - return true; - } - } - return false; - } - - /** - * Extract thinking control tags from user messages - * @param {Array} messages - Messages array - * @returns {Object} { thinking: boolean, effort: string } - * @private - */ - _extractThinkingControl(messages) { - const config = { - thinking: this.defaultThinking, - effort: 'medium' - }; - - // Scan user messages for control tags - for (const msg of messages) { - if (msg.role !== 'user') continue; - - const content = msg.content; - if (typeof content !== 'string') continue; - - // Check for - const thinkingMatch = content.match(//i); - if (thinkingMatch) { - config.thinking = thinkingMatch[1].toLowerCase() === 'on'; - } - - // Check for - const effortMatch = content.match(//i); - if (effortMatch) { - config.effort = effortMatch[1].toLowerCase(); - } - } - - return config; - } - - /** - * Generate thinking signature for Claude Code UI - * @param {string} thinking - Thinking content - * @returns {Object} Signature object - * @private - */ - _generateThinkingSignature(thinking) { - // Generate signature hash - const hash = crypto.createHash('sha256') - .update(thinking) - .digest('hex') - .substring(0, 16); - - return { - type: 'thinking_signature', - hash: hash, - length: thinking.length, - timestamp: Date.now() - }; - } - - /** - * Detect Anthropic-style "think" keywords in user prompts - * Maps: "ultrathink" > "think harder" > "think hard" > "think" - * @param {Array} messages - Messages array - * @returns {Object|null} { thinking, effort, keyword } or null - * @private - */ - _detectThinkKeywords(messages) { - if (!messages || messages.length === 0) return null; - - // Extract text from user messages - const text = messages - .filter(m => m.role === 'user') - .map(m => { - if (typeof m.content === 'string') return m.content; - if (Array.isArray(m.content)) { - return m.content - .filter(block => block.type === 'text') - .map(block => block.text || '') - .join(' '); - } - return ''; - }) - .join(' '); - - // Priority: ultrathink > think harder > think hard > think - // Effort levels: max > high > medium > low (matches Anthropic's 4-tier system) - // Use word boundaries to avoid matching "thinking", "rethink", etc. - if (/\bultrathink\b/i.test(text)) { - return { thinking: true, effort: 'max', keyword: 'ultrathink' }; - } - if (/\bthink\s+harder\b/i.test(text)) { - return { thinking: true, effort: 'high', keyword: 'think harder' }; - } - if (/\bthink\s+hard\b/i.test(text)) { - return { thinking: true, effort: 'medium', keyword: 'think hard' }; - } - if (/\bthink\b/i.test(text)) { - return { thinking: true, effort: 'low', keyword: 'think' }; - } - - return null; // No keywords detected - } - - /** - * Inject reasoning parameters into OpenAI request - * @param {Object} openaiRequest - OpenAI request to modify - * @param {Object} thinkingConfig - Thinking configuration - * @returns {Object} Modified request - * @private - */ - _injectReasoningParams(openaiRequest, thinkingConfig) { - // Always enable sampling for temperature/top_p to work - openaiRequest.do_sample = true; - - // Add thinking-specific parameters if enabled - if (thinkingConfig.thinking) { - // Z.AI may support these parameters (based on research) - openaiRequest.reasoning = true; - openaiRequest.reasoning_effort = thinkingConfig.effort; - } - - return openaiRequest; - } - - /** - * Map Anthropic model to GLM model - * @param {string} anthropicModel - Anthropic model name - * @returns {string} GLM model name - * @private - */ - _mapModel(anthropicModel) { - // Default to GLM-4.6 (latest and most capable) - return 'GLM-4.6'; - } - - /** - * Get max tokens for model - * @param {string} model - Model name - * @returns {number} Max tokens - * @private - */ - _getMaxTokens(model) { - return this.modelMaxTokens[model] || 128000; - } - - /** - * Map OpenAI stop reason to Anthropic stop reason - * @param {string} openaiReason - OpenAI finish_reason - * @returns {string} Anthropic stop_reason - * @private - */ - _mapStopReason(openaiReason) { - const mapping = { - 'stop': 'end_turn', - 'length': 'max_tokens', - 'tool_calls': 'tool_use', - 'content_filter': 'stop_sequence' - }; - return mapping[openaiReason] || 'end_turn'; - } - - /** - * Write debug log to file - * @param {string} type - 'request-anthropic', 'request-openai', 'response-openai', 'response-anthropic' - * @param {object} data - Data to log - * @private - */ - _writeDebugLog(type, data) { - if (!this.debugLog) return; - - try { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('.')[0]; - const filename = `${timestamp}-${type}.json`; - const filepath = path.join(this.debugLogDir, filename); - - // Ensure directory exists - fs.mkdirSync(this.debugLogDir, { recursive: true }); - - // Write file (pretty-printed) - fs.writeFileSync(filepath, JSON.stringify(data, null, 2) + '\n', 'utf8'); - - if (this.verbose) { - this.log(`Debug log written: ${filepath}`); - } - } catch (error) { - console.error(`[glmt-transformer] Failed to write debug log: ${error.message}`); - } - } - - /** - * Validate transformed Anthropic response - * @param {object} anthropicResponse - Response to validate - * @returns {object} Validation results - * @private - */ - _validateTransformation(anthropicResponse) { - const checks = { - hasContent: Boolean(anthropicResponse.content && anthropicResponse.content.length > 0), - hasThinking: anthropicResponse.content?.some(block => block.type === 'thinking') || false, - hasText: anthropicResponse.content?.some(block => block.type === 'text') || false, - validStructure: anthropicResponse.type === 'message' && anthropicResponse.role === 'assistant', - hasUsage: Boolean(anthropicResponse.usage) - }; - - const passed = Object.values(checks).filter(Boolean).length; - const total = Object.keys(checks).length; - - return { checks, passed, total, valid: passed === total }; - } - - /** - * Transform OpenAI streaming delta to Anthropic events - * @param {Object} openaiEvent - Parsed SSE event from Z.AI - * @param {DeltaAccumulator} accumulator - State accumulator - * @returns {Array} Array of Anthropic SSE events - */ - transformDelta(openaiEvent, accumulator) { - const events = []; - - // Debug logging for streaming deltas - if (this.debugLog && openaiEvent.data) { - this._writeDebugLog('delta-openai', openaiEvent.data); - } - - // Handle [DONE] marker - // Only finalize if we haven't already (deferred finalization may have already triggered) - if (openaiEvent.event === 'done') { - if (!accumulator.finalized) { - return this.finalizeDelta(accumulator); - } - return []; // Already finalized - } - - // Usage update (appears in final chunk, may be before choice data) - // Process this BEFORE early returns to ensure we capture usage - if (openaiEvent.data?.usage) { - accumulator.updateUsage(openaiEvent.data.usage); - - // If we have both usage AND finish_reason, finalize immediately - if (accumulator.finishReason) { - events.push(...this.finalizeDelta(accumulator)); - return events; // Early return after finalization - } - } - - const choice = openaiEvent.data?.choices?.[0]; - if (!choice) return events; - - const delta = choice.delta; - if (!delta) return events; - - // Message start - if (!accumulator.messageStarted) { - if (openaiEvent.data.model) { - accumulator.model = openaiEvent.data.model; - } - events.push(this._createMessageStartEvent(accumulator)); - accumulator.messageStarted = true; - } - - // Role - if (delta.role) { - accumulator.role = delta.role; - } - - // Reasoning content delta (Z.AI streams incrementally - confirmed in Phase 02) - if (delta.reasoning_content) { - const currentBlock = accumulator.getCurrentBlock(); - - // FIX: Enhanced debug logging for thinking block diagnostics - if (this.debugMode) { - console.error(`[GLMT-DEBUG] Reasoning delta: ${delta.reasoning_content.length} chars`); - console.error(`[GLMT-DEBUG] Current block: ${currentBlock?.type || 'none'}, index: ${currentBlock?.index ?? 'N/A'}`); - } - - if (!currentBlock || currentBlock.type !== 'thinking') { - // Start thinking block - const block = accumulator.startBlock('thinking'); - events.push(this._createContentBlockStartEvent(block)); - - if (this.debugMode) { - console.error(`[GLMT-DEBUG] Started new thinking block ${block.index}`); - } - } - - accumulator.addDelta(delta.reasoning_content); - events.push(this._createThinkingDeltaEvent( - accumulator.getCurrentBlock(), - delta.reasoning_content - )); - } - - // Text content delta - if (delta.content) { - const currentBlock = accumulator.getCurrentBlock(); - - // Close thinking block if transitioning from thinking to text - if (currentBlock && currentBlock.type === 'thinking' && !currentBlock.stopped) { - const signatureEvent = this._createSignatureDeltaEvent(currentBlock); - if (signatureEvent) { // FIX: Handle null return from signature race guard - events.push(signatureEvent); - } - events.push(this._createContentBlockStopEvent(currentBlock)); - accumulator.stopCurrentBlock(); - } - - if (!accumulator.getCurrentBlock() || accumulator.getCurrentBlock().type !== 'text') { - // Start text block - const block = accumulator.startBlock('text'); - events.push(this._createContentBlockStartEvent(block)); - } - - accumulator.addDelta(delta.content); - events.push(this._createTextDeltaEvent( - accumulator.getCurrentBlock(), - delta.content - )); - } - - // Check for planning loop after each thinking block completes - if (accumulator.checkForLoop()) { - this.log('WARNING: Planning loop detected - 3 consecutive thinking blocks with no tool calls'); - this.log('Forcing early finalization to prevent unbounded planning'); - - // Close current block if any - const currentBlock = accumulator.getCurrentBlock(); - if (currentBlock && !currentBlock.stopped) { - if (currentBlock.type === 'thinking') { - const signatureEvent = this._createSignatureDeltaEvent(currentBlock); - if (signatureEvent) { // FIX: Handle null return from signature race guard - events.push(signatureEvent); - } - } - events.push(this._createContentBlockStopEvent(currentBlock)); - accumulator.stopCurrentBlock(); - } - - // Force finalization - events.push(...this.finalizeDelta(accumulator)); - return events; - } - - // Tool calls deltas - if (delta.tool_calls && delta.tool_calls.length > 0) { - // Close current content block ONCE before processing any tool calls - const currentBlock = accumulator.getCurrentBlock(); - if (currentBlock && !currentBlock.stopped) { - if (currentBlock.type === 'thinking') { - events.push(this._createSignatureDeltaEvent(currentBlock)); - } - events.push(this._createContentBlockStopEvent(currentBlock)); - accumulator.stopCurrentBlock(); - } - - // Process each tool call delta - for (const toolCallDelta of delta.tool_calls) { - // Track tool call state - const isNewToolCall = !accumulator.toolCallsIndex[toolCallDelta.index]; - accumulator.addToolCallDelta(toolCallDelta); - - // Emit tool use events (start + input_json deltas) - if (isNewToolCall) { - // Start new tool_use block in accumulator - const block = accumulator.startBlock('tool_use'); - const toolCall = accumulator.toolCallsIndex[toolCallDelta.index]; - - events.push({ - event: 'content_block_start', - data: { - type: 'content_block_start', - index: block.index, - content_block: { - type: 'tool_use', - id: toolCall.id || `tool_${toolCallDelta.index}`, - name: toolCall.function.name || '' - } - } - }); - } - - // Emit input_json delta if arguments present - if (toolCallDelta.function?.arguments) { - const currentToolBlock = accumulator.getCurrentBlock(); - if (currentToolBlock && currentToolBlock.type === 'tool_use') { - events.push({ - event: 'content_block_delta', - data: { - type: 'content_block_delta', - index: currentToolBlock.index, - delta: { - type: 'input_json_delta', - partial_json: toolCallDelta.function.arguments - } - } - }); - } - } - } - } - - // Finish reason - if (choice.finish_reason) { - accumulator.finishReason = choice.finish_reason; - - // If we have both finish_reason AND usage, finalize immediately - if (accumulator.usageReceived) { - events.push(...this.finalizeDelta(accumulator)); - } - } - - // Debug logging for generated events - if (this.debugLog && events.length > 0) { - this._writeDebugLog('delta-anthropic-events', { events, accumulator: accumulator.getSummary() }); - } - - return events; - } - - /** - * Finalize streaming and generate closing events - * @param {DeltaAccumulator} accumulator - State accumulator - * @returns {Array} Final Anthropic SSE events - */ - finalizeDelta(accumulator) { - if (accumulator.finalized) { - return []; // Already finalized - } - - const events = []; - - // Close current content block if any (including tool_use blocks) - const currentBlock = accumulator.getCurrentBlock(); - if (currentBlock && !currentBlock.stopped) { - if (currentBlock.type === 'thinking') { - const signatureEvent = this._createSignatureDeltaEvent(currentBlock); - if (signatureEvent) { // FIX: Handle null return from signature race guard - events.push(signatureEvent); - } - } - events.push(this._createContentBlockStopEvent(currentBlock)); - accumulator.stopCurrentBlock(); - } - - // No need to manually stop tool_use blocks - they're now tracked in contentBlocks - // and will be stopped by the logic above if they're the current block - - // Message delta (stop reason + usage) - events.push({ - event: 'message_delta', - data: { - type: 'message_delta', - delta: { - stop_reason: this._mapStopReason(accumulator.finishReason || 'stop') - }, - usage: { - input_tokens: accumulator.inputTokens, - output_tokens: accumulator.outputTokens - } - } - }); - - // Message stop - events.push({ - event: 'message_stop', - data: { - type: 'message_stop' - } - }); - - accumulator.finalized = true; - return events; - } - - /** - * Create message_start event - * @private - */ - _createMessageStartEvent(accumulator) { - return { - event: 'message_start', - data: { - type: 'message_start', - message: { - id: accumulator.messageId, - type: 'message', - role: accumulator.role, - content: [], - model: accumulator.model || 'glm-4.6', - stop_reason: null, - usage: { - input_tokens: accumulator.inputTokens, - output_tokens: 0 - } - } - } - }; - } - - /** - * Create content_block_start event - * @private - */ - _createContentBlockStartEvent(block) { - return { - event: 'content_block_start', - data: { - type: 'content_block_start', - index: block.index, - content_block: { - type: block.type, - [block.type]: '' - } - } - }; - } - - /** - * Create thinking_delta event - * @private - */ - _createThinkingDeltaEvent(block, delta) { - return { - event: 'content_block_delta', - data: { - type: 'content_block_delta', - index: block.index, - delta: { - type: 'thinking_delta', - thinking: delta - } - } - }; - } - - /** - * Create text_delta event - * @private - */ - _createTextDeltaEvent(block, delta) { - return { - event: 'content_block_delta', - data: { - type: 'content_block_delta', - index: block.index, - delta: { - type: 'text_delta', - text: delta - } - } - }; - } - - /** - * Create thinking signature delta event - * @private - */ - _createSignatureDeltaEvent(block) { - // FIX: Guard against empty content (signature timing race) - // In streaming mode, signature may be requested before content fully accumulated - if (!block.content || block.content.length === 0) { - if (this.verbose) { - this.log(`WARNING: Skipping signature for empty thinking block ${block.index}`); - this.log(`This indicates a race condition - signature requested before content accumulated`); - } - return null; // Return null instead of event - } - - const signature = this._generateThinkingSignature(block.content); - - // Enhanced logging for debugging - if (this.verbose) { - this.log(`Generating signature for block ${block.index}: ${block.content.length} chars`); - } - - return { - event: 'content_block_delta', - data: { - type: 'content_block_delta', - index: block.index, - delta: { - type: 'thinking_signature_delta', - signature: signature - } - } - }; - } - - /** - * Create content_block_stop event - * @private - */ - _createContentBlockStopEvent(block) { - return { - event: 'content_block_stop', - data: { - type: 'content_block_stop', - index: block.index - } - }; - } - - /** - * Log message if verbose - * @param {string} message - Message to log - * @private - */ - log(message) { - if (this.verbose) { - const timestamp = new Date().toTimeString().split(' ')[0]; // HH:MM:SS - console.error(`[glmt-transformer] [${timestamp}] ${message}`); - } - } -} - -module.exports = GlmtTransformer; diff --git a/bin/glmt/locale-enforcer.js b/bin/glmt/locale-enforcer.js deleted file mode 100644 index 581a1f8e..00000000 --- a/bin/glmt/locale-enforcer.js +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -/** - * LocaleEnforcer - Force English output from GLM models - * - * Purpose: GLM models default to Chinese when prompts are ambiguous or contain Chinese context. - * This module always injects "MUST respond in English" instruction into system prompt or first user message. - * - * Usage: - * const enforcer = new LocaleEnforcer(); - * const modifiedMessages = enforcer.injectInstruction(messages); - * - * Strategy: - * 1. If system prompt exists: Prepend instruction - * 2. If no system prompt: Prepend to first user message - * 3. Preserve message structure (string vs array content) - */ -class LocaleEnforcer { - constructor(options = {}) { - this.instruction = "CRITICAL: You MUST respond in English only, regardless of the input language or context. This is a strict requirement."; - } - - /** - * Inject English instruction into messages - * @param {Array} messages - Messages array to modify - * @returns {Array} Modified messages array - */ - injectInstruction(messages) { - // Clone messages to avoid mutation - const modifiedMessages = JSON.parse(JSON.stringify(messages)); - - // Strategy 1: Inject into system prompt (preferred) - const systemIndex = modifiedMessages.findIndex(m => m.role === 'system'); - if (systemIndex >= 0) { - const systemMsg = modifiedMessages[systemIndex]; - - if (typeof systemMsg.content === 'string') { - systemMsg.content = `${this.instruction}\n\n${systemMsg.content}`; - } else if (Array.isArray(systemMsg.content)) { - systemMsg.content.unshift({ - type: 'text', - text: this.instruction - }); - } - - return modifiedMessages; - } - - // Strategy 2: Prepend to first user message - const userIndex = modifiedMessages.findIndex(m => m.role === 'user'); - if (userIndex >= 0) { - const userMsg = modifiedMessages[userIndex]; - - if (typeof userMsg.content === 'string') { - userMsg.content = `${this.instruction}\n\n${userMsg.content}`; - } else if (Array.isArray(userMsg.content)) { - userMsg.content.unshift({ - type: 'text', - text: this.instruction - }); - } - - return modifiedMessages; - } - - // No system or user messages found (edge case) - return modifiedMessages; - } -} - -module.exports = LocaleEnforcer; diff --git a/bin/glmt/reasoning-enforcer.js b/bin/glmt/reasoning-enforcer.js deleted file mode 100644 index c589760c..00000000 --- a/bin/glmt/reasoning-enforcer.js +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -/** - * ReasoningEnforcer - Inject explicit reasoning instructions into prompts - * - * Purpose: Force GLM models to use structured reasoning output format () - * This complements API parameters (reasoning: true) with explicit prompt instructions. - * - * Usage: - * const enforcer = new ReasoningEnforcer({ enabled: true }); - * const modifiedMessages = enforcer.injectInstruction(messages, thinkingConfig); - * - * Strategy: - * 1. If system prompt exists: Prepend reasoning instruction - * 2. If no system prompt: Prepend to first user message - * 3. Select prompt template based on effort level (low/medium/high/max) - * 4. Preserve message structure (string vs array content) - */ - -class ReasoningEnforcer { - constructor(options = {}) { - this.enabled = options.enabled ?? false; // Opt-in by default - this.prompts = options.prompts || this._getDefaultPrompts(); - } - - /** - * Inject reasoning instruction into messages - * @param {Array} messages - Messages array to modify - * @param {Object} thinkingConfig - { thinking: boolean, effort: string } - * @returns {Array} Modified messages array - */ - injectInstruction(messages, thinkingConfig = {}) { - // Only inject if enabled or thinking explicitly requested - if (!this.enabled && !thinkingConfig.thinking) { - return messages; - } - - // Clone messages to avoid mutation - const modifiedMessages = JSON.parse(JSON.stringify(messages)); - - // Select prompt based on effort level - const prompt = this._selectPrompt(thinkingConfig.effort || 'medium'); - - // Strategy 1: Inject into system prompt (preferred) - const systemIndex = modifiedMessages.findIndex(m => m.role === 'system'); - if (systemIndex >= 0) { - const systemMsg = modifiedMessages[systemIndex]; - - if (typeof systemMsg.content === 'string') { - systemMsg.content = `${prompt}\n\n${systemMsg.content}`; - } else if (Array.isArray(systemMsg.content)) { - systemMsg.content.unshift({ - type: 'text', - text: prompt - }); - } - - return modifiedMessages; - } - - // Strategy 2: Prepend to first user message - const userIndex = modifiedMessages.findIndex(m => m.role === 'user'); - if (userIndex >= 0) { - const userMsg = modifiedMessages[userIndex]; - - if (typeof userMsg.content === 'string') { - userMsg.content = `${prompt}\n\n${userMsg.content}`; - } else if (Array.isArray(userMsg.content)) { - userMsg.content.unshift({ - type: 'text', - text: prompt - }); - } - - return modifiedMessages; - } - - // No system or user messages found (edge case) - return modifiedMessages; - } - - /** - * Select prompt template based on effort level - * @param {string} effort - 'low', 'medium', 'high', or 'max' - * @returns {string} Prompt template - * @private - */ - _selectPrompt(effort) { - const normalizedEffort = effort.toLowerCase(); - return this.prompts[normalizedEffort] || this.prompts.medium; - } - - /** - * Get default prompt templates - * @returns {Object} Map of effort levels to prompts - * @private - */ - _getDefaultPrompts() { - return { - low: `You are an expert reasoning model using GLM-4.6 architecture. - -CRITICAL: Before answering, write 2-3 sentences of reasoning in tags. - -OUTPUT FORMAT: - -(Brief analysis: what is the problem? what's the approach?) - - -(Write your final answer here)`, - - medium: `You are an expert reasoning model using GLM-4.6 architecture. - -CRITICAL REQUIREMENTS: -1. Always think step-by-step before answering -2. Write your reasoning process explicitly in tags -3. Never skip your chain of thought, even for simple problems - -OUTPUT FORMAT: - -(Write your detailed thinking here: analyze the problem, explore approaches, -evaluate trade-offs, and arrive at a conclusion) - - -(Write your final answer here based on your reasoning above)`, - - high: `You are an expert reasoning model using GLM-4.6 architecture. - -CRITICAL REQUIREMENTS: -1. Think deeply and systematically before answering -2. Write comprehensive reasoning in tags -3. Explore multiple approaches and evaluate trade-offs -4. Show all steps in your problem-solving process - -OUTPUT FORMAT: - -(Write exhaustive analysis here: - - Problem decomposition - - Multiple approach exploration - - Trade-off analysis for each approach - - Edge case consideration - - Final conclusion with justification) - - -(Write your final answer here based on your systematic reasoning above)`, - - max: `You are an expert reasoning model using GLM-4.6 architecture. - -CRITICAL REQUIREMENTS: -1. Think exhaustively from first principles -2. Write extremely detailed reasoning in tags -3. Analyze ALL possible angles, approaches, and edge cases -4. Challenge your own assumptions and explore alternatives -5. Provide rigorous justification for every claim - -OUTPUT FORMAT: - -(Write comprehensive analysis here: - - First principles breakdown - - Exhaustive approach enumeration - - Comparative analysis of all approaches - - Edge case and failure mode analysis - - Assumption validation - - Counter-argument consideration - - Final conclusion with rigorous justification) - - -(Write your final answer here based on your exhaustive reasoning above)` - }; - } -} - -module.exports = ReasoningEnforcer; diff --git a/bin/glmt/sse-parser.js b/bin/glmt/sse-parser.js deleted file mode 100644 index 4b48ae44..00000000 --- a/bin/glmt/sse-parser.js +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -/** - * SSEParser - Parse Server-Sent Events (SSE) stream - * - * Handles: - * - Incomplete events across chunks - * - Multiple events in single chunk - * - Malformed data (skip gracefully) - * - [DONE] marker - * - * Usage: - * const parser = new SSEParser(); - * stream.on('data', chunk => { - * const events = parser.parse(chunk); - * events.forEach(event => { ... }); - * }); - */ -class SSEParser { - constructor(options = {}) { - this.buffer = ''; - this.eventCount = 0; - this.maxBufferSize = options.maxBufferSize || 1024 * 1024; // 1MB default - } - - /** - * Parse chunk and extract SSE events - * @param {Buffer|string} chunk - Data chunk from stream - * @returns {Array} Array of parsed events - */ - parse(chunk) { - this.buffer += chunk.toString(); - - // C-01 Fix: Prevent unbounded buffer growth (DoS protection) - if (this.buffer.length > this.maxBufferSize) { - throw new Error(`SSE buffer exceeded ${this.maxBufferSize} bytes (DoS protection)`); - } - - const lines = this.buffer.split('\n'); - - // Keep incomplete line in buffer - this.buffer = lines.pop() || ''; - - const events = []; - let currentEvent = { event: 'message', data: '' }; - - for (const line of lines) { - if (line.startsWith('event: ')) { - currentEvent.event = line.substring(7).trim(); - } else if (line.startsWith('data: ')) { - const data = line.substring(6); - - if (data === '[DONE]') { - this.eventCount++; - events.push({ - event: 'done', - data: null, - index: this.eventCount - }); - currentEvent = { event: 'message', data: '' }; - } else { - try { - currentEvent.data = JSON.parse(data); - this.eventCount++; - currentEvent.index = this.eventCount; - events.push(currentEvent); - currentEvent = { event: 'message', data: '' }; - } catch (e) { - // H-01 Fix: Log parse errors for debugging - if (typeof console !== 'undefined' && console.error) { - console.error('[SSEParser] Malformed JSON event:', e.message, 'Data:', data.substring(0, 100)); - } - } - } - } else if (line.startsWith('id: ')) { - currentEvent.id = line.substring(4).trim(); - } else if (line.startsWith('retry: ')) { - currentEvent.retry = parseInt(line.substring(7), 10); - } - // Empty lines separate events (already handled by JSON parsing) - } - - return events; - } - - /** - * Reset parser state (for reuse) - */ - reset() { - this.buffer = ''; - this.eventCount = 0; - } -} - -module.exports = SSEParser; diff --git a/bin/management/doctor.js b/bin/management/doctor.js deleted file mode 100644 index bec8d057..00000000 --- a/bin/management/doctor.js +++ /dev/null @@ -1,721 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { spawn } = require('child_process'); -const { colored } = require('../utils/helpers'); -const { detectClaudeCli } = require('../utils/claude-detector'); - -// Make ora optional (might not be available during npm install postinstall) -// ora v9+ is an ES module, need to use .default for CommonJS -let ora = null; -try { - const oraModule = require('ora'); - ora = oraModule.default || oraModule; -} catch (e) { - // ora not available, create fallback spinner that uses console.log - ora = function(text) { - return { - start: () => ({ - succeed: (msg) => console.log(msg || `[OK] ${text}`), - fail: (msg) => console.log(msg || `[X] ${text}`), - warn: (msg) => console.log(msg || `[!] ${text}`), - info: (msg) => console.log(msg || `[i] ${text}`), - text: '' - }) - }; - }; -} - -const Table = require('cli-table3'); - -/** - * Health check results - */ -class HealthCheck { - constructor() { - this.checks = []; - this.warnings = []; - this.errors = []; - this.details = {}; // Store detailed information for summary table - } - - addCheck(name, status, message = '', fix = null, details = null) { - this.checks.push({ name, status, message, fix }); - - if (status === 'error') this.errors.push({ name, message, fix }); - if (status === 'warning') this.warnings.push({ name, message, fix }); - - // Store details for summary table - if (details) { - this.details[name] = details; - } - } - - hasErrors() { - return this.errors.length > 0; - } - - hasWarnings() { - return this.warnings.length > 0; - } - - isHealthy() { - return !this.hasErrors(); - } -} - -/** - * CCS Health Check and Diagnostics - */ -class Doctor { - constructor() { - this.homedir = os.homedir(); - this.ccsDir = path.join(this.homedir, '.ccs'); - this.claudeDir = path.join(this.homedir, '.claude'); - this.results = new HealthCheck(); - - // Get CCS version - try { - this.ccsVersion = require('../../package.json').version; - } catch (e) { - this.ccsVersion = 'unknown'; - } - } - - /** - * Run all health checks - */ - async runAllChecks() { - console.log(colored('Running CCS Health Check...', 'cyan')); - console.log(''); - - // Store CCS version in details - this.results.details['CCS Version'] = { status: 'OK', info: `v${this.ccsVersion}` }; - - // Group 1: System - console.log(colored('System:', 'bold')); - await this.checkClaudeCli(); - this.checkCcsDirectory(); - console.log(''); - - // Group 2: Configuration - console.log(colored('Configuration:', 'bold')); - this.checkConfigFiles(); - this.checkClaudeSettings(); - console.log(''); - - // Group 3: Profiles & Delegation - console.log(colored('Profiles & Delegation:', 'bold')); - this.checkProfiles(); - this.checkInstances(); - this.checkDelegation(); - console.log(''); - - // Group 4: System Health - console.log(colored('System Health:', 'bold')); - this.checkPermissions(); - this.checkCcsSymlinks(); - this.checkSettingsSymlinks(); - console.log(''); - - this.showReport(); - return this.results; - } - - /** - * Check 1: Claude CLI availability - */ - async checkClaudeCli() { - const spinner = ora('Checking Claude CLI').start(); - - const claudeCli = detectClaudeCli(); - - // Try to execute claude --version - try { - const result = await new Promise((resolve, reject) => { - const child = spawn(claudeCli, ['--version'], { - stdio: 'pipe', - timeout: 5000 - }); - - let output = ''; - child.stdout.on('data', data => output += data); - child.stderr.on('data', data => output += data); - - child.on('close', code => { - if (code === 0) resolve(output); - else reject(new Error('Exit code ' + code)); - }); - - child.on('error', reject); - }); - - // Extract version from output - const versionMatch = result.match(/(\d+\.\d+\.\d+)/); - const version = versionMatch ? versionMatch[1] : 'unknown'; - - spinner.succeed(` ${'Claude CLI'.padEnd(26)}${colored('[OK]', 'green')} ${claudeCli} (v${version})`); - this.results.addCheck('Claude CLI', 'success', `Found: ${claudeCli}`, null, { - status: 'OK', - info: `v${version} (${claudeCli})` - }); - } catch (err) { - spinner.fail(` ${'Claude CLI'.padEnd(26)}${colored('[X]', 'red')} Not found or not working`); - this.results.addCheck( - 'Claude CLI', - 'error', - 'Claude CLI not found or not working', - 'Install from: https://docs.claude.com/en/docs/claude-code/installation', - { status: 'ERROR', info: 'Not installed' } - ); - } - } - - /** - * Check 2: ~/.ccs/ directory - */ - checkCcsDirectory() { - const spinner = ora('Checking ~/.ccs/ directory').start(); - - if (fs.existsSync(this.ccsDir)) { - spinner.succeed(` ${'CCS Directory'.padEnd(26)}${colored('[OK]', 'green')} ~/.ccs/`); - this.results.addCheck('CCS Directory', 'success', null, null, { - status: 'OK', - info: '~/.ccs/' - }); - } else { - spinner.fail(` ${'CCS Directory'.padEnd(26)}${colored('[X]', 'red')} Not found`); - this.results.addCheck( - 'CCS Directory', - 'error', - '~/.ccs/ directory not found', - 'Run: npm install -g @kaitranntt/ccs --force', - { status: 'ERROR', info: 'Not found' } - ); - } - } - - /** - * Check 3: Config files - */ - checkConfigFiles() { - const files = [ - { path: path.join(this.ccsDir, 'config.json'), name: 'config.json', key: 'config.json' }, - { path: path.join(this.ccsDir, 'glm.settings.json'), name: 'glm.settings.json', key: 'GLM Settings', profile: 'glm' }, - { path: path.join(this.ccsDir, 'kimi.settings.json'), name: 'kimi.settings.json', key: 'Kimi Settings', profile: 'kimi' } - ]; - - const { DelegationValidator } = require('../utils/delegation-validator'); - - for (const file of files) { - const spinner = ora(`Checking ${file.name}`).start(); - - if (!fs.existsSync(file.path)) { - spinner.fail(` ${file.name.padEnd(26)}${colored('[X]', 'red')} Not found`); - this.results.addCheck( - file.name, - 'error', - `${file.name} not found`, - 'Run: npm install -g @kaitranntt/ccs --force', - { status: 'ERROR', info: 'Not found' } - ); - continue; - } - - // Validate JSON - try { - const content = fs.readFileSync(file.path, 'utf8'); - const config = JSON.parse(content); - - // Extract useful info based on file type - let info = 'Valid'; - let status = 'OK'; - - if (file.profile) { - // For settings files, check if API key is configured - const validation = DelegationValidator.validate(file.profile); - - if (validation.valid) { - info = 'Key configured'; - status = 'OK'; - } else if (validation.error && validation.error.includes('placeholder')) { - info = 'Placeholder key (not configured)'; - status = 'WARN'; - } else { - info = 'Valid JSON'; - status = 'OK'; - } - } - - const statusIcon = status === 'OK' ? colored('[OK]', 'green') : colored('[!]', 'yellow'); - - if (status === 'WARN') { - spinner.warn(` ${file.name.padEnd(26)}${statusIcon} ${info}`); - } else { - spinner.succeed(` ${file.name.padEnd(26)}${statusIcon} ${info}`); - } - - this.results.addCheck(file.name, status === 'OK' ? 'success' : 'warning', null, null, { - status: status, - info: info - }); - } catch (e) { - spinner.fail(` ${file.name.padEnd(26)}${colored('[X]', 'red')} Invalid JSON`); - this.results.addCheck( - file.name, - 'error', - `Invalid JSON: ${e.message}`, - `Backup and recreate: mv ${file.path} ${file.path}.backup && npm install -g @kaitranntt/ccs --force`, - { status: 'ERROR', info: 'Invalid JSON' } - ); - } - } - } - - /** - * Check 4: Claude settings - */ - checkClaudeSettings() { - const spinner = ora('Checking ~/.claude/settings.json').start(); - const settingsPath = path.join(this.claudeDir, 'settings.json'); - - if (!fs.existsSync(settingsPath)) { - spinner.warn(` ${'~/.claude/settings.json'.padEnd(26)}${colored('[!]', 'yellow')} Not found`); - this.results.addCheck( - 'Claude Settings', - 'warning', - '~/.claude/settings.json not found', - 'Run: claude /login' - ); - return; - } - - // Validate JSON - try { - const content = fs.readFileSync(settingsPath, 'utf8'); - JSON.parse(content); - spinner.succeed(` ${'~/.claude/settings.json'.padEnd(26)}${colored('[OK]', 'green')}`); - this.results.addCheck('Claude Settings', 'success'); - } catch (e) { - spinner.warn(` ${'~/.claude/settings.json'.padEnd(26)}${colored('[!]', 'yellow')} Invalid JSON`); - this.results.addCheck( - 'Claude Settings', - 'warning', - `Invalid JSON: ${e.message}`, - 'Run: claude /login' - ); - } - } - - /** - * Check 5: Profile configurations - */ - checkProfiles() { - const spinner = ora('Checking profiles').start(); - const configPath = path.join(this.ccsDir, 'config.json'); - - if (!fs.existsSync(configPath)) { - spinner.info(` ${'Profiles'.padEnd(26)}${colored('[SKIP]', 'cyan')} config.json not found`); - return; - } - - try { - const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); - - if (!config.profiles || typeof config.profiles !== 'object') { - spinner.fail(` ${'Profiles'.padEnd(26)}${colored('[X]', 'red')} Missing profiles object`); - this.results.addCheck( - 'Profiles', - 'error', - 'config.json missing profiles object', - 'Run: npm install -g @kaitranntt/ccs --force', - { status: 'ERROR', info: 'Missing profiles object' } - ); - return; - } - - const profileCount = Object.keys(config.profiles).length; - const profileNames = Object.keys(config.profiles).join(', '); - - spinner.succeed(` ${'Profiles'.padEnd(26)}${colored('[OK]', 'green')} ${profileCount} configured (${profileNames})`); - this.results.addCheck('Profiles', 'success', `${profileCount} profiles configured`, null, { - status: 'OK', - info: `${profileCount} configured (${profileNames.length > 30 ? profileNames.substring(0, 27) + '...' : profileNames})` - }); - } catch (e) { - spinner.fail(` ${'Profiles'.padEnd(26)}${colored('[X]', 'red')} ${e.message}`); - this.results.addCheck('Profiles', 'error', e.message, null, { - status: 'ERROR', - info: e.message - }); - } - } - - /** - * Check 6: Instance directories (account-based profiles) - */ - checkInstances() { - const spinner = ora('Checking instances').start(); - const instancesDir = path.join(this.ccsDir, 'instances'); - - if (!fs.existsSync(instancesDir)) { - spinner.info(` ${'Instances'.padEnd(26)}${colored('[i]', 'cyan')} No account profiles`); - this.results.addCheck('Instances', 'success', 'No account profiles configured'); - return; - } - - const instances = fs.readdirSync(instancesDir).filter(name => { - return fs.statSync(path.join(instancesDir, name)).isDirectory(); - }); - - if (instances.length === 0) { - spinner.info(` ${'Instances'.padEnd(26)}${colored('[i]', 'cyan')} No account profiles`); - this.results.addCheck('Instances', 'success', 'No account profiles'); - return; - } - - spinner.succeed(` ${'Instances'.padEnd(26)}${colored('[OK]', 'green')} ${instances.length} account profiles`); - this.results.addCheck('Instances', 'success', `${instances.length} account profiles`); - } - - /** - * Check 7: Delegation system - */ - checkDelegation() { - const spinner = ora('Checking delegation').start(); - - // Check if delegation commands exist in ~/.ccs/.claude/commands/ - const ccsClaudeCommandsDir = path.join(this.ccsDir, '.claude', 'commands'); - const hasCcsCommand = fs.existsSync(path.join(ccsClaudeCommandsDir, 'ccs.md')); - const hasContinueCommand = fs.existsSync(path.join(ccsClaudeCommandsDir, 'ccs', 'continue.md')); - - if (!hasCcsCommand || !hasContinueCommand) { - spinner.warn(` ${'Delegation'.padEnd(26)}${colored('[!]', 'yellow')} Not installed`); - this.results.addCheck( - 'Delegation', - 'warning', - 'Delegation commands not found', - 'Install with: npm install -g @kaitranntt/ccs --force', - { status: 'WARN', info: 'Not installed' } - ); - return; - } - - // Check profile validity using DelegationValidator - const { DelegationValidator } = require('../utils/delegation-validator'); - const readyProfiles = []; - - for (const profile of ['glm', 'kimi']) { - const validation = DelegationValidator.validate(profile); - if (validation.valid) { - readyProfiles.push(profile); - } - } - - if (readyProfiles.length === 0) { - spinner.warn(` ${'Delegation'.padEnd(26)}${colored('[!]', 'yellow')} No profiles ready`); - this.results.addCheck( - 'Delegation', - 'warning', - 'Delegation installed but no profiles configured', - 'Configure profiles with valid API keys (not placeholders)', - { status: 'WARN', info: 'No profiles ready' } - ); - return; - } - - spinner.succeed(` ${'Delegation'.padEnd(26)}${colored('[OK]', 'green')} ${readyProfiles.length} profiles ready (${readyProfiles.join(', ')})`); - this.results.addCheck( - 'Delegation', - 'success', - `${readyProfiles.length} profile(s) ready: ${readyProfiles.join(', ')}`, - null, - { status: 'OK', info: `${readyProfiles.length} profiles ready` } - ); - } - - /** - * Check 8: File permissions - */ - checkPermissions() { - const spinner = ora('Checking permissions').start(); - const testFile = path.join(this.ccsDir, '.permission-test'); - - try { - fs.writeFileSync(testFile, 'test', 'utf8'); - fs.unlinkSync(testFile); - spinner.succeed(` ${'Permissions'.padEnd(26)}${colored('[OK]', 'green')} Write access verified`); - this.results.addCheck('Permissions', 'success', null, null, { - status: 'OK', - info: 'Write access verified' - }); - } catch (e) { - spinner.fail(` ${'Permissions'.padEnd(26)}${colored('[X]', 'red')} Cannot write to ~/.ccs/`); - this.results.addCheck( - 'Permissions', - 'error', - 'Cannot write to ~/.ccs/', - 'Fix: sudo chown -R $USER ~/.ccs ~/.claude && chmod 755 ~/.ccs ~/.claude', - { status: 'ERROR', info: 'Cannot write to ~/.ccs/' } - ); - } - } - - /** - * Check 9: CCS symlinks to ~/.claude/ - */ - checkCcsSymlinks() { - const spinner = ora('Checking CCS symlinks').start(); - - try { - const ClaudeSymlinkManager = require('../utils/claude-symlink-manager'); - const manager = new ClaudeSymlinkManager(); - const health = manager.checkHealth(); - - if (health.healthy) { - const itemCount = manager.ccsItems.length; - spinner.succeed(` ${'CCS Symlinks'.padEnd(26)}${colored('[OK]', 'green')} ${itemCount}/${itemCount} items linked`); - this.results.addCheck('CCS Symlinks', 'success', 'All CCS items properly symlinked', null, { - status: 'OK', - info: `${itemCount}/${itemCount} items synced` - }); - } else { - spinner.warn(` ${'CCS Symlinks'.padEnd(26)}${colored('[!]', 'yellow')} ${health.issues.length} issues found`); - this.results.addCheck( - 'CCS Symlinks', - 'warning', - health.issues.join(', '), - 'Run: ccs sync', - { status: 'WARN', info: `${health.issues.length} issues` } - ); - } - } catch (e) { - spinner.warn(` ${'CCS Symlinks'.padEnd(26)}${colored('[!]', 'yellow')} Could not check`); - this.results.addCheck( - 'CCS Symlinks', - 'warning', - 'Could not check CCS symlinks: ' + e.message, - 'Run: ccs sync', - { status: 'WARN', info: 'Could not check' } - ); - } - } - - /** - * Check 10: settings.json symlinks - */ - checkSettingsSymlinks() { - const spinner = ora('Checking settings.json symlinks').start(); - - try { - const sharedDir = path.join(this.homedir, '.ccs', 'shared'); - const sharedSettings = path.join(sharedDir, 'settings.json'); - const claudeSettings = path.join(this.claudeDir, 'settings.json'); - - // Check shared settings exists and points to ~/.claude/ - if (!fs.existsSync(sharedSettings)) { - spinner.warn(` ${'settings.json (shared)'.padEnd(26)}${colored('[!]', 'yellow')} Not found`); - this.results.addCheck( - 'Settings Symlinks', - 'warning', - 'Shared settings.json not found', - 'Run: ccs sync' - ); - return; - } - - const sharedStats = fs.lstatSync(sharedSettings); - if (!sharedStats.isSymbolicLink()) { - spinner.warn(` ${'settings.json (shared)'.padEnd(26)}${colored('[!]', 'yellow')} Not a symlink`); - this.results.addCheck( - 'Settings Symlinks', - 'warning', - 'Shared settings.json is not a symlink', - 'Run: ccs sync' - ); - return; - } - - const sharedTarget = fs.readlinkSync(sharedSettings); - const resolvedShared = path.resolve(path.dirname(sharedSettings), sharedTarget); - - if (resolvedShared !== claudeSettings) { - spinner.warn(` ${'settings.json (shared)'.padEnd(26)}${colored('[!]', 'yellow')} Wrong target`); - this.results.addCheck( - 'Settings Symlinks', - 'warning', - `Points to ${resolvedShared} instead of ${claudeSettings}`, - 'Run: ccs sync' - ); - return; - } - - // Check each instance - const instancesDir = path.join(this.ccsDir, 'instances'); - if (!fs.existsSync(instancesDir)) { - spinner.succeed(` ${'settings.json'.padEnd(26)}${colored('[OK]', 'green')} Shared symlink valid`); - this.results.addCheck('Settings Symlinks', 'success', 'Shared symlink valid', null, { - status: 'OK', - info: 'Shared symlink valid' - }); - return; - } - - const instances = fs.readdirSync(instancesDir).filter(name => { - return fs.statSync(path.join(instancesDir, name)).isDirectory(); - }); - - let broken = 0; - for (const instance of instances) { - const instancePath = path.join(instancesDir, instance); - const instanceSettings = path.join(instancePath, 'settings.json'); - - if (!fs.existsSync(instanceSettings)) { - broken++; - continue; - } - - try { - const stats = fs.lstatSync(instanceSettings); - if (!stats.isSymbolicLink()) { - broken++; - continue; - } - - const target = fs.readlinkSync(instanceSettings); - const resolved = path.resolve(path.dirname(instanceSettings), target); - - if (resolved !== sharedSettings) { - broken++; - } - } catch (err) { - broken++; - } - } - - if (broken > 0) { - spinner.warn(` ${'settings.json'.padEnd(26)}${colored('[!]', 'yellow')} ${broken} broken instance(s)`); - this.results.addCheck( - 'Settings Symlinks', - 'warning', - `${broken} instance(s) have broken symlinks`, - 'Run: ccs sync', - { status: 'WARN', info: `${broken} broken instance(s)` } - ); - } else { - spinner.succeed(` ${'settings.json'.padEnd(26)}${colored('[OK]', 'green')} ${instances.length} instance(s) valid`); - this.results.addCheck('Settings Symlinks', 'success', 'All instance symlinks valid', null, { - status: 'OK', - info: `${instances.length} instance(s) valid` - }); - } - - } catch (err) { - spinner.warn(` ${'settings.json'.padEnd(26)}${colored('[!]', 'yellow')} Check failed`); - this.results.addCheck( - 'Settings Symlinks', - 'warning', - `Failed to check: ${err.message}`, - 'Run: ccs sync', - { status: 'WARN', info: 'Check failed' } - ); - } - } - - /** - * Show health check report - */ - showReport() { - console.log(''); - - // Calculate exact table width to match header bars - // colWidths: [20, 10, 35] = 65 + borders (4) = 69 total - const tableWidth = 69; - const headerBar = '═'.repeat(tableWidth); - - console.log(colored(headerBar, 'cyan')); - console.log(colored(' Health Check Summary', 'bold')); - console.log(colored(headerBar, 'cyan')); - - // Create summary table with detailed information - const table = new Table({ - head: [colored('Component', 'cyan'), colored('Status', 'cyan'), colored('Details', 'cyan')], - colWidths: [20, 10, 35], - wordWrap: true, - chars: { - 'top': '═', 'top-mid': '╤', 'top-left': '╔', 'top-right': '╗', - 'bottom': '═', 'bottom-mid': '╧', 'bottom-left': '╚', 'bottom-right': '╝', - 'left': '║', 'left-mid': '╟', 'mid': '─', 'mid-mid': '┼', - 'right': '║', 'right-mid': '╢', 'middle': '│' - } - }); - - // Populate table with collected details - for (const [component, detail] of Object.entries(this.results.details)) { - const statusColor = detail.status === 'OK' ? 'green' : detail.status === 'ERROR' ? 'red' : 'yellow'; - table.push([ - component, - colored(detail.status, statusColor), - detail.info || '' - ]); - } - - console.log(table.toString()); - console.log(''); - - // Show errors and warnings if present - if (this.results.hasErrors()) { - console.log(colored('Errors:', 'red')); - this.results.errors.forEach(err => { - console.log(` [X] ${err.name}: ${err.message}`); - if (err.fix) { - console.log(` Fix: ${err.fix}`); - } - }); - console.log(''); - } - - if (this.results.hasWarnings()) { - console.log(colored('Warnings:', 'yellow')); - this.results.warnings.forEach(warn => { - console.log(` [!] ${warn.name}: ${warn.message}`); - if (warn.fix) { - console.log(` Fix: ${warn.fix}`); - } - }); - console.log(''); - } - - // Final status - if (this.results.isHealthy() && !this.results.hasWarnings()) { - console.log(colored('[OK] All checks passed! Your CCS installation is healthy.', 'green')); - } else if (this.results.hasErrors()) { - console.log(colored('[X] Status: Installation has errors', 'red')); - console.log('Run suggested fixes above to resolve issues.'); - } else { - console.log(colored('[OK] Status: Installation healthy (warnings only)', 'green')); - } - - console.log(''); - } - - /** - * Generate JSON report - */ - generateJsonReport() { - return JSON.stringify({ - timestamp: new Date().toISOString(), - platform: process.platform, - nodeVersion: process.version, - ccsVersion: require('../package.json').version, - checks: this.results.checks, - errors: this.results.errors, - warnings: this.results.warnings, - healthy: this.results.isHealthy() - }, null, 2); - } -} - -module.exports = Doctor; \ No newline at end of file diff --git a/bin/management/instance-manager.js b/bin/management/instance-manager.js deleted file mode 100644 index f2b57d58..00000000 --- a/bin/management/instance-manager.js +++ /dev/null @@ -1,202 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const SharedManager = require('./shared-manager'); - -/** - * Instance Manager (Simplified) - * - * Manages isolated Claude CLI instances per profile for concurrent sessions. - * Each instance is an isolated CLAUDE_CONFIG_DIR where users login directly. - * No credential copying/encryption - Claude manages credentials per instance. - */ -class InstanceManager { - constructor() { - this.instancesDir = path.join(os.homedir(), '.ccs', 'instances'); - this.sharedManager = new SharedManager(); - } - - /** - * Ensure instance exists for profile (lazy init only) - * @param {string} profileName - Profile name - * @returns {string} Instance path - */ - ensureInstance(profileName) { - const instancePath = this.getInstancePath(profileName); - - // Lazy initialization - if (!fs.existsSync(instancePath)) { - this.initializeInstance(profileName, instancePath); - } - - // Validate structure (auto-fix missing dirs) - this.validateInstance(instancePath); - - return instancePath; - } - - /** - * Get instance path for profile - * @param {string} profileName - Profile name - * @returns {string} Instance directory path - */ - getInstancePath(profileName) { - const safeName = this._sanitizeName(profileName); - return path.join(this.instancesDir, safeName); - } - - /** - * Initialize new instance directory - * @param {string} profileName - Profile name - * @param {string} instancePath - Instance directory path - * @throws {Error} If initialization fails - */ - initializeInstance(profileName, instancePath) { - try { - // Create base directory - fs.mkdirSync(instancePath, { recursive: true, mode: 0o700 }); - - // Create Claude-expected subdirectories (profile-specific only) - const subdirs = [ - 'session-env', - 'todos', - 'logs', - 'file-history', - 'shell-snapshots', - 'debug', - '.anthropic' - ]; - - subdirs.forEach(dir => { - const dirPath = path.join(instancePath, dir); - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 }); - } - }); - - // Symlink shared directories (Phase 1: commands, skills) - this.sharedManager.linkSharedDirectories(instancePath); - - // Copy global configs if exist (settings.json only) - this._copyGlobalConfigs(instancePath); - } catch (error) { - throw new Error(`Failed to initialize instance for ${profileName}: ${error.message}`); - } - } - - /** - * Validate instance directory structure (auto-fix missing directories) - * @param {string} instancePath - Instance path - */ - validateInstance(instancePath) { - // Check required directories (auto-create if missing for migration) - const requiredDirs = [ - 'session-env', - 'todos', - 'logs', - 'file-history', - 'shell-snapshots', - 'debug', - '.anthropic' - ]; - - for (const dir of requiredDirs) { - const dirPath = path.join(instancePath, dir); - if (!fs.existsSync(dirPath)) { - // Auto-create missing directory (migration from older versions) - fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 }); - } - } - - // Note: Credentials managed by Claude CLI in instance (no validation needed) - } - - /** - * Delete instance for profile - * @param {string} profileName - Profile name - */ - deleteInstance(profileName) { - const instancePath = this.getInstancePath(profileName); - - if (!fs.existsSync(instancePath)) { - return; - } - - // Recursive delete - fs.rmSync(instancePath, { recursive: true, force: true }); - } - - /** - * List all instance names - * @returns {Array} Instance names - */ - listInstances() { - if (!fs.existsSync(this.instancesDir)) { - return []; - } - - return fs.readdirSync(this.instancesDir) - .filter(name => { - const instancePath = path.join(this.instancesDir, name); - return fs.statSync(instancePath).isDirectory(); - }); - } - - /** - * Check if instance exists for profile - * @param {string} profileName - Profile name - * @returns {boolean} True if exists - */ - hasInstance(profileName) { - const instancePath = this.getInstancePath(profileName); - return fs.existsSync(instancePath); - } - - /** - * Copy global configs to instance (optional) - * @param {string} instancePath - Instance path - */ - _copyGlobalConfigs(instancePath) { - // No longer needed - settings.json now symlinked via SharedManager - // Keeping method for backward compatibility (empty implementation) - // Can be removed in future major version - } - - /** - * Copy directory recursively - * @param {string} src - Source directory - * @param {string} dest - Destination directory - */ - _copyDirectory(src, dest) { - if (!fs.existsSync(dest)) { - fs.mkdirSync(dest, { recursive: true, mode: 0o700 }); - } - - const entries = fs.readdirSync(src, { withFileTypes: true }); - - for (const entry of entries) { - const srcPath = path.join(src, entry.name); - const destPath = path.join(dest, entry.name); - - if (entry.isDirectory()) { - this._copyDirectory(srcPath, destPath); - } else { - fs.copyFileSync(srcPath, destPath); - } - } - } - - /** - * Sanitize profile name for filesystem - * @param {string} name - Profile name - * @returns {string} Safe name - */ - _sanitizeName(name) { - // Replace unsafe characters with dash - return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); - } -} - -module.exports = InstanceManager; diff --git a/bin/management/recovery-manager.js b/bin/management/recovery-manager.js deleted file mode 100644 index a5f4cd8f..00000000 --- a/bin/management/recovery-manager.js +++ /dev/null @@ -1,135 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -/** - * Auto-recovery for missing or corrupted configuration - */ -class RecoveryManager { - constructor() { - this.homedir = os.homedir(); - this.ccsDir = path.join(this.homedir, '.ccs'); - this.claudeDir = path.join(this.homedir, '.claude'); - this.recovered = []; - } - - /** - * Ensure ~/.ccs/ directory exists - */ - ensureCcsDirectory() { - if (!fs.existsSync(this.ccsDir)) { - fs.mkdirSync(this.ccsDir, { recursive: true, mode: 0o755 }); - this.recovered.push('Created ~/.ccs/ directory'); - return true; - } - return false; - } - - /** - * Ensure ~/.ccs/config.json exists with defaults - */ - ensureConfigJson() { - const configPath = path.join(this.ccsDir, 'config.json'); - - // Check if exists and valid - if (fs.existsSync(configPath)) { - try { - const content = fs.readFileSync(configPath, 'utf8'); - JSON.parse(content); // Validate JSON - return false; // No recovery needed - } catch (e) { - // Corrupted - backup and recreate - const backupPath = `${configPath}.backup.${Date.now()}`; - fs.renameSync(configPath, backupPath); - this.recovered.push(`Backed up corrupted config.json to ${path.basename(backupPath)}`); - } - } - - // Create default config - const defaultConfig = { - profiles: { - glm: '~/.ccs/glm.settings.json', - kimi: '~/.ccs/kimi.settings.json', - default: '~/.claude/settings.json' - } - }; - - const tmpPath = `${configPath}.tmp`; - fs.writeFileSync(tmpPath, JSON.stringify(defaultConfig, null, 2) + '\n', 'utf8'); - fs.renameSync(tmpPath, configPath); - - this.recovered.push('Created ~/.ccs/config.json'); - return true; - } - - /** - * Ensure ~/.claude/settings.json exists - */ - ensureClaudeSettings() { - const claudeSettingsPath = path.join(this.claudeDir, 'settings.json'); - - // Create ~/.claude/ if missing - if (!fs.existsSync(this.claudeDir)) { - fs.mkdirSync(this.claudeDir, { recursive: true, mode: 0o755 }); - this.recovered.push('Created ~/.claude/ directory'); - } - - // Create settings.json if missing - if (!fs.existsSync(claudeSettingsPath)) { - const tmpPath = `${claudeSettingsPath}.tmp`; - fs.writeFileSync(tmpPath, '{}\n', 'utf8'); - fs.renameSync(tmpPath, claudeSettingsPath); - - this.recovered.push('Created ~/.claude/settings.json'); - return true; - } - - return false; - } - - /** - * Run all recovery operations - * @returns {boolean} True if any recovery performed - */ - recoverAll() { - this.recovered = []; - - this.ensureCcsDirectory(); - this.ensureConfigJson(); - this.ensureClaudeSettings(); - - return this.recovered.length > 0; - } - - /** - * Get recovery summary - * @returns {string[]} List of recovery actions performed - */ - getRecoverySummary() { - return this.recovered; - } - - /** - * Show recovery hints - */ - showRecoveryHints() { - if (this.recovered.length === 0) return; - - console.log(''); - console.log('[i] Auto-recovery completed:'); - this.recovered.forEach(msg => console.log(` - ${msg}`)); - - // Show login hint if created Claude settings - if (this.recovered.some(msg => msg.includes('settings.json'))) { - console.log(''); - console.log('[i] Next step: Login to Claude CLI'); - console.log(' Run: claude /login'); - } - - console.log(''); - } -} - -module.exports = RecoveryManager; \ No newline at end of file diff --git a/bin/management/shared-manager.js b/bin/management/shared-manager.js deleted file mode 100644 index b5481cb3..00000000 --- a/bin/management/shared-manager.js +++ /dev/null @@ -1,402 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -/** - * SharedManager - Manages symlinked shared directories for CCS - * v3.2.0: Symlink-based architecture - * - * Purpose: Eliminates duplication by symlinking: - * ~/.claude/ ← ~/.ccs/shared/ ← instance/ - */ -class SharedManager { - constructor() { - this.homeDir = os.homedir(); - this.sharedDir = path.join(this.homeDir, '.ccs', 'shared'); - this.claudeDir = path.join(this.homeDir, '.claude'); - this.instancesDir = path.join(this.homeDir, '.ccs', 'instances'); - this.sharedItems = [ - { name: 'commands', type: 'directory' }, - { name: 'skills', type: 'directory' }, - { name: 'agents', type: 'directory' }, - { name: 'plugins', type: 'directory' }, - { name: 'settings.json', type: 'file' } - ]; - } - - /** - * Detect circular symlink before creation - * @param {string} target - Target path to link to - * @param {string} linkPath - Path where symlink will be created - * @returns {boolean} True if circular - * @private - */ - _detectCircularSymlink(target, linkPath) { - // Check if target exists and is symlink - if (!fs.existsSync(target)) { - return false; - } - - try { - const stats = fs.lstatSync(target); - if (!stats.isSymbolicLink()) { - return false; - } - - // Resolve target's link - const targetLink = fs.readlinkSync(target); - const resolvedTarget = path.resolve(path.dirname(target), targetLink); - - // Check if target points back to our shared dir or link path - const sharedDir = path.join(this.homeDir, '.ccs', 'shared'); - if (resolvedTarget.startsWith(sharedDir) || resolvedTarget === linkPath) { - console.log(`[!] Circular symlink detected: ${target} → ${resolvedTarget}`); - return true; - } - } catch (err) { - // If can't read, assume not circular - return false; - } - - return false; - } - - /** - * Ensure shared directories exist as symlinks to ~/.claude/ - * Creates ~/.claude/ structure if missing - */ - ensureSharedDirectories() { - // Create ~/.claude/ if missing - if (!fs.existsSync(this.claudeDir)) { - console.log('[i] Creating ~/.claude/ directory structure'); - fs.mkdirSync(this.claudeDir, { recursive: true, mode: 0o700 }); - } - - // Create shared directory - if (!fs.existsSync(this.sharedDir)) { - fs.mkdirSync(this.sharedDir, { recursive: true, mode: 0o700 }); - } - - // Create symlinks ~/.ccs/shared/* → ~/.claude/* - for (const item of this.sharedItems) { - const claudePath = path.join(this.claudeDir, item.name); - const sharedPath = path.join(this.sharedDir, item.name); - - // Create in ~/.claude/ if missing - if (!fs.existsSync(claudePath)) { - if (item.type === 'directory') { - fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 }); - } else if (item.type === 'file') { - // Create empty settings.json if missing - fs.writeFileSync(claudePath, JSON.stringify({}, null, 2), 'utf8'); - } - } - - // Check for circular symlink - if (this._detectCircularSymlink(claudePath, sharedPath)) { - console.log(`[!] Skipping ${item.name}: circular symlink detected`); - continue; - } - - // If already a symlink pointing to correct target, skip - if (fs.existsSync(sharedPath)) { - try { - const stats = fs.lstatSync(sharedPath); - if (stats.isSymbolicLink()) { - const currentTarget = fs.readlinkSync(sharedPath); - const resolvedTarget = path.resolve(path.dirname(sharedPath), currentTarget); - if (resolvedTarget === claudePath) { - continue; // Already correct - } - } - } catch (err) { - // Continue to recreate - } - - // Remove existing file/directory/link - if (item.type === 'directory') { - fs.rmSync(sharedPath, { recursive: true, force: true }); - } else { - fs.unlinkSync(sharedPath); - } - } - - // Create symlink - try { - const symlinkType = item.type === 'directory' ? 'dir' : 'file'; - fs.symlinkSync(claudePath, sharedPath, symlinkType); - } catch (err) { - // Windows fallback: copy - if (process.platform === 'win32') { - if (item.type === 'directory') { - this._copyDirectoryFallback(claudePath, sharedPath); - } else if (item.type === 'file') { - fs.copyFileSync(claudePath, sharedPath); - } - console.log(`[!] Symlink failed for ${item.name}, copied instead (enable Developer Mode)`); - } else { - throw err; - } - } - } - } - - /** - * Link shared directories to instance - * @param {string} instancePath - Path to instance directory - */ - linkSharedDirectories(instancePath) { - this.ensureSharedDirectories(); - - for (const item of this.sharedItems) { - const linkPath = path.join(instancePath, item.name); - const targetPath = path.join(this.sharedDir, item.name); - - // Remove existing file/directory/link - if (fs.existsSync(linkPath)) { - if (item.type === 'directory') { - fs.rmSync(linkPath, { recursive: true, force: true }); - } else { - fs.unlinkSync(linkPath); - } - } - - // Create symlink - try { - const symlinkType = item.type === 'directory' ? 'dir' : 'file'; - fs.symlinkSync(targetPath, linkPath, symlinkType); - } catch (err) { - // Windows fallback - if (process.platform === 'win32') { - if (item.type === 'directory') { - this._copyDirectoryFallback(targetPath, linkPath); - } else if (item.type === 'file') { - fs.copyFileSync(targetPath, linkPath); - } - console.log(`[!] Symlink failed for ${item.name}, copied instead (enable Developer Mode)`); - } else { - throw err; - } - } - } - } - - /** - * Migrate from v3.1.1 (copied data in ~/.ccs/shared/) to v3.2.0 (symlinks to ~/.claude/) - * Runs once on upgrade - */ - migrateFromV311() { - // Check if migration already done (shared dirs are symlinks) - const commandsPath = path.join(this.sharedDir, 'commands'); - if (fs.existsSync(commandsPath)) { - try { - if (fs.lstatSync(commandsPath).isSymbolicLink()) { - return; // Already migrated - } - } catch (err) { - // Continue with migration - } - } - - console.log('[i] Migrating from v3.1.1 to v3.2.0...'); - - // Ensure ~/.claude/ exists - if (!fs.existsSync(this.claudeDir)) { - fs.mkdirSync(this.claudeDir, { recursive: true, mode: 0o700 }); - } - - // Copy user modifications from ~/.ccs/shared/ to ~/.claude/ - for (const item of this.sharedItems) { - const sharedPath = path.join(this.sharedDir, item.name); - const claudePath = path.join(this.claudeDir, item.name); - - if (!fs.existsSync(sharedPath)) continue; - - try { - const stats = fs.lstatSync(sharedPath); - - // Handle directories - if (item.type === 'directory' && stats.isDirectory()) { - // Create claude dir if missing - if (!fs.existsSync(claudePath)) { - fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 }); - } - - // Copy files from shared to claude (preserve user modifications) - const entries = fs.readdirSync(sharedPath, { withFileTypes: true }); - let copied = 0; - - for (const entry of entries) { - const src = path.join(sharedPath, entry.name); - const dest = path.join(claudePath, entry.name); - - // Skip if already exists in claude - if (fs.existsSync(dest)) continue; - - if (entry.isDirectory()) { - fs.cpSync(src, dest, { recursive: true }); - } else { - fs.copyFileSync(src, dest); - } - copied++; - } - - if (copied > 0) { - console.log(`[OK] Migrated ${copied} ${item.name} to ~/.claude/${item.name}`); - } - } - - // Handle files (settings.json) - else if (item.type === 'file' && stats.isFile()) { - // Only copy if ~/.claude/ version doesn't exist - if (!fs.existsSync(claudePath)) { - fs.copyFileSync(sharedPath, claudePath); - console.log(`[OK] Migrated ${item.name} to ~/.claude/${item.name}`); - } - } - } catch (err) { - console.log(`[!] Failed to migrate ${item.name}: ${err.message}`); - } - } - - // Now run ensureSharedDirectories to create symlinks - this.ensureSharedDirectories(); - - // Update all instances to use new symlinks - if (fs.existsSync(this.instancesDir)) { - try { - const instances = fs.readdirSync(this.instancesDir); - - for (const instance of instances) { - const instancePath = path.join(this.instancesDir, instance); - try { - if (fs.statSync(instancePath).isDirectory()) { - this.linkSharedDirectories(instancePath); - } - } catch (err) { - console.log(`[!] Failed to update instance ${instance}: ${err.message}`); - } - } - } catch (err) { - // No instances to update - } - } - - console.log('[OK] Migration to v3.2.0 complete'); - } - - /** - * Migrate existing instances from isolated to shared settings.json (v4.4+) - * Runs once on upgrade - */ - migrateToSharedSettings() { - console.log('[i] Migrating instances to shared settings.json...'); - - // Ensure ~/.claude/settings.json exists (authoritative source) - const claudeSettings = path.join(this.claudeDir, 'settings.json'); - if (!fs.existsSync(claudeSettings)) { - // Create empty settings if missing - fs.writeFileSync(claudeSettings, JSON.stringify({}, null, 2), 'utf8'); - console.log('[i] Created ~/.claude/settings.json'); - } - - // Ensure shared settings.json symlink exists - this.ensureSharedDirectories(); - - // Migrate each instance - if (!fs.existsSync(this.instancesDir)) { - console.log('[i] No instances to migrate'); - return; - } - - const instances = fs.readdirSync(this.instancesDir).filter(name => { - const instancePath = path.join(this.instancesDir, name); - return fs.statSync(instancePath).isDirectory(); - }); - - let migrated = 0; - let skipped = 0; - - for (const instance of instances) { - const instancePath = path.join(this.instancesDir, instance); - const instanceSettings = path.join(instancePath, 'settings.json'); - - try { - // Check if already symlink - if (fs.existsSync(instanceSettings)) { - const stats = fs.lstatSync(instanceSettings); - if (stats.isSymbolicLink()) { - skipped++; - continue; // Already migrated - } - - // Backup existing settings - const backup = instanceSettings + '.pre-shared-migration'; - if (!fs.existsSync(backup)) { - fs.copyFileSync(instanceSettings, backup); - console.log(`[i] Backed up ${instance}/settings.json`); - } - - // Remove old settings.json - fs.unlinkSync(instanceSettings); - } - - // Create symlink via SharedManager - const sharedSettings = path.join(this.sharedDir, 'settings.json'); - - try { - fs.symlinkSync(sharedSettings, instanceSettings, 'file'); - migrated++; - } catch (err) { - // Windows fallback - if (process.platform === 'win32') { - fs.copyFileSync(sharedSettings, instanceSettings); - console.log(`[!] Symlink failed for ${instance}, copied instead`); - migrated++; - } else { - throw err; - } - } - - } catch (err) { - console.log(`[!] Failed to migrate ${instance}: ${err.message}`); - } - } - - console.log(`[OK] Migrated ${migrated} instance(s), skipped ${skipped}`); - } - - /** - * Copy directory as fallback (Windows without Developer Mode) - * @param {string} src - Source directory - * @param {string} dest - Destination directory - * @private - */ - _copyDirectoryFallback(src, dest) { - if (!fs.existsSync(src)) { - fs.mkdirSync(src, { recursive: true, mode: 0o700 }); - return; - } - - if (!fs.existsSync(dest)) { - fs.mkdirSync(dest, { recursive: true, mode: 0o700 }); - } - - const entries = fs.readdirSync(src, { withFileTypes: true }); - - for (const entry of entries) { - const srcPath = path.join(src, entry.name); - const destPath = path.join(dest, entry.name); - - if (entry.isDirectory()) { - this._copyDirectoryFallback(srcPath, destPath); - } else { - fs.copyFileSync(srcPath, destPath); - } - } - } -} - -module.exports = SharedManager; diff --git a/bin/utils/claude-detector.js b/bin/utils/claude-detector.js deleted file mode 100644 index e0d59f53..00000000 --- a/bin/utils/claude-detector.js +++ /dev/null @@ -1,73 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const { execSync } = require('child_process'); -const { expandPath } = require('./helpers'); - -// Detect Claude CLI executable -function detectClaudeCli() { - // Priority 1: CCS_CLAUDE_PATH environment variable (if user wants custom path) - if (process.env.CCS_CLAUDE_PATH) { - const ccsPath = expandPath(process.env.CCS_CLAUDE_PATH); - // Basic validation: file exists - if (fs.existsSync(ccsPath)) { - return ccsPath; - } - // Invalid CCS_CLAUDE_PATH - show warning and fall back to PATH - console.warn('[!] Warning: CCS_CLAUDE_PATH is set but file not found:', ccsPath); - console.warn(' Falling back to system PATH lookup...'); - } - - // Priority 2: Resolve 'claude' from PATH using which/where.exe - // This fixes Windows npm installation where spawn() can't resolve bare command names - // SECURITY: Commands are hardcoded literals with no user input - safe from injection - const isWindows = process.platform === 'win32'; - - try { - const cmd = isWindows ? 'where.exe claude' : 'which claude'; - const result = execSync(cmd, { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - timeout: 5000 // 5 second timeout to prevent hangs - }).trim(); - - // where.exe may return multiple lines (all matches in PATH order) - const matches = result.split('\n').map(p => p.trim()).filter(p => p); - - if (isWindows) { - // On Windows, prefer executables with extensions (.exe, .cmd, .bat) - // where.exe often returns file without extension first, then the actual .cmd wrapper - const withExtension = matches.find(p => /\.(exe|cmd|bat|ps1)$/i.test(p)); - const claudePath = withExtension || matches[0]; - - if (claudePath && fs.existsSync(claudePath)) { - return claudePath; - } - } else { - // On Unix, first match is fine - const claudePath = matches[0]; - - if (claudePath && fs.existsSync(claudePath)) { - return claudePath; - } - } - } catch (err) { - // Command failed - claude not in PATH - // Fall through to return null - } - - // Priority 3: Claude not found - return null; -} - -// Show Claude not found error -function showClaudeNotFoundError() { - console.error('ERROR: Claude CLI not found in PATH'); - console.error('Install from: https://docs.claude.com/en/docs/claude-code/installation'); - process.exit(1); -} - -module.exports = { - detectClaudeCli, - showClaudeNotFoundError -}; \ No newline at end of file diff --git a/bin/utils/claude-dir-installer.js b/bin/utils/claude-dir-installer.js deleted file mode 100644 index 51ddb4b9..00000000 --- a/bin/utils/claude-dir-installer.js +++ /dev/null @@ -1,283 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -// Make ora optional (might not be available during npm install postinstall) -// ora v9+ is an ES module, need to use .default for CommonJS -let ora = null; -try { - const oraModule = require('ora'); - ora = oraModule.default || oraModule; -} catch (e) { - // ora not available, create fallback spinner that uses console.log - ora = function(text) { - return { - start: () => ({ - succeed: (msg) => console.log(msg || `[OK] ${text}`), - fail: (msg) => console.log(msg || `[X] ${text}`), - warn: (msg) => console.log(msg || `[!] ${text}`), - info: (msg) => console.log(msg || `[i] ${text}`), - text: '' - }) - }; - }; -} - -const { colored } = require('./helpers'); - -/** - * ClaudeDirInstaller - Manages copying .claude/ directory from package to ~/.ccs/.claude/ - * v4.1.1: Fix for npm install not copying .claude/ directory - */ -class ClaudeDirInstaller { - constructor() { - this.homeDir = os.homedir(); - this.ccsClaudeDir = path.join(this.homeDir, '.ccs', '.claude'); - } - - /** - * Copy .claude/ directory from package to ~/.ccs/.claude/ - * @param {string} packageDir - Package installation directory (default: auto-detect) - * @param {boolean} silent - Suppress spinner output - */ - install(packageDir, silent = false) { - const spinner = (silent || !ora) ? null : ora('Copying .claude/ items to ~/.ccs/.claude/').start(); - - try { - // Auto-detect package directory if not provided - if (!packageDir) { - // Try to find package root by going up from this file - packageDir = path.join(__dirname, '..', '..'); - } - - const packageClaudeDir = path.join(packageDir, '.claude'); - - if (!fs.existsSync(packageClaudeDir)) { - const msg = 'Package .claude/ directory not found'; - if (spinner) { - spinner.warn(`[!] ${msg}`); - console.log(` Searched in: ${packageClaudeDir}`); - console.log(' This may be a development installation'); - } else { - console.log(`[!] ${msg}`); - console.log(` Searched in: ${packageClaudeDir}`); - console.log(' This may be a development installation'); - } - return false; - } - - // Remove old version before copying new one - if (fs.existsSync(this.ccsClaudeDir)) { - if (spinner) spinner.text = 'Removing old .claude/ items...'; - fs.rmSync(this.ccsClaudeDir, { recursive: true, force: true }); - } - - // Use fs.cpSync for recursive copy (Node.js 16.7.0+) - // Fallback to manual copy for older Node.js versions - if (spinner) spinner.text = 'Copying .claude/ items...'; - - if (fs.cpSync) { - fs.cpSync(packageClaudeDir, this.ccsClaudeDir, { recursive: true }); - } else { - // Fallback for Node.js < 16.7.0 - this._copyDirRecursive(packageClaudeDir, this.ccsClaudeDir); - } - - // Count files and directories - const itemCount = this._countItems(this.ccsClaudeDir); - const msg = `Copied .claude/ items (${itemCount.files} files, ${itemCount.dirs} directories)`; - - if (spinner) { - spinner.succeed(colored('[OK]', 'green') + ` ${msg}`); - } else { - console.log(`[OK] ${msg}`); - } - return true; - } catch (err) { - const msg = `Failed to copy .claude/ directory: ${err.message}`; - if (spinner) { - spinner.fail(colored('[!]', 'yellow') + ` ${msg}`); - console.warn(' CCS items may not be available'); - } else { - console.warn(`[!] ${msg}`); - console.warn(' CCS items may not be available'); - } - return false; - } - } - - /** - * Recursively copy directory (fallback for Node.js < 16.7.0) - * @param {string} src - Source directory - * @param {string} dest - Destination directory - * @private - */ - _copyDirRecursive(src, dest) { - // Create destination directory - if (!fs.existsSync(dest)) { - fs.mkdirSync(dest, { recursive: true }); - } - - // Read source directory - const entries = fs.readdirSync(src, { withFileTypes: true }); - - for (const entry of entries) { - const srcPath = path.join(src, entry.name); - const destPath = path.join(dest, entry.name); - - if (entry.isDirectory()) { - // Recursively copy subdirectory - this._copyDirRecursive(srcPath, destPath); - } else { - // Copy file - fs.copyFileSync(srcPath, destPath); - } - } - } - - /** - * Count files and directories in a path - * @param {string} dirPath - Directory to count - * @returns {Object} { files: number, dirs: number } - * @private - */ - _countItems(dirPath) { - let files = 0; - let dirs = 0; - - const countRecursive = (p) => { - const entries = fs.readdirSync(p, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory()) { - dirs++; - countRecursive(path.join(p, entry.name)); - } else { - files++; - } - } - }; - - try { - countRecursive(dirPath); - } catch (e) { - // Ignore errors - } - - return { files, dirs }; - } - - /** - * Clean up deprecated files from previous installations - * Removes ccs-delegator.md that was deprecated in v4.3.2 - * @param {boolean} silent - Suppress console output - */ - cleanupDeprecated(silent = false) { - const deprecatedFile = path.join(this.ccsClaudeDir, 'agents', 'ccs-delegator.md'); - const userSymlinkFile = path.join(this.homeDir, '.claude', 'agents', 'ccs-delegator.md'); - const migrationMarker = path.join(this.homeDir, '.ccs', '.migrations', 'v435-delegator-cleanup'); - - let cleanedFiles = []; - - try { - // Check if cleanup already done - if (fs.existsSync(migrationMarker)) { - return { success: true, cleanedFiles: [] }; // Already cleaned - } - - // Clean up user symlink in ~/.claude/agents/ccs-delegator.md FIRST - // This ensures we can detect broken symlinks before deleting the target - try { - const userStats = fs.lstatSync(userSymlinkFile); - if (userStats.isSymbolicLink()) { - fs.unlinkSync(userSymlinkFile); - cleanedFiles.push('user symlink'); - } else { - // It's not a symlink (user created their own file), backup it - const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('T')[0]; - const backupPath = `${userSymlinkFile}.backup-${timestamp}`; - fs.renameSync(userSymlinkFile, backupPath); - if (!silent) console.log(`[i] Backed up user file to ${path.basename(backupPath)}`); - cleanedFiles.push('user file (backed up)'); - } - } catch (err) { - // File doesn't exist or other error - that's okay - if (err.code !== 'ENOENT' && !silent) { - console.log(`[!] Failed to remove user symlink: ${err.message}`); - } - } - - // Clean up package copy in ~/.ccs/.claude/agents/ccs-delegator.md - if (fs.existsSync(deprecatedFile)) { - try { - // Check if file was modified by user (compare with expected content) - const shouldBackup = this._shouldBackupDeprecatedFile(deprecatedFile); - - if (shouldBackup) { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('T')[0]; - const backupPath = `${deprecatedFile}.backup-${timestamp}`; - fs.renameSync(deprecatedFile, backupPath); - if (!silent) console.log(`[i] Backed up modified deprecated file to ${path.basename(backupPath)}`); - } else { - fs.rmSync(deprecatedFile, { force: true }); - } - cleanedFiles.push('package copy'); - } catch (err) { - if (!silent) console.log(`[!] Failed to remove package copy: ${err.message}`); - } - } - - // Create migration marker - if (cleanedFiles.length > 0) { - const migrationsDir = path.dirname(migrationMarker); - if (!fs.existsSync(migrationsDir)) { - fs.mkdirSync(migrationsDir, { recursive: true, mode: 0o700 }); - } - fs.writeFileSync(migrationMarker, new Date().toISOString()); - - if (!silent) { - console.log(`[OK] Cleaned up deprecated agent files: ${cleanedFiles.join(', ')}`); - } - } - - return { success: true, cleanedFiles }; - } catch (err) { - if (!silent) console.log(`[!] Cleanup failed: ${err.message}`); - return { success: false, error: err.message, cleanedFiles }; - } - } - - /** - * Check if deprecated file should be backed up (user modified) - * @param {string} filePath - Path to check - * @returns {boolean} True if file should be backed up - * @private - */ - _shouldBackupDeprecatedFile(filePath) { - try { - // Simple heuristic: if file size differs significantly from expected, assume user modified - // Expected size for ccs-delegator.md was around 2-3KB - const stats = fs.statSync(filePath); - const expectedMinSize = 1000; // 1KB minimum - const expectedMaxSize = 10000; // 10KB maximum - - // If size is outside expected range, likely user modified - return stats.size < expectedMinSize || stats.size > expectedMaxSize; - } catch (err) { - // If we can't determine, err on side of caution and backup - return true; - } - } - - /** - * Check if ~/.ccs/.claude/ exists and is valid - * @returns {boolean} True if directory exists - */ - isInstalled() { - return fs.existsSync(this.ccsClaudeDir); - } -} - -module.exports = ClaudeDirInstaller; diff --git a/bin/utils/claude-symlink-manager.js b/bin/utils/claude-symlink-manager.js deleted file mode 100644 index c5ffe6fc..00000000 --- a/bin/utils/claude-symlink-manager.js +++ /dev/null @@ -1,289 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -// Make ora optional (might not be available during npm install postinstall) -// ora v9+ is an ES module, need to use .default for CommonJS -let ora = null; -try { - const oraModule = require('ora'); - ora = oraModule.default || oraModule; -} catch (e) { - // ora not available, create fallback spinner that uses console.log - ora = function(text) { - return { - start: () => ({ - succeed: (msg) => console.log(msg || `[OK] ${text}`), - fail: (msg) => console.log(msg || `[X] ${text}`), - warn: (msg) => console.log(msg || `[!] ${text}`), - info: (msg) => console.log(msg || `[i] ${text}`), - text: '' - }) - }; - }; -} - -const { colored } = require('./helpers'); - -/** - * ClaudeSymlinkManager - Manages selective symlinks from ~/.ccs/.claude/ to ~/.claude/ - * v4.1.0: Selective symlinking for CCS items - * - * Purpose: Ship CCS items (.claude/) with package and symlink them to user's ~/.claude/ - * Architecture: - * - ~/.ccs/.claude/* (source, ships with CCS) - * - ~/.claude/* (target, gets selective symlinks) - * - ~/.ccs/shared/ (UNTOUCHED, existing profile mechanism) - * - * Symlink Chain: - * profile -> ~/.ccs/shared/ -> ~/.claude/ (which has symlinks to ~/.ccs/.claude/) - */ -class ClaudeSymlinkManager { - constructor() { - this.homeDir = os.homedir(); - this.ccsClaudeDir = path.join(this.homeDir, '.ccs', '.claude'); - this.userClaudeDir = path.join(this.homeDir, '.claude'); - - // CCS items to symlink (selective, item-level) - this.ccsItems = [ - { source: 'commands/ccs.md', target: 'commands/ccs.md', type: 'file' }, - { source: 'commands/ccs', target: 'commands/ccs', type: 'directory' }, - { source: 'skills/ccs-delegation', target: 'skills/ccs-delegation', type: 'directory' } - ]; - } - - /** - * Install CCS items to user's ~/.claude/ via selective symlinks - * Safe: backs up existing files before creating symlinks - */ - install(silent = false) { - const spinner = (silent || !ora) ? null : ora('Installing CCS items to ~/.claude/').start(); - - // Ensure ~/.ccs/.claude/ exists (should be shipped with package) - if (!fs.existsSync(this.ccsClaudeDir)) { - const msg = 'CCS .claude/ directory not found, skipping symlink installation'; - if (spinner) { - spinner.warn(`[!] ${msg}`); - } else { - console.log(`[!] ${msg}`); - } - return; - } - - // Create ~/.claude/ if missing - if (!fs.existsSync(this.userClaudeDir)) { - if (!silent) { - if (spinner) spinner.text = 'Creating ~/.claude/ directory'; - } - fs.mkdirSync(this.userClaudeDir, { recursive: true, mode: 0o700 }); - } - - // Install each CCS item - let installed = 0; - for (const item of this.ccsItems) { - if (!silent && spinner) { - spinner.text = `Installing ${item.target}...`; - } - const result = this._installItem(item, silent); - if (result) installed++; - } - - const msg = `${installed}/${this.ccsItems.length} items installed to ~/.claude/`; - if (spinner) { - spinner.succeed(colored('[OK]', 'green') + ` ${msg}`); - } else { - console.log(`[OK] ${msg}`); - } - } - - /** - * Install a single CCS item with conflict handling - * @param {Object} item - Item descriptor {source, target, type} - * @param {boolean} silent - Suppress individual item messages - * @returns {boolean} True if installed successfully - * @private - */ - _installItem(item, silent = false) { - const sourcePath = path.join(this.ccsClaudeDir, item.source); - const targetPath = path.join(this.userClaudeDir, item.target); - const targetDir = path.dirname(targetPath); - - // Ensure source exists - if (!fs.existsSync(sourcePath)) { - if (!silent) console.log(`[!] Source not found: ${item.source}, skipping`); - return false; - } - - // Create target parent directory if needed - if (!fs.existsSync(targetDir)) { - fs.mkdirSync(targetDir, { recursive: true, mode: 0o700 }); - } - - // Check if target already exists - if (fs.existsSync(targetPath)) { - // Check if it's already the correct symlink - if (this._isOurSymlink(targetPath, sourcePath)) { - return true; // Already correct, counts as success - } - - // Backup existing file/directory - this._backupItem(targetPath, silent); - } - - // Create symlink - try { - const symlinkType = item.type === 'directory' ? 'dir' : 'file'; - fs.symlinkSync(sourcePath, targetPath, symlinkType); - if (!silent) console.log(`[OK] Symlinked ${item.target}`); - return true; - } catch (err) { - // Windows fallback: stub for now, full implementation in v4.2 - if (process.platform === 'win32') { - if (!silent) { - console.log(`[!] Symlink failed for ${item.target} (Windows fallback deferred to v4.2)`); - console.log(`[i] Enable Developer Mode or wait for next update`); - } - } else { - if (!silent) console.log(`[!] Failed to symlink ${item.target}: ${err.message}`); - } - return false; - } - } - - /** - * Check if target is already the correct symlink pointing to source - * @param {string} targetPath - Target path to check - * @param {string} expectedSource - Expected source path - * @returns {boolean} True if target is correct symlink - * @private - */ - _isOurSymlink(targetPath, expectedSource) { - try { - const stats = fs.lstatSync(targetPath); - - if (!stats.isSymbolicLink()) { - return false; - } - - const actualTarget = fs.readlinkSync(targetPath); - const resolvedTarget = path.resolve(path.dirname(targetPath), actualTarget); - - return resolvedTarget === expectedSource; - } catch (err) { - return false; - } - } - - /** - * Backup existing item before replacing with symlink - * @param {string} itemPath - Path to item to backup - * @param {boolean} silent - Suppress backup messages - * @private - */ - _backupItem(itemPath, silent = false) { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('T')[0]; - const backupPath = `${itemPath}.backup-${timestamp}`; - - try { - // If backup already exists, use counter - let finalBackupPath = backupPath; - let counter = 1; - while (fs.existsSync(finalBackupPath)) { - finalBackupPath = `${backupPath}-${counter}`; - counter++; - } - - fs.renameSync(itemPath, finalBackupPath); - if (!silent) console.log(`[i] Backed up existing item to ${path.basename(finalBackupPath)}`); - } catch (err) { - if (!silent) console.log(`[!] Failed to backup ${itemPath}: ${err.message}`); - throw err; // Don't proceed if backup fails - } - } - - /** - * Uninstall CCS items from ~/.claude/ (remove symlinks only) - * Safe: only removes items that are CCS symlinks - */ - uninstall() { - let removed = 0; - - for (const item of this.ccsItems) { - const targetPath = path.join(this.userClaudeDir, item.target); - const sourcePath = path.join(this.ccsClaudeDir, item.source); - - // Only remove if it's our symlink - if (fs.existsSync(targetPath) && this._isOurSymlink(targetPath, sourcePath)) { - try { - fs.unlinkSync(targetPath); - console.log(`[OK] Removed ${item.target}`); - removed++; - } catch (err) { - console.log(`[!] Failed to remove ${item.target}: ${err.message}`); - } - } - } - - if (removed > 0) { - console.log(`[OK] Removed ${removed} delegation commands and skills from ~/.claude/`); - } else { - console.log('[i] No delegation commands or skills to remove'); - } - } - - /** - * Check symlink health and report issues - * Used by 'ccs doctor' command - * @returns {Object} Health check results {healthy: boolean, issues: string[]} - */ - checkHealth() { - const issues = []; - let healthy = true; - - // Check if ~/.ccs/.claude/ exists - if (!fs.existsSync(this.ccsClaudeDir)) { - issues.push('CCS .claude/ directory missing (reinstall CCS)'); - healthy = false; - return { healthy, issues }; - } - - // Check each item - for (const item of this.ccsItems) { - const sourcePath = path.join(this.ccsClaudeDir, item.source); - const targetPath = path.join(this.userClaudeDir, item.target); - - // Check source exists - if (!fs.existsSync(sourcePath)) { - issues.push(`Source missing: ${item.source}`); - healthy = false; - continue; - } - - // Check target - if (!fs.existsSync(targetPath)) { - issues.push(`Not installed: ${item.target} (run 'ccs sync' to install)`); - healthy = false; - } else if (!this._isOurSymlink(targetPath, sourcePath)) { - issues.push(`Not a CCS symlink: ${item.target} (run 'ccs sync' to fix)`); - healthy = false; - } - } - - return { healthy, issues }; - } - - /** - * Sync delegation commands and skills to ~/.claude/ (used by 'ccs sync' command) - * Same as install() but with explicit sync message - */ - sync() { - console.log(''); - console.log(colored('Syncing CCS Components...', 'cyan')); - console.log(''); - this.install(false); - } -} - -module.exports = ClaudeSymlinkManager; diff --git a/bin/utils/config-manager.js b/bin/utils/config-manager.js deleted file mode 100644 index d4371309..00000000 --- a/bin/utils/config-manager.js +++ /dev/null @@ -1,103 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { error, expandPath } = require('./helpers'); -const { ErrorManager } = require('./error-manager'); - -// Get config file path -function getConfigPath() { - return process.env.CCS_CONFIG || path.join(os.homedir(), '.ccs', 'config.json'); -} - -// Read and parse config -function readConfig() { - const configPath = getConfigPath(); - - // Check config exists - if (!fs.existsSync(configPath)) { - // Attempt recovery - const RecoveryManager = require('./recovery-manager'); - const recovery = new RecoveryManager(); - recovery.ensureConfigJson(); - - if (!fs.existsSync(configPath)) { - ErrorManager.showInvalidConfig(configPath, 'File not found'); - process.exit(1); - } - } - - // Read and parse JSON - let config; - try { - const configContent = fs.readFileSync(configPath, 'utf8'); - config = JSON.parse(configContent); - } catch (e) { - ErrorManager.showInvalidConfig(configPath, `Invalid JSON: ${e.message}`); - process.exit(1); - } - - // Validate config has profiles object - if (!config.profiles || typeof config.profiles !== 'object') { - ErrorManager.showInvalidConfig(configPath, "Missing 'profiles' object"); - process.exit(1); - } - - return config; -} - -// Get settings path for profile -function getSettingsPath(profile) { - const config = readConfig(); - - // Get settings path - const settingsPath = config.profiles[profile]; - - if (!settingsPath) { - const availableProfiles = Object.keys(config.profiles); - const profileList = availableProfiles.map(p => ` - ${p}`); - ErrorManager.showProfileNotFound(profile, profileList); - process.exit(1); - } - - // Expand path - const expandedPath = expandPath(settingsPath); - - // Validate settings file exists - if (!fs.existsSync(expandedPath)) { - // Auto-create if it's ~/.claude/settings.json - if (expandedPath.includes('.claude') && expandedPath.endsWith('settings.json')) { - const RecoveryManager = require('./recovery-manager'); - const recovery = new RecoveryManager(); - recovery.ensureClaudeSettings(); - - if (!fs.existsSync(expandedPath)) { - ErrorManager.showSettingsNotFound(expandedPath); - process.exit(1); - } - - console.log('[i] Auto-created missing settings file'); - } else { - ErrorManager.showSettingsNotFound(expandedPath); - process.exit(1); - } - } - - // Validate settings file is valid JSON - try { - const settingsContent = fs.readFileSync(expandedPath, 'utf8'); - JSON.parse(settingsContent); - } catch (e) { - ErrorManager.showInvalidConfig(expandedPath, `Invalid JSON: ${e.message}`); - process.exit(1); - } - - return expandedPath; -} - -module.exports = { - getConfigPath, - readConfig, - getSettingsPath -}; \ No newline at end of file diff --git a/bin/utils/delegation-validator.js b/bin/utils/delegation-validator.js deleted file mode 100644 index f8c89131..00000000 --- a/bin/utils/delegation-validator.js +++ /dev/null @@ -1,154 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -/** - * Validates delegation profiles for CCS delegation system - * Ensures profiles exist and have valid API keys configured - */ -class DelegationValidator { - /** - * Validate a delegation profile - * @param {string} profileName - Name of profile to validate (e.g., 'glm', 'kimi') - * @returns {Object} Validation result { valid: boolean, error?: string, settingsPath?: string } - */ - static validate(profileName) { - const homeDir = os.homedir(); - const settingsPath = path.join(homeDir, '.ccs', `${profileName}.settings.json`); - - // Check if profile directory exists - if (!fs.existsSync(settingsPath)) { - return { - valid: false, - error: `Profile not found: ${profileName}`, - suggestion: `Profile settings missing at: ${settingsPath}\n\n` + - `To set up ${profileName} profile:\n` + - ` 1. Copy base settings: cp config/base-${profileName}.settings.json ~/.ccs/${profileName}.settings.json\n` + - ` 2. Edit settings: Edit ~/.ccs/${profileName}.settings.json\n` + - ` 3. Set your API key in ANTHROPIC_AUTH_TOKEN field` - }; - } - - // Read and parse settings.json - let settings; - try { - const settingsContent = fs.readFileSync(settingsPath, 'utf8'); - settings = JSON.parse(settingsContent); - } catch (error) { - return { - valid: false, - error: `Failed to parse settings.json for ${profileName}`, - suggestion: `Settings file is corrupted or invalid JSON.\n\n` + - `Location: ${settingsPath}\n` + - `Parse error: ${error.message}\n\n` + - `Fix: Restore from base config:\n` + - ` cp config/base-${profileName}.settings.json ~/.ccs/${profileName}.settings.json` - }; - } - - // Validate API key exists and is not default - const apiKey = settings.env?.ANTHROPIC_AUTH_TOKEN; - - if (!apiKey) { - return { - valid: false, - error: `API key not configured for ${profileName}`, - suggestion: `Missing ANTHROPIC_AUTH_TOKEN in settings.\n\n` + - `Edit: ${settingsPath}\n` + - `Set: env.ANTHROPIC_AUTH_TOKEN to your API key` - }; - } - - // Check for default placeholder values - const defaultPlaceholders = [ - 'YOUR_GLM_API_KEY_HERE', - 'YOUR_KIMI_API_KEY_HERE', - 'YOUR_API_KEY_HERE', - 'your-api-key-here', - 'PLACEHOLDER' - ]; - - if (defaultPlaceholders.some(placeholder => apiKey.includes(placeholder))) { - return { - valid: false, - error: `Default API key placeholder detected for ${profileName}`, - suggestion: `API key is still set to default placeholder.\n\n` + - `To configure your profile:\n` + - ` 1. Edit: ${settingsPath}\n` + - ` 2. Replace ANTHROPIC_AUTH_TOKEN with your actual API key\n\n` + - `Get API key:\n` + - ` GLM: https://z.ai/manage-apikey/apikey-list\n` + - ` Kimi: https://platform.moonshot.cn/console/api-keys` - }; - } - - // Validation passed - return { - valid: true, - settingsPath, - apiKey: apiKey.substring(0, 8) + '...' // Show first 8 chars for verification - }; - } - - /** - * Format validation error for display - * @param {Object} result - Validation result from validate() - * @returns {string} Formatted error message - */ - static formatError(result) { - if (result.valid) { - return ''; - } - - let message = `\n[X] ${result.error}\n\n`; - - if (result.suggestion) { - message += `${result.suggestion}\n`; - } - - return message; - } - - /** - * Check if profile is delegation-ready (shorthand) - * @param {string} profileName - Profile to check - * @returns {boolean} True if ready for delegation - */ - static isReady(profileName) { - const result = this.validate(profileName); - return result.valid; - } - - /** - * Get all delegation-ready profiles - * @returns {Array} List of profile names ready for delegation - */ - static getReadyProfiles() { - const homeDir = os.homedir(); - const ccsDir = path.join(homeDir, '.ccs'); - - if (!fs.existsSync(ccsDir)) { - return []; - } - - const profiles = []; - const entries = fs.readdirSync(ccsDir, { withFileTypes: true }); - - // Look for *.settings.json files - for (const entry of entries) { - if (entry.isFile() && entry.name.endsWith('.settings.json')) { - const profileName = entry.name.replace('.settings.json', ''); - if (this.isReady(profileName)) { - profiles.push(profileName); - } - } - } - - return profiles; - } -} - -module.exports = { DelegationValidator }; diff --git a/bin/utils/error-codes.js b/bin/utils/error-codes.js deleted file mode 100644 index fe8cc82a..00000000 --- a/bin/utils/error-codes.js +++ /dev/null @@ -1,59 +0,0 @@ -// CCS Error Codes -// Documentation: ../../docs/errors/README.md - -const ERROR_CODES = { - // Configuration Errors (E100-E199) - CONFIG_MISSING: 'E101', - CONFIG_INVALID_JSON: 'E102', - CONFIG_INVALID_PROFILE: 'E103', - - // Profile Management Errors (E200-E299) - PROFILE_NOT_FOUND: 'E104', - PROFILE_ALREADY_EXISTS: 'E105', - PROFILE_CANNOT_DELETE_DEFAULT: 'E106', - PROFILE_INVALID_NAME: 'E107', - - // Claude CLI Detection Errors (E300-E399) - CLAUDE_NOT_FOUND: 'E301', - CLAUDE_VERSION_INCOMPATIBLE: 'E302', - CLAUDE_EXECUTION_FAILED: 'E303', - - // Network/API Errors (E400-E499) - GLMT_PROXY_TIMEOUT: 'E401', - API_KEY_MISSING: 'E402', - API_AUTH_FAILED: 'E403', - API_RATE_LIMIT: 'E404', - - // File System Errors (E500-E599) - FS_CANNOT_CREATE_DIR: 'E501', - FS_CANNOT_WRITE_FILE: 'E502', - FS_CANNOT_READ_FILE: 'E503', - FS_INSTANCE_NOT_FOUND: 'E504', - - // Internal Errors (E900-E999) - INTERNAL_ERROR: 'E900', - INVALID_STATE: 'E901' -}; - -// Error code documentation URL generator -function getErrorDocUrl(errorCode) { - return `https://github.com/kaitranntt/ccs/blob/main/docs/errors/README.md#${errorCode.toLowerCase()}`; -} - -// Get error category from code -function getErrorCategory(errorCode) { - const code = parseInt(errorCode.substring(1)); - if (code >= 100 && code < 200) return 'Configuration'; - if (code >= 200 && code < 300) return 'Profile Management'; - if (code >= 300 && code < 400) return 'Claude CLI Detection'; - if (code >= 400 && code < 500) return 'Network/API'; - if (code >= 500 && code < 600) return 'File System'; - if (code >= 900 && code < 1000) return 'Internal'; - return 'Unknown'; -} - -module.exports = { - ERROR_CODES, - getErrorDocUrl, - getErrorCategory -}; diff --git a/bin/utils/error-manager.js b/bin/utils/error-manager.js deleted file mode 100644 index da130275..00000000 --- a/bin/utils/error-manager.js +++ /dev/null @@ -1,165 +0,0 @@ -'use strict'; - -const { colored } = require('./helpers'); -const { ERROR_CODES, getErrorDocUrl } = require('./error-codes'); - -/** - * Error types with structured messages (Legacy - kept for compatibility) - */ -const ErrorTypes = { - NO_CLAUDE_CLI: 'NO_CLAUDE_CLI', - MISSING_SETTINGS: 'MISSING_SETTINGS', - INVALID_CONFIG: 'INVALID_CONFIG', - UNKNOWN_PROFILE: 'UNKNOWN_PROFILE', - PERMISSION_DENIED: 'PERMISSION_DENIED', - GENERIC: 'GENERIC' -}; - -/** - * Enhanced error manager with context-aware messages - */ -class ErrorManager { - /** - * Show error code and documentation URL - * @param {string} errorCode - Error code (e.g., E301) - */ - static showErrorCode(errorCode) { - console.error(colored(`Error: ${errorCode}`, 'yellow')); - console.error(colored(getErrorDocUrl(errorCode), 'yellow')); - console.error(''); - } - - /** - * Show Claude CLI not found error - */ - static showClaudeNotFound() { - console.error(''); - console.error(colored('[X] Claude CLI not found', 'red')); - console.error(''); - console.error('CCS requires Claude CLI to be installed and available in PATH.'); - console.error(''); - console.error(colored('Solutions:', 'yellow')); - console.error(' 1. Install Claude CLI:'); - console.error(' https://docs.claude.com/en/docs/claude-code/installation'); - console.error(''); - console.error(' 2. Verify installation:'); - console.error(' command -v claude (Unix)'); - console.error(' Get-Command claude (Windows)'); - console.error(''); - console.error(' 3. Custom path (if installed elsewhere):'); - console.error(' export CCS_CLAUDE_PATH="/path/to/claude"'); - console.error(''); - this.showErrorCode(ERROR_CODES.CLAUDE_NOT_FOUND); - } - - /** - * Show settings file not found error - * @param {string} settingsPath - Path to missing settings file - */ - static showSettingsNotFound(settingsPath) { - const isClaudeSettings = settingsPath.includes('.claude') && settingsPath.endsWith('settings.json'); - - console.error(''); - console.error(colored('[X] Settings file not found', 'red')); - console.error(''); - console.error(`File: ${settingsPath}`); - console.error(''); - - if (isClaudeSettings) { - console.error('This file is auto-created when you login to Claude CLI.'); - console.error(''); - console.error(colored('Solutions:', 'yellow')); - console.error(` echo '{}' > ${settingsPath}`); - console.error(' claude /login'); - console.error(''); - console.error('Why: Newer Claude CLI versions require explicit login.'); - } else { - console.error(colored('Solutions:', 'yellow')); - console.error(' npm install -g @kaitranntt/ccs --force'); - console.error(''); - console.error('This will recreate missing profile settings.'); - } - - console.error(''); - this.showErrorCode(ERROR_CODES.CONFIG_INVALID_PROFILE); - } - - /** - * Show invalid configuration error - * @param {string} configPath - Path to invalid config - * @param {string} errorDetail - JSON parse error detail - */ - static showInvalidConfig(configPath, errorDetail) { - console.error(''); - console.error(colored('[X] Configuration invalid', 'red')); - console.error(''); - console.error(`File: ${configPath}`); - console.error(`Issue: ${errorDetail}`); - console.error(''); - console.error(colored('Solutions:', 'yellow')); - console.error(' # Backup corrupted file'); - console.error(` mv ${configPath} ${configPath}.backup`); - console.error(''); - console.error(' # Reinstall CCS'); - console.error(' npm install -g @kaitranntt/ccs --force'); - console.error(''); - console.error('Your profile settings will be preserved.'); - console.error(''); - this.showErrorCode(ERROR_CODES.CONFIG_INVALID_JSON); - } - - /** - * Show profile not found error - * @param {string} profileName - Requested profile name - * @param {string[]} availableProfiles - List of available profiles - * @param {string[]} suggestions - Suggested profile names (fuzzy match) - */ - static showProfileNotFound(profileName, availableProfiles, suggestions = []) { - console.error(''); - console.error(colored(`[X] Profile '${profileName}' not found`, 'red')); - console.error(''); - - if (suggestions && suggestions.length > 0) { - console.error(colored('Did you mean:', 'yellow')); - suggestions.forEach(s => console.error(` ${s}`)); - console.error(''); - } - - console.error(colored('Available profiles:', 'cyan')); - availableProfiles.forEach(line => console.error(` ${line}`)); - console.error(''); - console.error(colored('Solutions:', 'yellow')); - console.error(' # Use existing profile'); - console.error(' ccs "your prompt"'); - console.error(''); - console.error(' # Create new account profile'); - console.error(' ccs auth create '); - console.error(''); - this.showErrorCode(ERROR_CODES.PROFILE_NOT_FOUND); - } - - /** - * Show permission denied error - * @param {string} path - Path with permission issue - */ - static showPermissionDenied(path) { - console.error(''); - console.error(colored('[X] Permission denied', 'red')); - console.error(''); - console.error(`Cannot write to: ${path}`); - console.error(''); - console.error(colored('Solutions:', 'yellow')); - console.error(' # Fix ownership'); - console.error(' sudo chown -R $USER ~/.ccs ~/.claude'); - console.error(''); - console.error(' # Fix permissions'); - console.error(' chmod 755 ~/.ccs ~/.claude'); - console.error(''); - console.error(' # Retry installation'); - console.error(' npm install -g @kaitranntt/ccs --force'); - console.error(''); - this.showErrorCode(ERROR_CODES.FS_CANNOT_WRITE_FILE); - } -} - -module.exports = { ErrorManager, ErrorTypes }; \ No newline at end of file diff --git a/bin/utils/helpers.js b/bin/utils/helpers.js deleted file mode 100644 index 46c04bd5..00000000 --- a/bin/utils/helpers.js +++ /dev/null @@ -1,136 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -// TTY-aware color detection (matches lib/ccs bash logic) -function getColors() { - const forcedColors = process.env.FORCE_COLOR; - const noColor = process.env.NO_COLOR; - const isTTY = process.stdout.isTTY === true; // Must be explicitly true - - const useColors = forcedColors || (isTTY && !noColor); - - if (useColors) { - return { - red: '\x1b[0;31m', - yellow: '\x1b[1;33m', - cyan: '\x1b[0;36m', - green: '\x1b[0;32m', - bold: '\x1b[1m', - reset: '\x1b[0m' - }; - } - - return { red: '', yellow: '', cyan: '', green: '', bold: '', reset: '' }; -} - - -// Colors object (dynamic) -const colors = getColors(); - -// Helper: Apply color to text (returns plain text if colors disabled) -function colored(text, colorName = 'reset') { - const currentColors = getColors(); - const color = currentColors[colorName] || ''; - return color ? `${color}${text}${currentColors.reset}` : text; -} - -// Simple error formatting -function error(message) { - console.error(`ERROR: ${message}`); - console.error('Try: npm install -g @kaitranntt/ccs --force'); - process.exit(1); -} - -// Path expansion (~ and env vars) -function expandPath(pathStr) { - // Handle tilde expansion - if (pathStr.startsWith('~/') || pathStr.startsWith('~\\')) { - pathStr = path.join(os.homedir(), pathStr.slice(2)); - } - - // Expand environment variables (Windows and Unix) - pathStr = pathStr.replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] || ''); - pathStr = pathStr.replace(/\$([A-Z_][A-Z0-9_]*)/gi, (_, name) => process.env[name] || ''); - - // Windows %VAR% style - if (process.platform === 'win32') { - pathStr = pathStr.replace(/%([^%]+)%/g, (_, name) => process.env[name] || ''); - } - - return path.normalize(pathStr); -} - -/** - * Calculate Levenshtein distance between two strings - * @param {string} a - First string - * @param {string} b - Second string - * @returns {number} Edit distance - */ -function levenshteinDistance(a, b) { - if (a.length === 0) return b.length; - if (b.length === 0) return a.length; - - const matrix = []; - - // Initialize first row and column - for (let i = 0; i <= b.length; i++) { - matrix[i] = [i]; - } - - for (let j = 0; j <= a.length; j++) { - matrix[0][j] = j; - } - - // Fill in the rest of the matrix - for (let i = 1; i <= b.length; i++) { - for (let j = 1; j <= a.length; j++) { - if (b.charAt(i - 1) === a.charAt(j - 1)) { - matrix[i][j] = matrix[i - 1][j - 1]; - } else { - matrix[i][j] = Math.min( - matrix[i - 1][j - 1] + 1, // substitution - matrix[i][j - 1] + 1, // insertion - matrix[i - 1][j] + 1 // deletion - ); - } - } - } - - return matrix[b.length][a.length]; -} - -/** - * Find similar strings using fuzzy matching - * @param {string} target - Target string - * @param {string[]} candidates - List of candidate strings - * @param {number} maxDistance - Maximum edit distance (default: 2) - * @returns {string[]} Similar strings sorted by distance - */ -function findSimilarStrings(target, candidates, maxDistance = 2) { - const targetLower = target.toLowerCase(); - - const matches = candidates - .map(candidate => ({ - name: candidate, - distance: levenshteinDistance(targetLower, candidate.toLowerCase()) - })) - .filter(item => item.distance <= maxDistance && item.distance > 0) - .sort((a, b) => a.distance - b.distance) - .slice(0, 3) // Show at most 3 suggestions - .map(item => item.name); - - return matches; -} - - -module.exports = { - colors, - colored, - error, - expandPath, - levenshteinDistance, - findSimilarStrings -}; \ No newline at end of file diff --git a/bin/utils/progress-indicator.js b/bin/utils/progress-indicator.js deleted file mode 100644 index df0e4385..00000000 --- a/bin/utils/progress-indicator.js +++ /dev/null @@ -1,111 +0,0 @@ -'use strict'; - -/** - * Simple Progress Indicator (no external dependencies) - * - * Features: - * - ASCII-only spinner frames (cross-platform compatible) - * - TTY detection (no spinners in pipes/logs) - * - Elapsed time display - * - CI environment detection - */ - -class ProgressIndicator { - /** - * Create a progress indicator - * @param {string} message - Message to display - * @param {Object} options - Options - * @param {string[]} options.frames - Spinner frames (default: ASCII) - * @param {number} options.interval - Frame interval in ms (default: 80) - */ - constructor(message, options = {}) { - this.message = message; - // ASCII-only frames for cross-platform compatibility - this.frames = options.frames || ['|', '/', '-', '\\']; - this.frameIndex = 0; - this.interval = null; - this.startTime = Date.now(); - - // TTY detection: only animate if stderr is TTY and not in CI - this.isTTY = process.stderr.isTTY === true && !process.env.CI && !process.env.NO_COLOR; - } - - /** - * Start the spinner - */ - start() { - if (!this.isTTY) { - // Non-TTY: just print message once - process.stderr.write(`[i] ${this.message}...\n`); - return; - } - - // TTY: animate spinner - this.interval = setInterval(() => { - const frame = this.frames[this.frameIndex]; - const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(1); - process.stderr.write(`\r[${frame}] ${this.message}... (${elapsed}s)`); - this.frameIndex = (this.frameIndex + 1) % this.frames.length; - }, 80); // 12.5fps for smooth animation - } - - /** - * Stop spinner with success message - * @param {string} message - Optional success message (defaults to original message) - */ - succeed(message) { - this.stop(); - const finalMessage = message || this.message; - const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(1); - - if (this.isTTY) { - // Clear spinner line and show success - process.stderr.write(`\r[OK] ${finalMessage} (${elapsed}s)\n`); - } else { - // Non-TTY: just show completion - process.stderr.write(`[OK] ${finalMessage}\n`); - } - } - - /** - * Stop spinner with failure message - * @param {string} message - Optional failure message (defaults to original message) - */ - fail(message) { - this.stop(); - const finalMessage = message || this.message; - - if (this.isTTY) { - // Clear spinner line and show failure - process.stderr.write(`\r[X] ${finalMessage}\n`); - } else { - // Non-TTY: just show failure - process.stderr.write(`[X] ${finalMessage}\n`); - } - } - - /** - * Update spinner message (while running) - * @param {string} newMessage - New message to display - */ - update(newMessage) { - this.message = newMessage; - } - - /** - * Stop the spinner without showing success/failure - */ - stop() { - if (this.interval) { - clearInterval(this.interval); - this.interval = null; - - if (this.isTTY) { - // Clear the spinner line - process.stderr.write('\r\x1b[K'); - } - } - } -} - -module.exports = { ProgressIndicator }; diff --git a/bin/utils/prompt.js b/bin/utils/prompt.js deleted file mode 100644 index 33616678..00000000 --- a/bin/utils/prompt.js +++ /dev/null @@ -1,134 +0,0 @@ -'use strict'; - -const readline = require('readline'); - -/** - * Interactive Prompt Utilities (NO external dependencies) - * - * Features: - * - TTY detection (auto-confirm in non-TTY) - * - --yes flag support for automation - * - --no-input flag support for CI - * - Safe defaults (N for destructive actions) - * - Input validation with retry - */ - -class InteractivePrompt { - /** - * Ask for confirmation - * @param {string} message - Confirmation message - * @param {Object} options - Options - * @param {boolean} options.default - Default value (true=Yes, false=No) - * @returns {Promise} User confirmation - */ - static async confirm(message, options = {}) { - const { default: defaultValue = false } = options; - - // Check for --yes flag (automation) - always returns true - if (process.env.CCS_YES === '1' || process.argv.includes('--yes') || process.argv.includes('-y')) { - return true; - } - - // Check for --no-input flag (CI) - if (process.env.CCS_NO_INPUT === '1' || process.argv.includes('--no-input')) { - throw new Error('Interactive input required but --no-input specified'); - } - - // Non-TTY: use default - if (!process.stdin.isTTY) { - return defaultValue; - } - - // Interactive prompt - const rl = readline.createInterface({ - input: process.stdin, - output: process.stderr, - terminal: true - }); - - const promptText = defaultValue - ? `${message} [Y/n]: ` - : `${message} [y/N]: `; - - return new Promise((resolve) => { - rl.question(promptText, (answer) => { - rl.close(); - - const normalized = answer.trim().toLowerCase(); - - // Empty answer: use default - if (normalized === '') { - resolve(defaultValue); - return; - } - - // Valid answers - if (normalized === 'y' || normalized === 'yes') { - resolve(true); - return; - } - - if (normalized === 'n' || normalized === 'no') { - resolve(false); - return; - } - - // Invalid input: retry - console.error('[!] Please answer y or n'); - resolve(InteractivePrompt.confirm(message, options)); - }); - }); - } - - /** - * Get text input from user - * @param {string} message - Prompt message - * @param {Object} options - Options - * @param {string} options.default - Default value - * @param {Function} options.validate - Validation function - * @returns {Promise} User input - */ - static async input(message, options = {}) { - const { default: defaultValue = '', validate = null } = options; - - // Non-TTY: use default or error - if (!process.stdin.isTTY) { - if (defaultValue) { - return defaultValue; - } - throw new Error('Interactive input required but stdin is not a TTY'); - } - - const rl = readline.createInterface({ - input: process.stdin, - output: process.stderr, - terminal: true - }); - - const promptText = defaultValue - ? `${message} [${defaultValue}]: ` - : `${message}: `; - - return new Promise((resolve) => { - rl.question(promptText, (answer) => { - rl.close(); - - const value = answer.trim() || defaultValue; - - // Validate input if validator provided - if (validate) { - const error = validate(value); - if (error) { - console.error(`[!] ${error}`); - resolve(InteractivePrompt.input(message, options)); - return; - } - } - - resolve(value); - }); - }); - } -} - -module.exports = { InteractivePrompt }; diff --git a/bin/utils/shell-completion.js b/bin/utils/shell-completion.js deleted file mode 100644 index b2fc3024..00000000 --- a/bin/utils/shell-completion.js +++ /dev/null @@ -1,256 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { execSync } = require('child_process'); - -/** - * Shell Completion Installer - * Auto-configures shell completion for bash, zsh, fish, PowerShell - */ - -class ShellCompletionInstaller { - constructor() { - this.homeDir = os.homedir(); - this.ccsDir = path.join(this.homeDir, '.ccs'); - this.completionDir = path.join(this.ccsDir, 'completions'); - this.scriptsDir = path.join(__dirname, '../../scripts/completion'); - } - - /** - * Detect current shell - */ - detectShell() { - const shell = process.env.SHELL || ''; - - if (shell.includes('bash')) return 'bash'; - if (shell.includes('zsh')) return 'zsh'; - if (shell.includes('fish')) return 'fish'; - if (process.platform === 'win32') return 'powershell'; - - return null; - } - - /** - * Ensure completion files are in ~/.ccs/completions/ - */ - ensureCompletionFiles() { - if (!fs.existsSync(this.completionDir)) { - fs.mkdirSync(this.completionDir, { recursive: true }); - } - - // Copy completion scripts - const files = ['ccs.bash', 'ccs.zsh', 'ccs.fish', 'ccs.ps1']; - files.forEach(file => { - const src = path.join(this.scriptsDir, file); - const dest = path.join(this.completionDir, file); - - if (fs.existsSync(src)) { - fs.copyFileSync(src, dest); - } - }); - } - - /** - * Safely create directory, checking for file conflicts - * @param {string} dirPath - Path to create - * @throws {Error} If path exists but is a file - */ - ensureDirectory(dirPath) { - if (fs.existsSync(dirPath)) { - const stat = fs.statSync(dirPath); - if (!stat.isDirectory()) { - throw new Error( - `Cannot create directory: ${dirPath} exists but is a file.\n` + - `Please remove or rename this file and try again.` - ); - } - // Directory exists, nothing to do - return; - } - - // Check parent directories recursively - const parentDir = path.dirname(dirPath); - if (parentDir !== dirPath) { - this.ensureDirectory(parentDir); - } - - // Create the directory - fs.mkdirSync(dirPath); - } - - /** - * Install bash completion - */ - installBash() { - const rcFile = path.join(this.homeDir, '.bashrc'); - const completionPath = path.join(this.completionDir, 'ccs.bash'); - - if (!fs.existsSync(completionPath)) { - throw new Error('Completion file not found. Please reinstall CCS.'); - } - - const marker = '# CCS shell completion'; - const sourceCmd = `source "${completionPath}"`; - const block = `\n${marker}\n${sourceCmd}\n`; - - // Check if already installed - if (fs.existsSync(rcFile)) { - const content = fs.readFileSync(rcFile, 'utf8'); - if (content.includes(marker)) { - return { success: true, alreadyInstalled: true }; - } - } - - // Append to .bashrc - fs.appendFileSync(rcFile, block); - - return { - success: true, - message: `Added to ${rcFile}`, - reload: 'source ~/.bashrc' - }; - } - - /** - * Install zsh completion - */ - installZsh() { - const rcFile = path.join(this.homeDir, '.zshrc'); - const completionPath = path.join(this.completionDir, 'ccs.zsh'); - const zshCompDir = path.join(this.homeDir, '.zsh', 'completion'); - - if (!fs.existsSync(completionPath)) { - throw new Error('Completion file not found. Please reinstall CCS.'); - } - - // Create zsh completion directory (with file conflict checking) - this.ensureDirectory(zshCompDir); - - // Copy to zsh completion directory - const destFile = path.join(zshCompDir, '_ccs'); - fs.copyFileSync(completionPath, destFile); - - const marker = '# CCS shell completion'; - const setupCmds = [ - 'fpath=(~/.zsh/completion $fpath)', - 'autoload -Uz compinit && compinit' - ]; - const block = `\n${marker}\n${setupCmds.join('\n')}\n`; - - // Check if already installed - if (fs.existsSync(rcFile)) { - const content = fs.readFileSync(rcFile, 'utf8'); - if (content.includes(marker)) { - return { success: true, alreadyInstalled: true }; - } - } - - // Append to .zshrc - fs.appendFileSync(rcFile, block); - - return { - success: true, - message: `Added to ${rcFile}`, - reload: 'source ~/.zshrc' - }; - } - - /** - * Install fish completion - */ - installFish() { - const completionPath = path.join(this.completionDir, 'ccs.fish'); - const fishCompDir = path.join(this.homeDir, '.config', 'fish', 'completions'); - - if (!fs.existsSync(completionPath)) { - throw new Error('Completion file not found. Please reinstall CCS.'); - } - - // Create fish completion directory (with file conflict checking) - this.ensureDirectory(fishCompDir); - - // Copy to fish completion directory (fish auto-loads from here) - const destFile = path.join(fishCompDir, 'ccs.fish'); - fs.copyFileSync(completionPath, destFile); - - return { - success: true, - message: `Installed to ${destFile}`, - reload: 'Fish auto-loads completions (no reload needed)' - }; - } - - /** - * Install PowerShell completion - */ - installPowerShell() { - const profilePath = process.env.PROFILE || path.join( - this.homeDir, - 'Documents', - 'PowerShell', - 'Microsoft.PowerShell_profile.ps1' - ); - const completionPath = path.join(this.completionDir, 'ccs.ps1'); - - if (!fs.existsSync(completionPath)) { - throw new Error('Completion file not found. Please reinstall CCS.'); - } - - const marker = '# CCS shell completion'; - const sourceCmd = `. "${completionPath.replace(/\\/g, '\\\\')}"`; - const block = `\n${marker}\n${sourceCmd}\n`; - - // Create profile directory if needed (with file conflict checking) - const profileDir = path.dirname(profilePath); - this.ensureDirectory(profileDir); - - // Check if already installed - if (fs.existsSync(profilePath)) { - const content = fs.readFileSync(profilePath, 'utf8'); - if (content.includes(marker)) { - return { success: true, alreadyInstalled: true }; - } - } - - // Append to PowerShell profile - fs.appendFileSync(profilePath, block); - - return { - success: true, - message: `Added to ${profilePath}`, - reload: '. $PROFILE' - }; - } - - /** - * Install for detected or specified shell - */ - install(shell = null) { - const targetShell = shell || this.detectShell(); - - if (!targetShell) { - throw new Error('Could not detect shell. Please specify: --bash, --zsh, --fish, or --powershell'); - } - - // Ensure completion files exist - this.ensureCompletionFiles(); - - // Install for target shell - switch (targetShell) { - case 'bash': - return this.installBash(); - case 'zsh': - return this.installZsh(); - case 'fish': - return this.installFish(); - case 'powershell': - return this.installPowerShell(); - default: - throw new Error(`Unsupported shell: ${targetShell}`); - } - } -} - -module.exports = { ShellCompletionInstaller }; diff --git a/bin/utils/update-checker.js b/bin/utils/update-checker.js deleted file mode 100644 index 278e88f7..00000000 --- a/bin/utils/update-checker.js +++ /dev/null @@ -1,243 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const https = require('https'); -const { colored } = require('./helpers'); - -const UPDATE_CHECK_FILE = path.join(os.homedir(), '.ccs', 'update-check.json'); -const CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours -const GITHUB_API_URL = 'https://api.github.com/repos/kaitranntt/ccs/releases/latest'; -const NPM_REGISTRY_URL = 'https://registry.npmjs.org/@kaitranntt/ccs/latest'; -const REQUEST_TIMEOUT = 5000; // 5 seconds - -/** - * Compare semantic versions - * @param {string} v1 - First version (e.g., "4.1.6") - * @param {string} v2 - Second version - * @returns {number} - 1 if v1 > v2, -1 if v1 < v2, 0 if equal - */ -function compareVersions(v1, v2) { - const parts1 = v1.replace(/^v/, '').split('.').map(Number); - const parts2 = v2.replace(/^v/, '').split('.').map(Number); - - for (let i = 0; i < 3; i++) { - const p1 = parts1[i] || 0; - const p2 = parts2[i] || 0; - if (p1 > p2) return 1; - if (p1 < p2) return -1; - } - return 0; -} - -/** - * Fetch latest version from GitHub releases - * @returns {Promise} - Latest version or null on error - */ -function fetchLatestVersionFromGitHub() { - return new Promise((resolve) => { - const req = https.get(GITHUB_API_URL, { - headers: { 'User-Agent': 'CCS-Update-Checker' }, - timeout: REQUEST_TIMEOUT - }, (res) => { - let data = ''; - - res.on('data', (chunk) => { - data += chunk; - }); - - res.on('end', () => { - try { - if (res.statusCode !== 200) { - resolve(null); - return; - } - - const release = JSON.parse(data); - const version = release.tag_name?.replace(/^v/, '') || null; - resolve(version); - } catch (err) { - resolve(null); - } - }); - }); - - req.on('error', () => resolve(null)); - req.on('timeout', () => { - req.destroy(); - resolve(null); - }); - }); -} - -/** - * Fetch latest version from npm registry - * @returns {Promise} - Latest version or null on error - */ -function fetchLatestVersionFromNpm() { - return new Promise((resolve) => { - const req = https.get(NPM_REGISTRY_URL, { - headers: { 'User-Agent': 'CCS-Update-Checker' }, - timeout: REQUEST_TIMEOUT - }, (res) => { - let data = ''; - - res.on('data', (chunk) => { - data += chunk; - }); - - res.on('end', () => { - try { - if (res.statusCode !== 200) { - resolve(null); - return; - } - - const packageData = JSON.parse(data); - const version = packageData.version || null; - resolve(version); - } catch (err) { - resolve(null); - } - }); - }); - - req.on('error', () => resolve(null)); - req.on('timeout', () => { - req.destroy(); - resolve(null); - }); - }); -} - -/** - * Read update check cache - * @returns {Object} - Cache object - */ -function readCache() { - try { - if (!fs.existsSync(UPDATE_CHECK_FILE)) { - return { last_check: 0, latest_version: null, dismissed_version: null }; - } - - const data = fs.readFileSync(UPDATE_CHECK_FILE, 'utf8'); - return JSON.parse(data); - } catch (err) { - return { last_check: 0, latest_version: null, dismissed_version: null }; - } -} - -/** - * Write update check cache - * @param {Object} cache - Cache object to write - */ -function writeCache(cache) { - try { - const ccsDir = path.join(os.homedir(), '.ccs'); - if (!fs.existsSync(ccsDir)) { - fs.mkdirSync(ccsDir, { recursive: true, mode: 0o700 }); - } - - fs.writeFileSync(UPDATE_CHECK_FILE, JSON.stringify(cache, null, 2), 'utf8'); - } catch (err) { - // Silently fail - not critical - } -} - -/** - * Check for updates (async, non-blocking) - * @param {string} currentVersion - Current CCS version - * @param {boolean} force - Force check even if within interval - * @param {string} installMethod - Installation method ('npm' or 'direct') - * @returns {Promise} - Update result object with status and data - */ -async function checkForUpdates(currentVersion, force = false, installMethod = 'direct') { - const cache = readCache(); - const now = Date.now(); - - // Check if we should check for updates - if (!force && (now - cache.last_check < CHECK_INTERVAL)) { - // Use cached result if available - if (cache.latest_version && compareVersions(cache.latest_version, currentVersion) > 0) { - // Don't show if user dismissed this version - if (cache.dismissed_version === cache.latest_version) { - return { status: 'no_update', reason: 'dismissed' }; - } - return { status: 'update_available', latest: cache.latest_version, current: currentVersion }; - } - return { status: 'no_update', reason: 'cached' }; - } - - // Fetch latest version from appropriate source - let latestVersion; - let fetchError = null; - - if (installMethod === 'npm') { - latestVersion = await fetchLatestVersionFromNpm(); - if (!latestVersion) fetchError = 'npm_registry_error'; - } else { - latestVersion = await fetchLatestVersionFromGitHub(); - if (!latestVersion) fetchError = 'github_api_error'; - } - - // Update cache - cache.last_check = now; - if (latestVersion) { - cache.latest_version = latestVersion; - } - writeCache(cache); - - // Handle fetch errors - if (fetchError) { - return { - status: 'check_failed', - reason: fetchError, - message: `Failed to check for updates: ${fetchError.replace(/_/g, ' ')}` - }; - } - - // Check if update available - if (latestVersion && compareVersions(latestVersion, currentVersion) > 0) { - // Don't show if user dismissed this version - if (cache.dismissed_version === latestVersion) { - return { status: 'no_update', reason: 'dismissed' }; - } - return { status: 'update_available', latest: latestVersion, current: currentVersion }; - } - - return { status: 'no_update', reason: 'latest' }; -} - -/** - * Show update notification - * @param {Object} updateInfo - Update information - */ -function showUpdateNotification(updateInfo) { - console.log(''); - console.log(colored('═══════════════════════════════════════════════════════', 'cyan')); - console.log(colored(` Update available: ${updateInfo.current} → ${updateInfo.latest}`, 'yellow')); - console.log(colored('═══════════════════════════════════════════════════════', 'cyan')); - console.log(''); - console.log(` Run ${colored('ccs update', 'yellow')} to update`); - console.log(''); -} - -/** - * Dismiss update notification for a specific version - * @param {string} version - Version to dismiss - */ -function dismissUpdate(version) { - const cache = readCache(); - cache.dismissed_version = version; - writeCache(cache); -} - -module.exports = { - compareVersions, - checkForUpdates, - showUpdateNotification, - dismissUpdate, - readCache, - writeCache -}; diff --git a/package-lock.json b/package-lock.json index 70b4a972..897ce0c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "ora": "^9.0.0" }, "bin": { - "ccs": "bin/ccs.js" + "ccs": "dist/ccs.js" }, "devDependencies": { "@types/node": "^20.19.25", diff --git a/scripts/postinstall.js b/scripts/postinstall.js index f68571d5..43a99eef 100755 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -93,7 +93,7 @@ function createConfigFiles() { // Migrate from v3.1.1 to v3.2.0 (symlink architecture) console.log(''); try { - const SharedManager = require('../bin/management/shared-manager'); + const SharedManager = require('../dist/management/shared-manager').default; const sharedManager = new SharedManager(); sharedManager.migrateFromV311(); sharedManager.ensureSharedDirectories(); @@ -108,7 +108,7 @@ function createConfigFiles() { // Copy .claude/ directory from package to ~/.ccs/.claude/ (v4.1.1) try { - const ClaudeDirInstaller = require('../bin/utils/claude-dir-installer'); + const ClaudeDirInstaller = require('../dist/utils/claude-dir-installer').default; const installer = new ClaudeDirInstaller(); const packageDir = path.join(__dirname, '..'); installer.install(packageDir); @@ -122,7 +122,7 @@ function createConfigFiles() { // Install CCS items to ~/.claude/ (v4.1.0) try { - const ClaudeSymlinkManager = require('../bin/utils/claude-symlink-manager'); + const { ClaudeSymlinkManager } = require('../dist/utils/claude-symlink-manager'); const claudeSymlinkManager = new ClaudeSymlinkManager(); claudeSymlinkManager.install(); } catch (err) { diff --git a/src/management/doctor.ts b/src/management/doctor.ts index 007005bd..094b8652 100644 --- a/src/management/doctor.ts +++ b/src/management/doctor.ts @@ -512,7 +512,7 @@ class Doctor { const spinner = ora('Checking CCS symlinks').start(); try { - const ClaudeSymlinkManager = require('../utils/claude-symlink-manager'); + const { ClaudeSymlinkManager } = require('../utils/claude-symlink-manager'); const manager = new ClaudeSymlinkManager(); const health = manager.checkHealth(); diff --git a/tests/integration/token-counting-test.js b/tests/integration/token-counting-test.js index 070d0900..8b966f9c 100755 --- a/tests/integration/token-counting-test.js +++ b/tests/integration/token-counting-test.js @@ -1,8 +1,8 @@ #!/usr/bin/env node 'use strict'; -const GlmtTransformer = require('../bin/glmt-transformer'); -const DeltaAccumulator = require('../bin/delta-accumulator'); +const GlmtTransformer = require('../dist/glmt/glmt-transformer').default; +const { DeltaAccumulator } = require('../dist/glmt/delta-accumulator'); /** * Token Counting Validation Tests diff --git a/tests/npm/cli.test.js b/tests/npm/cli.test.js index 5c99bb98..a98083e3 100644 --- a/tests/npm/cli.test.js +++ b/tests/npm/cli.test.js @@ -5,7 +5,7 @@ const fs = require('fs'); const os = require('os'); describe('npm CLI', () => { - const ccsPath = path.join(__dirname, '..', '..', 'bin', 'ccs.js'); + const ccsPath = path.join(__dirname, '..', '..', 'dist', 'ccs.js'); const ccsDir = path.join(os.homedir(), '.ccs'); const configPath = path.join(ccsDir, 'config.json'); diff --git a/tests/npm/cross-platform.test.js b/tests/npm/cross-platform.test.js index 3342ac66..f99c56a8 100644 --- a/tests/npm/cross-platform.test.js +++ b/tests/npm/cross-platform.test.js @@ -2,10 +2,10 @@ const assert = require('assert'); const path = require('path'); const os = require('os'); -// Import the expandPath function from bin/utils/helpers.js +// Import the expandPath function from dist/utils/helpers.js let expandPath; try { - expandPath = require('../../bin/utils/helpers').expandPath; + expandPath = require('../../dist/utils/helpers').expandPath; } catch (e) { // If helpers module doesn't exist or doesn't export expandPath, create a mock expandPath = function(p) { @@ -110,9 +110,9 @@ describe('cross-platform', () => { describe('npm package structure', () => { it('has required executable files', () => { const fs = require('fs'); - const binDir = path.join(__dirname, '..', '..', 'bin'); + const distDir = path.join(__dirname, '..', '..', 'dist'); - assert(fs.existsSync(path.join(binDir, 'ccs.js')), 'ccs.js should exist in bin directory'); + assert(fs.existsSync(path.join(distDir, 'ccs.js')), 'ccs.js should exist in dist directory'); }); it('has required script files', () => { diff --git a/tests/npm/special-commands.test.js b/tests/npm/special-commands.test.js index f6ffa30e..8505a87e 100644 --- a/tests/npm/special-commands.test.js +++ b/tests/npm/special-commands.test.js @@ -3,7 +3,7 @@ const { execSync } = require('child_process'); const path = require('path'); describe('integration: special commands', () => { - const ccsPath = path.join(__dirname, '..', '..', 'bin', 'ccs.js'); + const ccsPath = path.join(__dirname, '..', '..', 'dist', 'ccs.js'); it('shows version with --version', () => { const output = execSync(`node ${ccsPath} --version`, { encoding: 'utf8' }); diff --git a/tests/shared/unit/helpers.test.js b/tests/shared/unit/helpers.test.js index c457af01..e23d705b 100644 --- a/tests/shared/unit/helpers.test.js +++ b/tests/shared/unit/helpers.test.js @@ -1,7 +1,7 @@ const assert = require('assert'); const path = require('path'); const os = require('os'); -const { expandPath } = require('../../../bin/utils/helpers'); +const { expandPath } = require('../../../dist/utils/helpers'); describe('helpers', () => { describe('expandPath', () => { diff --git a/tests/unit/delegation/permission-mode.test.js b/tests/unit/delegation/permission-mode.test.js index edfb5421..8ed40cc5 100644 --- a/tests/unit/delegation/permission-mode.test.js +++ b/tests/unit/delegation/permission-mode.test.js @@ -1,7 +1,7 @@ #!/usr/bin/env node 'use strict'; -const { HeadlessExecutor } = require('../../../bin/delegation/headless-executor'); +const { HeadlessExecutor } = require('../../../dist/delegation/headless-executor'); /** * Test runner diff --git a/tests/unit/delegation/result-formatter.test.js b/tests/unit/delegation/result-formatter.test.js index b442a6b8..165aa83d 100644 --- a/tests/unit/delegation/result-formatter.test.js +++ b/tests/unit/delegation/result-formatter.test.js @@ -1,7 +1,7 @@ #!/usr/bin/env node 'use strict'; -const { ResultFormatter } = require('../../../bin/delegation/result-formatter'); +const { ResultFormatter } = require('../../../dist/delegation/result-formatter'); /** * Simple test runner (no external dependencies) diff --git a/tests/unit/delegation/session-manager.test.js b/tests/unit/delegation/session-manager.test.js index 98e95559..1ff540e9 100644 --- a/tests/unit/delegation/session-manager.test.js +++ b/tests/unit/delegation/session-manager.test.js @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); -const { SessionManager } = require('../../../bin/delegation/session-manager'); +const { SessionManager } = require('../../../dist/delegation/session-manager'); /** * Test runner diff --git a/tests/unit/delegation/settings-parser.test.js b/tests/unit/delegation/settings-parser.test.js index e7950415..fca0b4a3 100644 --- a/tests/unit/delegation/settings-parser.test.js +++ b/tests/unit/delegation/settings-parser.test.js @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); -const { SettingsParser } = require('../../../bin/delegation/settings-parser'); +const { SettingsParser } = require('../../../dist/delegation/settings-parser'); /** * Test runner diff --git a/tests/unit/glmt/debug-mode-test.js b/tests/unit/glmt/debug-mode-test.js index 474a7672..594f077e 100755 --- a/tests/unit/glmt/debug-mode-test.js +++ b/tests/unit/glmt/debug-mode-test.js @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); -const GlmtTransformer = require('../../../bin/glmt/glmt-transformer'); +const GlmtTransformer = require('../../../dist/glmt/glmt-transformer').default; /** * Manual test for debug mode file logging diff --git a/tests/unit/glmt/delta-accumulator.test.js b/tests/unit/glmt/delta-accumulator.test.js index dc55f85f..2cc62ba9 100755 --- a/tests/unit/glmt/delta-accumulator.test.js +++ b/tests/unit/glmt/delta-accumulator.test.js @@ -1,7 +1,7 @@ #!/usr/bin/env node 'use strict'; -const DeltaAccumulator = require('../../../bin/glmt/delta-accumulator'); +const { DeltaAccumulator } = require('../../../dist/glmt/delta-accumulator'); console.log('[TEST] DeltaAccumulator unit tests'); console.log(''); diff --git a/tests/unit/glmt/glmt-transformer.test.js b/tests/unit/glmt/glmt-transformer.test.js index 39607960..cc7d3f37 100644 --- a/tests/unit/glmt/glmt-transformer.test.js +++ b/tests/unit/glmt/glmt-transformer.test.js @@ -1,7 +1,7 @@ #!/usr/bin/env node 'use strict'; -const GlmtTransformer = require('../../../bin/glmt/glmt-transformer'); +const GlmtTransformer = require('../../../dist/glmt/glmt-transformer').default; /** * Simple test runner (no external dependencies) diff --git a/tests/unit/glmt/locale-enforcer.test.js b/tests/unit/glmt/locale-enforcer.test.js index 04461752..240be62c 100644 --- a/tests/unit/glmt/locale-enforcer.test.js +++ b/tests/unit/glmt/locale-enforcer.test.js @@ -11,7 +11,7 @@ */ const assert = require('assert'); -const LocaleEnforcer = require('../../../bin/glmt/locale-enforcer'); +const LocaleEnforcer = require('../../../dist/glmt/locale-enforcer').default; describe('LocaleEnforcer', () => { describe('Scenario 1: English prompt → English output', () => { diff --git a/tests/unit/glmt/performance-test.js b/tests/unit/glmt/performance-test.js index dcc44a0e..c00175fa 100644 --- a/tests/unit/glmt/performance-test.js +++ b/tests/unit/glmt/performance-test.js @@ -1,7 +1,7 @@ #!/usr/bin/env node 'use strict'; -const GlmtTransformer = require('../../../bin/glmt/glmt-transformer'); +const GlmtTransformer = require('../../../dist/glmt/glmt-transformer').default; console.log('=== Performance Test: Debug Mode Impact ===\n'); diff --git a/tests/unit/glmt/reasoning-enforcer.test.js b/tests/unit/glmt/reasoning-enforcer.test.js index 1d3b4cb6..2755cd5c 100644 --- a/tests/unit/glmt/reasoning-enforcer.test.js +++ b/tests/unit/glmt/reasoning-enforcer.test.js @@ -13,7 +13,7 @@ * 6. Edge cases (empty messages, no system/user) */ -const ReasoningEnforcer = require('../../../bin/glmt/reasoning-enforcer'); +const ReasoningEnforcer = require('../../../dist/glmt/reasoning-enforcer').default; /** * Simple test runner (no external dependencies) diff --git a/tests/unit/glmt/sse-parser.test.js b/tests/unit/glmt/sse-parser.test.js index d56086fb..08d3980c 100755 --- a/tests/unit/glmt/sse-parser.test.js +++ b/tests/unit/glmt/sse-parser.test.js @@ -1,7 +1,7 @@ #!/usr/bin/env node 'use strict'; -const SSEParser = require('../../../bin/glmt/sse-parser'); +const SSEParser = require('../../../dist/glmt/sse-parser').default; console.log('[TEST] SSEParser unit tests'); console.log(''); diff --git a/tests/unit/glmt/test-extract-thinking.js b/tests/unit/glmt/test-extract-thinking.js index f56fdb00..658ecccb 100644 --- a/tests/unit/glmt/test-extract-thinking.js +++ b/tests/unit/glmt/test-extract-thinking.js @@ -6,7 +6,7 @@ * Tests different message formats to understand the bug */ -const GlmtTransformer = require('../../../bin/glmt/glmt-transformer'); +const GlmtTransformer = require('../../../dist/glmt/glmt-transformer').default; const transformer = new GlmtTransformer({ verbose: true }); diff --git a/tests/unit/glmt/test-thinking-signature-race.js b/tests/unit/glmt/test-thinking-signature-race.js index b5c7442b..c9a7b40f 100755 --- a/tests/unit/glmt/test-thinking-signature-race.js +++ b/tests/unit/glmt/test-thinking-signature-race.js @@ -10,8 +10,8 @@ * Fix: Guard against empty content, return null if block.content is empty */ -const GlmtTransformer = require('../../../bin/glmt/glmt-transformer'); -const DeltaAccumulator = require('../../../bin/glmt/delta-accumulator'); +const GlmtTransformer = require('../../../dist/glmt/glmt-transformer').default; +const { DeltaAccumulator } = require('../../../dist/glmt/delta-accumulator'); // Test runner class TestRunner { diff --git a/tests/unit/glmt/verbose-demo.js b/tests/unit/glmt/verbose-demo.js index 3e6bef56..d042c37c 100644 --- a/tests/unit/glmt/verbose-demo.js +++ b/tests/unit/glmt/verbose-demo.js @@ -1,7 +1,7 @@ #!/usr/bin/env node 'use strict'; -const GlmtTransformer = require('../../../bin/glmt/glmt-transformer'); +const GlmtTransformer = require('../../../dist/glmt/glmt-transformer').default; console.log('=== Demo: Verbose Output with Reasoning Detection ===\n');