UNPKG

claudekit

Version:

CLI tools for Claude Code development workflow

431 lines (388 loc) 14.3 kB
import { Colors, symbols } from '../utils/colors.js'; import { promises as fs } from 'node:fs'; import * as path from 'node:path'; import { createProgressReporter } from '../utils/progress.js'; import { validateProject, checkAllPrerequisites, formatValidationErrors, type ValidationResult, } from '../lib/validation.js'; import { validateClaudekitConfig } from '../types/claudekit-config.js'; interface DoctorOptions { type?: string; prerequisites?: boolean; verbose?: boolean; quiet?: boolean; } interface LegacyValidationResult { passed: boolean; message: string; } export async function doctor(options: DoctorOptions): Promise<void> { const progressReporter = createProgressReporter({ quiet: options.quiet, verbose: options.verbose, }); const legacyResults: LegacyValidationResult[] = []; const validationResults: ValidationResult[] = []; try { const projectRoot = process.cwd(); // Enhanced project validation using new validation module progressReporter.start('Running diagnostic checks...'); const projectValidation = await validateProject(projectRoot, { requireGitRepository: false, requireNodeProject: false, }); validationResults.push(projectValidation); // Check for .claude directory progressReporter.update('Checking claudekit installation...'); const claudeDir = path.join(projectRoot, '.claude'); try { await fs.access(claudeDir); legacyResults.push({ passed: true, message: '.claude directory exists', }); } catch { legacyResults.push({ passed: false, message: '.claude directory not found - run "claudekit setup" first', }); } // Check for settings.json with enhanced validation progressReporter.update('Checking configuration files...'); const settingsPath = path.join(claudeDir, 'settings.json'); try { const settings = await fs.readFile(settingsPath, 'utf-8'); JSON.parse(settings); // Validate JSON legacyResults.push({ passed: true, message: 'settings.json is valid', }); } catch (error) { legacyResults.push({ passed: false, message: error instanceof SyntaxError ? 'settings.json contains invalid JSON' : 'settings.json not found', }); } // Check for hooks in settings.json (embedded hooks system) progressReporter.update('Checking hooks configuration...'); try { const settingsContent = await fs.readFile(settingsPath, 'utf-8'); const settings = JSON.parse(settingsContent); // Count configured hooks let hookCount = 0; if (settings.hooks !== null && settings.hooks !== undefined) { // Count hooks in PostToolUse if ( settings.hooks.PostToolUse !== null && settings.hooks.PostToolUse !== undefined && Array.isArray(settings.hooks.PostToolUse) ) { for (const config of settings.hooks.PostToolUse) { if ( config.hooks !== null && config.hooks !== undefined && Array.isArray(config.hooks) ) { hookCount += config.hooks.length; } } } // Count hooks in Stop if ( settings.hooks.Stop !== null && settings.hooks.Stop !== undefined && Array.isArray(settings.hooks.Stop) ) { for (const config of settings.hooks.Stop) { if ( config.hooks !== null && config.hooks !== undefined && Array.isArray(config.hooks) ) { hookCount += config.hooks.length; } } } // Count hooks in other events (PreToolUse, etc.) for (const [event, configs] of Object.entries(settings.hooks)) { if (event !== 'PostToolUse' && event !== 'Stop' && Array.isArray(configs)) { for (const config of configs as unknown[]) { if ( config !== null && config !== undefined && typeof config === 'object' && 'hooks' in config && config.hooks !== null && config.hooks !== undefined && Array.isArray(config.hooks) ) { hookCount += config.hooks.length; } } } } } if (hookCount > 0) { legacyResults.push({ passed: true, message: `Found ${hookCount} hook(s)`, }); } else { legacyResults.push({ passed: true, message: 'Found 0 hook(s)', }); } } catch { // No settings.json or invalid JSON - report no hooks legacyResults.push({ passed: true, message: 'Found 0 hook(s)', }); } // Check for .claudekit/config.json progressReporter.update('Checking claudekit configuration...'); const claudekitDir = path.join(projectRoot, '.claudekit'); const configPath = path.join(claudekitDir, 'config.json'); try { const configContent = await fs.readFile(configPath, 'utf-8'); const configData = JSON.parse(configContent); // Validate the configuration schema const validation = validateClaudekitConfig(configData); if (validation.valid) { legacyResults.push({ passed: true, message: '.claudekit/config.json is valid', }); } else { // Schema validation failed const errorCount = validation.errors?.length ?? 0; const firstError = validation.errors?.[0] ?? 'Invalid configuration'; legacyResults.push({ passed: false, message: `.claudekit/config.json has ${errorCount} validation error${errorCount > 1 ? 's' : ''}: ${firstError}`, }); // Add additional errors as warnings if (validation.errors && validation.errors.length > 1) { for (let i = 1; i < Math.min(validation.errors.length, 3); i++) { legacyResults.push({ passed: false, message: ` - ${validation.errors[i]}`, }); } if (validation.errors.length > 3) { legacyResults.push({ passed: false, message: ` - ... and ${validation.errors.length - 3} more errors`, }); } } } } catch (error) { if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { // File doesn't exist - this is OK, claudekit can work without it legacyResults.push({ passed: true, message: '.claudekit/config.json not found (optional)', }); } else if (error instanceof SyntaxError) { // Invalid JSON legacyResults.push({ passed: false, message: '.claudekit/config.json contains invalid JSON', }); } else { // Other errors (permissions, etc.) legacyResults.push({ passed: false, message: `.claudekit/config.json error: ${error instanceof Error ? error.message : 'Unknown error'}`, }); } } // Check for commands directory progressReporter.update('Checking commands installation...'); const commandsDir = path.join(claudeDir, 'commands'); try { await fs.access(commandsDir); // Count commands recursively let commandCount = 0; async function countCommands(dir: string): Promise<void> { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { await countCommands(path.join(dir, entry.name)); } else if (entry.name.endsWith('.md')) { commandCount++; } } } await countCommands(commandsDir); legacyResults.push({ passed: true, message: `Found ${commandCount} command(s)`, }); } catch { // Commands directory doesn't exist - this is valid, just means no commands installed legacyResults.push({ passed: true, message: 'No commands installed', }); } // Check for agents directory progressReporter.update('Checking agents installation...'); const agentsDir = path.join(claudeDir, 'agents'); try { await fs.access(agentsDir); // Count agents recursively let agentCount = 0; // Helper function to check if a file is a valid agent async function isValidAgent(filePath: string): Promise<boolean> { try { const content = await fs.readFile(filePath, 'utf-8'); // Check for frontmatter with name and description if (content.startsWith('---')) { const frontmatterEnd = content.indexOf('---', 3); if (frontmatterEnd !== -1) { const frontmatter = content.substring(3, frontmatterEnd); return frontmatter.includes('name:') && frontmatter.includes('description:'); } } } catch { // Can't read file, skip } return false; } async function countAgents(dir: string): Promise<void> { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); // Handle both regular directories and symlinks to directories if (entry.isDirectory()) { await countAgents(fullPath); } else if (entry.isSymbolicLink()) { try { const stats = await fs.stat(fullPath); if (stats.isDirectory()) { await countAgents(fullPath); } else if (stats.isFile() && entry.name.endsWith('.md')) { if (await isValidAgent(fullPath)) { agentCount++; } } } catch { // Broken symlink, ignore } } else if (entry.name.endsWith('.md')) { if (await isValidAgent(fullPath)) { agentCount++; } } } } await countAgents(agentsDir); legacyResults.push({ passed: true, message: `Found ${agentCount} agent(s)`, }); } catch { // Agents directory doesn't exist - this is valid, just means no agents installed legacyResults.push({ passed: true, message: 'No agents installed', }); } // Optional: Check prerequisites if (options.prerequisites === true) { progressReporter.update('Checking development prerequisites...'); const prereqResult = await checkAllPrerequisites({ requireTypeScript: false, requireESLint: false, requireGitRepository: false, }); validationResults.push(prereqResult); } progressReporter.succeed('Diagnostic checks completed'); // Display results console.log('\nDiagnostic Results:\n'); // Display legacy validation results let allPassed = true; for (const result of legacyResults) { const icon = result.passed ? symbols.success : symbols.error; const message = result.passed ? Colors.success(result.message) : Colors.error(result.message); console.log(` ${icon} ${message}`); if (!result.passed) { allPassed = false; } } // Display enhanced validation results if verbose mode or if there are issues if ( options.verbose === true || !projectValidation.isValid || (options.prerequisites === true && validationResults.some((r) => !r.isValid)) ) { console.log('\nDiagnostic Details:\n'); for (const result of validationResults) { if (!result.isValid || result.warnings.length > 0) { const formatted = formatValidationErrors(result); if (formatted !== null && formatted !== undefined && formatted !== '') { console.log(formatted); console.log(''); } } if (!result.isValid) { allPassed = false; } } } console.log(''); if (allPassed) { console.log(Colors.bold(Colors.success('All diagnostic checks passed!'))); // Show summary of what was checked if (options.verbose === true) { console.log(Colors.dim('\nChecked:')); console.log(Colors.dim('• claudekit directory structure')); console.log(Colors.dim('• Configuration file validity')); console.log(Colors.dim('• claudekit config (.claudekit/config.json)')); console.log(Colors.dim('• Hook installation')); console.log(Colors.dim('• Command installation')); console.log(Colors.dim('• Project path security')); if (options.prerequisites === true) { console.log(Colors.dim('• Development prerequisites')); } } // Show helpful suggestions for empty installations const suggestions: string[] = []; if (legacyResults.some((r) => r.message === 'No hooks configured')) { suggestions.push('• Run "claudekit setup" to configure hooks'); } if (legacyResults.some((r) => r.message === 'No commands installed')) { suggestions.push('• Run "claudekit setup" to install commands'); } if (suggestions.length > 0) { console.log(Colors.dim('\nTo get started:')); suggestions.forEach((s) => console.log(Colors.dim(s))); } } else { console.log(Colors.bold(Colors.error('Some diagnostic checks failed.'))); // Provide helpful suggestions console.log(Colors.warn('\nNext steps:')); if (legacyResults.some((r) => !r.passed && r.message.includes('.claude directory'))) { console.log(Colors.warn('• Run "claudekit setup" to set up ClaudeKit')); } if (legacyResults.some((r) => !r.passed && r.message.includes('settings.json'))) { console.log(Colors.warn('• Check your .claude/settings.json file for syntax errors')); } // Hooks/commands suggestions are now shown in the success case process.exit(1); } } catch (error) { progressReporter.fail('Diagnostic checks failed'); throw error; } }