feat(ui): enhance doctor and error manager with new ui layer

- doctor.ts: add styled header box, semantic status indicators,
  formatted summary table, grouped check sections
- error-manager.ts: add boxed error messages, styled solution
  sections, semantic color coding for all error types
- ccs.ts, shell-executor.ts: await async error methods

Phase 4 of CLI UI Enhancement plan complete.
This commit is contained in:
kaitranntt
2025-12-01 19:54:44 -05:00
parent 6f42a6527b
commit 57016f3f76
4 changed files with 272 additions and 259 deletions
+2 -2
View File
@@ -284,7 +284,7 @@ async function main(): Promise<void> {
// Detect Claude CLI first (needed for all paths)
const claudeCli = detectClaudeCli();
if (!claudeCli) {
ErrorManager.showClaudeNotFound();
await ErrorManager.showClaudeNotFound();
process.exit(1);
}
@@ -341,7 +341,7 @@ async function main(): Promise<void> {
// Check if this is a profile not found error with suggestions
if (err.profileName && err.availableProfiles !== undefined) {
const allProfiles = err.availableProfiles.split('\n');
ErrorManager.showProfileNotFound(err.profileName, allProfiles, err.suggestions);
await ErrorManager.showProfileNotFound(err.profileName, allProfiles, err.suggestions);
} else {
console.error(`[X] ${err.message}`);
}
+141 -162
View File
@@ -6,8 +6,8 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { spawn } from 'child_process';
import { colored } from '../utils/helpers';
import { detectClaudeCli } from '../utils/claude-detector';
import { initUI, header, box, table, color, ok, fail, warn, info } from '../utils/ui';
import packageJson from '../../package.json';
import {
isCLIProxyInstalled,
@@ -50,9 +50,6 @@ try {
};
}
// Import cli-table3
const Table = require('cli-table3');
interface HealthCheckDetails {
status: 'OK' | 'ERROR' | 'WARN';
info: string;
@@ -133,40 +130,43 @@ class Doctor {
* Run all health checks
*/
async runAllChecks(): Promise<HealthCheck> {
console.log(colored('Running CCS Health Check...', 'cyan'));
await initUI();
// Hero header box
console.log(box(`CCS Health Check v${this.ccsVersion}`, { borderStyle: 'round', padding: 0 }));
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'));
console.log(header('SYSTEM'));
await this.checkClaudeCli();
this.checkCcsDirectory();
console.log('');
// Group 2: Configuration
console.log(colored('Configuration:', 'bold'));
console.log(header('CONFIGURATION'));
this.checkConfigFiles();
this.checkClaudeSettings();
console.log('');
// Group 3: Profiles & Delegation
console.log(colored('Profiles & Delegation:', 'bold'));
console.log(header('PROFILES & DELEGATION'));
this.checkProfiles();
this.checkInstances();
this.checkDelegation();
console.log('');
// Group 4: System Health
console.log(colored('System Health:', 'bold'));
console.log(header('SYSTEM HEALTH'));
this.checkPermissions();
this.checkCcsSymlinks();
this.checkSettingsSymlinks();
console.log('');
// Group 5: CLIProxy (OAuth profiles)
console.log(colored('CLIProxy (OAuth Profiles):', 'bold'));
console.log(header('CLIPROXY (OAUTH PROFILES)'));
await this.checkCLIProxy();
console.log('');
@@ -183,7 +183,8 @@ class Doctor {
const claudeCli = detectClaudeCli();
if (!claudeCli) {
spinner.fail(` ${'Claude CLI'.padEnd(26)}${colored('[X]', 'red')} Not found in PATH`);
spinner.fail();
console.log(` ${fail('Claude CLI'.padEnd(22))} Not found in PATH`);
this.results.addCheck(
'Claude CLI',
'error',
@@ -218,17 +219,15 @@ class Doctor {
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})`
);
spinner.succeed();
console.log(` ${ok('Claude CLI'.padEnd(22))} v${version} (${claudeCli})`);
this.results.addCheck('Claude CLI', 'success', `Found: ${claudeCli}`, undefined, {
status: 'OK',
info: `v${version} (${claudeCli})`,
});
} catch (_err) {
spinner.fail(
` ${'Claude CLI'.padEnd(26)}${colored('[X]', 'red')} Not found or not working`
);
spinner.fail();
console.log(` ${fail('Claude CLI'.padEnd(22))} Not found or not working`);
this.results.addCheck(
'Claude CLI',
'error',
@@ -246,13 +245,15 @@ class Doctor {
const spinner = ora('Checking ~/.ccs/ directory').start();
if (fs.existsSync(this.ccsDir)) {
spinner.succeed(` ${'CCS Directory'.padEnd(26)}${colored('[OK]', 'green')} ~/.ccs/`);
spinner.succeed();
console.log(` ${ok('CCS Directory'.padEnd(22))} ~/.ccs/`);
this.results.addCheck('CCS Directory', 'success', undefined, undefined, {
status: 'OK',
info: '~/.ccs/',
});
} else {
spinner.fail(` ${'CCS Directory'.padEnd(26)}${colored('[X]', 'red')} Not found`);
spinner.fail();
console.log(` ${fail('CCS Directory'.padEnd(22))} Not found`);
this.results.addCheck(
'CCS Directory',
'error',
@@ -289,7 +290,8 @@ class Doctor {
const spinner = ora(`Checking ${file.name}`).start();
if (!fs.existsSync(file.path)) {
spinner.fail(` ${file.name.padEnd(26)}${colored('[X]', 'red')} Not found`);
spinner.fail();
console.log(` ${fail(file.name.padEnd(22))} Not found`);
this.results.addCheck(
file.name,
'error',
@@ -306,7 +308,7 @@ class Doctor {
JSON.parse(content);
// Extract useful info based on file type
let info = 'Valid';
let fileInfo = 'Valid';
let status: 'OK' | 'WARN' = 'OK';
if (file.profile) {
@@ -314,23 +316,23 @@ class Doctor {
const validation = DelegationValidator.validate(file.profile);
if (validation.valid) {
info = 'Key configured';
fileInfo = 'Key configured';
status = 'OK';
} else if (validation.error && validation.error.includes('placeholder')) {
info = 'Placeholder key (not configured)';
fileInfo = 'Placeholder key';
status = 'WARN';
} else {
info = 'Valid JSON';
fileInfo = 'Valid JSON';
status = 'OK';
}
}
const statusIcon = status === 'OK' ? colored('[OK]', 'green') : colored('[!]', 'yellow');
if (status === 'WARN') {
spinner.warn(` ${file.name.padEnd(26)}${statusIcon} ${info}`);
spinner.warn();
console.log(` ${warn(file.name.padEnd(22))} ${fileInfo}`);
} else {
spinner.succeed(` ${file.name.padEnd(26)}${statusIcon} ${info}`);
spinner.succeed();
console.log(` ${ok(file.name.padEnd(22))} ${fileInfo}`);
}
this.results.addCheck(
@@ -340,11 +342,12 @@ class Doctor {
undefined,
{
status: status,
info: info,
info: fileInfo,
}
);
} catch (e) {
spinner.fail(` ${file.name.padEnd(26)}${colored('[X]', 'red')} Invalid JSON`);
spinner.fail();
console.log(` ${fail(file.name.padEnd(22))} Invalid JSON`);
this.results.addCheck(
file.name,
'error',
@@ -362,11 +365,11 @@ class Doctor {
private checkClaudeSettings(): void {
const spinner = ora('Checking ~/.claude/settings.json').start();
const settingsPath = path.join(this.claudeDir, 'settings.json');
const settingsName = '~/.claude/settings.json';
if (!fs.existsSync(settingsPath)) {
spinner.warn(
` ${'~/.claude/settings.json'.padEnd(26)}${colored('[!]', 'yellow')} Not found`
);
spinner.warn();
console.log(` ${warn(settingsName.padEnd(22))} Not found`);
this.results.addCheck(
'Claude Settings',
'warning',
@@ -380,12 +383,12 @@ class Doctor {
try {
const content = fs.readFileSync(settingsPath, 'utf8');
JSON.parse(content);
spinner.succeed(` ${'~/.claude/settings.json'.padEnd(26)}${colored('[OK]', 'green')}`);
spinner.succeed();
console.log(` ${ok(settingsName.padEnd(22))} Valid`);
this.results.addCheck('Claude Settings', 'success');
} catch (e) {
spinner.warn(
` ${'~/.claude/settings.json'.padEnd(26)}${colored('[!]', 'yellow')} Invalid JSON`
);
spinner.warn();
console.log(` ${warn(settingsName.padEnd(22))} Invalid JSON`);
this.results.addCheck(
'Claude Settings',
'warning',
@@ -403,7 +406,8 @@ class Doctor {
const configPath = path.join(this.ccsDir, 'config.json');
if (!fs.existsSync(configPath)) {
spinner.info(` ${'Profiles'.padEnd(26)}${colored('[SKIP]', 'cyan')} config.json not found`);
spinner.info();
console.log(` ${info('Profiles'.padEnd(22))} config.json not found`);
return;
}
@@ -411,7 +415,8 @@ class Doctor {
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`);
spinner.fail();
console.log(` ${fail('Profiles'.padEnd(22))} Missing profiles object`);
this.results.addCheck(
'Profiles',
'error',
@@ -425,9 +430,8 @@ class Doctor {
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})`
);
spinner.succeed();
console.log(` ${ok('Profiles'.padEnd(22))} ${profileCount} configured (${profileNames})`);
this.results.addCheck(
'Profiles',
'success',
@@ -439,7 +443,8 @@ class Doctor {
}
);
} catch (e) {
spinner.fail(` ${'Profiles'.padEnd(26)}${colored('[X]', 'red')} ${(e as Error).message}`);
spinner.fail();
console.log(` ${fail('Profiles'.padEnd(22))} ${(e as Error).message}`);
this.results.addCheck('Profiles', 'error', (e as Error).message, undefined, {
status: 'ERROR',
info: (e as Error).message,
@@ -455,7 +460,8 @@ class Doctor {
const instancesDir = path.join(this.ccsDir, 'instances');
if (!fs.existsSync(instancesDir)) {
spinner.info(` ${'Instances'.padEnd(26)}${colored('[i]', 'cyan')} No account profiles`);
spinner.info();
console.log(` ${info('Instances'.padEnd(22))} No account profiles`);
this.results.addCheck('Instances', 'success', 'No account profiles configured');
return;
}
@@ -465,14 +471,14 @@ class Doctor {
});
if (instances.length === 0) {
spinner.info(` ${'Instances'.padEnd(26)}${colored('[i]', 'cyan')} No account profiles`);
spinner.info();
console.log(` ${info('Instances'.padEnd(22))} No account profiles`);
this.results.addCheck('Instances', 'success', 'No account profiles');
return;
}
spinner.succeed(
` ${'Instances'.padEnd(26)}${colored('[OK]', 'green')} ${instances.length} account profiles`
);
spinner.succeed();
console.log(` ${ok('Instances'.padEnd(22))} ${instances.length} account profiles`);
this.results.addCheck('Instances', 'success', `${instances.length} account profiles`);
}
@@ -488,7 +494,8 @@ class Doctor {
const hasContinueCommand = fs.existsSync(path.join(ccsClaudeCommandsDir, 'ccs', 'continue.md'));
if (!hasCcsCommand || !hasContinueCommand) {
spinner.warn(` ${'Delegation'.padEnd(26)}${colored('[!]', 'yellow')} Not installed`);
spinner.warn();
console.log(` ${warn('Delegation'.padEnd(22))} Not installed`);
this.results.addCheck(
'Delegation',
'warning',
@@ -511,7 +518,8 @@ class Doctor {
}
if (readyProfiles.length === 0) {
spinner.warn(` ${'Delegation'.padEnd(26)}${colored('[!]', 'yellow')} No profiles ready`);
spinner.warn();
console.log(` ${warn('Delegation'.padEnd(22))} No profiles ready`);
this.results.addCheck(
'Delegation',
'warning',
@@ -522,8 +530,9 @@ class Doctor {
return;
}
spinner.succeed(
` ${'Delegation'.padEnd(26)}${colored('[OK]', 'green')} ${readyProfiles.length} profiles ready (${readyProfiles.join(', ')})`
spinner.succeed();
console.log(
` ${ok('Delegation'.padEnd(22))} ${readyProfiles.length} profiles ready (${readyProfiles.join(', ')})`
);
this.results.addCheck(
'Delegation',
@@ -544,17 +553,15 @@ class Doctor {
try {
fs.writeFileSync(testFile, 'test', 'utf8');
fs.unlinkSync(testFile);
spinner.succeed(
` ${'Permissions'.padEnd(26)}${colored('[OK]', 'green')} Write access verified`
);
spinner.succeed();
console.log(` ${ok('Permissions'.padEnd(22))} Write access verified`);
this.results.addCheck('Permissions', 'success', undefined, undefined, {
status: 'OK',
info: 'Write access verified',
});
} catch (_e) {
spinner.fail(
` ${'Permissions'.padEnd(26)}${colored('[X]', 'red')} Cannot write to ~/.ccs/`
);
spinner.fail();
console.log(` ${fail('Permissions'.padEnd(22))} Cannot write to ~/.ccs/`);
this.results.addCheck(
'Permissions',
'error',
@@ -578,9 +585,8 @@ class Doctor {
if (health.healthy) {
const itemCount = manager.ccsItems.length;
spinner.succeed(
` ${'CCS Symlinks'.padEnd(26)}${colored('[OK]', 'green')} ${itemCount}/${itemCount} items linked`
);
spinner.succeed();
console.log(` ${ok('CCS Symlinks'.padEnd(22))} ${itemCount}/${itemCount} items linked`);
this.results.addCheck(
'CCS Symlinks',
'success',
@@ -592,9 +598,8 @@ class Doctor {
}
);
} else {
spinner.warn(
` ${'CCS Symlinks'.padEnd(26)}${colored('[!]', 'yellow')} ${health.issues.length} issues found`
);
spinner.warn();
console.log(` ${warn('CCS Symlinks'.padEnd(22))} ${health.issues.length} issues found`);
this.results.addCheck(
'CCS Symlinks',
'warning',
@@ -604,7 +609,8 @@ class Doctor {
);
}
} catch (e) {
spinner.warn(` ${'CCS Symlinks'.padEnd(26)}${colored('[!]', 'yellow')} Could not check`);
spinner.warn();
console.log(` ${warn('CCS Symlinks'.padEnd(22))} Could not check`);
this.results.addCheck(
'CCS Symlinks',
'warning',
@@ -620,6 +626,7 @@ class Doctor {
*/
private checkSettingsSymlinks(): void {
const spinner = ora('Checking settings.json symlinks').start();
const settingsLabel = 'settings.json';
try {
const sharedDir = path.join(this.homedir, '.ccs', 'shared');
@@ -628,9 +635,8 @@ class Doctor {
// Check shared settings exists and points to ~/.claude/
if (!fs.existsSync(sharedSettings)) {
spinner.warn(
` ${'settings.json (shared)'.padEnd(26)}${colored('[!]', 'yellow')} Not found`
);
spinner.warn();
console.log(` ${warn(settingsLabel.padEnd(22))} Not found (shared)`);
this.results.addCheck(
'Settings Symlinks',
'warning',
@@ -642,9 +648,8 @@ class Doctor {
const sharedStats = fs.lstatSync(sharedSettings);
if (!sharedStats.isSymbolicLink()) {
spinner.warn(
` ${'settings.json (shared)'.padEnd(26)}${colored('[!]', 'yellow')} Not a symlink`
);
spinner.warn();
console.log(` ${warn(settingsLabel.padEnd(22))} Not a symlink (shared)`);
this.results.addCheck(
'Settings Symlinks',
'warning',
@@ -658,9 +663,8 @@ class Doctor {
const resolvedShared = path.resolve(path.dirname(sharedSettings), sharedTarget);
if (resolvedShared !== claudeSettings) {
spinner.warn(
` ${'settings.json (shared)'.padEnd(26)}${colored('[!]', 'yellow')} Wrong target`
);
spinner.warn();
console.log(` ${warn(settingsLabel.padEnd(22))} Wrong target (shared)`);
this.results.addCheck(
'Settings Symlinks',
'warning',
@@ -673,9 +677,8 @@ class Doctor {
// 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`
);
spinner.succeed();
console.log(` ${ok(settingsLabel.padEnd(22))} Shared symlink valid`);
this.results.addCheck('Settings Symlinks', 'success', 'Shared symlink valid', undefined, {
status: 'OK',
info: 'Shared symlink valid',
@@ -716,9 +719,8 @@ class Doctor {
}
if (broken > 0) {
spinner.warn(
` ${'settings.json'.padEnd(26)}${colored('[!]', 'yellow')} ${broken} broken instance(s)`
);
spinner.warn();
console.log(` ${warn(settingsLabel.padEnd(22))} ${broken} broken instance(s)`);
this.results.addCheck(
'Settings Symlinks',
'warning',
@@ -727,9 +729,8 @@ class Doctor {
{ status: 'WARN', info: `${broken} broken instance(s)` }
);
} else {
spinner.succeed(
` ${'settings.json'.padEnd(26)}${colored('[OK]', 'green')} ${instances.length} instance(s) valid`
);
spinner.succeed();
console.log(` ${ok(settingsLabel.padEnd(22))} ${instances.length} instance(s) valid`);
this.results.addCheck(
'Settings Symlinks',
'success',
@@ -742,7 +743,8 @@ class Doctor {
);
}
} catch (err) {
spinner.warn(` ${'settings.json'.padEnd(26)}${colored('[!]', 'yellow')} Check failed`);
spinner.warn();
console.log(` ${warn(settingsLabel.padEnd(22))} Check failed`);
this.results.addCheck(
'Settings Symlinks',
'warning',
@@ -762,16 +764,16 @@ class Doctor {
if (isCLIProxyInstalled()) {
const binaryPath = getCLIProxyPath();
binarySpinner.succeed(
` ${'CLIProxy Binary'.padEnd(26)}${colored('[OK]', 'green')} v${CLIPROXY_VERSION}`
);
binarySpinner.succeed();
console.log(` ${ok('CLIProxy Binary'.padEnd(22))} v${CLIPROXY_VERSION}`);
this.results.addCheck('CLIProxy Binary', 'success', undefined, undefined, {
status: 'OK',
info: `v${CLIPROXY_VERSION} (${binaryPath})`,
});
} else {
binarySpinner.info(
` ${'CLIProxy Binary'.padEnd(26)}${colored('[i]', 'cyan')} Not installed (downloads on first use)`
binarySpinner.info();
console.log(
` ${info('CLIProxy Binary'.padEnd(22))} Not installed (downloads on first use)`
);
this.results.addCheck(
'CLIProxy Binary',
@@ -787,17 +789,15 @@ class Doctor {
const configPath = getConfigPath();
if (fs.existsSync(configPath)) {
configSpinner.succeed(
` ${'CLIProxy Config'.padEnd(26)}${colored('[OK]', 'green')} cliproxy/config.yaml`
);
configSpinner.succeed();
console.log(` ${ok('CLIProxy Config'.padEnd(22))} cliproxy/config.yaml`);
this.results.addCheck('CLIProxy Config', 'success', undefined, undefined, {
status: 'OK',
info: 'cliproxy/config.yaml',
});
} else {
configSpinner.info(
` ${'CLIProxy Config'.padEnd(26)}${colored('[i]', 'cyan')} Not created (generated on first use)`
);
configSpinner.info();
console.log(` ${info('CLIProxy Config'.padEnd(22))} Not created (on first use)`);
this.results.addCheck('CLIProxy Config', 'success', 'Not created yet', undefined, {
status: 'OK',
info: 'Generated on first use',
@@ -812,17 +812,15 @@ class Doctor {
if (status.authenticated) {
const lastAuth = status.lastAuth ? ` (${status.lastAuth.toLocaleDateString()})` : '';
authSpinner.succeed(
` ${`${providerName} Auth`.padEnd(26)}${colored('[OK]', 'green')} Authenticated${lastAuth}`
);
authSpinner.succeed();
console.log(` ${ok(`${providerName} Auth`.padEnd(22))} Authenticated${lastAuth}`);
this.results.addCheck(`${providerName} Auth`, 'success', undefined, undefined, {
status: 'OK',
info: `Authenticated${lastAuth}`,
});
} else {
authSpinner.info(
` ${`${providerName} Auth`.padEnd(26)}${colored('[i]', 'cyan')} Not authenticated`
);
authSpinner.info();
console.log(` ${info(`${providerName} Auth`.padEnd(22))} Not authenticated`);
this.results.addCheck(
`${providerName} Auth`,
'success',
@@ -838,17 +836,15 @@ class Doctor {
const portAvailable = await isPortAvailable(CLIPROXY_DEFAULT_PORT);
if (portAvailable) {
portSpinner.succeed(
` ${'CLIProxy Port'.padEnd(26)}${colored('[OK]', 'green')} ${CLIPROXY_DEFAULT_PORT} available`
);
portSpinner.succeed();
console.log(` ${ok('CLIProxy Port'.padEnd(22))} ${CLIPROXY_DEFAULT_PORT} available`);
this.results.addCheck('CLIProxy Port', 'success', undefined, undefined, {
status: 'OK',
info: `Port ${CLIPROXY_DEFAULT_PORT} available`,
});
} else {
portSpinner.warn(
` ${'CLIProxy Port'.padEnd(26)}${colored('[!]', 'yellow')} ${CLIPROXY_DEFAULT_PORT} in use`
);
portSpinner.warn();
console.log(` ${warn('CLIProxy Port'.padEnd(22))} ${CLIPROXY_DEFAULT_PORT} in use`);
this.results.addCheck(
'CLIProxy Port',
'warning',
@@ -864,68 +860,48 @@ class Doctor {
*/
private showReport(): void {
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(header('HEALTH CHECK SUMMARY'));
console.log('');
// Show errors and warnings if present
// Build summary table rows
const rows: string[][] = Object.entries(this.results.details).map(([component, detail]) => {
const statusIndicator =
detail.status === 'OK'
? color('[OK]', 'success')
: detail.status === 'ERROR'
? color('[X]', 'error')
: color('[!]', 'warning');
return [component, statusIndicator, detail.info || ''];
});
console.log(
table(rows, {
head: ['Component', 'Status', 'Details'],
colWidths: [20, 12, 35],
})
);
console.log('');
// Show errors if present
if (this.results.hasErrors()) {
console.log(colored('Errors:', 'red'));
console.log(header('ERRORS'));
this.results.errors.forEach((err) => {
console.log(` [X] ${err.name}: ${err.message}`);
console.log(` ${fail(err.name)}: ${err.message}`);
if (err.fix) {
console.log(` Fix: ${err.fix}`);
console.log(` Fix: ${color(err.fix, 'command')}`);
}
});
console.log('');
}
// Show warnings if present
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(header('WARNINGS'));
this.results.warnings.forEach((w) => {
console.log(` ${warn(w.name)}: ${w.message}`);
if (w.fix) {
console.log(` Fix: ${color(w.fix, 'command')}`);
}
});
console.log('');
@@ -933,12 +909,15 @@ class Doctor {
// Final status
if (this.results.isHealthy() && !this.results.hasWarnings()) {
console.log(colored('[OK] All checks passed! Your CCS installation is healthy.', 'green'));
console.log(ok('All checks passed! Installation is healthy.'));
} else if (this.results.hasErrors()) {
console.log(colored('[X] Status: Installation has errors', 'red'));
console.log('Run suggested fixes above to resolve issues.');
console.log(fail('Installation has errors. Run suggested fixes above.'));
} else {
console.log(colored('[OK] Status: Installation healthy (warnings only)', 'green'));
console.log(
ok(
`Installation healthy (${this.results.warnings.length} warning${this.results.warnings.length !== 1 ? 's' : ''})`
)
);
}
console.log('');
+127 -93
View File
@@ -1,4 +1,4 @@
import { colored } from './helpers';
import { initUI, header, color, dim, errorBox } from './ui';
import { ERROR_CODES, getErrorDocUrl, ErrorCode } from './error-codes';
/**
@@ -23,60 +23,70 @@ export class ErrorManager {
* Show error code and documentation URL
*/
static showErrorCode(errorCode: ErrorCode): void {
console.error(colored(`Error: ${errorCode}`, 'yellow'));
console.error(colored(getErrorDocUrl(errorCode), 'yellow'));
console.error(dim(`Error: ${errorCode}`));
console.error(dim(getErrorDocUrl(errorCode)));
console.error('');
}
/**
* Show Claude CLI not found error
*/
static showClaudeNotFound(): void {
static async showClaudeNotFound(): Promise<void> {
await initUI();
console.error('');
console.error(colored('[X] Claude CLI not found', 'red'));
console.error(
errorBox(
'Claude CLI not found\n\n' +
'CCS requires Claude CLI to be installed\n' +
'and available in PATH.',
'ERROR'
)
);
console.error('');
console.error('CCS requires Claude CLI to be installed and available in PATH.');
console.error(header('SOLUTIONS'));
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(' 1. Install Claude CLI');
console.error(` ${color('https://docs.claude.com/install', 'path')}`);
console.error('');
console.error(' 2. Verify installation:');
console.error(' command -v claude (Unix)');
console.error(' Get-Command claude (Windows)');
console.error(' 2. Verify installation');
console.error(` ${color('command -v claude', 'command')} (Unix)`);
console.error(` ${color('Get-Command claude', 'command')} (Windows)`);
console.error('');
console.error(' 3. Custom path (if installed elsewhere):');
console.error(' export CCS_CLAUDE_PATH="/path/to/claude"');
console.error(' 3. Custom path (if installed elsewhere)');
console.error(` ${color('export CCS_CLAUDE_PATH="/path/to/claude"', 'command')}`);
console.error('');
this.showErrorCode(ERROR_CODES.CLAUDE_NOT_FOUND);
}
/**
* Show settings file not found error
*/
static showSettingsNotFound(settingsPath: string): void {
static async showSettingsNotFound(settingsPath: string): Promise<void> {
await initUI();
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(errorBox('Settings file not found\n\n' + `File: ${settingsPath}`, 'ERROR'));
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(header('SOLUTIONS'));
console.error(` ${color(`echo '{}' > ${settingsPath}`, 'command')}`);
console.error(` ${color('claude /login', 'command')}`);
console.error('');
console.error('Why: Newer Claude CLI versions require explicit login.');
console.error(dim('Why: Newer Claude CLI versions require explicit login.'));
} else {
console.error(colored('Solutions:', 'yellow'));
console.error(' npm install -g @kaitranntt/ccs --force');
console.error(header('SOLUTIONS'));
console.error(` ${color('npm install -g @kaitranntt/ccs --force', 'command')}`);
console.error('');
console.error('This will recreate missing profile settings.');
console.error(dim('This will recreate missing profile settings.'));
}
console.error('');
@@ -86,120 +96,139 @@ export class ErrorManager {
/**
* Show invalid configuration error
*/
static showInvalidConfig(configPath: string, errorDetail: string): void {
static async showInvalidConfig(configPath: string, errorDetail: string): Promise<void> {
await initUI();
console.error('');
console.error(colored('[X] Configuration invalid', 'red'));
console.error(
errorBox(
'Configuration invalid\n\n' + `File: ${configPath}\n` + `Issue: ${errorDetail}`,
'ERROR'
)
);
console.error('');
console.error(`File: ${configPath}`);
console.error(`Issue: ${errorDetail}`);
console.error(header('SOLUTIONS'));
console.error('');
console.error(colored('Solutions:', 'yellow'));
console.error(' # Backup corrupted file');
console.error(` mv ${configPath} ${configPath}.backup`);
console.error(` ${dim('# Backup corrupted file')}`);
console.error(` ${color(`mv ${configPath} ${configPath}.backup`, 'command')}`);
console.error('');
console.error(' # Reinstall CCS');
console.error(' npm install -g @kaitranntt/ccs --force');
console.error(` ${dim('# Reinstall CCS')}`);
console.error(` ${color('npm install -g @kaitranntt/ccs --force', 'command')}`);
console.error('');
console.error('Your profile settings will be preserved.');
console.error(dim('Your profile settings will be preserved.'));
console.error('');
this.showErrorCode(ERROR_CODES.CONFIG_INVALID_JSON);
}
/**
* Show profile not found error
*/
static showProfileNotFound(
static async showProfileNotFound(
profileName: string,
availableProfiles: string[],
suggestions: string[] = []
): void {
): Promise<void> {
await initUI();
console.error('');
console.error(colored(`[X] Profile '${profileName}' not found`, 'red'));
console.error(errorBox(`Profile '${profileName}' not found`, 'ERROR'));
console.error('');
if (suggestions && suggestions.length > 0) {
console.error(colored('Did you mean:', 'yellow'));
suggestions.forEach((s) => console.error(` ${s}`));
console.error(header('DID YOU MEAN'));
suggestions.forEach((s) => console.error(` ${color(s, 'command')}`));
console.error('');
}
console.error(colored('Available profiles:', 'cyan'));
availableProfiles.forEach((line) => console.error(` ${line}`));
console.error(header('AVAILABLE PROFILES'));
availableProfiles.forEach((line) => console.error(` ${color(line, 'info')}`));
console.error('');
console.error(colored('Solutions:', 'yellow'));
console.error(' # Use existing profile');
console.error(' ccs <profile> "your prompt"');
console.error(header('SOLUTIONS'));
console.error('');
console.error(' # Create new account profile');
console.error(' ccs auth create <name>');
console.error(' Use an existing profile:');
console.error(` ${color('ccs <profile> "your prompt"', 'command')}`);
console.error('');
console.error(' Create a new account profile:');
console.error(` ${color('ccs auth create <name>', 'command')}`);
console.error('');
this.showErrorCode(ERROR_CODES.PROFILE_NOT_FOUND);
}
/**
* Show permission denied error
*/
static showPermissionDenied(path: string): void {
static async showPermissionDenied(filePath: string): Promise<void> {
await initUI();
console.error('');
console.error(colored('[X] Permission denied', 'red'));
console.error(errorBox('Permission denied\n\n' + `Cannot write to: ${filePath}`, 'ERROR'));
console.error('');
console.error(`Cannot write to: ${path}`);
console.error(header('SOLUTIONS'));
console.error('');
console.error(colored('Solutions:', 'yellow'));
console.error(' # Fix ownership');
console.error(' sudo chown -R $USER ~/.ccs ~/.claude');
console.error(` ${dim('# Fix ownership')}`);
console.error(` ${color('sudo chown -R $USER ~/.ccs ~/.claude', 'command')}`);
console.error('');
console.error(' # Fix permissions');
console.error(' chmod 755 ~/.ccs ~/.claude');
console.error(` ${dim('# Fix permissions')}`);
console.error(` ${color('chmod 755 ~/.ccs ~/.claude', 'command')}`);
console.error('');
console.error(' # Retry installation');
console.error(' npm install -g @kaitranntt/ccs --force');
console.error(` ${dim('# Retry installation')}`);
console.error(` ${color('npm install -g @kaitranntt/ccs --force', 'command')}`);
console.error('');
this.showErrorCode(ERROR_CODES.FS_CANNOT_WRITE_FILE);
}
/**
* Show CLIProxy OAuth timeout error
*/
static showOAuthTimeout(provider: string): void {
static async showOAuthTimeout(provider: string): Promise<void> {
await initUI();
console.error('');
console.error(colored('+---------------------------------------------------------+', 'red'));
console.error(colored('| OAuth Timeout |', 'red'));
console.error(colored('+---------------------------------------------------------+', 'red'));
console.error(
errorBox('OAuth Timeout\n\n' + 'Authentication did not complete within 2 minutes.', 'ERROR')
);
console.error('');
console.error('Authentication did not complete within 2 minutes.');
console.error('');
console.error(colored('Troubleshooting:', 'yellow'));
console.error(header('TROUBLESHOOTING'));
console.error(' 1. Check if browser opened (popup blocker?)');
console.error(' 2. Complete login in browser, then return here');
console.error(' 3. Try different browser');
console.error(' 4. Disable browser extensions temporarily');
console.error('');
console.error(colored('For headless/SSH environments:', 'cyan'));
console.error(` ccs ${provider} --auth --headless`);
console.error(header('FOR HEADLESS/SSH ENVIRONMENTS'));
console.error(` ${color(`ccs ${provider} --auth --headless`, 'command')}`);
console.error('');
console.error('This displays manual authentication steps.');
console.error(dim('This displays manual authentication steps.'));
console.error('');
}
/**
* Show CLIProxy port conflict error
*/
static showPortConflict(port: number): void {
static async showPortConflict(port: number): Promise<void> {
await initUI();
console.error('');
console.error(colored('+---------------------------------------------------------+', 'red'));
console.error(colored('| Port Conflict |', 'red'));
console.error(colored('+---------------------------------------------------------+', 'red'));
console.error(
errorBox('Port Conflict\n\n' + `CLIProxy port ${port} is already in use.`, 'ERROR')
);
console.error('');
console.error(`CLIProxy port ${port} is already in use.`);
console.error(header('SOLUTIONS'));
console.error('');
console.error(colored('Solutions:', 'yellow'));
console.error(' 1. Find process using port:');
console.error(` lsof -i :${port} (macOS/Linux)`);
console.error(` netstat -ano | findstr ${port} (Windows)`);
console.error(` ${color(`lsof -i :${port}`, 'command')} (macOS/Linux)`);
console.error(` ${color(`netstat -ano | findstr ${port}`, 'command')} (Windows)`);
console.error('');
console.error(' 2. Kill the process:');
console.error(` lsof -ti:${port} | xargs kill -9`);
console.error(` ${color(`lsof -ti:${port} | xargs kill -9`, 'command')}`);
console.error('');
console.error(' 3. Wait and retry (process may exit on its own)');
console.error('');
@@ -208,38 +237,43 @@ export class ErrorManager {
/**
* Show CLIProxy binary download failure error
*/
static showBinaryDownloadFailed(url: string, error: string): void {
static async showBinaryDownloadFailed(url: string, error: string): Promise<void> {
await initUI();
console.error('');
console.error(colored('+---------------------------------------------------------+', 'red'));
console.error(colored('| Binary Download Failed |', 'red'));
console.error(colored('+---------------------------------------------------------+', 'red'));
console.error(errorBox('Binary Download Failed\n\n' + `Error: ${error}`, 'ERROR'));
console.error('');
console.error(`Error: ${error}`);
console.error('');
console.error(colored('Troubleshooting:', 'yellow'));
console.error(header('TROUBLESHOOTING'));
console.error(' 1. Check internet connection');
console.error(' 2. Check firewall/proxy settings');
console.error(' 3. Try again in a few minutes');
console.error('');
console.error(colored('Manual download:', 'cyan'));
console.error(` URL: ${url}`);
console.error(' Save to: ~/.ccs/bin/cliproxyapi');
console.error(' chmod +x ~/.ccs/bin/cliproxyapi');
console.error(header('MANUAL DOWNLOAD'));
console.error(` URL: ${color(url, 'path')}`);
console.error(` Save to: ${color('~/.ccs/bin/cliproxyapi', 'path')}`);
console.error(` ${color('chmod +x ~/.ccs/bin/cliproxyapi', 'command')}`);
console.error('');
}
/**
* Show CLIProxy authentication required error
*/
static showAuthRequired(provider: string): void {
static async showAuthRequired(provider: string): Promise<void> {
await initUI();
const displayName = provider.charAt(0).toUpperCase() + provider.slice(1);
console.error('');
console.error(colored(`[X] ${provider} authentication required`, 'red'));
console.error(errorBox(`${displayName} authentication required`, 'ERROR'));
console.error('');
console.error(colored('To authenticate:', 'yellow'));
console.error(` ccs ${provider} --auth`);
console.error(header('TO AUTHENTICATE'));
console.error(` ${color(`ccs ${provider} --auth`, 'command')}`);
console.error('');
console.error('This will open a browser for OAuth login.');
console.error('After login, you can use the profile normally.');
console.error(dim('This will open a browser for OAuth login.'));
console.error(dim('After login, you can use the profile normally.'));
console.error('');
}
}
+2 -2
View File
@@ -52,8 +52,8 @@ export function execClaude(
else process.exit(code || 0);
});
child.on('error', () => {
ErrorManager.showClaudeNotFound();
child.on('error', async () => {
await ErrorManager.showClaudeNotFound();
process.exit(1);
});
}