UNPKG

cerberus-claude-cli

Version:

CLI tool for preparing files and projects for Claude AI. Collect, organize, and analyze source code files to create comprehensive project contexts.

531 lines (456 loc) 15.1 kB
/** * Bundle Creator - Utility for creating file bundles for Claude Projects */ const fs = require('fs-extra'); const path = require('path'); const logger = require('./logger'); const config = require('./config'); // const fileSystem = require('./fileSystem'); // TODO: Use if needed // const pathHelper = require('./pathHelper'); // TODO: Use if needed class BundleCreator { constructor() { // Get bundle config from app config with fallbacks const appBundleConfig = config.get('bundle', {}); this.bundleConfig = { maxFilesPerBundle: appBundleConfig.maxFilesPerBundle || 50, bundleFormat: appBundleConfig.bundleFormat || 'markdown', includeEmptyFiles: appBundleConfig.includeEmptyFiles || false, maxFileSizeForBundle: appBundleConfig.maxFileSizeForBundle || 1048576, // 1MB maxBundleSize: appBundleConfig.maxBundleSize || 5242880, // 5MB warning threshold }; } /** * Get language identifier for syntax highlighting * @param {string} filePath - Path to the file * @returns {string} - Language identifier */ getLanguageFromPath(filePath) { const ext = path.extname(filePath).toLowerCase(); const languageMap = { '.js': 'javascript', '.jsx': 'jsx', '.ts': 'typescript', '.tsx': 'tsx', '.py': 'python', '.php': 'php', '.html': 'html', '.htm': 'html', '.css': 'css', '.scss': 'scss', '.sass': 'sass', '.json': 'json', '.xml': 'xml', '.yaml': 'yaml', '.yml': 'yaml', '.md': 'markdown', '.txt': 'text', '.sh': 'bash', '.sql': 'sql', '.java': 'java', '.c': 'c', '.cpp': 'cpp', '.h': 'c', '.hpp': 'cpp', '.cs': 'csharp', '.rb': 'ruby', '.go': 'go', '.rs': 'rust', '.swift': 'swift', '.kt': 'kotlin', '.dart': 'dart', '.r': 'r', '.m': 'matlab', '.pl': 'perl', '.scala': 'scala', }; return languageMap[ext] || 'text'; } /** * Check if file should be included in bundle * @param {string} filePath - Path to the file * @param {Object} stats - File stats * @returns {boolean} - Whether to include the file */ shouldIncludeFile(filePath, stats) { // Skip directories if (stats.isDirectory()) { return false; } // Skip if file is too large if (stats.size > this.bundleConfig.maxFileSizeForBundle) { logger.warn(`Skipping large file: ${filePath} (${Math.round(stats.size / 1024)}KB)`); return false; } // Skip empty files if configured if (!this.bundleConfig.includeEmptyFiles && stats.size === 0) { return false; } // Skip binary files (basic check) const ext = path.extname(filePath).toLowerCase(); const binaryExtensions = [ '.exe', '.dll', '.so', '.dylib', '.bin', '.img', '.iso', '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.ico', '.svg', '.mp3', '.mp4', '.avi', '.mkv', '.mov', '.wav', '.ogg', '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', ]; if (binaryExtensions.includes(ext)) { logger.debug(`Skipping binary file: ${filePath}`); return false; } return true; } /** * Read and validate file content * @param {string} filePath - Path to the file * @returns {Promise<string|null>} - File content or null if invalid */ async readFileContent(filePath) { try { const content = await fs.readFile(filePath, 'utf8'); // Basic check for binary content (null bytes) if (content.includes('\0')) { logger.debug(`Skipping binary file detected by content: ${filePath}`); return null; } return content; } catch (error) { if (error.code === 'ENOENT') { logger.warn(`File not found: ${filePath}`); } else { logger.warn(`Error reading file ${filePath}: ${error.message}`); } return null; } } /** * Create bundle header with metadata * @param {Object} bundleInfo - Bundle information * @returns {string} - Formatted header */ createBundleHeader(bundleInfo) { const timestamp = new Date().toISOString(); return `# CODE_BUNDLE_START ## Project: ${bundleInfo.projectName} ## Created: ${timestamp} ## Total Files: ${bundleInfo.totalFiles} ## Bundle: ${bundleInfo.bundleNumber} of ${bundleInfo.totalBundles} ${bundleInfo.description ? `## Description: ${bundleInfo.description}` : ''} --- `; } /** * Create bundle footer * @returns {string} - Formatted footer */ createBundleFooter() { return '\n# CODE_BUNDLE_END\n'; } /** * Format file entry for bundle * @param {string} originalPath - Original file path * @param {string} content - File content * @returns {string} - Formatted file entry */ formatFileEntry(originalPath, content) { const language = this.getLanguageFromPath(originalPath); return `### FILE: ${originalPath} \`\`\`${language} ${content} \`\`\` --- `; } /** * Create a single bundle with all project files * @param {Object} project - Project instance * @returns {Promise<Object>} - Bundle creation result */ async createSingleBundle(project) { logger.info(`Creating single bundle for project: ${project.name}`); const projectDir = project.getProjectPath(); const files = await this.getProjectFiles(projectDir); if (files.length === 0) { throw new Error('No files found in project'); } const bundleInfo = { projectName: project.name, totalFiles: files.length, bundleNumber: 1, totalBundles: 1, description: `Complete project bundle containing all ${files.length} files`, }; let bundleContent = this.createBundleHeader(bundleInfo); let bundleSize = bundleContent.length; for (const file of files) { const content = await this.readFileContent(file.fullPath); if (content !== null) { const fileEntry = this.formatFileEntry(file.originalPath, content); bundleContent += fileEntry; bundleSize += fileEntry.length; } } bundleContent += this.createBundleFooter(); // Check bundle size if (bundleSize > this.bundleConfig.maxBundleSize) { logger.warn( `Bundle size (${Math.round(bundleSize / 1024 / 1024)}MB) exceeds recommended limit` ); } return { bundles: [ { filename: `${project.name}-bundle-1.md`, content: bundleContent, size: bundleSize, fileCount: files.length, }, ], totalFiles: files.length, totalSize: bundleSize, }; } /** * Create multiple bundles with specified max files per bundle * @param {Object} project - Project instance * @param {number} maxFilesPerBundle - Maximum files per bundle * @returns {Promise<Object>} - Bundle creation result */ async createMultipleBundles(project, maxFilesPerBundle = this.bundleConfig.maxFilesPerBundle) { logger.info( `Creating multiple bundles for project: ${project.name} (max ${maxFilesPerBundle} files per bundle)` ); const projectDir = project.getProjectPath(); const files = await this.getProjectFiles(projectDir); if (files.length === 0) { throw new Error('No files found in project'); } const totalBundles = Math.ceil(files.length / maxFilesPerBundle); const bundles = []; let totalSize = 0; for (let i = 0; i < totalBundles; i++) { const startIndex = i * maxFilesPerBundle; const endIndex = Math.min(startIndex + maxFilesPerBundle, files.length); const bundleFiles = files.slice(startIndex, endIndex); const bundleInfo = { projectName: project.name, totalFiles: files.length, bundleNumber: i + 1, totalBundles: totalBundles, description: `Bundle ${i + 1} containing files ${startIndex + 1}-${endIndex} of ${files.length}`, }; let bundleContent = this.createBundleHeader(bundleInfo); for (const file of bundleFiles) { const content = await this.readFileContent(file.fullPath); if (content !== null) { bundleContent += this.formatFileEntry(file.originalPath, content); } } bundleContent += this.createBundleFooter(); const bundleSize = bundleContent.length; totalSize += bundleSize; bundles.push({ filename: `${project.name}-bundle-${i + 1}.md`, content: bundleContent, size: bundleSize, fileCount: bundleFiles.length, }); if (bundleSize > this.bundleConfig.maxBundleSize) { logger.warn( `Bundle ${i + 1} size (${Math.round(bundleSize / 1024 / 1024)}MB) exceeds recommended limit` ); } } return { bundles, totalFiles: files.length, totalSize, }; } /** * Create custom bundle with selected files * @param {Object} project - Project instance * @param {Array} selectedFiles - Array of selected file paths * @returns {Promise<Object>} - Bundle creation result */ async createCustomBundle(project, selectedFiles) { logger.info( `Creating custom bundle for project: ${project.name} with ${selectedFiles.length} selected files` ); const projectDir = project.getProjectPath(); const allFiles = await this.getProjectFiles(projectDir); // Filter to only selected files const files = allFiles.filter( file => selectedFiles.includes(file.originalPath) || selectedFiles.includes(file.flattenedName) ); if (files.length === 0) { throw new Error('No valid files selected'); } const bundleInfo = { projectName: project.name, totalFiles: files.length, bundleNumber: 1, totalBundles: 1, description: `Custom bundle with ${files.length} selected files`, }; let bundleContent = this.createBundleHeader(bundleInfo); for (const file of files) { const content = await this.readFileContent(file.fullPath); if (content !== null) { bundleContent += this.formatFileEntry(file.originalPath, content); } } bundleContent += this.createBundleFooter(); return { bundles: [ { filename: `${project.name}-custom-bundle.md`, content: bundleContent, size: bundleContent.length, fileCount: files.length, }, ], totalFiles: files.length, totalSize: bundleContent.length, }; } /** * Generate system message for Claude Projects * @param {Object} bundleResult - Result from bundle creation * @param {Object} project - Project instance * @returns {string} - Formatted system message */ generateSystemMessage(bundleResult, project) { const bundleCount = bundleResult.bundles.length; const totalFiles = bundleResult.totalFiles; return `You are working with a code bundle containing multiple files from the "${project.name}" project. ## Bundle Format: Each file in the bundle is marked with: ### FILE: [path/to/file] ## Important Instructions: 1. When referencing code, ALWAYS specify the exact file path using: \`path/to/file\` 2. When suggesting changes, use: "In file \`path/to/file\`, change..." 3. When creating new files, use: "Create new file \`path/to/newfile.ext\` with:" 4. Maintain awareness that you're working with ${totalFiles} files across ${bundleCount} bundle(s) ## Project Structure: The files follow the original project structure. Use the file paths to understand the project organization. ## Bundle Information: - Total files: ${totalFiles} - Number of bundles: ${bundleCount} - Total size: ${Math.round(bundleResult.totalSize / 1024)}KB ${ bundleCount > 1 ? ` ## Working with Multiple Bundles: This project is split across ${bundleCount} bundles. Make sure to: 1. Upload ALL bundles to your Claude Project 2. Reference the correct bundle when discussing specific files 3. Consider the complete project context across all bundles ` : '' } Please confirm you understand the bundle format and are ready to work with the ${project.name} project files.`; } /** * Get all project files with metadata * @param {string} projectDir - Project directory path * @returns {Promise<Array>} - Array of file objects */ async getProjectFiles(projectDir) { const files = []; try { const entries = await fs.readdir(projectDir); for (const entry of entries) { const fullPath = path.join(projectDir, entry); try { const stats = await fs.stat(fullPath); if (this.shouldIncludeFile(fullPath, stats)) { // Try to determine original path from filename const originalPath = this.getOriginalPathFromFlattenedName(entry); files.push({ flattenedName: entry, originalPath: originalPath, fullPath: fullPath, size: stats.size, }); } } catch (error) { logger.debug(`Error processing file ${entry}: ${error.message}`); } } } catch (error) { logger.error(`Error reading project directory: ${error.message}`); throw error; } return files.sort((a, b) => a.originalPath.localeCompare(b.originalPath)); } /** * Convert flattened filename back to original path * @param {string} flattenedName - Flattened filename * @returns {string} - Original path */ getOriginalPathFromFlattenedName(flattenedName) { // Skip special files if (['structure.txt', 'metadata.json'].includes(flattenedName)) { return flattenedName; } // Convert underscores back to slashes return flattenedName.replace(/_/g, '/'); } /** * Save bundles to project directory * @param {Object} project - Project instance * @param {Object} bundleResult - Bundle creation result * @returns {Promise<Array>} - Array of saved file paths */ async saveBundles(project, bundleResult) { const bundleDir = path.join(project.getProjectPath(), 'bundles'); await fs.ensureDir(bundleDir); const savedFiles = []; for (const bundle of bundleResult.bundles) { const bundlePath = path.join(bundleDir, bundle.filename); await fs.writeFile(bundlePath, bundle.content, 'utf8'); savedFiles.push(bundlePath); logger.info( `Saved bundle: ${bundle.filename} (${Math.round(bundle.size / 1024)}KB, ${bundle.fileCount} files)` ); } // Save system message const systemMessage = this.generateSystemMessage(bundleResult, project); const systemMessagePath = path.join(bundleDir, `${project.name}-claude-instructions.md`); await fs.writeFile(systemMessagePath, systemMessage, 'utf8'); savedFiles.push(systemMessagePath); return savedFiles; } } module.exports = new BundleCreator();