const { Command } = require('commander'); const fs = require('fs-extra'); const path = require('node:path'); const process = require('node:process'); // Modularized components const { findProjectRoot } = require('./projectRoot.js'); const { promptYesNo, promptPath } = require('./prompts.js'); const { discoverFiles, filterFiles, aggregateFileContents } = require('./files.js'); const { generateXMLOutput } = require('./xml.js'); const { calculateStatistics } = require('./stats.js'); /** * Recursively discover all files in a directory * @param {string} rootDir - The root directory to scan * @returns {Promise} Array of file paths */ /** * Parse .gitignore file and return ignore patterns * @param {string} gitignorePath - Path to .gitignore file * @returns {Promise} Array of ignore patterns */ /** * Check if a file is binary using file command and heuristics * @param {string} filePath - Path to the file * @returns {Promise} True if file is binary */ /** * Read and aggregate content from text files * @param {string[]} files - Array of file paths * @param {string} rootDir - The root directory * @param {Object} spinner - Optional spinner instance for progress display * @returns {Promise} Object containing file contents and metadata */ /** * Generate XML output with aggregated file contents using streaming * @param {Object} aggregatedContent - The aggregated content object * @param {string} outputPath - The output file path * @returns {Promise} Promise that resolves when writing is complete */ /** * Calculate statistics for the processed files * @param {Object} aggregatedContent - The aggregated content object * @param {number} xmlFileSize - The size of the generated XML file in bytes * @returns {Object} Statistics object */ /** * Filter files based on .gitignore patterns * @param {string[]} files - Array of file paths * @param {string} rootDir - The root directory * @returns {Promise} Filtered array of file paths */ /** * Attempt to find the project root by walking up from startDir * Looks for common project markers like .git, package.json, pyproject.toml, etc. * @param {string} startDir * @returns {Promise} project root directory or null if not found */ const program = new Command(); program .name('bmad-flatten') .description('BMad-Method codebase flattener tool') .version('1.0.0') .option('-i, --input ', 'Input directory to flatten', process.cwd()) .option('-o, --output ', 'Output file path', 'flattened-codebase.xml') .action(async (options) => { let inputDir = path.resolve(options.input); let outputPath = path.resolve(options.output); // Detect if user explicitly provided -i/--input or -o/--output const argv = process.argv.slice(2); const userSpecifiedInput = argv.some( (a) => a === '-i' || a === '--input' || a.startsWith('--input='), ); const userSpecifiedOutput = argv.some( (a) => a === '-o' || a === '--output' || a.startsWith('--output='), ); const noPathArguments = !userSpecifiedInput && !userSpecifiedOutput; if (noPathArguments) { const detectedRoot = await findProjectRoot(process.cwd()); const suggestedOutput = detectedRoot ? path.join(detectedRoot, 'flattened-codebase.xml') : path.resolve('flattened-codebase.xml'); if (detectedRoot) { const useDefaults = await promptYesNo( `Detected project root at "${detectedRoot}". Use it as input and write output to "${suggestedOutput}"?`, true, ); if (useDefaults) { inputDir = detectedRoot; outputPath = suggestedOutput; } else { inputDir = await promptPath('Enter input directory path', process.cwd()); outputPath = await promptPath( 'Enter output file path', path.join(inputDir, 'flattened-codebase.xml'), ); } } else { console.log('Could not auto-detect a project root.'); inputDir = await promptPath('Enter input directory path', process.cwd()); outputPath = await promptPath( 'Enter output file path', path.join(inputDir, 'flattened-codebase.xml'), ); } } else { console.error( 'Could not auto-detect a project root and no arguments were provided. Please specify -i/--input and -o/--output.', ); process.exit(1); } // Ensure output directory exists await fs.ensureDir(path.dirname(outputPath)); console.log(`Flattening codebase from: ${inputDir}`); console.log(`Output file: ${outputPath}`); try { // Verify input directory exists if (!(await fs.pathExists(inputDir))) { console.error(`āŒ Error: Input directory does not exist: ${inputDir}`); process.exit(1); } // Import ora dynamically const { default: ora } = await import('ora'); // Start file discovery with spinner const discoverySpinner = ora('šŸ” Discovering files...').start(); const files = await discoverFiles(inputDir); const filteredFiles = await filterFiles(files, inputDir); discoverySpinner.succeed(`šŸ“ Found ${filteredFiles.length} files to include`); // Process files with progress tracking console.log('Reading file contents'); const processingSpinner = ora('šŸ“„ Processing files...').start(); const aggregatedContent = await aggregateFileContents( filteredFiles, inputDir, processingSpinner, ); processingSpinner.succeed( `āœ… Processed ${aggregatedContent.processedFiles}/${filteredFiles.length} files`, ); if (aggregatedContent.errors.length > 0) { console.log(`Errors: ${aggregatedContent.errors.length}`); } console.log(`Text files: ${aggregatedContent.textFiles.length}`); if (aggregatedContent.binaryFiles.length > 0) { console.log(`Binary files: ${aggregatedContent.binaryFiles.length}`); } // Generate XML output using streaming const xmlSpinner = ora('šŸ”§ Generating XML output...').start(); await generateXMLOutput(aggregatedContent, outputPath); xmlSpinner.succeed('šŸ“ XML generation completed'); // Calculate and display statistics const outputStats = await fs.stat(outputPath); const stats = calculateStatistics(aggregatedContent, outputStats.size); // Display completion summary console.log('\nšŸ“Š Completion Summary:'); console.log( `āœ… Successfully processed ${filteredFiles.length} files into ${path.basename(outputPath)}`, ); console.log(`šŸ“ Output file: ${outputPath}`); console.log(`šŸ“ Total source size: ${stats.totalSize}`); console.log(`šŸ“„ Generated XML size: ${stats.xmlSize}`); console.log(`šŸ“ Total lines of code: ${stats.totalLines.toLocaleString()}`); console.log(`šŸ”¢ Estimated tokens: ${stats.estimatedTokens}`); console.log( `šŸ“Š File breakdown: ${stats.textFiles} text, ${stats.binaryFiles} binary, ${stats.errorFiles} errors`, ); } catch (error) { console.error('āŒ Critical error:', error.message); console.error('An unexpected error occurred.'); process.exit(1); } }); if (require.main === module) { program.parse(); } module.exports = program;