986 lines
30 KiB
JavaScript
Executable File
986 lines
30 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* Code Extractor for Expert Consultations
|
|
*
|
|
* Extracts file contents, line ranges, or git diffs with automatic size tracking
|
|
* to stay within the 125 KB limit for expert consultation documents.
|
|
*
|
|
* @author Propstreet
|
|
* @license MIT
|
|
* @requires Node.js 18+
|
|
*/
|
|
|
|
import fs from "fs";
|
|
import path from "path";
|
|
import { fileURLToPath } from "url";
|
|
import { parseArgs } from "util";
|
|
import { execSync } from "child_process";
|
|
|
|
// ============================================================================
|
|
// Constants
|
|
// ============================================================================
|
|
|
|
/** Maximum size for expert consultation documents (125 KB) */
|
|
const MAX_SIZE_BYTES = 125 * 1024;
|
|
|
|
/** Warning threshold at 100 KB */
|
|
const WARNING_THRESHOLD_1 = 100 * 1024;
|
|
|
|
/** Warning threshold at 115 KB (very close to limit) */
|
|
const WARNING_THRESHOLD_2 = 115 * 1024;
|
|
|
|
/** Regex pattern for parsing file arguments with ranges/diffs */
|
|
const FILE_ARG_PATTERN = /^(.+?):([\d,:-]+|diff(?:=.+)?)$/;
|
|
|
|
// ============================================================================
|
|
// Utility Functions
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Format bytes as human-readable size in KB
|
|
* @param {number} bytes - Size in bytes
|
|
* @returns {string} Formatted size (e.g., "25.5 KB")
|
|
*/
|
|
function formatSize(bytes) {
|
|
return (bytes / 1024).toFixed(1) + " KB";
|
|
}
|
|
|
|
/**
|
|
* Detect programming language from file extension
|
|
* @param {string} filePath - Path to file
|
|
* @returns {string} Language identifier for syntax highlighting
|
|
*/
|
|
function detectLanguage(filePath) {
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
const langMap = {
|
|
".cs": "csharp",
|
|
".js": "javascript",
|
|
".ts": "typescript",
|
|
".vue": "vue",
|
|
".json": "json",
|
|
".md": "markdown",
|
|
".sql": "sql",
|
|
".html": "html",
|
|
".css": "css",
|
|
".scss": "scss",
|
|
".xml": "xml",
|
|
".yaml": "yaml",
|
|
".yml": "yaml",
|
|
".sh": "bash",
|
|
".py": "python",
|
|
".jsx": "jsx",
|
|
".tsx": "tsx",
|
|
};
|
|
return langMap[ext] || "text";
|
|
}
|
|
|
|
// ============================================================================
|
|
// File Argument Parsing
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Parse file argument into path and range/diff specification
|
|
* Supports formats:
|
|
* - "path/to/file.cs" (full file)
|
|
* - "path/to/file.cs:100-200" (line range)
|
|
* - "path/to/file.cs:1-30,100-150" (multiple ranges)
|
|
* - "path/to/file.cs:diff" (git diff vs master)
|
|
* - "path/to/file.cs:diff=master..HEAD" (git diff with range)
|
|
*
|
|
* @param {string} fileArg - File argument from command line
|
|
* @returns {{filePath: string, rangeStr: string|null}} Parsed components
|
|
*/
|
|
function parseFileArgument(fileArg) {
|
|
const rangeMatch = fileArg.match(FILE_ARG_PATTERN);
|
|
|
|
if (rangeMatch) {
|
|
return {
|
|
filePath: rangeMatch[1],
|
|
rangeStr: rangeMatch[2],
|
|
};
|
|
}
|
|
|
|
return {
|
|
filePath: fileArg,
|
|
rangeStr: null,
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// Git Operations
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Check if current directory is in a git repository
|
|
* @throws {Error} If not in a git repository
|
|
*/
|
|
function validateGitRepository() {
|
|
try {
|
|
execSync("git rev-parse --git-dir", { stdio: "pipe" });
|
|
} catch {
|
|
throw new Error("Not in a git repository");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Split git diff range into individual refs for validation
|
|
* @param {string} diffRange - Git range (e.g., "master", "master..HEAD", "HEAD~3")
|
|
* @returns {string[]} Array of git refs to validate
|
|
*/
|
|
function splitGitRefs(diffRange) {
|
|
return diffRange.includes("..")
|
|
? diffRange.split("..").filter((r) => r)
|
|
: [diffRange];
|
|
}
|
|
|
|
/**
|
|
* Validate that git references exist
|
|
* @param {string[]} refs - Array of git refs to validate
|
|
* @throws {Error} If any ref is invalid
|
|
*/
|
|
function validateGitRefs(refs) {
|
|
for (const ref of refs) {
|
|
try {
|
|
execSync(`git rev-parse --verify ${ref}`, { stdio: "pipe" });
|
|
} catch {
|
|
throw new Error(`Invalid git reference: ${ref}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse diff specification from range string
|
|
* @param {string|null} specStr - Diff specification (e.g., "diff", "diff=master..HEAD")
|
|
* @returns {{type: string, range: string}|null} Parsed diff spec or null
|
|
*/
|
|
function parseDiffSpec(specStr) {
|
|
if (!specStr) {
|
|
return null;
|
|
}
|
|
|
|
if (specStr === "diff") {
|
|
return { type: "diff", range: "master" };
|
|
}
|
|
|
|
const match = specStr.match(/^diff=(.+)$/);
|
|
if (match) {
|
|
return { type: "diff", range: match[1] };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Read git diff content for a file
|
|
* @param {string} filePath - Absolute path to the file
|
|
* @param {string} diffRange - Git range (e.g., "master", "master..HEAD", "HEAD~3")
|
|
* @returns {string} Unified diff output
|
|
* @throws {Error} If git operations fail
|
|
*/
|
|
function readDiffContent(filePath, diffRange) {
|
|
try {
|
|
validateGitRepository();
|
|
|
|
// Validate all refs in the range
|
|
const refs = splitGitRefs(diffRange);
|
|
validateGitRefs(refs);
|
|
|
|
// Get relative path from git root for git diff
|
|
const gitRoot = execSync("git rev-parse --show-toplevel", {
|
|
encoding: "utf8",
|
|
}).trim();
|
|
const relativePath = path.relative(gitRoot, filePath);
|
|
|
|
// Execute git diff
|
|
const diffCommand = diffRange.includes("..")
|
|
? `git diff ${diffRange} -- "${relativePath}"`
|
|
: `git diff ${diffRange} -- "${relativePath}"`;
|
|
|
|
return execSync(diffCommand, {
|
|
encoding: "utf8",
|
|
cwd: gitRoot,
|
|
});
|
|
} catch (error) {
|
|
// Re-throw with context
|
|
if (
|
|
error.message.includes("Not in a git repository") ||
|
|
error.message.includes("Invalid git reference")
|
|
) {
|
|
throw error;
|
|
}
|
|
throw new Error(`Git diff failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Line Range Operations
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Parse line range string into structured format
|
|
* Supports: "10-20", "10:20", "10-20,50-60,100-150"
|
|
*
|
|
* @param {string|null} rangeStr - Line range string
|
|
* @returns {{from: number, to: number}[]|null} Array of range objects or null
|
|
* @throws {Error} If range format is invalid
|
|
*/
|
|
function parseLineRanges(rangeStr) {
|
|
if (!rangeStr) {
|
|
return null;
|
|
}
|
|
|
|
const ranges = rangeStr.split(",").map((r) => r.trim());
|
|
const parsed = [];
|
|
|
|
for (const range of ranges) {
|
|
const match = range.match(/^(\d+)[-:](\d+)$/);
|
|
if (!match) {
|
|
throw new Error(
|
|
`Invalid line range format: "${range}". Use format "10-20" or "10:20"`
|
|
);
|
|
}
|
|
|
|
const from = parseInt(match[1], 10);
|
|
const to = parseInt(match[2], 10);
|
|
|
|
if (from < 1) {
|
|
throw new Error(`Line numbers must be >= 1, got ${from}`);
|
|
}
|
|
|
|
if (to < from) {
|
|
throw new Error(`End line (${to}) must be >= start line (${from})`);
|
|
}
|
|
|
|
parsed.push({ from, to });
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
/**
|
|
* Read file content and optionally extract line ranges
|
|
* @param {string} filePath - Absolute path to file
|
|
* @param {{from: number, to: number}[]|null} lineRanges - Line ranges to extract
|
|
* @returns {string} File content (full or extracted ranges)
|
|
* @throws {Error} If line ranges exceed file length
|
|
*/
|
|
function readFileContent(filePath, lineRanges) {
|
|
const content = fs.readFileSync(filePath, "utf8");
|
|
|
|
if (!lineRanges || lineRanges.length === 0) {
|
|
return content;
|
|
}
|
|
|
|
const lines = content.split("\n");
|
|
const totalLines = lines.length;
|
|
const extractedSegments = [];
|
|
|
|
for (const range of lineRanges) {
|
|
if (range.from > totalLines) {
|
|
throw new Error(
|
|
`Start line ${range.from} exceeds file length (${totalLines} lines)`
|
|
);
|
|
}
|
|
|
|
const endLine = Math.min(range.to, totalLines);
|
|
const segment = lines.slice(range.from - 1, endLine);
|
|
extractedSegments.push(segment.join("\n"));
|
|
}
|
|
|
|
return extractedSegments.join("\n\n");
|
|
}
|
|
|
|
// ============================================================================
|
|
// Output Formatting
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Format file content as markdown code block
|
|
* @param {string} filePath - Path to file
|
|
* @param {string} language - Language for syntax highlighting
|
|
* @param {string} content - File content
|
|
* @param {{from: number, to: number}[]|null} lineRanges - Line ranges (for display)
|
|
* @returns {string} Formatted markdown code block
|
|
*/
|
|
function formatCodeBlock(filePath, language, content, lineRanges) {
|
|
let lineRangeStr = "";
|
|
if (lineRanges && lineRanges.length > 0) {
|
|
const rangeStrings = lineRanges.map((r) => `${r.from}-${r.to}`);
|
|
lineRangeStr = ` (lines ${rangeStrings.join(", ")})`;
|
|
}
|
|
|
|
return `# File: ${filePath}${lineRangeStr}
|
|
\`\`\`${language}
|
|
${content}
|
|
\`\`\``;
|
|
}
|
|
|
|
/**
|
|
* Format git diff output as markdown code block
|
|
* @param {string} filePath - Path to file
|
|
* @param {string} diffContent - Git diff output
|
|
* @param {string} diffRange - Git range for display
|
|
* @returns {string} Formatted markdown diff block
|
|
*/
|
|
function formatDiffBlock(filePath, diffContent, diffRange) {
|
|
return `# File: ${filePath} (diff=${diffRange})
|
|
\`\`\`diff
|
|
${diffContent}
|
|
\`\`\``;
|
|
}
|
|
|
|
// ============================================================================
|
|
// File Validation
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Validate file argument without processing it
|
|
* Checks file existence, git refs, and line ranges
|
|
*
|
|
* @param {string} fileArg - File argument from command line
|
|
* @returns {{valid: boolean, fileArg?: string, error?: string}} Validation result
|
|
*/
|
|
function validateFile(fileArg) {
|
|
const { filePath: parsedPath, rangeStr } = parseFileArgument(fileArg);
|
|
|
|
// Resolve to absolute path
|
|
const filePath = path.isAbsolute(parsedPath)
|
|
? parsedPath
|
|
: path.resolve(process.cwd(), parsedPath);
|
|
|
|
// Check if file exists
|
|
if (!fs.existsSync(filePath)) {
|
|
const cwd = process.cwd();
|
|
const suggestion = getSuggestion(filePath);
|
|
return {
|
|
valid: false,
|
|
fileArg,
|
|
error: `File not found: ${filePath}\n Current directory: ${cwd}${suggestion}`,
|
|
};
|
|
}
|
|
|
|
// Validate range specification if present
|
|
if (rangeStr) {
|
|
const diffSpec = parseDiffSpec(rangeStr);
|
|
|
|
if (diffSpec) {
|
|
// Validate git diff specification
|
|
try {
|
|
validateGitRepository();
|
|
const refs = splitGitRefs(diffSpec.range);
|
|
validateGitRefs(refs);
|
|
} catch (error) {
|
|
return {
|
|
valid: false,
|
|
fileArg,
|
|
error: error.message,
|
|
};
|
|
}
|
|
} else {
|
|
// Validate line ranges
|
|
try {
|
|
const lineRanges = parseLineRanges(rangeStr);
|
|
const content = fs.readFileSync(filePath, "utf8");
|
|
const totalLines = content.split("\n").length;
|
|
|
|
for (const range of lineRanges) {
|
|
if (range.from > totalLines || range.to > totalLines) {
|
|
return {
|
|
valid: false,
|
|
fileArg,
|
|
error: `Line range ${range.from}-${range.to} exceeds file length (${totalLines} lines) in ${filePath}`,
|
|
};
|
|
}
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
valid: false,
|
|
fileArg,
|
|
error: `Invalid line range format in "${fileArg}": ${error.message}`,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
/**
|
|
* Process a single file argument and return formatted content
|
|
* @param {string} fileArg - File argument (path with optional range/diff spec)
|
|
* @returns {string} Formatted markdown output
|
|
* @throws {Error} If file processing fails
|
|
*/
|
|
function processFile(fileArg) {
|
|
const { filePath: parsedPath, rangeStr } = parseFileArgument(fileArg);
|
|
|
|
// Resolve to absolute path
|
|
const filePath = path.isAbsolute(parsedPath)
|
|
? parsedPath
|
|
: path.resolve(process.cwd(), parsedPath);
|
|
|
|
// Check if file exists
|
|
if (!fs.existsSync(filePath)) {
|
|
const cwd = process.cwd();
|
|
const suggestion = getSuggestion(filePath);
|
|
throw new Error(
|
|
`File not found: ${filePath}\n Current directory: ${cwd}${suggestion}`
|
|
);
|
|
}
|
|
|
|
// Check for diff specification
|
|
const diffSpec = rangeStr ? parseDiffSpec(rangeStr) : null;
|
|
|
|
if (diffSpec) {
|
|
// Handle git diff mode
|
|
const diffContent = readDiffContent(filePath, diffSpec.range);
|
|
|
|
// Handle empty diff
|
|
if (!diffContent || diffContent.trim() === "") {
|
|
return formatDiffBlock(
|
|
filePath,
|
|
`(No changes between ${diffSpec.range})`,
|
|
diffSpec.range
|
|
);
|
|
}
|
|
|
|
return formatDiffBlock(filePath, diffContent, diffSpec.range);
|
|
}
|
|
|
|
// Handle line range or full file mode
|
|
const lineRanges = rangeStr ? parseLineRanges(rangeStr) : null;
|
|
const language = detectLanguage(filePath);
|
|
const content = readFileContent(filePath, lineRanges);
|
|
|
|
return formatCodeBlock(filePath, language, content, lineRanges);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Config File Operations
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Read and validate JSON config file
|
|
* @param {string} configPath - Path to config file
|
|
* @returns {object} Validated config object
|
|
* @throws {Error} If config is invalid
|
|
*/
|
|
function readConfigFile(configPath) {
|
|
if (!fs.existsSync(configPath)) {
|
|
throw new Error(`Config file not found: ${configPath}`);
|
|
}
|
|
|
|
let config;
|
|
try {
|
|
const content = fs.readFileSync(configPath, "utf8");
|
|
config = JSON.parse(content);
|
|
} catch (error) {
|
|
throw new Error(`Failed to parse config file: ${error.message}`);
|
|
}
|
|
|
|
// Validate schema
|
|
if (!config.sections || !Array.isArray(config.sections)) {
|
|
throw new Error(
|
|
'Config must have "sections" array. See example config for format.'
|
|
);
|
|
}
|
|
|
|
if (config.sections.length === 0) {
|
|
throw new Error("Config must have at least one section");
|
|
}
|
|
|
|
for (const [index, section] of config.sections.entries()) {
|
|
if (!section.files || !Array.isArray(section.files)) {
|
|
throw new Error(
|
|
`Section ${index + 1} must have "files" array. Header: ${section.header || "(no header)"}`
|
|
);
|
|
}
|
|
|
|
if (section.files.length === 0) {
|
|
throw new Error(
|
|
`Section ${index + 1} must have at least one file. Header: ${section.header || "(no header)"}`
|
|
);
|
|
}
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helper Functions
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Provide helpful error suggestions when file is not found
|
|
* @param {string} filePath - Path that was not found
|
|
* @returns {string} Suggestion text (empty if none applicable)
|
|
*/
|
|
function getSuggestion(filePath) {
|
|
// Provide helpful suggestion for relative path issues
|
|
if (!path.isAbsolute(filePath) && filePath.includes(path.sep)) {
|
|
return `\n 💡 Tip: Verify you're in the correct directory:\n pwd # Check current directory\n ls ${path.dirname(filePath)} # Check parent directory exists`;
|
|
}
|
|
|
|
// Check if file exists with different casing
|
|
const dir = path.dirname(filePath);
|
|
const filename = path.basename(filePath);
|
|
if (fs.existsSync(dir)) {
|
|
try {
|
|
const files = fs.readdirSync(dir);
|
|
const match = files.find(
|
|
(f) => f.toLowerCase() === filename.toLowerCase()
|
|
);
|
|
if (match && match !== filename) {
|
|
return `\n 💡 Tip: File exists with different casing: ${match}`;
|
|
}
|
|
} catch {
|
|
// Ignore directory read errors
|
|
}
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
// ============================================================================
|
|
// CLI Interface
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Display help message
|
|
*/
|
|
function showHelp() {
|
|
console.log(`
|
|
📄 Code Extractor for Expert Consultations
|
|
|
|
Usage:
|
|
extract-code [options] <file1> [file2] [file3] ...
|
|
extract-code --help
|
|
|
|
Arguments:
|
|
file File path with optional line range(s) or diff spec
|
|
Formats:
|
|
/path/to/file.cs (extract full file)
|
|
/path/to/file.cs:10-50 (extract lines 10-50)
|
|
/path/to/file.cs:10:50 (extract lines 10-50, alternative)
|
|
/path/to/file.cs:10-50,100-150 (multiple ranges, comma-separated)
|
|
/path/to/file.cs:1-30,86-213,500-600 (multiple ranges)
|
|
/path/to/file.cs:diff (git diff vs master)
|
|
/path/to/file.cs:diff=master..HEAD (git diff with explicit range)
|
|
/path/to/file.cs:diff=HEAD~3 (git diff vs 3 commits ago)
|
|
relative/path/file.cs (resolved to absolute path)
|
|
relative/path/file.cs:5-15 (with line range)
|
|
|
|
Options:
|
|
--help, -h Show this help message
|
|
--output, -o <file> Write output to file (appends to existing file)
|
|
--track-size Show size tracking and progress (requires --output)
|
|
--section <header> Add markdown section header before next file
|
|
Can be used multiple times for different files
|
|
--config <file> Use JSON config file for batch extraction
|
|
See example-config.json for format
|
|
|
|
Output:
|
|
Prints markdown-formatted code blocks with file paths and line ranges.
|
|
Output can be redirected to a file or piped to other commands.
|
|
|
|
Examples:
|
|
# Extract full files
|
|
extract-code src/Service.cs tests/ServiceTests.cs
|
|
|
|
# Extract specific line ranges
|
|
extract-code src/Service.cs:100-200 tests/ServiceTests.cs:50-75
|
|
|
|
# Extract multiple ranges from a single file
|
|
extract-code src/Service.cs:1-30,86-213
|
|
|
|
# Mix full files and ranges
|
|
extract-code src/Models/User.cs src/Service.cs:100-150
|
|
|
|
# Show git diff vs master (default)
|
|
extract-code src/Service.cs:diff
|
|
|
|
# Show git diff with explicit range
|
|
extract-code src/Service.cs:diff=master..feature-branch
|
|
extract-code src/Service.cs:diff=HEAD~3..HEAD
|
|
|
|
# Show what changed in recent commits
|
|
extract-code src/Service.cs:diff=HEAD~5
|
|
|
|
# Combine diffs with regular files
|
|
extract-code src/Service.cs:diff src/Tests.cs:100-200
|
|
|
|
# Save to file with size tracking (appends to existing file)
|
|
extract-code --track-size --output=consultation.md src/Service.cs
|
|
|
|
# Add section headers
|
|
extract-code --section="Core Interfaces" Interface.cs \\
|
|
--section="Domain Models" Contact.cs Company.cs \\
|
|
--output=doc.md
|
|
|
|
# Combined: size tracking + sections
|
|
extract-code --track-size --output=doc.md \\
|
|
--section="Core" Interface.cs \\
|
|
--section="Tests" Tests.cs:100-200
|
|
|
|
# Include diffs in consultation documents
|
|
extract-code --track-size --output=consultation.md \\
|
|
--section="What Changed" \\
|
|
src/NetworkService.cs:diff \\
|
|
src/PineconeHelper.cs:diff=master..feature-branch
|
|
|
|
# Build document incrementally (appends each time)
|
|
extract-code --track-size -o doc.md File1.cs
|
|
extract-code --track-size -o doc.md File2.cs # Appends to existing
|
|
extract-code --track-size -o doc.md File3.cs # Appends again
|
|
|
|
# Traditional output redirection still works
|
|
extract-code src/Service.cs > expert-consultation.md
|
|
extract-code src/Tests.cs >> expert-consultation.md # Append
|
|
|
|
# Use config file for complex extractions
|
|
extract-code --config=extraction-plan.json
|
|
extract-code --config=extraction-plan.json --track-size # Override trackSize
|
|
|
|
Notes:
|
|
• Automatically detects language from file extension
|
|
• Line numbers are 1-indexed (first line is line 1)
|
|
• Line ranges are inclusive (10-20 includes both lines 10 and 20)
|
|
• Multiple ranges are separated by blank lines in output
|
|
• Supports 20+ file types (cs, js, ts, vue, py, etc.)
|
|
• Error messages go to stderr, formatted output to stdout
|
|
• --output mode always appends (matches >> behavior)
|
|
• Size tracking shows warnings at 100KB, 115KB, errors at 125KB
|
|
• Section headers apply to the immediately following file only
|
|
• Diff mode requires git repository and valid refs
|
|
• Diff output uses unified diff format (standard git diff)
|
|
`);
|
|
}
|
|
|
|
/**
|
|
* Main entry point
|
|
*/
|
|
function main() {
|
|
const options = {
|
|
help: {
|
|
type: "boolean",
|
|
short: "h",
|
|
},
|
|
"track-size": {
|
|
type: "boolean",
|
|
},
|
|
output: {
|
|
type: "string",
|
|
short: "o",
|
|
},
|
|
section: {
|
|
type: "string",
|
|
multiple: true,
|
|
},
|
|
config: {
|
|
type: "string",
|
|
},
|
|
};
|
|
|
|
let args;
|
|
try {
|
|
const parsed = parseArgs({ options, allowPositionals: true });
|
|
args = parsed.values;
|
|
args.positionals = parsed.positionals;
|
|
} catch (error) {
|
|
console.error(`❌ ${error.message}`);
|
|
showHelp();
|
|
process.exit(1);
|
|
}
|
|
|
|
if (args.help) {
|
|
showHelp();
|
|
process.exit(0);
|
|
}
|
|
|
|
// Handle config file mode
|
|
if (args.config) {
|
|
try {
|
|
const config = readConfigFile(args.config);
|
|
processConfigFile(config, args);
|
|
return;
|
|
} catch (error) {
|
|
console.error(`❌ Error processing config file: ${error.message}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
if (!args.positionals || args.positionals.length === 0) {
|
|
console.error("❌ No files specified");
|
|
showHelp();
|
|
process.exit(1);
|
|
}
|
|
|
|
// Filter out empty arguments
|
|
const fileArgs = args.positionals.filter((arg) => arg && arg.trim() !== "");
|
|
|
|
// VALIDATE ALL FILES FIRST - before writing anything
|
|
const validationErrors = [];
|
|
for (const fileArg of fileArgs) {
|
|
const validation = validateFile(fileArg);
|
|
if (!validation.valid) {
|
|
validationErrors.push({
|
|
fileArg: validation.fileArg,
|
|
error: validation.error,
|
|
});
|
|
}
|
|
}
|
|
|
|
// If any validation errors, report them all and exit without modifying output
|
|
if (validationErrors.length > 0) {
|
|
console.error(
|
|
`❌ Validation failed for ${validationErrors.length} file(s):\n`
|
|
);
|
|
for (const { fileArg, error } of validationErrors) {
|
|
console.error(` • "${fileArg}":`);
|
|
console.error(` ${error.replace(/\n/g, "\n ")}`);
|
|
console.error("");
|
|
}
|
|
console.error("⚠️ No files were written to avoid partial output.");
|
|
process.exit(1);
|
|
}
|
|
|
|
const results = [];
|
|
let hasErrors = false;
|
|
let totalBytes = 0;
|
|
let sectionIndex = 0;
|
|
|
|
// Read existing file size if output file specified
|
|
if (args.output && fs.existsSync(args.output)) {
|
|
const stats = fs.statSync(args.output);
|
|
totalBytes = stats.size;
|
|
if (args["track-size"]) {
|
|
console.error(`📄 ${args.output}: ${formatSize(totalBytes)} (existing)`);
|
|
}
|
|
}
|
|
|
|
// Process each file
|
|
for (const [index, fileArg] of fileArgs.entries()) {
|
|
try {
|
|
// Add section header if specified
|
|
let output = "";
|
|
if (args.section && sectionIndex < args.section.length) {
|
|
const sectionHeader = args.section[sectionIndex];
|
|
if (sectionHeader) {
|
|
output = `### ${sectionHeader}\n\n`;
|
|
}
|
|
sectionIndex++;
|
|
}
|
|
|
|
const result = processFile(fileArg);
|
|
output += result;
|
|
results.push(output);
|
|
|
|
// Calculate size
|
|
const contentSize = Buffer.byteLength(output + "\n\n", "utf8");
|
|
totalBytes += contentSize;
|
|
|
|
// Write to file or collect for stdout
|
|
if (args.output) {
|
|
fs.appendFileSync(args.output, output + "\n\n", "utf8");
|
|
}
|
|
|
|
// Show progress if tracking size
|
|
if (args["track-size"]) {
|
|
const percent = ((totalBytes / MAX_SIZE_BYTES) * 100).toFixed(1);
|
|
const filename = path.basename(fileArg.split(":")[0]);
|
|
console.error(
|
|
`[${index + 1}/${fileArgs.length}] ${filename} → +${formatSize(contentSize)} (${formatSize(totalBytes)} / 125 KB, ${percent}%)`
|
|
);
|
|
|
|
// Check thresholds
|
|
if (totalBytes >= MAX_SIZE_BYTES) {
|
|
console.error(
|
|
`❌ Error: Exceeded 125 KB limit (${formatSize(totalBytes)})`
|
|
);
|
|
console.error(
|
|
` Stop processing to stay within expert consultation limits`
|
|
);
|
|
process.exit(1);
|
|
} else if (totalBytes >= WARNING_THRESHOLD_2) {
|
|
console.error(`⚠️ Very close to 125 KB limit!`);
|
|
} else if (totalBytes >= WARNING_THRESHOLD_1) {
|
|
console.error(`⚠️ Approaching 100 KB`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`❌ Error processing "${fileArg}": ${error.message}`);
|
|
hasErrors = true;
|
|
}
|
|
}
|
|
|
|
if (results.length === 0) {
|
|
console.error("\n❌ No files were successfully processed");
|
|
process.exit(1);
|
|
}
|
|
|
|
// Output results to stdout if no output file specified
|
|
if (!args.output) {
|
|
console.log(results.join("\n\n"));
|
|
} else if (args["track-size"]) {
|
|
const status = hasErrors ? "⚠️ Completed with errors" : "✅ Saved";
|
|
const fileCount = `${results.length} ${results.length === 1 ? "file" : "files"}`;
|
|
console.error(
|
|
`${status}: ${fileCount} to ${args.output} (${formatSize(totalBytes)} / 125 KB)`
|
|
);
|
|
}
|
|
|
|
process.exit(hasErrors ? 1 : 0);
|
|
}
|
|
|
|
/**
|
|
* Process files from config file
|
|
* @param {object} config - Validated config object
|
|
* @param {object} args - Parsed command-line arguments
|
|
*/
|
|
function processConfigFile(config, args) {
|
|
let totalBytes = 0;
|
|
let totalFilesProcessed = 0;
|
|
let hasErrors = false;
|
|
|
|
// Use output from config or args
|
|
const outputFile = args.output || config.output;
|
|
const trackSize = args["track-size"] || config.trackSize || false;
|
|
|
|
if (!outputFile) {
|
|
console.error(
|
|
"❌ Config mode requires output file. Specify in config file or use --output flag"
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Read existing file size if output file exists
|
|
if (fs.existsSync(outputFile)) {
|
|
const stats = fs.statSync(outputFile);
|
|
totalBytes = stats.size;
|
|
if (trackSize) {
|
|
console.error(`📄 ${outputFile}: ${formatSize(totalBytes)} (existing)`);
|
|
}
|
|
}
|
|
|
|
// VALIDATE ALL FILES FIRST - before writing anything
|
|
const validationErrors = [];
|
|
for (const section of config.sections) {
|
|
for (const fileArg of section.files) {
|
|
const validation = validateFile(fileArg);
|
|
if (!validation.valid) {
|
|
validationErrors.push({
|
|
fileArg: validation.fileArg,
|
|
error: validation.error,
|
|
section: section.header || "(no header)",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// If any validation errors, report them all and exit without modifying output
|
|
if (validationErrors.length > 0) {
|
|
console.error(
|
|
`❌ Validation failed for ${validationErrors.length} file(s):\n`
|
|
);
|
|
for (const { fileArg, error, section } of validationErrors) {
|
|
console.error(` • "${fileArg}" in section "${section}":`);
|
|
console.error(` ${error.replace(/\n/g, "\n ")}`);
|
|
console.error("");
|
|
}
|
|
console.error("⚠️ No files were written to avoid partial output.");
|
|
process.exit(1);
|
|
}
|
|
|
|
// Process each section
|
|
for (const [sectionIndex, section] of config.sections.entries()) {
|
|
if (trackSize) {
|
|
console.error(
|
|
`[Section ${sectionIndex + 1}/${config.sections.length}] ${section.header || "(no header)"}`
|
|
);
|
|
}
|
|
|
|
// Add section header if specified
|
|
if (section.header) {
|
|
const headerContent = `### ${section.header}\n\n`;
|
|
fs.appendFileSync(outputFile, headerContent, "utf8");
|
|
totalBytes += Buffer.byteLength(headerContent, "utf8");
|
|
}
|
|
|
|
// Process each file in section
|
|
for (const [fileIndex, fileArg] of section.files.entries()) {
|
|
try {
|
|
const result = processFile(fileArg);
|
|
const content = result + "\n\n";
|
|
const contentSize = Buffer.byteLength(content, "utf8");
|
|
totalBytes += contentSize;
|
|
|
|
// Write to file
|
|
fs.appendFileSync(outputFile, content, "utf8");
|
|
totalFilesProcessed++;
|
|
|
|
// Show progress if tracking size
|
|
if (trackSize) {
|
|
const percent = ((totalBytes / MAX_SIZE_BYTES) * 100).toFixed(1);
|
|
const filename = path.basename(fileArg.split(":")[0]);
|
|
console.error(
|
|
` [${fileIndex + 1}/${section.files.length}] ${filename} → +${formatSize(contentSize)} (${formatSize(totalBytes)} / 125 KB, ${percent}%)`
|
|
);
|
|
|
|
// Check thresholds
|
|
if (totalBytes >= MAX_SIZE_BYTES) {
|
|
console.error(
|
|
`❌ Error: Exceeded 125 KB limit (${formatSize(totalBytes)})`
|
|
);
|
|
console.error(
|
|
` Stop processing to stay within expert consultation limits`
|
|
);
|
|
process.exit(1);
|
|
} else if (totalBytes >= WARNING_THRESHOLD_2) {
|
|
console.error(`⚠️ Very close to 125 KB limit!`);
|
|
} else if (totalBytes >= WARNING_THRESHOLD_1) {
|
|
console.error(`⚠️ Approaching 100 KB`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
`❌ Error processing "${fileArg}" in section "${section.header || "(no header)"}": ${error.message}`
|
|
);
|
|
hasErrors = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (trackSize) {
|
|
const status = hasErrors ? "⚠️ Completed with errors" : "✅ Saved";
|
|
const fileCount = `${totalFilesProcessed} ${totalFilesProcessed === 1 ? "file" : "files"}`;
|
|
const sectionCount = `${config.sections.length} ${config.sections.length === 1 ? "section" : "sections"}`;
|
|
console.error(
|
|
`${status}: ${fileCount}, ${sectionCount} to ${outputFile} (${formatSize(totalBytes)} / 125 KB)`
|
|
);
|
|
}
|
|
|
|
process.exit(hasErrors ? 1 : 0);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Entry Point
|
|
// ============================================================================
|
|
|
|
// Handle unhandled errors
|
|
process.on("unhandledRejection", (err) => {
|
|
console.error("❌ Error:", err.message);
|
|
process.exit(1);
|
|
});
|
|
|
|
// Run the script only if executed directly (not imported)
|
|
const scriptPath = path.normalize(process.argv[1]);
|
|
const modulePath = path.normalize(fileURLToPath(import.meta.url));
|
|
if (modulePath === scriptPath) {
|
|
try {
|
|
main();
|
|
} catch (err) {
|
|
console.error("❌ Error:", err.message);
|
|
process.exit(1);
|
|
}
|
|
}
|