claudewatch
Version:
Automated validation and self-healing system for Claude Code projects
299 lines (254 loc) • 9.74 kB
JavaScript
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const chalk = require('chalk');
const ora = require('ora');
const CLAUDEWATCH_DIR = '.claudewatch';
const CLAUDE_DIR = '.claude';
async function initProject(options = {}) {
const config = {
baseUrl: options.baseUrl || 'http://localhost:3000',
selfHealing: options.selfHealing !== false,
validationTypes: options.validationTypes || ['visual', 'accessibility', 'performance', 'forms', 'api', 'console', 'links'],
...options
};
const dirs = [CLAUDEWATCH_DIR, CLAUDE_DIR, 'validation-logs', 'validation-screenshots'];
dirs.forEach(dir => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
console.log(chalk.green(`Created ${dir}/`));
}
});
const templateDir = path.join(__dirname, '..', 'templates');
const configTemplate = fs.readFileSync(path.join(templateDir, 'config.js.template'), 'utf-8');
const configContent = configTemplate
.replace('{{BASE_URL}}', config.baseUrl)
.replace('{{VALIDATION_TYPES}}', JSON.stringify(config.validationTypes));
fs.writeFileSync(path.join(CLAUDEWATCH_DIR, 'config.js'), configContent);
console.log(chalk.green('Created validation config'));
const validatorTemplate = fs.readFileSync(path.join(templateDir, 'validator.js.template'), 'utf-8');
fs.writeFileSync(path.join(CLAUDEWATCH_DIR, 'validator.js'), validatorTemplate);
console.log(chalk.green('Created validation script'));
if (config.selfHealing) {
const healerTemplate = fs.readFileSync(path.join(templateDir, 'self-healer.js.template'), 'utf-8');
fs.writeFileSync(path.join(CLAUDEWATCH_DIR, 'self-healer.js'), healerTemplate);
console.log(chalk.green('Created self-healing script'));
}
const hookCommand = config.selfHealing
? 'node .claudewatch/self-healer.js'
: 'node .claudewatch/validator.js';
const claudeSettings = {
hooks: {
Stop: [{
matcher: "",
hooks: [{
type: "command",
command: hookCommand,
timeout: config.selfHealing ? 300000 : 120000,
retries: 1
}]
}],
PostToolUse: [{
matcher: "Bash|Edit|Write|MultiEdit",
hooks: [{
type: "command",
command: "echo 'Code changes detected - ClaudeWatch validation recommended'",
timeout: 5000,
retries: 0
}]
}]
}
};
fs.writeFileSync(
path.join(CLAUDE_DIR, 'settings.json'),
JSON.stringify(claudeSettings, null, 2)
);
console.log(chalk.green('Created Claude settings'));
const claudeMdTemplate = fs.readFileSync(path.join(templateDir, 'CLAUDE.md.template'), 'utf-8');
const claudeMdContent = claudeMdTemplate
.replace(/{{SELF_HEALING}}/g, config.selfHealing ? 'enabled' : 'disabled');
fs.writeFileSync('CLAUDE.md', claudeMdContent);
console.log(chalk.green('Created CLAUDE.md instructions'));
const gitignoreEntries = [
'\n# ClaudeWatch',
'validation-logs/',
'validation-screenshots/',
'.claudewatch/logs/',
''
].join('\n');
if (fs.existsSync('.gitignore')) {
const gitignore = fs.readFileSync('.gitignore', 'utf-8');
if (!gitignore.includes('# ClaudeWatch')) {
fs.appendFileSync('.gitignore', gitignoreEntries);
console.log(chalk.green('Updated .gitignore'));
}
} else {
fs.writeFileSync('.gitignore', gitignoreEntries);
console.log(chalk.green('Created .gitignore'));
}
const spinner = ora('Installing Playwright...').start();
try {
execSync('npx playwright install chromium', { stdio: 'ignore' });
spinner.succeed('Playwright installed');
} catch (error) {
spinner.fail('Failed to install Playwright');
console.log(chalk.yellow('Run `npx playwright install` manually'));
}
console.log(chalk.green('\nClaudeWatch initialized successfully!'));
if (config.claudeOptimized) {
console.log(chalk.blue('\nClaude-specific optimizations applied:'));
console.log(' - Self-healing enabled by default');
console.log(' - Automatic validation on task completion');
console.log(' - Instructions added to CLAUDE.md');
}
console.log(chalk.gray('\nNext steps:'));
console.log(' 1. Edit .claudewatch/config.js to customize validations');
console.log(' 2. Run `claudewatch validate` to test');
console.log(' 3. Claude will now automatically validate your work!');
}
async function runValidation(configPath) {
const config = configPath || path.join(CLAUDEWATCH_DIR, 'config.js');
const validatorPath = path.join(CLAUDEWATCH_DIR, 'validator.js');
if (!fs.existsSync(validatorPath)) {
throw new Error('Validator not found. Run `claudewatch init` first.');
}
try {
execSync(`node ${validatorPath} ${config}`, { stdio: 'inherit' });
return { success: true };
} catch (error) {
return { success: false, error };
}
}
async function runSelfHealing(options = {}) {
const healerPath = path.join(CLAUDEWATCH_DIR, 'self-healer.js');
if (!fs.existsSync(healerPath)) {
throw new Error('Self-healer not found. Run `claudewatch init` with self-healing enabled.');
}
try {
const env = {
...process.env,
MAX_ATTEMPTS: options.maxAttempts || 5
};
execSync(`node ${healerPath}`, { stdio: 'inherit', env });
return { success: true };
} catch (error) {
return { success: false, error };
}
}
async function showStatus() {
console.log(chalk.blue('\nClaudeWatch Status\n'));
const checks = [
{ name: 'Config file', path: path.join(CLAUDEWATCH_DIR, 'config.js') },
{ name: 'Validator script', path: path.join(CLAUDEWATCH_DIR, 'validator.js') },
{ name: 'Self-healer script', path: path.join(CLAUDEWATCH_DIR, 'self-healer.js') },
{ name: 'Claude settings', path: path.join(CLAUDE_DIR, 'settings.json') },
{ name: 'Instructions', path: 'CLAUDE.md' }
];
checks.forEach(check => {
const exists = fs.existsSync(check.path);
const status = exists ? chalk.green('[OK]') : chalk.red('[MISSING]');
console.log(`${status} ${check.name}`);
});
const configPath = path.join(CLAUDEWATCH_DIR, 'config.js');
if (fs.existsSync(configPath)) {
console.log(chalk.blue('\nConfiguration:'));
try {
delete require.cache[require.resolve(path.resolve(configPath))];
const config = require(path.resolve(configPath));
console.log(` Base URL: ${config.baseUrl}`);
console.log(` Pages: ${config.pages.length}`);
console.log(` Viewports: ${config.viewports.map(v => v.name).join(', ')}`);
} catch (error) {
console.log(chalk.red(' Error reading config'));
}
}
console.log(chalk.blue('\nRecent Activity:'));
const logsDir = 'validation-logs';
if (fs.existsSync(logsDir)) {
const logs = fs.readdirSync(logsDir)
.filter(f => f.startsWith('report-'))
.sort()
.slice(-5);
if (logs.length > 0) {
logs.forEach(log => {
const stat = fs.statSync(path.join(logsDir, log));
const time = stat.mtime.toLocaleString();
console.log(` ${time} - ${log}`);
});
} else {
console.log(' No validation logs found');
}
}
}
async function generateReport(count = 10) {
console.log(chalk.blue('\nValidation Report\n'));
const logsDir = 'validation-logs';
if (!fs.existsSync(logsDir)) {
console.log(chalk.yellow('No validation logs found'));
return;
}
const reports = fs.readdirSync(logsDir)
.filter(f => f.startsWith('report-'))
.sort()
.slice(-count);
if (reports.length === 0) {
console.log(chalk.yellow('No reports found'));
return;
}
let totalTests = 0;
let totalPassed = 0;
let totalFailed = 0;
reports.forEach(file => {
try {
const report = JSON.parse(fs.readFileSync(path.join(logsDir, file), 'utf-8'));
const { timestamp, total, passed, failed } = report.summary;
const time = new Date(timestamp).toLocaleString();
const status = failed === 0 ? chalk.green('[PASS]') : chalk.red('[FAIL]');
console.log(`${status} ${time} - ${passed}/${total} passed`);
totalTests += total;
totalPassed += passed;
totalFailed += failed;
} catch (error) {
console.log(chalk.red(`[INVALID] ${file} - Invalid report`));
}
});
console.log(chalk.blue('\nSummary:'));
console.log(` Total validations: ${reports.length}`);
console.log(` Tests run: ${totalTests}`);
console.log(` Passed: ${totalPassed}`);
console.log(` Failed: ${totalFailed}`);
console.log(` Success rate: ${totalTests > 0 ? ((totalPassed / totalTests) * 100).toFixed(1) : 0}%`);
}
async function cleanup(options = {}) {
const { keepDays = 7, dryRun = false } = options;
const cutoffDate = new Date(Date.now() - (keepDays * 24 * 60 * 60 * 1000));
console.log(chalk.blue(`\nCleaning files older than ${keepDays} days...\n`));
const dirs = ['validation-logs', 'validation-screenshots'];
let totalDeleted = 0;
dirs.forEach(dir => {
if (!fs.existsSync(dir)) return;
const files = fs.readdirSync(dir).map(file => ({
name: file,
path: path.join(dir, file),
stat: fs.statSync(path.join(dir, file))
})).filter(file => file.stat.mtime < cutoffDate);
console.log(`${dir}: ${files.length} old files`);
if (!dryRun) {
files.forEach(file => fs.unlinkSync(file.path));
}
totalDeleted += files.length;
});
if (dryRun) {
console.log(chalk.yellow(`\nDry run: Would delete ${totalDeleted} files`));
} else {
console.log(chalk.green(`\nDeleted ${totalDeleted} files`));
}
}
module.exports = {
initProject,
runValidation,
runSelfHealing,
showStatus,
generateReport,
cleanup
};