@calmhive/calmhive-cli
Version:
🐝 Intelligent Claude CLI wrapper with rule injection, AFk background processing, voice interface, and adaptive retry
481 lines (414 loc) • 12.6 kB
JavaScript
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
/**
* ConfigManager - Handles CLAUDE.md configuration file management
* Provides safe operations with backups, diffs, and validation
*/
class ConfigManager {
constructor() {
this.homeDir = process.env.HOME || process.env.USERPROFILE;
this.claudeDir = path.join(this.homeDir, '.claude');
this.configPath = path.join(this.claudeDir, 'CLAUDE.md');
this.backupDir = path.join(this.claudeDir, 'backups');
// Find calmhive root - works in both dev and npm installed scenarios
this.calmhiveRoot = this.findCalmhiveRoot();
}
/**
* Find the calmhive package root directory
*/
findCalmhiveRoot() {
// Method 1: Try to resolve the package.json from installed package
try {
const packagePath = require.resolve('@calmhive/calmhive-cli/package.json');
return path.dirname(packagePath);
} catch (error) {
// Method 2: Look for package.json starting from current location
let currentDir = __dirname;
while (currentDir !== path.dirname(currentDir)) {
const packagePath = path.join(currentDir, 'package.json');
if (fs.existsSync(packagePath)) {
try {
const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
if (pkg.name === '@calmhive/calmhive-cli') {
return currentDir;
}
} catch (error) {
// Continue searching
}
}
currentDir = path.dirname(currentDir);
}
// Method 3: Fallback to relative path (development)
return path.join(__dirname, '..');
}
}
/**
* Ensure necessary directories exist
*/
ensureDirectories() {
if (!fs.existsSync(this.claudeDir)) {
fs.mkdirSync(this.claudeDir, { recursive: true });
}
if (!fs.existsSync(this.backupDir)) {
fs.mkdirSync(this.backupDir, { recursive: true });
}
}
/**
* Get path to template file
*/
getTemplatePath() {
// Single CLAUDE.md template for both CLI and desktop
const filename = 'CLAUDE.md';
return path.join(this.calmhiveRoot, filename);
}
/**
* Generate timestamped backup filename
*/
generateBackupPath() {
const timestamp = new Date().toISOString()
.replace(/:/g, '-')
.replace(/\..+/, '')
.replace(/T/, '_');
return path.join(this.backupDir, `CLAUDE.md.backup.${timestamp}`);
}
/**
* Get configuration status
*/
getStatus() {
const status = {
configExists: fs.existsSync(this.configPath),
configPath: this.configPath,
backupDir: this.backupDir,
templateExists: fs.existsSync(this.getTemplatePath())
};
if (status.configExists) {
const stats = fs.statSync(this.configPath);
status.lastModified = stats.mtime;
status.size = stats.size;
// Try to detect version from file
try {
const content = fs.readFileSync(this.configPath, 'utf8');
const versionMatch = content.match(/Version:\s*([0-9]+\.[0-9]+\.[0-9]+)/);
status.version = versionMatch ? versionMatch[1] : 'unknown';
} catch (error) {
status.version = 'unknown';
}
}
// Get available backups
status.backups = this.listBackups();
return status;
}
/**
* List available backups
*/
listBackups() {
if (!fs.existsSync(this.backupDir)) {
return [];
}
return fs.readdirSync(this.backupDir)
.filter(file => file.startsWith('CLAUDE.md.backup.'))
.map(file => {
const filePath = path.join(this.backupDir, file);
const stats = fs.statSync(filePath);
return {
filename: file,
path: filePath,
created: stats.mtime,
size: stats.size
};
})
.sort((a, b) => b.created - a.created);
}
/**
* Create backup of current config
*/
createBackup() {
if (!fs.existsSync(this.configPath)) {
throw new Error('No existing CLAUDE.md to backup');
}
this.ensureDirectories();
const backupPath = this.generateBackupPath();
fs.copyFileSync(this.configPath, backupPath);
return {
backupPath,
size: fs.statSync(backupPath).size
};
}
/**
* Restore from backup
*/
restoreFromBackup(backupFilename) {
let backupPath;
if (backupFilename) {
// Specific backup requested
backupPath = path.join(this.backupDir, backupFilename);
if (!fs.existsSync(backupPath)) {
throw new Error(`Backup file not found: ${backupFilename}`);
}
} else {
// Use most recent backup
const backups = this.listBackups();
if (backups.length === 0) {
throw new Error('No backups available');
}
backupPath = backups[0].path;
}
// Create backup of current config before restoring
let currentBackup = null;
if (fs.existsSync(this.configPath)) {
currentBackup = this.createBackup();
}
// Restore from backup
this.ensureDirectories();
fs.copyFileSync(backupPath, this.configPath);
return {
restoredFrom: backupPath,
currentBackedUpTo: currentBackup ? currentBackup.backupPath : null
};
}
/**
* Get diff between current config and template
*/
async getDiff() {
const templatePath = this.getTemplatePath();
if (!fs.existsSync(templatePath)) {
throw new Error(`Template file not found: ${templatePath}`);
}
const templateName = 'CLAUDE.md';
if (!fs.existsSync(this.configPath)) {
return {
isNewFile: true,
templateName,
templateSize: fs.statSync(templatePath).size,
diff: null
};
}
// Use diff command to compare files
return new Promise((resolve, reject) => {
const diffProcess = spawn('diff', ['-u', this.configPath, templatePath], {
stdio: ['ignore', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
diffProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
diffProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
diffProcess.on('close', (code) => {
if (code === 0) {
// Files are identical
resolve({
isNewFile: false,
isIdentical: true,
templateName,
diff: null
});
} else if (code === 1) {
// Files differ
resolve({
isNewFile: false,
isIdentical: false,
templateName,
diff: stdout
});
} else {
// Error occurred
reject(new Error(`Diff command failed: ${stderr || 'Unknown error'}`));
}
});
diffProcess.on('error', (error) => {
reject(new Error(`Failed to run diff: ${error.message}`));
});
});
}
/**
* Install configuration from template
*/
async installConfig(dryRun = false) {
const templatePath = this.getTemplatePath();
const templateName = 'CLAUDE.md'; // Single template for both CLI and desktop
if (!fs.existsSync(templatePath)) {
throw new Error(`Template file not found: ${templatePath}`);
}
const result = {
templateName,
templatePath,
configPath: this.configPath,
isUpdate: fs.existsSync(this.configPath),
dryRun,
backupCreated: null
};
if (dryRun) {
// Just return what would happen
result.templateSize = fs.statSync(templatePath).size;
return result;
}
// Create backup if config exists
if (result.isUpdate) {
result.backupCreated = this.createBackup();
}
// Ensure directories exist
this.ensureDirectories();
// Copy template to config location
fs.copyFileSync(templatePath, this.configPath);
result.size = fs.statSync(this.configPath).size;
return result;
}
/**
* Validate template file
*/
validateTemplate() {
const templatePath = this.getTemplatePath();
if (!fs.existsSync(templatePath)) {
return { valid: false, error: 'Template file not found' };
}
try {
const content = fs.readFileSync(templatePath, 'utf8');
// Basic validation
if (content.length < 100) {
return { valid: false, error: 'Template file too small' };
}
if (!content.includes('CLAUDE CODE PRACTICAL ENGINEERING FRAMEWORK')) {
return { valid: false, error: 'Not a valid CLAUDE.md template' };
}
// Check for version
const versionMatch = content.match(/Version:\s*([0-9]+\.[0-9]+\.[0-9]+)/);
if (!versionMatch) {
return { valid: false, error: 'No version found in template' };
}
return {
valid: true,
version: versionMatch[1],
size: content.length
};
} catch (error) {
return { valid: false, error: error.message };
}
}
/**
* Get available command files from ~/.claude/commands
*/
getCommandFiles() {
const commandsDir = path.join(this.claudeDir, 'commands');
if (!fs.existsSync(commandsDir)) {
return [];
}
const files = [];
const scan = (dir, relativePath = '') => {
const items = fs.readdirSync(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
const relPath = path.join(relativePath, item);
let stat;
try {
stat = fs.statSync(fullPath);
} catch (error) {
// Skip broken symlinks or files that can't be read
continue;
}
if (stat.isDirectory()) {
// Skip certain directories
if (!['archive', 'old', 'delete', 'cleanup'].includes(item)) {
scan(fullPath, relPath);
}
} else if (item.endsWith('.md') && !item.startsWith('.')) {
files.push({
name: item,
path: fullPath,
relativePath: relPath,
size: stat.size,
modified: stat.mtime
});
}
}
};
scan(commandsDir);
return files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
}
/**
* Copy command files to destination directory
*/
copyCommandFiles(destDir, options = {}) {
const { dryRun = false, patterns = [], force = false } = options;
const commandFiles = this.getCommandFiles();
// Filter files based on patterns if provided
let filesToCopy = commandFiles;
if (patterns.length > 0) {
filesToCopy = commandFiles.filter(file =>
patterns.some(pattern =>
file.name.includes(pattern) || file.relativePath.includes(pattern)
)
);
}
const results = {
totalFiles: filesToCopy.length,
copied: [],
skipped: [],
errors: [],
dryRun
};
for (const file of filesToCopy) {
const destPath = path.join(destDir, file.relativePath);
const destExists = fs.existsSync(destPath);
if (destExists && !force && !dryRun) {
results.skipped.push({
file: file.relativePath,
reason: 'exists'
});
continue;
}
if (dryRun) {
results.copied.push({
file: file.relativePath,
action: destExists ? 'overwrite' : 'create',
size: file.size
});
} else {
try {
// Ensure destination directory exists
const destDirPath = path.dirname(destPath);
if (!fs.existsSync(destDirPath)) {
fs.mkdirSync(destDirPath, { recursive: true });
}
// Copy file
fs.copyFileSync(file.path, destPath);
results.copied.push({
file: file.relativePath,
action: destExists ? 'overwritten' : 'created',
size: file.size
});
} catch (error) {
results.errors.push({
file: file.relativePath,
error: error.message
});
}
}
}
return results;
}
/**
* Get summary of command files for display
*/
getCommandFilesSummary() {
const files = this.getCommandFiles();
const categories = {};
for (const file of files) {
const dir = path.dirname(file.relativePath);
const category = dir === '.' ? 'root' : dir;
if (!categories[category]) {
categories[category] = [];
}
categories[category].push(file);
}
return {
totalFiles: files.length,
categories,
totalSize: files.reduce((sum, f) => sum + f.size, 0)
};
}
}
module.exports = ConfigManager;