scanex
Version:
A powerful CLI tool that automatically discovers and bundles related source code into a single markdown file, perfect for sharing with LLMs like ChatGPT, Claude, or Cursor AI.
413 lines (349 loc) • 15.2 kB
JavaScript
import { program } from 'commander';
import { resolve, dirname, extname, join, relative, basename } from 'node:path';
import { readFileSync, writeFileSync, existsSync, statSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { loadPlugins, walk, bundle, log, makeTree } from '../lib/core.js';
const __filename = fileURLToPath(import.meta.url);
const PKG_ROOT = dirname(dirname(__filename)); // repo root
// Read package.json for version info
const packageJson = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'));
/* CLI -------------------------------------------------------------------- */
program
.name('scanex')
.description('📸 A tool that extracts and bundles related code from files or directories into a markdown file.\n\nScanEx analyzes your code dependencies and creates a comprehensive documentation file containing all related source files in a structured format.')
.version(packageJson.version, '-v, --version', 'display version number')
.option('-i, --input <paths>',
'comma-separated files or directories to analyze (default: current directory)\n' +
' Examples:\n' +
' --input src/main.js\n' +
' --input src/,lib/utils.js\n' +
' --input .')
.option('-e, --exclude <pattern>',
'regex pattern of paths to ignore (default: "node_modules|test|routes/index.js")\n' +
' Note: .gitignore patterns are automatically included\n' +
' Examples:\n' +
' --exclude "node_modules|test|dist"\n' +
' --exclude ".*\\.spec\\.js$|routes/index.js"',
'node_modules|test|routes/index\\.js')
.option('-o, --output <file>',
'write output to specified file instead of stdout\n' +
' Default: output goes to stdout\n' +
' Examples:\n' +
' --output documentation.md\n' +
' --output ./docs/codebase.md')
.addHelpText('after', `
Examples:
$ scanex
Analyze all files in current directory, output to stdout
$ scanex > scanex.md
Analyze current directory, save to scanex.md
$ scanex --input src/main.js > documentation.md
Analyze main.js and its dependencies, save to documentation.md
$ scanex --input src/ --output docs/codebase.md
Analyze all files in src/ directory, save to specified file
$ scanex --input lib/,src/utils.js --exclude "test|spec"
Analyze lib/ directory and utils.js, output to stdout
$ scanex --exclude "node_modules|dist|build" > project.md
Analyze entire project, save to project.md
$ scanex | pbcopy
Copy bundled code directly to clipboard (macOS)
$ scanex | xclip -selection clipboard
Copy bundled code directly to clipboard (Linux)
$ scanex | grep "function"
Pipe output through grep to find functions
Features:
• 🔍 Automatically discovers file dependencies
• 📁 Creates directory tree visualization
• 📝 Bundles all related code into organized markdown
• 🚀 Supports multiple programming languages
• ⚡ Fast dependency resolution with tree-sitter
• 🙈 Automatically respects .gitignore files
For more information, visit: https://github.com/darkamenosa/scanex
`)
.parse();
const opts = program.opts();
/* plug-ins --------------------------------------------------------------- */
const { scanners, resolvers, ALL_EXT } =
await loadPlugins(join(PKG_ROOT, 'lib/lang'));
// Default to current directory if no input provided
const INPUTS = (opts.input || '.').split(',').map(p => resolve(p.trim()));
// Validate inputs exist
for (const inputPath of INPUTS) {
if (!existsSync(inputPath)) {
console.error(`❌ Input path does not exist: ${inputPath}`);
// Try to provide helpful suggestions
const inputDir = dirname(inputPath);
const inputFile = basename(inputPath);
if (existsSync(inputDir)) {
console.error(` Directory ${inputDir} exists, but file ${inputFile} was not found.`);
// Look for similar files
try {
const files = readdirSync(inputDir);
const similar = files.filter(f =>
f.toLowerCase().includes(inputFile.toLowerCase()) ||
inputFile.toLowerCase().includes(f.toLowerCase())
);
if (similar.length > 0) {
console.error(` Did you mean one of these?`);
similar.slice(0, 3).forEach(f => console.error(` - ${join(inputDir, f)}`));
}
} catch (e) {
// Ignore errors when trying to read directory
}
} else {
console.error(` Directory ${inputDir} also does not exist.`);
}
process.exit(1);
}
}
/* find project root ------------------------------------------------------ */
let projectRoot = INPUTS[0];
// If input is a file, start from its directory
if (existsSync(projectRoot) && statSync(projectRoot).isFile()) {
projectRoot = dirname(projectRoot);
}
// Function to check for repository root markers
function isRepositoryRoot(dir) {
// Only consider it a repo root if it has .git directory (the definitive marker)
// .gitignore alone is not enough as it can exist in subdirectories
return existsSync(join(dir, '.git'));
}
// Function to check for project root markers
function isProjectRoot(dir) {
const projectMarkers = [
'package.json', // Node.js/JavaScript
'pyproject.toml', // Python
'Cargo.toml', // Rust
'go.mod', // Go
'pom.xml', // Java/Maven
'build.gradle', // Java/Gradle
'composer.json', // PHP
'Gemfile', // Ruby
'requirements.txt' // Python (alternative)
];
return projectMarkers.some(marker => existsSync(join(dir, marker)));
}
// Function to safely parse JSON-like config files (tsconfig, jsconfig)
function parseConfigFile(content) {
try {
// First, try to parse as-is (in case it's already valid JSON)
return JSON.parse(content);
} catch (e) {
// If that fails, clean it up
let cleanContent = content;
// Remove single-line comments, but not inside strings
cleanContent = cleanContent.replace(/\/\/.*$/gm, '');
// Remove multi-line comments, but be careful about strings
cleanContent = cleanContent.replace(/\/\*[\s\S]*?\*\//g, '');
// Remove trailing commas before closing brackets/braces
cleanContent = cleanContent.replace(/,(\s*[}\]])/g, '$1');
// Handle common tsconfig.json issues
cleanContent = cleanContent
// Remove any remaining trailing commas
.replace(/,(\s*[}\],])/g, '$1')
// Clean up any double commas that might have been created
.replace(/,,+/g, ',')
// Remove commas before closing braces/brackets
.replace(/,(\s*[}\]])/g, '$1');
try {
return JSON.parse(cleanContent);
} catch (e2) {
// If still failing, try a more aggressive approach
console.warn(`⚠️ TSConfig parsing failed, attempting fallback parsing...`);
// Try to extract just the compilerOptions section if possible
const compilerOptionsMatch = cleanContent.match(/"compilerOptions"\s*:\s*({[^}]*})/);
if (compilerOptionsMatch) {
try {
const compilerOptions = JSON.parse(compilerOptionsMatch[1]);
return { compilerOptions };
} catch (e3) {
// If even that fails, return minimal config
console.warn(`⚠️ Could not parse tsconfig.json, path aliases will not work`);
return null;
}
}
// Last resort: return null to disable path alias resolution
console.warn(`⚠️ Could not parse tsconfig.json, path aliases will not work`);
return null;
}
}
}
// Search strategy: prioritize repository root, then project root
const originalProjectRoot = projectRoot;
let foundRepoRoot = null;
let foundProjectRoot = null;
// Search upward for repository and project markers
let currentDir = projectRoot;
while (currentDir !== dirname(currentDir)) { // Stop at filesystem root
if (isRepositoryRoot(currentDir) && !foundRepoRoot) {
foundRepoRoot = currentDir;
}
if (isProjectRoot(currentDir) && !foundProjectRoot) {
foundProjectRoot = currentDir;
}
// If we found a repository root, we can stop searching
if (foundRepoRoot) break;
const parent = dirname(currentDir);
if (parent === currentDir) break; // Additional safety check
currentDir = parent;
}
// Choose the best project root:
// 1. Repository root (if found)
// 2. Project root (if found)
// 3. Original input directory (fallback)
if (foundRepoRoot) {
projectRoot = foundRepoRoot;
console.error(`[scanex] Repository root detected as: ${projectRoot}`);
} else if (foundProjectRoot) {
projectRoot = foundProjectRoot;
console.error(`[scanex] Project root detected as: ${projectRoot}`);
} else {
projectRoot = originalProjectRoot;
console.error(`[scanex] Using input directory as project root: ${projectRoot}`);
}
/* read .gitignore patterns ----------------------------------------------- */
// .gitignore files are now handled on-demand in the walk function
// Only keep user exclude patterns
const IGNORE = new RegExp(opts.exclude, 'i');
/* load ts/jsconfig for path aliases -------------------------------------- */
// Function to find the nearest tsconfig.json or jsconfig.json
function findNearestConfig(startPath, projectRoot) {
let currentDir = dirname(startPath);
// Search upward from the file location, but don't go above project root
while (currentDir.startsWith(projectRoot)) {
const tsconfigPath = join(currentDir, 'tsconfig.json');
const jsconfigPath = join(currentDir, 'jsconfig.json');
if (existsSync(tsconfigPath)) {
return { path: tsconfigPath, type: 'tsconfig' };
}
if (existsSync(jsconfigPath)) {
return { path: jsconfigPath, type: 'jsconfig' };
}
const parent = dirname(currentDir);
if (parent === currentDir) break; // Reached filesystem root
currentDir = parent;
}
return null;
}
let aliasConfig = null;
let configBasePath = projectRoot;
// If we have specific input files, find config relative to them
if (INPUTS.length > 0 && INPUTS[0] !== projectRoot) {
const inputFile = INPUTS[0];
if (existsSync(inputFile) && statSync(inputFile).isFile()) {
const config = findNearestConfig(inputFile, projectRoot);
if (config) {
configBasePath = dirname(config.path);
try {
const content = readFileSync(config.path, 'utf8');
aliasConfig = parseConfigFile(content);
console.error(`[scanex] Loaded ${config.type}.json from ${relative(projectRoot, config.path)} for path aliases`);
} catch (e) {
console.error(`Error parsing ${config.type}.json: ${e.message}`);
}
}
}
}
// Fallback: look in project root
if (!aliasConfig) {
const tsconfigPath = join(projectRoot, 'tsconfig.json');
const jsconfigPath = join(projectRoot, 'jsconfig.json');
if (existsSync(tsconfigPath)) {
try {
const content = readFileSync(tsconfigPath, 'utf8');
aliasConfig = parseConfigFile(content);
console.error(`[scanex] Loaded tsconfig.json for path aliases`);
} catch (e) {
console.error(`Error parsing tsconfig.json: ${e.message}`);
}
} else if (existsSync(jsconfigPath)) {
try {
const content = readFileSync(jsconfigPath, 'utf8');
aliasConfig = parseConfigFile(content);
console.error(`[scanex] Loaded jsconfig.json for path aliases`);
} catch (e) {
console.error(`Error parsing jsconfig.json: ${e.message}`);
}
}
}
/* helper function to get proper extension including composite ones -------- */
function getFileExtension(filepath, allExtensions) {
// Handle Dockerfiles first (special case with no traditional extension)
const fileName = filepath.split('/').pop();
if (fileName === 'Dockerfile' || fileName.startsWith('Dockerfile.')) {
if (allExtensions.includes('Dockerfile')) {
return 'Dockerfile';
}
}
// Check for composite extensions (like .html.erb)
for (const ext of allExtensions) {
if (filepath.endsWith(ext)) {
return ext;
}
}
// Fallback to standard extname for simple extensions
return extname(filepath);
}
/* seed queue ------------------------------------------------------------- */
const queue = [];
for (const p of INPUTS) queue.push(...walk(p, IGNORE, projectRoot));
const visited = new Set(queue.filter(f => ALL_EXT.includes(getFileExtension(f, ALL_EXT))));
/* BFS over imports ------------------------------------------------------- */
for (let i = 0; i < queue.length; i++) {
const file = queue[i];
const scanner = scanners.get(getFileExtension(file, ALL_EXT));
if (!scanner) continue;
const src = readFileSync(file, 'utf8');
for (const spec of scanner.scan(src, { file })) {
let target = null;
// Handle both string specs (JavaScript) and object specs (Ruby)
const specValue = typeof spec === 'string' ? spec : spec.value;
/* relative ('./foo') */
if (specValue.startsWith('.')) {
const base = resolve(dirname(file), specValue);
target = ALL_EXT.map(e => base.endsWith(e) ? base : base + e)
.find(existsSync);
}
/* plug-in custom resolver */
if (!target) {
for (const r of resolvers) {
const resolved = r.resolve?.(spec, { projectRoot, aliasConfig, configBasePath, file });
if (resolved) {
target = resolved;
break;
}
}
}
if (target && !IGNORE.test(target) && !visited.has(target)) {
// Safety check: ensure target is a file, not a directory
try {
const stat = statSync(target);
if (stat.isFile()) {
visited.add(target);
queue.push(target);
log('⊕', relative(projectRoot, target));
} else if (stat.isDirectory()) {
console.warn(`⚠️ Skipping directory: ${relative(projectRoot, target)}`);
}
} catch (e) {
// File doesn't exist or can't be accessed
console.warn(`⚠️ Skipping invalid path: ${relative(projectRoot, target)} (${e.message})`);
}
}
}
}
/* build nice directory tree --------------------------------------------- */
const treeStr = makeTree([...visited].map(f => relative(projectRoot, f)));
/* write output ----------------------------------------------------------- */
const bundledContent = bundle([...visited].sort(), projectRoot, treeStr);
if (opts.output) {
// Write to specified file
writeFileSync(opts.output, bundledContent);
console.error(`✅ wrote ${relative('.', opts.output)} (${visited.size} files)`);
} else {
// Default: write to stdout
console.log(bundledContent);
// Success info goes to stderr so it doesn't interfere with piping
console.error(`✅ processed ${visited.size} files`);
}