#!/usr/bin/env node 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 noPathArgs = !userSpecifiedInput && !userSpecifiedOutput; if (noPathArgs) { 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;