diff --git a/package.json b/package.json index 8f356dcc..80cffcb2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.5.0", + "version": "7.5.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/src/ccs.ts b/src/ccs.ts index 8b37d138..f347819f 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -12,6 +12,7 @@ import { getWebSearchHookEnv, } from './utils/websearch-manager'; import { getGlobalEnvConfig } from './config/unified-config-loader'; +import { fail, info } from './utils/ui'; // Import centralized error handling import { handleError, runCleanup } from './errors'; @@ -75,7 +76,7 @@ async function execClaudeWithProxy( const apiKey = envData['ANTHROPIC_AUTH_TOKEN']; if (!apiKey || apiKey === 'YOUR_GLM_API_KEY_HERE') { - console.error('[X] GLMT profile requires Z.AI API key'); + console.error(fail('GLMT profile requires Z.AI API key')); console.error(' Edit ~/.ccs/glmt.settings.json and set ANTHROPIC_AUTH_TOKEN'); process.exit(1); } @@ -134,7 +135,7 @@ async function execClaudeWithProxy( } catch (error) { const err = error as Error; spinner.fail('Failed to start GLMT proxy'); - console.error('[X] Error:', err.message); + console.error(fail(`Error: ${err.message}`)); console.error(''); console.error('Possible causes:'); console.error(' 1. Port conflict (unlikely with random port)'); @@ -192,7 +193,7 @@ async function execClaudeWithProxy( }); claude.on('error', (error) => { - console.error('[X] Claude CLI error:', error); + console.error(fail(`Claude CLI error: ${error}`)); proxy.kill('SIGTERM'); process.exit(1); }); @@ -475,7 +476,7 @@ async function main(): Promise { const { executeCopilotProfile } = await import('./copilot'); const copilotConfig = profileInfo.copilotConfig; if (!copilotConfig) { - console.error('[X] Copilot configuration not found'); + console.error(fail('Copilot configuration not found')); process.exit(1); } const exitCode = await executeCopilotProfile(copilotConfig, remainingArgs); @@ -505,7 +506,7 @@ async function main(): Promise { // Log global env injection for visibility (debug mode only) if (globalEnvConfig.enabled && Object.keys(globalEnv).length > 0 && process.env.CCS_DEBUG) { const envNames = Object.keys(globalEnv).join(', '); - console.error(`[i] Global env: ${envNames}`); + console.error(info(`Global env: ${envNames}`)); } // CRITICAL: Load settings and explicitly set ANTHROPIC_* env vars @@ -563,7 +564,7 @@ async function main(): Promise { const allProfiles = err.availableProfiles.split('\n'); await ErrorManager.showProfileNotFound(err.profileName, allProfiles, err.suggestions); } else { - console.error(`[X] ${err.message}`); + console.error(fail(err.message)); } process.exit(1); } diff --git a/src/cliproxy/binary/downloader.ts b/src/cliproxy/binary/downloader.ts index 9ab46e60..b06a4e08 100644 --- a/src/cliproxy/binary/downloader.ts +++ b/src/cliproxy/binary/downloader.ts @@ -1,6 +1,7 @@ /** * Binary Downloader * Handles downloading files with retry logic, progress tracking, and redirect following. + * Robust handling for transient network errors (socket hang up, ECONNRESET, etc.) */ import * as fs from 'fs'; @@ -14,40 +15,111 @@ export interface DownloaderConfig { maxRetries: number; /** Enable verbose logging */ verbose: boolean; + /** Timeout in milliseconds (default: 120000 for large files) */ + timeout?: number; } const DEFAULT_CONFIG: DownloaderConfig = { - maxRetries: 3, + maxRetries: 5, verbose: false, + timeout: 120000, // 2 minutes for large binaries }; +/** Error types for categorized handling */ +export type NetworkErrorType = 'socket' | 'timeout' | 'http' | 'redirect' | 'unknown'; + +/** Categorize error for appropriate retry/reporting */ +export function categorizeError(error: Error): NetworkErrorType { + const msg = error.message.toLowerCase(); + if (msg.includes('socket hang up') || msg.includes('econnreset') || msg.includes('epipe')) { + return 'socket'; + } + if (msg.includes('timeout') || msg.includes('etimedout')) { + return 'timeout'; + } + if (msg.includes('http ')) { + return 'http'; + } + if (msg.includes('redirect')) { + return 'redirect'; + } + return 'unknown'; +} + +/** Get user-friendly error message */ +export function getErrorMessage(error: Error, attempt: number, maxAttempts: number): string { + const type = categorizeError(error); + const prefix = `[Attempt ${attempt}/${maxAttempts}]`; + + switch (type) { + case 'socket': + return `${prefix} Connection dropped (socket hang up) - retrying with fresh connection...`; + case 'timeout': + return `${prefix} Download timed out - retrying with extended timeout...`; + case 'http': + return `${prefix} Server error: ${error.message}`; + case 'redirect': + return `${prefix} Redirect failed: ${error.message}`; + default: + return `${prefix} Network error: ${error.message}`; + } +} + +/** Check if error is retryable */ +export function isRetryableError(error: Error): boolean { + const type = categorizeError(error); + // Retry socket errors, timeouts, and unknown errors + if (type === 'socket' || type === 'timeout' || type === 'unknown') { + return true; + } + if (type === 'http') { + const msg = error.message; + // Retry 5xx server errors and 429 rate limit (HTTP 5xx matches 500, 502, 503, 504, etc.) + return msg.includes('HTTP 5') || msg.includes('HTTP 429'); + } + return false; +} + /** * Download file from URL with progress tracking + * @param timeout Timeout in ms (default 120000 for large files) */ export function downloadFile( url: string, destPath: string, onProgress?: ProgressCallback, - verbose = false + verbose = false, + timeout = 120000 ): Promise { return new Promise((resolve, reject) => { + let resolved = false; + + const cleanup = (err?: Error) => { + if (resolved) return; + resolved = true; + fs.unlink(destPath, () => {}); // Cleanup partial file + reject(err || new Error('Download aborted')); + }; + const handleResponse = (res: http.IncomingMessage) => { // Handle redirects (GitHub releases use 302) if (res.statusCode === 301 || res.statusCode === 302) { const redirectUrl = res.headers.location; if (!redirectUrl) { - reject(new Error('Redirect without location header')); + cleanup(new Error('Redirect without location header')); return; } if (verbose) { console.error(`[cliproxy] Following redirect: ${redirectUrl}`); } - downloadFile(redirectUrl, destPath, onProgress, verbose).then(resolve).catch(reject); + downloadFile(redirectUrl, destPath, onProgress, verbose, timeout) + .then(resolve) + .catch(reject); return; } if (res.statusCode !== 200) { - reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`)); + cleanup(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`)); return; } @@ -70,58 +142,94 @@ export function downloadFile( res.pipe(fileStream); fileStream.on('finish', () => { + if (resolved) return; + resolved = true; fileStream.close(); resolve(); }); - fileStream.on('error', (err) => { - fs.unlink(destPath, () => {}); // Cleanup partial file - reject(err); - }); - - res.on('error', (err) => { - fs.unlink(destPath, () => {}); - reject(err); - }); + fileStream.on('error', cleanup); + res.on('error', cleanup); }; const protocol = url.startsWith('https') ? https : http; - const req = protocol.get(url, handleResponse); - req.on('error', reject); - req.setTimeout(60000, () => { + // Use agent: false to prevent connection pooling (allows process to exit) + const options = { + headers: { + 'User-Agent': 'CCS-CLIProxyPlus-Downloader/1.0', + }, + agent: false, // Disable connection pooling for clean exit + }; + + const req = protocol.get(url, options, handleResponse); + + req.on('error', (err) => { + if (!resolved) { + cleanup(err); + } + }); + + req.setTimeout(timeout, () => { req.destroy(); - reject(new Error('Download timeout (60s)')); + if (!resolved) { + cleanup(new Error(`Download timeout (${timeout / 1000}s)`)); + } }); }); } /** * Download file with retry logic and exponential backoff + * Uses smarter backoff for socket errors (longer delays) */ export async function downloadWithRetry( url: string, destPath: string, config: Partial = {} ): Promise { - const { maxRetries, verbose } = { ...DEFAULT_CONFIG, ...config }; - let lastError = ''; + const { maxRetries, verbose, timeout } = { ...DEFAULT_CONFIG, ...config }; + let lastError: Error | null = null; let retries = 0; + let currentTimeout = timeout || 120000; while (retries < maxRetries) { try { - await downloadFile(url, destPath, undefined, verbose); + await downloadFile(url, destPath, undefined, verbose, currentTimeout); return { success: true, filePath: destPath, retries }; } catch (error) { const err = error as Error; - lastError = err.message; + lastError = err; retries++; - if (retries < maxRetries) { - // Exponential backoff: 1s, 2s, 4s - const delay = Math.pow(2, retries - 1) * 1000; + // Check if error is retryable + if (!isRetryableError(err)) { if (verbose) { - console.error(`[cliproxy] Retry ${retries}/${maxRetries} after ${delay}ms: ${lastError}`); + console.error(`[cliproxy] Non-retryable error: ${err.message}`); + } + break; + } + + if (retries < maxRetries) { + const errorType = categorizeError(err); + // Socket errors: longer backoff (2s, 4s, 8s, 16s, 32s) + // Timeout errors: increase timeout and use standard backoff + // Other errors: standard exponential backoff (1s, 2s, 4s...) + let delay: number; + + if (errorType === 'socket') { + delay = Math.pow(2, retries) * 1000; // 2s, 4s, 8s, 16s, 32s + } else if (errorType === 'timeout') { + delay = Math.pow(2, retries - 1) * 1000; + currentTimeout = Math.min(currentTimeout * 1.5, 300000); // Increase timeout up to 5 min + } else { + delay = Math.pow(2, retries - 1) * 1000; + } + + // Log with user-friendly message + console.error(`[cliproxy] ${getErrorMessage(err, retries, maxRetries)}`); + if (verbose) { + console.error(`[cliproxy] Waiting ${delay}ms before retry...`); } await sleep(delay); } @@ -130,16 +238,18 @@ export async function downloadWithRetry( return { success: false, - error: `Download failed after ${retries} attempts: ${lastError}`, + error: `Download failed after ${retries} attempts: ${lastError?.message || 'Unknown error'}`, retries, }; } /** - * Fetch text content from URL + * Fetch text content from URL (single attempt) */ -export function fetchText(url: string, verbose = false): Promise { +function fetchTextOnce(url: string, verbose = false, timeout = 30000): Promise { return new Promise((resolve, reject) => { + let resolved = false; + const handleResponse = (res: http.IncomingMessage) => { // Handle redirects if (res.statusCode === 301 || res.statusCode === 302) { @@ -148,7 +258,7 @@ export function fetchText(url: string, verbose = false): Promise { reject(new Error('Redirect without location header')); return; } - fetchText(redirectUrl, verbose).then(resolve).catch(reject); + fetchTextOnce(redirectUrl, verbose, timeout).then(resolve).catch(reject); return; } @@ -159,30 +269,90 @@ export function fetchText(url: string, verbose = false): Promise { let data = ''; res.on('data', (chunk) => (data += chunk)); - res.on('end', () => resolve(data)); - res.on('error', reject); + res.on('end', () => { + if (!resolved) { + resolved = true; + resolve(data); + } + }); + res.on('error', (err) => { + if (!resolved) { + resolved = true; + reject(err); + } + }); }; const protocol = url.startsWith('https') ? https : http; - const req = protocol.get(url, handleResponse); - req.on('error', reject); - req.setTimeout(30000, () => { + const options = { + headers: { + 'User-Agent': 'CCS-CLIProxyPlus-Downloader/1.0', + }, + agent: false, // Disable connection pooling for clean exit + }; + + const req = protocol.get(url, options, handleResponse); + req.on('error', (err) => { + if (!resolved) { + resolved = true; + reject(err); + } + }); + req.setTimeout(timeout, () => { req.destroy(); - reject(new Error('Request timeout (30s)')); + if (!resolved) { + resolved = true; + reject(new Error(`Request timeout (${timeout / 1000}s)`)); + } }); }); } /** - * Fetch JSON from URL (for GitHub API) + * Fetch text content from URL with retry logic */ -export function fetchJson(url: string, verbose = false): Promise> { +export async function fetchText(url: string, verbose = false, maxRetries = 3): Promise { + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await fetchTextOnce(url, verbose); + } catch (error) { + const err = error as Error; + lastError = err; + + if (!isRetryableError(err) || attempt === maxRetries) { + break; + } + + const delay = Math.pow(2, attempt - 1) * 1000; + if (verbose) { + console.error(`[cliproxy] fetchText retry ${attempt}/${maxRetries}: ${err.message}`); + } + await sleep(delay); + } + } + + throw lastError || new Error('fetchText failed'); +} + +/** + * Fetch JSON from URL (single attempt, for GitHub API) + */ +function fetchJsonOnce( + url: string, + verbose = false, + timeout = 15000 +): Promise> { return new Promise((resolve, reject) => { - const options = { + let resolved = false; + + const options: https.RequestOptions = { headers: { 'User-Agent': 'CCS-CLIProxyPlus-Updater/1.0', Accept: 'application/vnd.github.v3+json', }, + agent: false, // Disable connection pooling for clean exit }; const handleResponse = (res: http.IncomingMessage) => { @@ -192,7 +362,7 @@ export function fetchJson(url: string, verbose = false): Promise (data += chunk)); res.on('end', () => { + if (resolved) return; + resolved = true; try { resolve(JSON.parse(data)); } catch { reject(new Error('Invalid JSON from GitHub API')); } }); - res.on('error', reject); + res.on('error', (err) => { + if (!resolved) { + resolved = true; + reject(err); + } + }); }; const req = https.get(url, options, handleResponse); - req.on('error', reject); - req.setTimeout(10000, () => { + req.on('error', (err) => { + if (!resolved) { + resolved = true; + reject(err); + } + }); + req.setTimeout(timeout, () => { req.destroy(); - reject(new Error('GitHub API timeout (10s)')); + if (!resolved) { + resolved = true; + reject(new Error(`GitHub API timeout (${timeout / 1000}s)`)); + } }); }); } +/** + * Fetch JSON from URL (for GitHub API) with retry logic + */ +export async function fetchJson( + url: string, + verbose = false, + maxRetries = 3 +): Promise> { + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await fetchJsonOnce(url, verbose); + } catch (error) { + const err = error as Error; + lastError = err; + + if (!isRetryableError(err) || attempt === maxRetries) { + break; + } + + const delay = Math.pow(2, attempt - 1) * 1000; + if (verbose) { + console.error(`[cliproxy] GitHub API retry ${attempt}/${maxRetries}: ${err.message}`); + } + await sleep(delay); + } + } + + throw lastError || new Error('fetchJson failed'); +} + /** * Sleep helper */ diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 16e08434..6152be27 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -424,13 +424,17 @@ export async function execClaudeWithCLIProxy( if (proxyStatus.running && proxyStatus.verified) { // Healthy proxy found - join it if (proxyStatus.pid) { - sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid) ?? undefined; + sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid, verbose) ?? undefined; } if (sessionId) { console.log(info(`Joined existing CLIProxy on port ${cfg.port} (${proxyStatus.method})`)); } else { - // Failed to register session, but proxy is running - continue anyway - log(`Failed to register session but proxy is healthy, continuing...`); + // Failed to register session - proxy is running but we can't track it + // This happens when port-process detection fails (permissions, platform issues) + console.log( + info(`Using existing CLIProxy on port ${cfg.port} (session tracking unavailable)`) + ); + log(`PID=${proxyStatus.pid ?? 'unknown'}, session registration skipped`); } return; // Exit lock early, skip spawning } @@ -441,7 +445,7 @@ export async function execClaudeWithCLIProxy( const becameHealthy = await waitForProxyHealthy(cfg.port, cfg.timeout); if (becameHealthy) { if (proxyStatus.pid) { - sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid) ?? undefined; + sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid, verbose) ?? undefined; } console.log(info(`Joined CLIProxy after startup wait`)); return; // Exit lock early @@ -459,7 +463,7 @@ export async function execClaudeWithCLIProxy( // Last resort: try HTTP health check (handles Windows PID-XXXXX case) const isActuallyOurs = await waitForProxyHealthy(cfg.port, 1000); if (isActuallyOurs) { - sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.blocker.pid) ?? undefined; + sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.blocker.pid, verbose) ?? undefined; console.log(info(`Reclaimed CLIProxy with unrecognized process name`)); return; } diff --git a/src/cliproxy/proxy-detector.ts b/src/cliproxy/proxy-detector.ts index b2afe545..1a7bc812 100644 --- a/src/cliproxy/proxy-detector.ts +++ b/src/cliproxy/proxy-detector.ts @@ -73,12 +73,25 @@ export async function detectRunningProxy( // Proxy is running and responsive // Try to get PID from session lock if available const lock = getExistingProxy(port); - log(`HTTP check passed, proxy healthy (PID: ${lock?.pid ?? 'unknown'})`); + let pid = lock?.pid; + + // If no PID from session lock, try port-process detection + // This handles orphaned proxies (running but no sessions.json) + if (!pid) { + log('No PID from session lock, checking port process...'); + const portProcess = await getPortProcess(port); + if (portProcess) { + pid = portProcess.pid; + log(`Got PID from port process: ${pid}`); + } + } + + log(`HTTP check passed, proxy healthy (PID: ${pid ?? 'unknown'})`); return { running: true, verified: true, method: 'http', - pid: lock?.pid, + pid, sessionCount: lock?.sessions?.length, }; } diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts index 83c3b5a5..2c14804b 100644 --- a/src/commands/cliproxy-command.ts +++ b/src/commands/cliproxy-command.ts @@ -616,7 +616,7 @@ export async function handleCliproxyCommand(args: string[]): Promise { if (installIdx !== -1) { const version = args[installIdx + 1]; if (!version || version.startsWith('-')) { - console.error('[X] Missing version argument for --install'); + console.error(fail('Missing version argument for --install')); console.error(' Usage: ccs cliproxy --install '); console.error(' Example: ccs cliproxy --install 6.5.53'); process.exit(1); diff --git a/src/commands/config-command.ts b/src/commands/config-command.ts index 838ee899..1814fa06 100644 --- a/src/commands/config-command.ts +++ b/src/commands/config-command.ts @@ -12,7 +12,7 @@ import { startServer } from '../web-server'; import { setupGracefulShutdown } from '../web-server/shutdown'; import { ensureCliproxyService } from '../cliproxy/service-manager'; import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config-generator'; -import { initUI, header, ok, info, warn } from '../utils/ui'; +import { initUI, header, ok, info, warn, fail } from '../utils/ui'; interface ConfigOptions { port?: number; @@ -33,7 +33,7 @@ function parseArgs(args: string[]): ConfigOptions { if (!isNaN(port) && port > 0 && port < 65536) { result.port = port; } else { - console.error('[X] Invalid port number'); + console.error(fail('Invalid port number')); process.exit(1); } } else if (arg === '--dev') { @@ -137,7 +137,7 @@ export async function handleConfigCommand(args: string[]): Promise { console.log(''); console.log(info('Press Ctrl+C to stop')); } catch (error) { - console.error('[X] Failed to start server:', (error as Error).message); + console.error(fail(`Failed to start server: ${(error as Error).message}`)); process.exit(1); } } diff --git a/src/commands/copilot-command.ts b/src/commands/copilot-command.ts index 09c0904b..306d272e 100644 --- a/src/commands/copilot-command.ts +++ b/src/commands/copilot-command.ts @@ -14,6 +14,7 @@ import { } from '../copilot'; import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../config/unified-config-loader'; import { DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types'; +import { ok, fail, info, color } from '../utils/ui'; /** * Handle copilot subcommand. @@ -42,7 +43,7 @@ export async function handleCopilotCommand(args: string[]): Promise { case '-h': return handleHelp(); default: - console.error(`[X] Unknown subcommand: ${subcommand}`); + console.error(fail(`Unknown subcommand: ${subcommand}`)); console.error(''); return handleHelp(); } @@ -81,7 +82,7 @@ function handleHelp(): number { */ async function handleAuth(): Promise { if (!isCopilotApiInstalled()) { - console.error('[X] copilot-api is not installed.'); + console.error(fail('copilot-api is not installed.')); console.error(''); console.error('Install with: npm install -g copilot-api'); return 1; @@ -91,7 +92,7 @@ async function handleAuth(): Promise { if (result.success) { console.log(''); - console.log('[OK] Authentication successful!'); + console.log(ok('Authentication successful!')); console.log(''); console.log('Next steps:'); console.log(' 1. Enable copilot: ccs copilot enable'); @@ -100,7 +101,7 @@ async function handleAuth(): Promise { return 0; } else { console.error(''); - console.error(`[X] ${result.error}`); + console.error(fail(result.error || 'Authentication failed')); return 1; } } @@ -119,17 +120,17 @@ async function handleStatus(): Promise { console.log(''); // Enabled status - const enabledIcon = copilotConfig.enabled ? '[OK]' : '[X]'; + const enabledIcon = copilotConfig.enabled ? color('[OK]', 'success') : color('[X]', 'error'); const enabledText = copilotConfig.enabled ? 'Enabled' : 'Disabled'; console.log(`Integration: ${enabledIcon} ${enabledText}`); // Auth status - const authIcon = status.auth.authenticated ? '[OK]' : '[X]'; + const authIcon = status.auth.authenticated ? color('[OK]', 'success') : color('[X]', 'error'); const authText = status.auth.authenticated ? 'Authenticated' : 'Not authenticated'; console.log(`Authentication: ${authIcon} ${authText}`); // Daemon status - const daemonIcon = status.daemon.running ? '[OK]' : '[X]'; + const daemonIcon = status.daemon.running ? color('[OK]', 'success') : color('[X]', 'error'); const daemonText = status.daemon.running ? 'Running' : 'Not running'; console.log(`Daemon: ${daemonIcon} ${daemonText}`); @@ -196,15 +197,15 @@ async function handleStart(): Promise { const config = loadOrCreateUnifiedConfig(); const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG; - console.log(`[i] Starting copilot-api daemon on port ${copilotConfig.port}...`); + console.log(info(`Starting copilot-api daemon on port ${copilotConfig.port}...`)); const result = await startDaemon(copilotConfig); if (result.success) { - console.log(`[OK] Daemon started (PID: ${result.pid})`); + console.log(ok(`Daemon started (PID: ${result.pid})`)); return 0; } else { - console.error(`[X] ${result.error}`); + console.error(fail(result.error || 'Failed to start daemon')); return 1; } } @@ -213,15 +214,15 @@ async function handleStart(): Promise { * Handle stop subcommand. */ async function handleStop(): Promise { - console.log('[i] Stopping copilot-api daemon...'); + console.log(info('Stopping copilot-api daemon...')); const result = await stopDaemon(); if (result.success) { - console.log('[OK] Daemon stopped'); + console.log(ok('Daemon stopped')); return 0; } else { - console.error(`[X] ${result.error}`); + console.error(fail(result.error || 'Failed to stop daemon')); return 1; } } @@ -239,7 +240,7 @@ async function handleEnable(): Promise { config.copilot.enabled = true; saveUnifiedConfig(config); - console.log('[OK] Copilot integration enabled'); + console.log(ok('Copilot integration enabled')); console.log(''); console.log('Next steps:'); console.log(' 1. Authenticate: ccs copilot auth'); @@ -260,7 +261,7 @@ async function handleDisable(): Promise { saveUnifiedConfig(config); } - console.log('[OK] Copilot integration disabled'); + console.log(ok('Copilot integration disabled')); return 0; } diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts index 794cf8ff..d2364aba 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -12,6 +12,7 @@ import { checkAuthStatus, isCopilotApiInstalled } from './copilot-auth'; import { isDaemonRunning, startDaemon } from './copilot-daemon'; import { ensureCopilotApi } from './copilot-package-manager'; import { CopilotStatus } from './types'; +import { fail, info, ok } from '../utils/ui'; /** * Get full copilot status (auth + daemon). @@ -73,7 +74,7 @@ export async function executeCopilotProfile( try { await ensureCopilotApi(); } catch (error) { - console.error('[X] Failed to install copilot-api.'); + console.error(fail('Failed to install copilot-api.')); console.error(''); console.error(`Error: ${(error as Error).message}`); console.error(''); @@ -84,7 +85,7 @@ export async function executeCopilotProfile( // Check if copilot-api is installed (should be after ensureCopilotApi) if (!isCopilotApiInstalled()) { - console.error('[X] copilot-api is not installed.'); + console.error(fail('copilot-api is not installed.')); console.error(''); console.error('Install with: ccs copilot --install'); return 1; @@ -93,7 +94,7 @@ export async function executeCopilotProfile( // Check authentication const authStatus = await checkAuthStatus(); if (!authStatus.authenticated) { - console.error('[X] Not authenticated with GitHub.'); + console.error(fail('Not authenticated with GitHub.')); console.error(''); console.error('Run: npx copilot-api auth'); console.error('Or: ccs copilot auth'); @@ -105,16 +106,16 @@ export async function executeCopilotProfile( if (!daemonRunning) { if (config.auto_start) { - console.log('[i] Starting copilot-api daemon...'); + console.log(info('Starting copilot-api daemon...')); const result = await startDaemon(config); if (!result.success) { - console.error(`[X] Failed to start daemon: ${result.error}`); + console.error(fail(`Failed to start daemon: ${result.error}`)); return 1; } - console.log(`[OK] Daemon started on port ${config.port}`); + console.log(ok(`Daemon started on port ${config.port}`)); daemonRunning = true; } else { - console.error('[X] copilot-api daemon is not running.'); + console.error(fail('copilot-api daemon is not running.')); console.error(''); console.error('Start the daemon manually:'); console.error(` npx copilot-api start --port ${config.port}`); @@ -139,7 +140,7 @@ export async function executeCopilotProfile( ...copilotEnv, }; - console.log(`[i] Using GitHub Copilot proxy (model: ${config.model})`); + console.log(info(`Using GitHub Copilot proxy (model: ${config.model})`)); console.log(''); // Spawn Claude CLI @@ -155,7 +156,7 @@ export async function executeCopilotProfile( }); proc.on('error', (err) => { - console.error(`[X] Failed to start Claude: ${err.message}`); + console.error(fail(`Failed to start Claude: ${err.message}`)); resolve(1); }); }); diff --git a/src/management/checks/types.ts b/src/management/checks/types.ts index 4485575a..c55b0f71 100644 --- a/src/management/checks/types.ts +++ b/src/management/checks/types.ts @@ -2,6 +2,8 @@ * Health Check Types and Interfaces */ +import { ok, fail, warn, info } from '../../utils/ui'; + /** * Spinner interface for ora or fallback */ @@ -98,14 +100,14 @@ export function createSpinner(): (text: string) => Spinner { const oraModule = require('ora'); return oraModule.default || oraModule; } catch (_e) { - // ora not available, create fallback spinner that uses console.log + // ora not available, create fallback spinner that uses console.log with UI colors return function (text: string): Spinner { return { start: () => ({ - succeed: (msg?: string) => console.log(msg || `[OK] ${text}`), - fail: (msg?: string) => console.log(msg || `[X] ${text}`), - warn: (msg?: string) => console.log(msg || `[!] ${text}`), - info: (msg?: string) => console.log(msg || `[i] ${text}`), + succeed: (msg?: string) => console.log(msg || ok(text)), + fail: (msg?: string) => console.log(msg || fail(text)), + warn: (msg?: string) => console.log(msg || warn(text)), + info: (msg?: string) => console.log(msg || info(text)), text: '', }), }; diff --git a/src/utils/claude-dir-installer.ts b/src/utils/claude-dir-installer.ts index 9664f241..05ac1de0 100644 --- a/src/utils/claude-dir-installer.ts +++ b/src/utils/claude-dir-installer.ts @@ -6,7 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { ok, warn } from './ui'; +import { ok, fail, warn, info } from './ui'; // Ora fallback type for when ora is not available interface OraSpinner { @@ -27,14 +27,14 @@ try { const oraModule = require('ora'); ora = oraModule.default || oraModule; } catch { - // ora not available, create fallback spinner that uses console.log + // ora not available, create fallback spinner that uses console.log with UI colors ora = function (text: string): OraInstance { return { start: () => ({ - succeed: (msg?: string) => console.log(msg || `[OK] ${text}`), - fail: (msg?: string) => console.log(msg || `[X] ${text}`), - warn: (msg?: string) => console.log(msg || `[!] ${text}`), - info: (msg?: string) => console.log(msg || `[i] ${text}`), + succeed: (msg?: string) => console.log(msg || ok(text)), + fail: (msg?: string) => console.log(msg || fail(text)), + warn: (msg?: string) => console.log(msg || warn(text)), + info: (msg?: string) => console.log(msg || info(text)), text: '', }), }; @@ -85,11 +85,11 @@ export class ClaudeDirInstaller { if (!fs.existsSync(packageClaudeDir)) { const msg = 'Package .claude/ directory not found'; if (spinner) { - spinner.warn(`[!] ${msg}`); + spinner.warn(warn(msg)); console.log(` Searched in: ${packageClaudeDir}`); console.log(' This may be a development installation'); } else { - console.log(`[!] ${msg}`); + console.log(warn(msg)); console.log(` Searched in: ${packageClaudeDir}`); console.log(' This may be a development installation'); } @@ -119,7 +119,7 @@ export class ClaudeDirInstaller { if (spinner) { spinner.succeed(ok(msg)); } else { - console.log(`[OK] ${msg}`); + console.log(ok(msg)); } return true; } catch (err) { @@ -129,7 +129,7 @@ export class ClaudeDirInstaller { spinner.fail(warn(msg)); console.warn(' CCS items may not be available'); } else { - console.warn(`[!] ${msg}`); + console.warn(warn(msg)); console.warn(' CCS items may not be available'); } return false; @@ -223,14 +223,14 @@ export class ClaudeDirInstaller { 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)}`); + if (!silent) console.log(info(`Backed up user file to ${path.basename(backupPath)}`)); cleanedFiles.push('user file (backed up)'); } } catch (err) { const error = err as NodeJS.ErrnoException; // File doesn't exist or other error - that's okay if (error.code !== 'ENOENT' && !silent) { - console.log(`[!] Failed to remove user symlink: ${error.message}`); + console.log(warn(`Failed to remove user symlink: ${error.message}`)); } } @@ -245,14 +245,16 @@ export class ClaudeDirInstaller { const backupPath = `${deprecatedFile}.backup-${timestamp}`; fs.renameSync(deprecatedFile, backupPath); if (!silent) - console.log(`[i] Backed up modified deprecated file to ${path.basename(backupPath)}`); + console.log( + info(`Backed up modified deprecated file to ${path.basename(backupPath)}`) + ); } else { fs.rmSync(deprecatedFile, { force: true }); } cleanedFiles.push('package copy'); } catch (err) { const error = err as Error; - if (!silent) console.log(`[!] Failed to remove package copy: ${error.message}`); + if (!silent) console.log(warn(`Failed to remove package copy: ${error.message}`)); } } @@ -265,14 +267,14 @@ export class ClaudeDirInstaller { fs.writeFileSync(migrationMarker, new Date().toISOString()); if (!silent) { - console.log(`[OK] Cleaned up deprecated agent files: ${cleanedFiles.join(', ')}`); + console.log(ok(`Cleaned up deprecated agent files: ${cleanedFiles.join(', ')}`)); } } return { success: true, cleanedFiles }; } catch (err) { const error = err as Error; - if (!silent) console.log(`[!] Cleanup failed: ${error.message}`); + if (!silent) console.log(warn(`Cleanup failed: ${error.message}`)); return { success: false, error: error.message, cleanedFiles }; } } diff --git a/src/utils/claude-symlink-manager.ts b/src/utils/claude-symlink-manager.ts index dc252610..4902d698 100644 --- a/src/utils/claude-symlink-manager.ts +++ b/src/utils/claude-symlink-manager.ts @@ -15,7 +15,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { ok, color } from './ui'; +import { ok, fail, warn, info, color } from './ui'; // Ora fallback type for when ora is not available interface OraSpinner { @@ -36,14 +36,14 @@ try { const oraModule = require('ora'); ora = oraModule.default || oraModule; } catch { - // ora not available, create fallback spinner that uses console.log + // ora not available, create fallback spinner that uses console.log with UI colors ora = function (text: string): OraInstance { return { start: () => ({ - succeed: (msg?: string) => console.log(msg || `[OK] ${text}`), - fail: (msg?: string) => console.log(msg || `[X] ${text}`), - warn: (msg?: string) => console.log(msg || `[!] ${text}`), - info: (msg?: string) => console.log(msg || `[i] ${text}`), + succeed: (msg?: string) => console.log(msg || ok(text)), + fail: (msg?: string) => console.log(msg || fail(text)), + warn: (msg?: string) => console.log(msg || warn(text)), + info: (msg?: string) => console.log(msg || info(text)), text: '', }), }; @@ -94,9 +94,9 @@ export class ClaudeSymlinkManager { if (!fs.existsSync(this.ccsClaudeDir)) { const msg = 'CCS .claude/ directory not found, skipping symlink installation'; if (spinner) { - spinner.warn(`[!] ${msg}`); + spinner.warn(warn(msg)); } else { - console.log(`[!] ${msg}`); + console.log(warn(msg)); } return; } @@ -123,7 +123,7 @@ export class ClaudeSymlinkManager { if (spinner) { spinner.succeed(ok(msg)); } else { - console.log(`[OK] ${msg}`); + console.log(ok(msg)); } } @@ -137,7 +137,7 @@ export class ClaudeSymlinkManager { // Ensure source exists if (!fs.existsSync(sourcePath)) { - if (!silent) console.log(`[!] Source not found: ${item.source}, skipping`); + if (!silent) console.log(warn(`Source not found: ${item.source}, skipping`)); return false; } @@ -161,7 +161,7 @@ export class ClaudeSymlinkManager { try { const symlinkType = item.type === 'directory' ? 'dir' : 'file'; fs.symlinkSync(sourcePath, targetPath, symlinkType); - if (!silent) console.log(`[OK] Symlinked ${item.target}`); + if (!silent) console.log(ok(`Symlinked ${item.target}`)); return true; } catch (err) { // Windows fallback: copy instead of symlink when symlinks unavailable @@ -169,7 +169,7 @@ export class ClaudeSymlinkManager { return this.copyFallback(sourcePath, targetPath, item, silent); } else { const error = err as Error; - if (!silent) console.log(`[!] Failed to symlink ${item.target}: ${error.message}`); + if (!silent) console.log(warn(`Failed to symlink ${item.target}: ${error.message}`)); } return false; } @@ -194,15 +194,15 @@ export class ClaudeSymlinkManager { fs.copyFileSync(sourcePath, targetPath); } if (!silent) { - console.log(`[OK] Copied ${item.target} (symlink unavailable)`); - console.log(`[i] Run 'ccs sync' after CCS updates to refresh`); + console.log(ok(`Copied ${item.target} (symlink unavailable)`)); + console.log(info("Run 'ccs sync' after CCS updates to refresh")); } return true; } catch (copyErr) { const error = copyErr as Error; if (!silent) { - console.log(`[!] Failed to copy ${item.target}: ${error.message}`); - console.log(`[i] Enable Developer Mode for symlinks, or check permissions`); + console.log(warn(`Failed to copy ${item.target}: ${error.message}`)); + console.log(info('Enable Developer Mode for symlinks, or check permissions')); } return false; } @@ -267,10 +267,11 @@ export class ClaudeSymlinkManager { } fs.renameSync(itemPath, finalBackupPath); - if (!silent) console.log(`[i] Backed up existing item to ${path.basename(finalBackupPath)}`); + if (!silent) + console.log(info(`Backed up existing item to ${path.basename(finalBackupPath)}`)); } catch (err) { const error = err as Error; - if (!silent) console.log(`[!] Failed to backup ${itemPath}: ${error.message}`); + if (!silent) console.log(warn(`Failed to backup ${itemPath}: ${error.message}`)); throw err; // Don't proceed if backup fails } } @@ -300,19 +301,19 @@ export class ClaudeSymlinkManager { // Remove symlink or file fs.unlinkSync(targetPath); } - console.log(`[OK] Removed ${item.target}`); + console.log(ok(`Removed ${item.target}`)); removed++; } catch (err) { const error = err as Error; - console.log(`[!] Failed to remove ${item.target}: ${error.message}`); + console.log(warn(`Failed to remove ${item.target}: ${error.message}`)); } } } if (removed > 0) { - console.log(`[OK] Removed ${removed} delegation commands and skills from ~/.claude/`); + console.log(ok(`Removed ${removed} delegation commands and skills from ~/.claude/`)); } else { - console.log('[i] No delegation commands or skills to remove'); + console.log(info('No delegation commands or skills to remove')); } } diff --git a/src/utils/progress-indicator.ts b/src/utils/progress-indicator.ts index 36f1b195..104d000c 100644 --- a/src/utils/progress-indicator.ts +++ b/src/utils/progress-indicator.ts @@ -1,13 +1,16 @@ /** - * Simple Progress Indicator (no external dependencies) + * Simple Progress Indicator * * Features: * - ASCII-only spinner frames (cross-platform compatible) * - TTY detection (no spinners in pipes/logs) * - Elapsed time display * - CI environment detection + * - Color support via UI system */ +import { color } from './ui/colors'; + interface ProgressOptions { frames?: string[]; interval?: number; @@ -44,7 +47,7 @@ export class ProgressIndicator { start(): void { if (!this.isTTY) { // Non-TTY: just print message once - process.stderr.write(`[i] ${this.message}...\n`); + process.stderr.write(`${color('[i]', 'info')} ${this.message}...\n`); return; } @@ -52,7 +55,7 @@ export class ProgressIndicator { 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)`); + process.stderr.write(`\r${color(`[${frame}]`, 'info')} ${this.message}... (${elapsed}s)`); this.frameIndex = (this.frameIndex + 1) % this.frames.length; }, 80); // 12.5fps for smooth animation } @@ -68,10 +71,10 @@ export class ProgressIndicator { if (this.isTTY) { // Clear spinner line and show success - process.stderr.write(`\r[OK] ${finalMessage} (${elapsed}s)\n`); + process.stderr.write(`\r${color('[OK]', 'success')} ${finalMessage} (${elapsed}s)\n`); } else { // Non-TTY: just show completion - process.stderr.write(`[OK] ${finalMessage}\n`); + process.stderr.write(`${color('[OK]', 'success')} ${finalMessage}\n`); } } @@ -85,10 +88,10 @@ export class ProgressIndicator { if (this.isTTY) { // Clear spinner line and show failure - process.stderr.write(`\r[X] ${finalMessage}\n`); + process.stderr.write(`\r${color('[X]', 'error')} ${finalMessage}\n`); } else { // Non-TTY: just show failure - process.stderr.write(`[X] ${finalMessage}\n`); + process.stderr.write(`${color('[X]', 'error')} ${finalMessage}\n`); } } diff --git a/src/utils/websearch/status.ts b/src/utils/websearch/status.ts index 7c47462e..95ddea17 100644 --- a/src/utils/websearch/status.ts +++ b/src/utils/websearch/status.ts @@ -71,6 +71,7 @@ export function hasAnyWebSearchCli(): boolean { /** * Get install hints for CLI-only users when no WebSearch CLI is installed + * Returns raw message strings (without indicator prefix) for display */ export function getCliInstallHints(): string[] { if (hasAnyWebSearchCli()) { @@ -78,7 +79,7 @@ export function getCliInstallHints(): string[] { } return [ - '[i] WebSearch: No CLI tools installed', + 'WebSearch: No CLI tools installed', ' Gemini CLI (FREE): npm i -g @google/gemini-cli', ' OpenCode (FREE): curl -fsSL https://opencode.ai/install | bash', ' Grok CLI (paid): npm i -g @vibe-kit/grok-cli', @@ -182,8 +183,13 @@ export function displayWebSearchStatus(): void { console.error(fail(`WebSearch: ${status.message}`)); const hints = getCliInstallHints(); if (hints.length > 0) { - for (const hint of hints) { - console.error(info(hint)); + // First line gets [i] prefix, rest are continuation (indented, no prefix) + for (let i = 0; i < hints.length; i++) { + if (i === 0) { + console.error(info(hints[i])); + } else { + console.error(hints[i]); + } } } break;