@johnlindquist/file-forge
Version:
File Forge is a powerful CLI tool for deep analysis of codebases, generating markdown reports to feed AI reasoning models.
356 lines • 15.6 kB
JavaScript
import { hideBin } from "yargs/helpers";
import yargs from "yargs";
import { APP_COMMAND, APP_DESCRIPTION } from "./constants.js";
import { getVersion } from "./version.js";
import { formatDebugMessage } from "./formatter.js";
const DEFAULT_MAX_SIZE = 10 * 1024 * 1024; // 10MB
export async function runCli(configData) {
// --- Define yargs options --- Start
const yargsInstanceWithOptions = yargs(hideBin(process.argv))
.scriptName(APP_COMMAND)
.usage(`${APP_COMMAND} [options] <path|repo>`)
.positional("path", {
describe: "Path to directory or file to analyze",
type: "string",
})
.epilogue(APP_DESCRIPTION)
.version("version", "Show version number", getVersion())
.alias("version", "v")
.option("repo", {
type: "string",
describe: "Git repository URL to clone and analyze",
})
.option("path", {
type: "string",
describe: "Local file system path to analyze",
})
.option("include", {
type: "array",
describe: "Glob patterns for files/directories to include",
default: [], // Initialize as empty array for potential merging
})
.option("exclude", {
alias: "e",
type: "array",
describe: "Glob patterns for files/directories to exclude",
default: [], // Initialize as empty array for potential merging
})
.option("extension", {
alias: "x",
type: "array",
describe: "File extensions to include (e.g., .ts, .js)",
default: [],
})
.option("find", {
alias: "f",
type: "array",
describe: "Search for files containing ANY of the provided terms",
default: [],
})
.option("require", {
alias: "r",
type: "array",
describe: "Require files to contain ALL of the provided terms",
default: [],
})
.option("branch", {
alias: "b",
type: "string",
describe: "Specify a Git branch to analyze (when using a repo URL)",
})
.option("commit", {
alias: "c",
type: "string",
describe: "Checkout a specific commit (when using a repo URL)",
})
.option("max-size", {
alias: "s",
type: "number",
default: DEFAULT_MAX_SIZE,
describe: "Maximum file size to process in bytes (default 10MB)",
})
.option("pipe", {
alias: "p",
type: "boolean",
default: false,
describe: "Pipe output to stdout instead of opening in an editor",
})
.option("debug", {
type: "boolean",
default: false,
describe: "Enable debug logging for troubleshooting",
})
.option("bulk", {
alias: "k",
type: "boolean",
default: false,
describe: "Append AI processing instructions to the output",
})
.option("ignore", {
type: "boolean",
default: true,
describe: "Whether to respect .gitignore files",
})
.option("skip-artifacts", {
type: "boolean",
default: true,
describe: "Skip dependency files, build artifacts, and generated assets",
})
.option("clipboard", {
alias: "y",
type: "boolean",
default: false,
describe: "Copy the analysis result to the clipboard",
})
.option("no-editor", {
alias: "n",
type: "boolean",
default: false,
describe: "Save results to file but don't open in editor",
})
.option("use-regular-git", {
type: "boolean",
default: false,
describe: "Use regular system Git commands (authenticated git) instead of simple-git",
})
.option("open", {
alias: "o",
type: "string",
describe: "Open results in editor, optionally specify editor command (e.g., --open=code)",
})
.option("config", {
type: "boolean",
default: false,
describe: "Open the configuration file in the default editor",
})
.option("verbose", {
type: "boolean",
default: false,
describe: "Include detailed file contents in the output",
})
.option("graph", {
alias: "g",
type: "string",
describe: "Generate a dependency graph starting from the given file",
})
.option("name", {
type: "string",
describe: "Custom name to use in header and XML wrapping tags",
})
.option("svg", {
type: "boolean",
default: false,
describe: "Include SVG files in the output (excluded by default)",
})
.option("template", {
alias: "t",
type: "string",
describe: "Apply a prompt template to the output (use --list-templates to see available templates)",
})
.option("list-templates", {
type: "boolean",
default: false,
describe: "List all available prompt templates",
})
.option("create-template", {
type: "string",
describe: "Create a new template file with the given name in the user templates directory",
})
.option("edit-template", {
type: "string",
describe: "Find an existing template by name and display its file path for editing",
})
.option("markdown", {
type: "boolean",
description: "Output in Markdown format (default output is XML)",
default: false,
})
.option("no-token-count", {
describe: "Disable token counting in the output",
type: "boolean",
default: false,
})
.option("whitespace", {
type: "boolean",
default: false,
describe: "Enable extra indentation and spacing in output",
})
.option("render-template", {
type: "string",
describe: "Render a template (including partials) and open it in the editor, skipping analysis. Provide template name.",
})
.option("dry-run", {
alias: "D",
type: "boolean",
default: false,
describe: "Perform analysis and print output to stdout without saving to file or opening editor."
})
.option("use", {
alias: "config-name",
type: "string",
describe: "Use a named command defined in ffg.config.jsonc"
})
.option("save", {
type: "boolean",
default: false,
describe: "Save the current flags as the default command in ffg.config.jsonc"
})
.option("save-as", {
type: "string",
describe: "Save the current flags as a named command in ffg.config.jsonc (e.g., --save-as my-query)"
})
.example("$0 --path /path/to/project", "Analyze a local project directory")
.example("$0 https://github.com/owner/repo --branch develop", "Clone and analyze a GitHub repository on the 'develop' branch")
.example('$0 /path/to/project --include "**/*.ts" --exclude "*.spec.ts"', "Include all TypeScript files but exclude test files")
.example('$0 /path/to/project --find "console,debug" --require "log"', "Find files containing either 'console' or 'debug' and require them to contain 'log'")
.example('$0 /path/to/project --template "refactor"', "Analyze a project and apply the 'refactor' prompt template for AI processing")
.example('$0 /path/to/project --open code-insiders', "Analyze project and open results in VS Code Insiders")
.example('$0 --config', "Open the File Forge configuration file")
.example('$0 --render-template worktree', "Render the 'worktree' template and open it in the editor")
.help()
.alias("help", "h");
// --- Define yargs options --- End
// Minimal parse just to check for config-related flags and user input presence
const initialArgs = yargs(hideBin(process.argv))
.option('use', { type: 'string' })
.help(false) // Prevent this minimal instance from handling --help
.version(false) // Prevent this minimal instance from handling --version
.parseSync();
let configToApply = {};
let appliedConfigType = 'none'; // 'none', 'default', 'named'
// Determine which config settings to apply as defaults
if (configData) {
// 1. Check for --use flag
if (initialArgs.use && configData.commands && configData.commands[initialArgs.use]) {
const commandName = initialArgs.use;
configToApply = { ...configData.commands[commandName] };
appliedConfigType = 'named';
if (initialArgs['debug']) {
console.log(formatDebugMessage(`Applying config from named command: ${commandName}`));
console.log(formatDebugMessage(`Named command flags: ${JSON.stringify(configToApply, null, 2)}`));
}
}
// 2. If --use was NOT provided, apply defaultCommand if it exists as base defaults
else if (!initialArgs.use && configData.defaultCommand) {
configToApply = { ...configData.defaultCommand };
appliedConfigType = 'default';
if (initialArgs['debug']) {
console.log(formatDebugMessage("Applying config from defaultCommand as base defaults"));
console.log(formatDebugMessage(`Default command flags: ${JSON.stringify(configToApply, null, 2)}`));
}
}
}
// Apply the determined config as defaults BEFORE the final parse
// Yargs will handle precedence: command line > config defaults > yargs option default
// Note: yargs merges defaults shallowly. For deep merge, other methods or libraries might be needed,
// but for CLI flags, shallow merging + command line override is usually sufficient.
// Arrays from defaults are NOT automatically merged by yargs' .default().
// We need a custom approach if we want CLI array flags to ADD to config array flags.
// Apply config defaults
const yargsInstanceWithDefaults = yargsInstanceWithOptions.default(configToApply);
// Parse the actual command line arguments ONCE
let argv = await yargsInstanceWithDefaults.parse();
// --- Manual Array Merging --- Start
// Merge arrays if a config was applied (default or named)
if (appliedConfigType !== 'none') {
// Keep track of the actual user input for arrays before defaults were applied
const initialArrayArgs = {};
for (const key of ['include', 'exclude', 'extension', 'find', 'require']) {
// Check initialArgs directly to see if user provided the flag
if (initialArgs[key] !== undefined) {
// Ensure it's always an array, even if a single string was passed via CLI
initialArrayArgs[key] = Array.isArray(initialArgs[key]) ? initialArgs[key] : [initialArgs[key]];
}
}
for (const key of ['include', 'exclude', 'extension', 'find', 'require']) {
const configValue = configToApply[key];
const userProvidedValue = initialArrayArgs[key]; // User input BEFORE defaults
// Only merge if user actually provided the flag AND the config also has it (as an array)
if (userProvidedValue && Array.isArray(configValue)) {
const mergedArray = [...new Set([...configValue, ...userProvidedValue])];
argv[key] = mergedArray; // Update the final argv
if (initialArgs['debug'])
console.log(formatDebugMessage(`Manually merged array flag --${key}: ${JSON.stringify(mergedArray)} (Config: ${JSON.stringify(configValue)}, User: ${JSON.stringify(userProvidedValue)})`));
}
// If user did NOT provide the flag, argv[key] already holds the config value via .default(), so do nothing.
// If user provided the flag but config did NOT have it, argv[key] already holds the user value via parsing, so do nothing.
}
}
// --- Manual Array Merging --- End
// --- Original Logic - Slightly Modified --- Start
// In test mode with graph flag, bypass standard argument processing (Remains the same)
if (process.env["VITEST"] && argv.graph) {
console.log("[DEBUG] Test mode with graph flag detected, bypassing standard argument processing");
return {
...argv,
_: [], // Ensure _ is empty here
skipArtifacts: true,
pipe: true,
noColor: true,
noIntro: true,
};
}
// Map --list-templates to listTemplates for consistency (Remains the same)
if (argv["list-templates"]) {
const result = { ...argv, listTemplates: argv["list-templates"] };
return result;
}
// Convert kebab-case options to camelCase
const parsedArgs = {
...argv,
// Remove explicit boolean fallbacks - rely on yargs .default() and configToApply
// pipe: argv.pipe ?? false, // Removed
// debug: argv.debug ?? false, // Removed
// bulk: argv.bulk ?? false, // Removed
// ignore: argv.ignore ?? true, // Removed
// skipArtifacts: argv.skipArtifacts ?? true, // Removed
// clipboard: argv.clipboard ?? false, // Removed
// noEditor: argv["no-editor"] ?? false, // Removed
// useRegularGit: argv["use-regular-git"] ?? false, // Removed
// config: argv.config ?? false, // Removed
// verbose: argv.verbose ?? false, // Removed
// svg: argv.svg ?? false, // Removed
// markdown: argv.markdown ?? false, // Removed
// noTokenCount: argv["no-token-count"] ?? false, // Removed
// whitespace: argv.whitespace ?? false, // Removed
// dryRun: argv["dry-run"] ?? false, // Removed
// Map kebab-case aliases correctly
createTemplate: argv["create-template"],
editTemplate: argv["edit-template"],
renderTemplate: argv["render-template"],
useConfigName: argv["config-name"],
use: argv["use"],
// Ensure these aliases are mapped if they exist
noTokenCount: argv["no-token-count"],
dryRun: argv["dry-run"],
noEditor: argv["no-editor"],
useRegularGit: argv["use-regular-git"],
};
// If 'path' wasn't provided as an option, check the first positional arg
if (!parsedArgs.path && argv._ && argv._.length > 0) {
// Check if the first positional arg doesn't look like another flag
const firstPositional = String(argv._[0]);
if (!firstPositional.startsWith('-')) {
parsedArgs.path = firstPositional;
if (initialArgs['debug'])
console.log(formatDebugMessage(`Using first positional argument as path: ${parsedArgs.path}`));
}
}
// Ensure array types are always arrays, even if empty
for (const key of ['include', 'exclude', 'extension', 'find', 'require']) {
// Use bracket notation with a type assertion for dynamic access
const typedKey = key;
if (!Array.isArray(parsedArgs[typedKey])) {
// Assert that the property exists and can be assigned an array
parsedArgs[typedKey] = [];
}
}
// Add final debug logging after all merging is done
if (argv.debug) {
console.log(formatDebugMessage(`Final merged configuration type: ${appliedConfigType}`));
console.log(formatDebugMessage("Final merged argv after config and CLI processing:"));
console.log(formatDebugMessage(JSON.stringify(argv, null, 2)));
}
return parsedArgs;
}
//# sourceMappingURL=cli.js.map