refactor(web-server): modularize health-service into health/ directory

- extract system, environment, config, profile, symlink checks

- extract cliproxy, oauth, websearch checks and types

- slim health-service.ts from 901 to 146 lines (84% reduction)

- add barrel export at health/index.ts
This commit is contained in:
kaitranntt
2025-12-19 12:35:13 -05:00
parent d3c94fe6a2
commit c1f30ae800
11 changed files with 926 additions and 774 deletions
+34 -774
View File
@@ -1,60 +1,43 @@
/**
* Health Check Service (Phase 06)
* Health Check Service
*
* Runs comprehensive health checks for CCS dashboard matching `ccs doctor` output.
* Orchestrates comprehensive health checks for CCS dashboard matching `ccs doctor` output.
* Groups: System, Environment, Configuration, Profiles & Delegation, System Health, CLIProxy, OAuth Readiness
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { execSync } from 'child_process';
import { getCcsDir, getConfigPath } from '../utils/config-manager';
import {
isCLIProxyInstalled,
getInstalledCliproxyVersion,
getCLIProxyPath,
getConfigPath as getCliproxyConfigPath,
getAllAuthStatus,
CLIPROXY_DEFAULT_PORT,
} from '../cliproxy';
import { getClaudeCliInfo } from '../utils/claude-detector';
import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils';
import packageJson from '../../package.json';
import { getEnvironmentDiagnostics } from '../management/environment-diagnostics';
import { checkAuthCodePorts } from '../management/oauth-port-diagnostics';
import { getWebSearchCliProviders, hasAnyWebSearchCli } from '../utils/websearch-manager';
export interface HealthCheck {
id: string;
name: string;
status: 'ok' | 'warning' | 'error' | 'info';
message: string;
details?: string;
fix?: string;
fixable?: boolean;
}
// Import all check functions from modular components
import {
type HealthCheck,
type HealthGroup,
type HealthReport,
checkClaudeCli,
checkCcsDirectory,
checkPermissions,
checkCcsSymlinks,
checkSettingsSymlinks,
checkEnvironment,
checkConfigFile,
checkSettingsFiles,
checkClaudeSettings,
checkProfiles,
checkInstances,
checkDelegation,
checkCliproxyBinary,
checkCliproxyConfig,
checkOAuthProviders,
checkCliproxyPort,
checkOAuthPortsForDashboard,
checkWebSearchClis,
} from './health';
export interface HealthGroup {
id: string;
name: string;
icon: string;
checks: HealthCheck[];
}
export interface HealthReport {
timestamp: number;
version: string;
groups: HealthGroup[];
checks: HealthCheck[]; // Flat list for backward compatibility
summary: {
total: number;
passed: number;
warnings: number;
errors: number;
info: number;
};
}
// Re-export types for external consumers
export type { HealthCheck, HealthGroup, HealthReport };
/**
* Run all health checks and return report
@@ -73,7 +56,7 @@ export async function runHealthChecks(): Promise<HealthReport> {
systemChecks.push(checkCcsDirectory(ccsDir));
groups.push({ id: 'system', name: 'System', icon: 'Monitor', checks: systemChecks });
// Group 2: Environment (OAuth readiness diagnostics)
// Group 2: Environment
const envChecks: HealthCheck[] = [];
envChecks.push(checkEnvironment());
groups.push({ id: 'environment', name: 'Environment', icon: 'Laptop', checks: envChecks });
@@ -107,12 +90,7 @@ export async function runHealthChecks(): Promise<HealthReport> {
healthChecks.push(checkPermissions(ccsDir));
healthChecks.push(checkCcsSymlinks());
healthChecks.push(checkSettingsSymlinks(homedir, ccsDir, claudeDir));
groups.push({
id: 'system-health',
name: 'System Health',
icon: 'Shield',
checks: healthChecks,
});
groups.push({ id: 'system-health', name: 'System Health', icon: 'Shield', checks: healthChecks });
// Group 6: CLIProxy
const cliproxyChecks: HealthCheck[] = [];
@@ -120,14 +98,9 @@ export async function runHealthChecks(): Promise<HealthReport> {
cliproxyChecks.push(checkCliproxyConfig());
cliproxyChecks.push(...checkOAuthProviders());
cliproxyChecks.push(await checkCliproxyPort());
groups.push({
id: 'cliproxy',
name: 'CLIProxy (OAuth)',
icon: 'Zap',
checks: cliproxyChecks,
});
groups.push({ id: 'cliproxy', name: 'CLIProxy (OAuth)', icon: 'Zap', checks: cliproxyChecks });
// Group 7: OAuth Readiness (port availability)
// Group 7: OAuth Readiness
const oauthReadinessChecks: HealthCheck[] = [];
oauthReadinessChecks.push(...(await checkOAuthPortsForDashboard()));
groups.push({
@@ -140,12 +113,7 @@ export async function runHealthChecks(): Promise<HealthReport> {
// Group 8: WebSearch CLI Providers
const websearchChecks: HealthCheck[] = [];
websearchChecks.push(...checkWebSearchClis());
groups.push({
id: 'websearch',
name: 'WebSearch',
icon: 'Search',
checks: websearchChecks,
});
groups.push({ id: 'websearch', name: 'WebSearch', icon: 'Search', checks: websearchChecks });
// Flatten all checks for backward compatibility
const allChecks = groups.flatMap((g) => g.checks);
@@ -159,715 +127,7 @@ export async function runHealthChecks(): Promise<HealthReport> {
info: allChecks.filter((c) => c.status === 'info').length,
};
return {
timestamp: Date.now(),
version,
groups,
checks: allChecks,
summary,
};
}
// Check 1: Claude CLI
async function checkClaudeCli(): Promise<HealthCheck> {
const cliInfo = getClaudeCliInfo();
if (!cliInfo) {
return {
id: 'claude-cli',
name: 'Claude CLI',
status: 'error',
message: 'Not found in PATH',
fix: 'Install: npm install -g @anthropic-ai/claude-code',
};
}
try {
const version = execSync('claude --version', {
encoding: 'utf8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
const versionMatch = version.match(/(\d+\.\d+\.\d+)/);
const versionStr = versionMatch ? versionMatch[1] : 'unknown';
return {
id: 'claude-cli',
name: 'Claude CLI',
status: 'ok',
message: `v${versionStr}`,
details: cliInfo.path,
};
} catch {
return {
id: 'claude-cli',
name: 'Claude CLI',
status: 'error',
message: 'Not working',
details: cliInfo.path,
fix: 'Reinstall Claude CLI',
};
}
}
// Check 2: CCS Directory
function checkCcsDirectory(ccsDir: string): HealthCheck {
if (fs.existsSync(ccsDir)) {
return {
id: 'ccs-dir',
name: 'CCS Directory',
status: 'ok',
message: 'Exists',
details: '~/.ccs/',
};
}
return {
id: 'ccs-dir',
name: 'CCS Directory',
status: 'error',
message: 'Not found',
details: ccsDir,
fix: 'Run: npm install -g @kaitranntt/ccs --force',
fixable: true,
};
}
// Check 3: Config file
function checkConfigFile(): HealthCheck {
const configPath = getConfigPath();
if (!fs.existsSync(configPath)) {
return {
id: 'config-file',
name: 'config.json',
status: 'warning',
message: 'Not found',
details: configPath,
fixable: true,
};
}
try {
const content = fs.readFileSync(configPath, 'utf8');
JSON.parse(content);
return {
id: 'config-file',
name: 'config.json',
status: 'ok',
message: 'Valid',
details: configPath,
};
} catch {
return {
id: 'config-file',
name: 'config.json',
status: 'error',
message: 'Invalid JSON',
details: configPath,
};
}
}
// Check 4: Settings files (glm, kimi)
function checkSettingsFiles(ccsDir: string): HealthCheck[] {
const checks: HealthCheck[] = [];
const files = [
{ name: 'glm.settings.json', profile: 'glm' },
{ name: 'kimi.settings.json', profile: 'kimi' },
];
const { DelegationValidator } = require('../utils/delegation-validator');
for (const file of files) {
const filePath = path.join(ccsDir, file.name);
if (!fs.existsSync(filePath)) {
checks.push({
id: `settings-${file.profile}`,
name: file.name,
status: 'info',
message: 'Not configured',
details: filePath,
});
continue;
}
try {
const content = fs.readFileSync(filePath, 'utf8');
JSON.parse(content);
const validation = DelegationValidator.validate(file.profile);
if (validation.valid) {
checks.push({
id: `settings-${file.profile}`,
name: file.name,
status: 'ok',
message: 'Key configured',
details: filePath,
});
} else if (validation.error && validation.error.includes('placeholder')) {
checks.push({
id: `settings-${file.profile}`,
name: file.name,
status: 'warning',
message: 'Placeholder key',
details: filePath,
});
} else {
checks.push({
id: `settings-${file.profile}`,
name: file.name,
status: 'ok',
message: 'Valid JSON',
details: filePath,
});
}
} catch {
checks.push({
id: `settings-${file.profile}`,
name: file.name,
status: 'error',
message: 'Invalid JSON',
details: filePath,
});
}
}
return checks;
}
// Check 5: Claude settings
function checkClaudeSettings(claudeDir: string): HealthCheck {
const settingsPath = path.join(claudeDir, 'settings.json');
if (!fs.existsSync(settingsPath)) {
return {
id: 'claude-settings',
name: '~/.claude/settings.json',
status: 'warning',
message: 'Not found',
fix: 'Run: claude /login',
};
}
try {
const content = fs.readFileSync(settingsPath, 'utf8');
JSON.parse(content);
return {
id: 'claude-settings',
name: '~/.claude/settings.json',
status: 'ok',
message: 'Valid',
};
} catch {
return {
id: 'claude-settings',
name: '~/.claude/settings.json',
status: 'warning',
message: 'Invalid JSON',
fix: 'Run: claude /login',
};
}
}
// Check 6: Profiles
function checkProfiles(ccsDir: string): HealthCheck {
const configPath = path.join(ccsDir, 'config.json');
if (!fs.existsSync(configPath)) {
return {
id: 'profiles',
name: 'Profiles',
status: 'info',
message: 'config.json not found',
};
}
try {
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
if (!config.profiles || typeof config.profiles !== 'object') {
return {
id: 'profiles',
name: 'Profiles',
status: 'error',
message: 'Missing profiles object',
fix: 'Run: npm install -g @kaitranntt/ccs --force',
};
}
const profileCount = Object.keys(config.profiles).length;
const profileNames = Object.keys(config.profiles).join(', ');
return {
id: 'profiles',
name: 'Profiles',
status: 'ok',
message: `${profileCount} configured`,
details: profileNames.length > 40 ? profileNames.substring(0, 37) + '...' : profileNames,
};
} catch (e) {
return {
id: 'profiles',
name: 'Profiles',
status: 'error',
message: (e as Error).message,
};
}
}
// Check 7: Instances
function checkInstances(ccsDir: string): HealthCheck {
const instancesDir = path.join(ccsDir, 'instances');
if (!fs.existsSync(instancesDir)) {
return {
id: 'instances',
name: 'Instances',
status: 'ok',
message: 'No account profiles',
};
}
const instances = fs.readdirSync(instancesDir).filter((name) => {
return fs.statSync(path.join(instancesDir, name)).isDirectory();
});
if (instances.length === 0) {
return {
id: 'instances',
name: 'Instances',
status: 'ok',
message: 'No account profiles',
};
}
return {
id: 'instances',
name: 'Instances',
status: 'ok',
message: `${instances.length} account profile${instances.length !== 1 ? 's' : ''}`,
};
}
// Check 8: Delegation
function checkDelegation(ccsDir: string): HealthCheck {
const ccsClaudeCommandsDir = path.join(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) {
return {
id: 'delegation',
name: 'Delegation',
status: 'warning',
message: 'Not installed',
fix: 'Run: npm install -g @kaitranntt/ccs --force',
};
}
const { DelegationValidator } = require('../utils/delegation-validator');
const readyProfiles: string[] = [];
for (const profile of ['glm', 'kimi']) {
const validation = DelegationValidator.validate(profile);
if (validation.valid) {
readyProfiles.push(profile);
}
}
if (readyProfiles.length === 0) {
return {
id: 'delegation',
name: 'Delegation',
status: 'warning',
message: 'No profiles ready',
fix: 'Configure profiles with valid API keys',
};
}
return {
id: 'delegation',
name: 'Delegation',
status: 'ok',
message: `${readyProfiles.length} profiles ready`,
details: readyProfiles.join(', '),
};
}
// Check 9: Permissions
function checkPermissions(ccsDir: string): HealthCheck {
const testFile = path.join(ccsDir, '.permission-test');
try {
fs.writeFileSync(testFile, 'test', 'utf8');
fs.unlinkSync(testFile);
return {
id: 'permissions',
name: 'Permissions',
status: 'ok',
message: 'Write access verified',
};
} catch {
return {
id: 'permissions',
name: 'Permissions',
status: 'error',
message: 'Cannot write to ~/.ccs/',
fix: 'sudo chown -R $USER ~/.ccs ~/.claude && chmod 755 ~/.ccs ~/.claude',
};
}
}
// Check 10: CCS Symlinks
function checkCcsSymlinks(): HealthCheck {
try {
const { ClaudeSymlinkManager } = require('../utils/claude-symlink-manager');
const manager = new ClaudeSymlinkManager();
const health = manager.checkHealth();
if (health.healthy) {
const itemCount = manager.ccsItems.length;
return {
id: 'ccs-symlinks',
name: 'CCS Symlinks',
status: 'ok',
message: `${itemCount}/${itemCount} items linked`,
};
}
return {
id: 'ccs-symlinks',
name: 'CCS Symlinks',
status: 'warning',
message: `${health.issues.length} issues found`,
fix: 'Run: ccs sync',
};
} catch (e) {
return {
id: 'ccs-symlinks',
name: 'CCS Symlinks',
status: 'warning',
message: 'Could not check',
details: (e as Error).message,
fix: 'Run: ccs sync',
};
}
}
// Check 11: Settings Symlinks
function checkSettingsSymlinks(homedir: string, ccsDir: string, claudeDir: string): HealthCheck {
try {
const sharedDir = path.join(homedir, '.ccs', 'shared');
const sharedSettings = path.join(sharedDir, 'settings.json');
const claudeSettings = path.join(claudeDir, 'settings.json');
if (!fs.existsSync(sharedSettings)) {
return {
id: 'settings-symlinks',
name: 'settings.json',
status: 'warning',
message: 'Shared not found',
fix: 'Run: ccs sync',
};
}
const sharedStats = fs.lstatSync(sharedSettings);
if (!sharedStats.isSymbolicLink()) {
return {
id: 'settings-symlinks',
name: 'settings.json',
status: 'warning',
message: 'Not a symlink',
fix: 'Run: ccs sync',
};
}
const sharedTarget = fs.readlinkSync(sharedSettings);
const resolvedShared = path.resolve(path.dirname(sharedSettings), sharedTarget);
if (resolvedShared !== claudeSettings) {
return {
id: 'settings-symlinks',
name: 'settings.json',
status: 'warning',
message: 'Wrong target',
fix: 'Run: ccs sync',
};
}
// Check instances
const instancesDir = path.join(ccsDir, 'instances');
if (!fs.existsSync(instancesDir)) {
return {
id: 'settings-symlinks',
name: 'settings.json',
status: 'ok',
message: 'Shared symlink valid',
};
}
const instances = fs.readdirSync(instancesDir).filter((name) => {
return fs.statSync(path.join(instancesDir, name)).isDirectory();
});
let broken = 0;
for (const instance of instances) {
const instanceSettings = path.join(instancesDir, instance, '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 {
broken++;
}
}
if (broken > 0) {
return {
id: 'settings-symlinks',
name: 'settings.json',
status: 'warning',
message: `${broken} broken instance(s)`,
fix: 'Run: ccs sync',
};
}
return {
id: 'settings-symlinks',
name: 'settings.json',
status: 'ok',
message: `${instances.length} instance(s) valid`,
};
} catch (e) {
return {
id: 'settings-symlinks',
name: 'settings.json',
status: 'warning',
message: 'Check failed',
details: (e as Error).message,
fix: 'Run: ccs sync',
};
}
}
// Check 12: CLIProxy Binary
function checkCliproxyBinary(): HealthCheck {
if (isCLIProxyInstalled()) {
const version = getInstalledCliproxyVersion();
const binaryPath = getCLIProxyPath();
return {
id: 'cliproxy-binary',
name: 'CLIProxy Binary',
status: 'ok',
message: `v${version}`,
details: binaryPath,
};
}
return {
id: 'cliproxy-binary',
name: 'CLIProxy Binary',
status: 'info',
message: 'Not installed',
details: 'Downloads on first use',
};
}
// Check 13: CLIProxy Config
function checkCliproxyConfig(): HealthCheck {
const configPath = getCliproxyConfigPath();
if (fs.existsSync(configPath)) {
return {
id: 'cliproxy-config',
name: 'CLIProxy Config',
status: 'ok',
message: 'cliproxy/config.yaml',
};
}
return {
id: 'cliproxy-config',
name: 'CLIProxy Config',
status: 'info',
message: 'Not created',
details: 'Generated on first use',
};
}
// Check 14: OAuth Providers
function checkOAuthProviders(): HealthCheck[] {
const authStatuses = getAllAuthStatus();
const checks: HealthCheck[] = [];
for (const status of authStatuses) {
const providerName = status.provider.charAt(0).toUpperCase() + status.provider.slice(1);
if (status.authenticated) {
const lastAuth = status.lastAuth ? status.lastAuth.toLocaleDateString() : '';
checks.push({
id: `oauth-${status.provider}`,
name: `${providerName} Auth`,
status: 'ok',
message: 'Authenticated',
details: lastAuth,
});
} else {
checks.push({
id: `oauth-${status.provider}`,
name: `${providerName} Auth`,
status: 'info',
message: 'Not authenticated',
fix: `Run: ccs ${status.provider} --auth`,
});
}
}
return checks;
}
// Check 15: CLIProxy Port
async function checkCliproxyPort(): Promise<HealthCheck> {
const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT);
if (!portProcess) {
return {
id: 'cliproxy-port',
name: 'CLIProxy Port',
status: 'info',
message: `${CLIPROXY_DEFAULT_PORT} free`,
details: 'Proxy not running',
};
}
if (isCLIProxyProcess(portProcess)) {
return {
id: 'cliproxy-port',
name: 'CLIProxy Port',
status: 'ok',
message: 'CLIProxy running',
details: `PID ${portProcess.pid}`,
};
}
return {
id: 'cliproxy-port',
name: 'CLIProxy Port',
status: 'warning',
message: `Occupied by ${portProcess.processName}`,
details: `PID ${portProcess.pid}`,
fix: `Kill process: kill ${portProcess.pid}`,
};
}
// Check 16: Environment (platform, SSH, TTY, browser capability)
function checkEnvironment(): HealthCheck {
const diag = getEnvironmentDiagnostics();
let status: 'ok' | 'warning' | 'info' = 'ok';
let message = 'Browser available';
if (diag.detectedHeadless) {
if (diag.platform === 'win32' && diag.ttyStatus === 'undefined') {
status = 'warning';
message = 'Possible headless false positive (Windows)';
} else if (diag.sshSession) {
status = 'info';
message = 'SSH session (headless mode)';
} else {
status = 'info';
message = 'Headless environment';
}
}
return {
id: 'environment',
name: 'Environment',
status,
message,
details: `${diag.platformName} | SSH: ${diag.sshSession ? 'Yes' : 'No'} | Browser: ${diag.browserReason}`,
};
}
// Check 17: OAuth Ports (port availability for Gemini, Codex, Agy)
async function checkOAuthPortsForDashboard(): Promise<HealthCheck[]> {
const portDiagnostics = await checkAuthCodePorts();
return portDiagnostics.map((diag) => {
const providerName = diag.provider.charAt(0).toUpperCase() + diag.provider.slice(1);
const portStr = diag.port ? ` (${diag.port})` : '';
let status: 'ok' | 'warning' | 'info' = 'ok';
if (diag.status === 'occupied') status = 'warning';
if (diag.status === 'not_applicable') status = 'info';
return {
id: `oauth-port-${diag.provider}`,
name: `${providerName}${portStr}`,
status,
message: diag.message,
details: diag.process ? `PID ${diag.process.pid}` : undefined,
fix: diag.recommendation || undefined,
};
});
}
// Check 18: WebSearch CLI Providers (Gemini CLI, Grok CLI)
function checkWebSearchClis(): HealthCheck[] {
const providers = getWebSearchCliProviders();
const checks: HealthCheck[] = [];
for (const provider of providers) {
if (provider.installed) {
const freeTag = provider.freeTier ? ' (FREE)' : '';
checks.push({
id: `websearch-${provider.id}`,
name: provider.name,
status: 'ok',
message: `v${provider.version || 'unknown'}${freeTag}`,
details: provider.description,
});
} else {
const keyNote = provider.requiresApiKey ? ` (needs ${provider.apiKeyEnvVar})` : ' (FREE)';
checks.push({
id: `websearch-${provider.id}`,
name: provider.name,
status: 'info',
message: `Not installed${keyNote}`,
fix: provider.installCommand,
details: provider.description,
});
}
}
// Add summary check if no providers installed
if (!hasAnyWebSearchCli()) {
checks.push({
id: 'websearch-summary',
name: 'WebSearch Status',
status: 'warning',
message: 'No CLI tools installed',
fix: 'npm install -g @google/gemini-cli (FREE)',
details: 'Install a WebSearch CLI for real-time web access',
});
}
return checks;
return { timestamp: Date.now(), version, groups, checks: allChecks, summary };
}
/**
+135
View File
@@ -0,0 +1,135 @@
/**
* CLIProxy Health Checks
*
* Check CLIProxy binary, config, OAuth providers, and port.
*/
import * as fs from 'fs';
import {
isCLIProxyInstalled,
getInstalledCliproxyVersion,
getCLIProxyPath,
getConfigPath as getCliproxyConfigPath,
getAllAuthStatus,
CLIPROXY_DEFAULT_PORT,
} from '../../cliproxy';
import { getPortProcess, isCLIProxyProcess } from '../../utils/port-utils';
import type { HealthCheck } from './types';
/**
* Check CLIProxy binary installation
*/
export function checkCliproxyBinary(): HealthCheck {
if (isCLIProxyInstalled()) {
const version = getInstalledCliproxyVersion();
const binaryPath = getCLIProxyPath();
return {
id: 'cliproxy-binary',
name: 'CLIProxy Binary',
status: 'ok',
message: `v${version}`,
details: binaryPath,
};
}
return {
id: 'cliproxy-binary',
name: 'CLIProxy Binary',
status: 'info',
message: 'Not installed',
details: 'Downloads on first use',
};
}
/**
* Check CLIProxy config file
*/
export function checkCliproxyConfig(): HealthCheck {
const configPath = getCliproxyConfigPath();
if (fs.existsSync(configPath)) {
return {
id: 'cliproxy-config',
name: 'CLIProxy Config',
status: 'ok',
message: 'cliproxy/config.yaml',
};
}
return {
id: 'cliproxy-config',
name: 'CLIProxy Config',
status: 'info',
message: 'Not created',
details: 'Generated on first use',
};
}
/**
* Check OAuth providers authentication status
*/
export function checkOAuthProviders(): HealthCheck[] {
const authStatuses = getAllAuthStatus();
const checks: HealthCheck[] = [];
for (const status of authStatuses) {
const providerName = status.provider.charAt(0).toUpperCase() + status.provider.slice(1);
if (status.authenticated) {
const lastAuth = status.lastAuth ? status.lastAuth.toLocaleDateString() : '';
checks.push({
id: `oauth-${status.provider}`,
name: `${providerName} Auth`,
status: 'ok',
message: 'Authenticated',
details: lastAuth,
});
} else {
checks.push({
id: `oauth-${status.provider}`,
name: `${providerName} Auth`,
status: 'info',
message: 'Not authenticated',
fix: `Run: ccs ${status.provider} --auth`,
});
}
}
return checks;
}
/**
* Check CLIProxy port status
*/
export async function checkCliproxyPort(): Promise<HealthCheck> {
const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT);
if (!portProcess) {
return {
id: 'cliproxy-port',
name: 'CLIProxy Port',
status: 'info',
message: `${CLIPROXY_DEFAULT_PORT} free`,
details: 'Proxy not running',
};
}
if (isCLIProxyProcess(portProcess)) {
return {
id: 'cliproxy-port',
name: 'CLIProxy Port',
status: 'ok',
message: 'CLIProxy running',
details: `PID ${portProcess.pid}`,
};
}
return {
id: 'cliproxy-port',
name: 'CLIProxy Port',
status: 'warning',
message: `Occupied by ${portProcess.processName}`,
details: `PID ${portProcess.pid}`,
fix: `Kill process: kill ${portProcess.pid}`,
};
}
+155
View File
@@ -0,0 +1,155 @@
/**
* Configuration Health Checks
*
* Check config.json, settings files, and Claude settings.
*/
import * as fs from 'fs';
import * as path from 'path';
import { getConfigPath } from '../../utils/config-manager';
import type { HealthCheck } from './types';
/**
* Check config.json file
*/
export function checkConfigFile(): HealthCheck {
const configPath = getConfigPath();
if (!fs.existsSync(configPath)) {
return {
id: 'config-file',
name: 'config.json',
status: 'warning',
message: 'Not found',
details: configPath,
fixable: true,
};
}
try {
const content = fs.readFileSync(configPath, 'utf8');
JSON.parse(content);
return {
id: 'config-file',
name: 'config.json',
status: 'ok',
message: 'Valid',
details: configPath,
};
} catch {
return {
id: 'config-file',
name: 'config.json',
status: 'error',
message: 'Invalid JSON',
details: configPath,
};
}
}
/**
* Check settings files (glm, kimi)
*/
export function checkSettingsFiles(ccsDir: string): HealthCheck[] {
const checks: HealthCheck[] = [];
const files = [
{ name: 'glm.settings.json', profile: 'glm' },
{ name: 'kimi.settings.json', profile: 'kimi' },
];
const { DelegationValidator } = require('../../utils/delegation-validator');
for (const file of files) {
const filePath = path.join(ccsDir, file.name);
if (!fs.existsSync(filePath)) {
checks.push({
id: `settings-${file.profile}`,
name: file.name,
status: 'info',
message: 'Not configured',
details: filePath,
});
continue;
}
try {
const content = fs.readFileSync(filePath, 'utf8');
JSON.parse(content);
const validation = DelegationValidator.validate(file.profile);
if (validation.valid) {
checks.push({
id: `settings-${file.profile}`,
name: file.name,
status: 'ok',
message: 'Key configured',
details: filePath,
});
} else if (validation.error && validation.error.includes('placeholder')) {
checks.push({
id: `settings-${file.profile}`,
name: file.name,
status: 'warning',
message: 'Placeholder key',
details: filePath,
});
} else {
checks.push({
id: `settings-${file.profile}`,
name: file.name,
status: 'ok',
message: 'Valid JSON',
details: filePath,
});
}
} catch {
checks.push({
id: `settings-${file.profile}`,
name: file.name,
status: 'error',
message: 'Invalid JSON',
details: filePath,
});
}
}
return checks;
}
/**
* Check Claude settings file
*/
export function checkClaudeSettings(claudeDir: string): HealthCheck {
const settingsPath = path.join(claudeDir, 'settings.json');
if (!fs.existsSync(settingsPath)) {
return {
id: 'claude-settings',
name: '~/.claude/settings.json',
status: 'warning',
message: 'Not found',
fix: 'Run: claude /login',
};
}
try {
const content = fs.readFileSync(settingsPath, 'utf8');
JSON.parse(content);
return {
id: 'claude-settings',
name: '~/.claude/settings.json',
status: 'ok',
message: 'Valid',
};
} catch {
return {
id: 'claude-settings',
name: '~/.claude/settings.json',
status: 'warning',
message: 'Invalid JSON',
fix: 'Run: claude /login',
};
}
}
@@ -0,0 +1,39 @@
/**
* Environment Health Checks
*
* Check platform, SSH, TTY, and browser capability.
*/
import { getEnvironmentDiagnostics } from '../../management/environment-diagnostics';
import type { HealthCheck } from './types';
/**
* Check environment (platform, SSH, TTY, browser capability)
*/
export function checkEnvironment(): HealthCheck {
const diag = getEnvironmentDiagnostics();
let status: 'ok' | 'warning' | 'info' = 'ok';
let message = 'Browser available';
if (diag.detectedHeadless) {
if (diag.platform === 'win32' && diag.ttyStatus === 'undefined') {
status = 'warning';
message = 'Possible headless false positive (Windows)';
} else if (diag.sshSession) {
status = 'info';
message = 'SSH session (headless mode)';
} else {
status = 'info';
message = 'Headless environment';
}
}
return {
id: 'environment',
name: 'Environment',
status,
message,
details: `${diag.platformName} | SSH: ${diag.sshSession ? 'Yes' : 'No'} | Browser: ${diag.browserReason}`,
};
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Health Check Modules - Barrel Export
*/
// Types
export type { HealthCheck, HealthGroup, HealthReport } from './types';
// System checks
export { checkClaudeCli, checkCcsDirectory, checkPermissions } from './system-checks';
// Symlink checks
export { checkCcsSymlinks, checkSettingsSymlinks } from './symlink-checks';
// Environment checks
export { checkEnvironment } from './environment-checks';
// Config checks
export { checkConfigFile, checkSettingsFiles, checkClaudeSettings } from './config-checks';
// Profile checks
export { checkProfiles, checkInstances, checkDelegation } from './profile-checks';
// CLIProxy checks
export {
checkCliproxyBinary,
checkCliproxyConfig,
checkOAuthProviders,
checkCliproxyPort,
} from './cliproxy-checks';
// OAuth checks
export { checkOAuthPortsForDashboard } from './oauth-checks';
// WebSearch checks
export { checkWebSearchClis } from './websearch-checks';
+33
View File
@@ -0,0 +1,33 @@
/**
* OAuth Readiness Health Checks
*
* Check OAuth port availability for providers.
*/
import { checkAuthCodePorts } from '../../management/oauth-port-diagnostics';
import type { HealthCheck } from './types';
/**
* Check OAuth ports for dashboard (Gemini, Codex, Agy)
*/
export async function checkOAuthPortsForDashboard(): Promise<HealthCheck[]> {
const portDiagnostics = await checkAuthCodePorts();
return portDiagnostics.map((diag) => {
const providerName = diag.provider.charAt(0).toUpperCase() + diag.provider.slice(1);
const portStr = diag.port ? ` (${diag.port})` : '';
let status: 'ok' | 'warning' | 'info' = 'ok';
if (diag.status === 'occupied') status = 'warning';
if (diag.status === 'not_applicable') status = 'info';
return {
id: `oauth-port-${diag.provider}`,
name: `${providerName}${portStr}`,
status,
message: diag.message,
details: diag.process ? `PID ${diag.process.pid}` : undefined,
fix: diag.recommendation || undefined,
};
});
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Profile Health Checks
*
* Check profiles, instances, and delegation.
*/
import * as fs from 'fs';
import * as path from 'path';
import type { HealthCheck } from './types';
/**
* Check profiles configuration
*/
export function checkProfiles(ccsDir: string): HealthCheck {
const configPath = path.join(ccsDir, 'config.json');
if (!fs.existsSync(configPath)) {
return {
id: 'profiles',
name: 'Profiles',
status: 'info',
message: 'config.json not found',
};
}
try {
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
if (!config.profiles || typeof config.profiles !== 'object') {
return {
id: 'profiles',
name: 'Profiles',
status: 'error',
message: 'Missing profiles object',
fix: 'Run: npm install -g @kaitranntt/ccs --force',
};
}
const profileCount = Object.keys(config.profiles).length;
const profileNames = Object.keys(config.profiles).join(', ');
return {
id: 'profiles',
name: 'Profiles',
status: 'ok',
message: `${profileCount} configured`,
details: profileNames.length > 40 ? profileNames.substring(0, 37) + '...' : profileNames,
};
} catch (e) {
return {
id: 'profiles',
name: 'Profiles',
status: 'error',
message: (e as Error).message,
};
}
}
/**
* Check instances (account profiles)
*/
export function checkInstances(ccsDir: string): HealthCheck {
const instancesDir = path.join(ccsDir, 'instances');
if (!fs.existsSync(instancesDir)) {
return {
id: 'instances',
name: 'Instances',
status: 'ok',
message: 'No account profiles',
};
}
const instances = fs.readdirSync(instancesDir).filter((name) => {
return fs.statSync(path.join(instancesDir, name)).isDirectory();
});
if (instances.length === 0) {
return {
id: 'instances',
name: 'Instances',
status: 'ok',
message: 'No account profiles',
};
}
return {
id: 'instances',
name: 'Instances',
status: 'ok',
message: `${instances.length} account profile${instances.length !== 1 ? 's' : ''}`,
};
}
/**
* Check delegation setup
*/
export function checkDelegation(ccsDir: string): HealthCheck {
const ccsClaudeCommandsDir = path.join(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) {
return {
id: 'delegation',
name: 'Delegation',
status: 'warning',
message: 'Not installed',
fix: 'Run: npm install -g @kaitranntt/ccs --force',
};
}
const { DelegationValidator } = require('../../utils/delegation-validator');
const readyProfiles: string[] = [];
for (const profile of ['glm', 'kimi']) {
const validation = DelegationValidator.validate(profile);
if (validation.valid) {
readyProfiles.push(profile);
}
}
if (readyProfiles.length === 0) {
return {
id: 'delegation',
name: 'Delegation',
status: 'warning',
message: 'No profiles ready',
fix: 'Configure profiles with valid API keys',
};
}
return {
id: 'delegation',
name: 'Delegation',
status: 'ok',
message: `${readyProfiles.length} profiles ready`,
details: readyProfiles.join(', '),
};
}
+160
View File
@@ -0,0 +1,160 @@
/**
* Symlink Health Checks
*
* Check CCS symlinks and settings symlinks.
*/
import * as fs from 'fs';
import * as path from 'path';
import type { HealthCheck } from './types';
/**
* Check CCS symlinks health
*/
export function checkCcsSymlinks(): HealthCheck {
try {
const { ClaudeSymlinkManager } = require('../../utils/claude-symlink-manager');
const manager = new ClaudeSymlinkManager();
const health = manager.checkHealth();
if (health.healthy) {
const itemCount = manager.ccsItems.length;
return {
id: 'ccs-symlinks',
name: 'CCS Symlinks',
status: 'ok',
message: `${itemCount}/${itemCount} items linked`,
};
}
return {
id: 'ccs-symlinks',
name: 'CCS Symlinks',
status: 'warning',
message: `${health.issues.length} issues found`,
fix: 'Run: ccs sync',
};
} catch (e) {
return {
id: 'ccs-symlinks',
name: 'CCS Symlinks',
status: 'warning',
message: 'Could not check',
details: (e as Error).message,
fix: 'Run: ccs sync',
};
}
}
/**
* Check settings symlinks
*/
export function checkSettingsSymlinks(
homedir: string,
ccsDir: string,
claudeDir: string
): HealthCheck {
try {
const sharedDir = `${homedir}/.ccs/shared`;
const sharedSettings = `${sharedDir}/settings.json`;
const claudeSettings = `${claudeDir}/settings.json`;
if (!fs.existsSync(sharedSettings)) {
return {
id: 'settings-symlinks',
name: 'settings.json',
status: 'warning',
message: 'Shared not found',
fix: 'Run: ccs sync',
};
}
const sharedStats = fs.lstatSync(sharedSettings);
if (!sharedStats.isSymbolicLink()) {
return {
id: 'settings-symlinks',
name: 'settings.json',
status: 'warning',
message: 'Not a symlink',
fix: 'Run: ccs sync',
};
}
const sharedTarget = fs.readlinkSync(sharedSettings);
const resolvedShared = path.resolve(path.dirname(sharedSettings), sharedTarget);
if (resolvedShared !== claudeSettings) {
return {
id: 'settings-symlinks',
name: 'settings.json',
status: 'warning',
message: 'Wrong target',
fix: 'Run: ccs sync',
};
}
// Check instances
const instancesDir = `${ccsDir}/instances`;
if (!fs.existsSync(instancesDir)) {
return {
id: 'settings-symlinks',
name: 'settings.json',
status: 'ok',
message: 'Shared symlink valid',
};
}
const instances = fs.readdirSync(instancesDir).filter((name) => {
return fs.statSync(`${instancesDir}/${name}`).isDirectory();
});
let broken = 0;
for (const instance of instances) {
const instanceSettings = `${instancesDir}/${instance}/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 {
broken++;
}
}
if (broken > 0) {
return {
id: 'settings-symlinks',
name: 'settings.json',
status: 'warning',
message: `${broken} broken instance(s)`,
fix: 'Run: ccs sync',
};
}
return {
id: 'settings-symlinks',
name: 'settings.json',
status: 'ok',
message: `${instances.length} instance(s) valid`,
};
} catch (e) {
return {
id: 'settings-symlinks',
name: 'settings.json',
status: 'warning',
message: 'Check failed',
details: (e as Error).message,
fix: 'Run: ccs sync',
};
}
}
+106
View File
@@ -0,0 +1,106 @@
/**
* System Health Checks
*
* Checks for Claude CLI, CCS directory, and permissions.
*/
import * as fs from 'fs';
import { execSync } from 'child_process';
import { getClaudeCliInfo } from '../../utils/claude-detector';
import type { HealthCheck } from './types';
/**
* Check Claude CLI installation and version
*/
export async function checkClaudeCli(): Promise<HealthCheck> {
const cliInfo = getClaudeCliInfo();
if (!cliInfo) {
return {
id: 'claude-cli',
name: 'Claude CLI',
status: 'error',
message: 'Not found in PATH',
fix: 'Install: npm install -g @anthropic-ai/claude-code',
};
}
try {
const version = execSync('claude --version', {
encoding: 'utf8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
const versionMatch = version.match(/(\d+\.\d+\.\d+)/);
const versionStr = versionMatch ? versionMatch[1] : 'unknown';
return {
id: 'claude-cli',
name: 'Claude CLI',
status: 'ok',
message: `v${versionStr}`,
details: cliInfo.path,
};
} catch {
return {
id: 'claude-cli',
name: 'Claude CLI',
status: 'error',
message: 'Not working',
details: cliInfo.path,
fix: 'Reinstall Claude CLI',
};
}
}
/**
* Check CCS directory existence
*/
export function checkCcsDirectory(ccsDir: string): HealthCheck {
if (fs.existsSync(ccsDir)) {
return {
id: 'ccs-dir',
name: 'CCS Directory',
status: 'ok',
message: 'Exists',
details: '~/.ccs/',
};
}
return {
id: 'ccs-dir',
name: 'CCS Directory',
status: 'error',
message: 'Not found',
details: ccsDir,
fix: 'Run: npm install -g @kaitranntt/ccs --force',
fixable: true,
};
}
/**
* Check permissions on CCS directory
*/
export function checkPermissions(ccsDir: string): HealthCheck {
const testFile = `${ccsDir}/.permission-test`;
try {
fs.writeFileSync(testFile, 'test', 'utf8');
fs.unlinkSync(testFile);
return {
id: 'permissions',
name: 'Permissions',
status: 'ok',
message: 'Write access verified',
};
} catch {
return {
id: 'permissions',
name: 'Permissions',
status: 'error',
message: 'Cannot write to ~/.ccs/',
fix: 'sudo chown -R $USER ~/.ccs ~/.claude && chmod 755 ~/.ccs ~/.claude',
};
}
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Health Check Types
*
* Core interfaces for the health check system.
*/
export interface HealthCheck {
id: string;
name: string;
status: 'ok' | 'warning' | 'error' | 'info';
message: string;
details?: string;
fix?: string;
fixable?: boolean;
}
export interface HealthGroup {
id: string;
name: string;
icon: string;
checks: HealthCheck[];
}
export interface HealthReport {
timestamp: number;
version: string;
groups: HealthGroup[];
checks: HealthCheck[]; // Flat list for backward compatibility
summary: {
total: number;
passed: number;
warnings: number;
errors: number;
info: number;
};
}
+53
View File
@@ -0,0 +1,53 @@
/**
* WebSearch CLI Health Checks
*
* Check WebSearch CLI providers (Gemini CLI, Grok CLI).
*/
import { getWebSearchCliProviders, hasAnyWebSearchCli } from '../../utils/websearch-manager';
import type { HealthCheck } from './types';
/**
* Check WebSearch CLI providers
*/
export function checkWebSearchClis(): HealthCheck[] {
const providers = getWebSearchCliProviders();
const checks: HealthCheck[] = [];
for (const provider of providers) {
if (provider.installed) {
const freeTag = provider.freeTier ? ' (FREE)' : '';
checks.push({
id: `websearch-${provider.id}`,
name: provider.name,
status: 'ok',
message: `v${provider.version || 'unknown'}${freeTag}`,
details: provider.description,
});
} else {
const keyNote = provider.requiresApiKey ? ` (needs ${provider.apiKeyEnvVar})` : ' (FREE)';
checks.push({
id: `websearch-${provider.id}`,
name: provider.name,
status: 'info',
message: `Not installed${keyNote}`,
fix: provider.installCommand,
details: provider.description,
});
}
}
// Add summary check if no providers installed
if (!hasAnyWebSearchCli()) {
checks.push({
id: 'websearch-summary',
name: 'WebSearch Status',
status: 'warning',
message: 'No CLI tools installed',
fix: 'npm install -g @google/gemini-cli (FREE)',
details: 'Install a WebSearch CLI for real-time web access',
});
}
return checks;
}