#!/usr/bin/env node /** * CodeGraph CLI * * Command-line interface for CodeGraph code intelligence. * * Usage: * codegraph Run interactive installer (when no args) * codegraph install Run interactive installer * codegraph uninstall Remove CodeGraph from your agents * codegraph init [path] Initialize CodeGraph in a project * codegraph uninit [path] Remove CodeGraph from a project * codegraph index [path] Index all files in the project * codegraph sync [path] Sync changes since last index * codegraph status [path] Show index status * codegraph query Search for symbols * codegraph files [options] Show project file structure * codegraph context Build context for a task * codegraph callers Find what calls a function/method * codegraph callees Find what a function/method calls * codegraph impact Analyze what code is affected by changing a symbol * codegraph affected [files] Find test files affected by changes */ import { Command } from 'commander'; import * as path from 'path'; import * as fs from 'fs'; import { getCodeGraphDir, isInitialized } from '../directory'; import { createShimmerProgress } from '../ui/shimmer-progress'; import { getGlyphs } from '../ui/glyphs'; import { buildNode25BlockBanner, buildNodeTooOldBanner, MIN_NODE_MAJOR } from './node-version-check'; import { relaunchWithWasmRuntimeFlagsIfNeeded } from '../extraction/wasm-runtime-flags'; // Lazy-load heavy modules (CodeGraph, runInstaller) to keep CLI startup fast. async function loadCodeGraph(): Promise { try { return await import('../index'); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`\x1b[31m${getGlyphs().err}\x1b[0m Failed to load CodeGraph modules.`); console.error(`\n Node: ${process.version} Platform: ${process.platform} ${process.arch}`); console.error(`\n Error: ${msg}`); console.error('\n Try reinstalling with: npm install -g @colbymchenry/codegraph\n'); process.exit(1); } } // Dynamic import helper — tsc compiles import() to require() in CJS mode, // which fails for ESM-only packages. This bypasses the transformation. // eslint-disable-next-line @typescript-eslint/no-implied-eval const importESM = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise; // Block CodeGraph on Node.js 25.x — V8's turboshaft WASM JIT has a Zone // allocator bug that reliably crashes when compiling tree-sitter // grammars (see #54, #81, #140). The previous behaviour was a soft // console.warn that scrolls off-screen before the OOM crash 30 seconds // later, leading to a steady stream of "what is this OOM" reports. // Hard-exit before any WASM work; allow override via env var for users // who patched V8 themselves or want to test a future fix. const nodeVersion = process.versions.node; const nodeMajor = parseInt(nodeVersion.split('.')[0] ?? '0', 10); if (nodeMajor >= 25) { process.stderr.write(buildNode25BlockBanner(nodeVersion) + '\n'); if (!process.env.CODEGRAPH_ALLOW_UNSAFE_NODE) { process.exit(1); } // Override active — banner shown for visibility, continuing. } // Enforce the supported Node floor. `engines` in package.json only *warns* on // install (unless engine-strict), so hard-block here to actually keep users off // unsupported versions. Mirrors the 25+ block above. See package.json `engines`. if (nodeMajor < MIN_NODE_MAJOR) { process.stderr.write(buildNodeTooOldBanner(nodeVersion) + '\n'); if (!process.env.CODEGRAPH_ALLOW_UNSAFE_NODE) { process.exit(1); } // Override active — banner shown for visibility, continuing. } // Re-exec with V8's `--liftoff-only` if it isn't already set, so tree-sitter's // large WASM grammars never hit the turboshaft Zone OOM (`Fatal process out of // memory: Zone`) on Node >= 22. No-op under the bundled launcher, which already // passes the flag. Must run before any grammar (in the parse worker, which // inherits this process's flags) is compiled. See ../extraction/wasm-runtime-flags. relaunchWithWasmRuntimeFlagsIfNeeded(__filename); // Check if running with no arguments - run installer if (process.argv.length === 2) { import('../installer').then(({ runInstaller }) => runInstaller() ).catch((err) => { console.error('Installation failed:', err instanceof Error ? err.message : String(err)); process.exit(1); }); } else { // Normal CLI flow main(); } process.on('uncaughtException', (error) => { console.error('[CodeGraph] Uncaught exception:', error); }); process.on('unhandledRejection', (reason) => { console.error('[CodeGraph] Unhandled rejection:', reason); }); function main() { const program = new Command(); // Version from package.json const packageJson = JSON.parse( fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf-8') ); // ============================================================================= // ANSI Color Helpers (avoid chalk ESM issues) // ============================================================================= const colors = { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', cyan: '\x1b[36m', white: '\x1b[37m', gray: '\x1b[90m', }; const chalk = { bold: (s: string) => `${colors.bold}${s}${colors.reset}`, dim: (s: string) => `${colors.dim}${s}${colors.reset}`, red: (s: string) => `${colors.red}${s}${colors.reset}`, green: (s: string) => `${colors.green}${s}${colors.reset}`, yellow: (s: string) => `${colors.yellow}${s}${colors.reset}`, blue: (s: string) => `${colors.blue}${s}${colors.reset}`, cyan: (s: string) => `${colors.cyan}${s}${colors.reset}`, white: (s: string) => `${colors.white}${s}${colors.reset}`, gray: (s: string) => `${colors.gray}${s}${colors.reset}`, }; program .name('codegraph') .description('Code intelligence and knowledge graph for any codebase') .version(packageJson.version); // ============================================================================= // Helper Functions // ============================================================================= /** * Resolve project path from argument or current directory * Walks up parent directories to find nearest initialized CodeGraph project * (must have .codegraph/codegraph.db, not just .codegraph/lessons.db) */ function resolveProjectPath(pathArg?: string): string { const absolutePath = path.resolve(pathArg || process.cwd()); // If exact path is initialized (has codegraph.db), use it if (isInitialized(absolutePath)) { return absolutePath; } // Walk up to find nearest parent with CodeGraph initialized // Note: findNearestCodeGraphRoot finds any .codegraph folder, but we need one with codegraph.db let current = absolutePath; const root = path.parse(current).root; while (current !== root) { const parent = path.dirname(current); if (parent === current) break; current = parent; if (isInitialized(current)) { return current; } } // Not found - return original path (will fail later with helpful error) return absolutePath; } /** * Format a number with commas */ function formatNumber(n: number): string { return n.toLocaleString(); } /** * Format duration in milliseconds to human readable */ function formatDuration(ms: number): string { if (ms < 1000) { return `${ms}ms`; } const seconds = ms / 1000; if (seconds < 60) { return `${seconds.toFixed(1)}s`; } const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; return `${minutes}m ${remainingSeconds.toFixed(0)}s`; } // Shimmer progress renderer (runs in a worker thread for smooth animation) // Imported at top of file from '../ui/shimmer-progress' /** * Create a plain-text progress callback for --verbose mode. * No animations, no ANSI tricks — just timestamped lines to stdout. */ function createVerboseProgress(): (progress: { phase: string; current: number; total: number; currentFile?: string }) => void { let lastPhase = ''; let lastPct = -1; const startTime = Date.now(); return (progress) => { const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); if (progress.phase !== lastPhase) { lastPhase = progress.phase; lastPct = -1; console.log(`[${elapsed}s] Phase: ${progress.phase}`); } if (progress.total > 0) { const pct = Math.floor((progress.current / progress.total) * 100); // Log every 5% to keep output manageable if (pct >= lastPct + 5 || progress.current === progress.total) { lastPct = pct; console.log(`[${elapsed}s] ${progress.current}/${progress.total} (${pct}%)${progress.currentFile ? ` ${getGlyphs().dash} ${progress.currentFile}` : ''}`); } } else if (progress.current > 0) { // Scanning phase (no total yet) — log periodically if (progress.current % 1000 === 0 || progress.current === 1) { console.log(`[${elapsed}s] ${formatNumber(progress.current)} files found`); } } }; } /** * Print success message */ function success(message: string): void { console.log(chalk.green(getGlyphs().ok) + ' ' + message); } /** * Print error message */ function error(message: string): void { console.error(chalk.red(getGlyphs().err) + ' ' + message); } /** * Print info message */ function info(message: string): void { console.log(chalk.blue(getGlyphs().info) + ' ' + message); } /** * Print warning message */ function warn(message: string): void { console.log(chalk.yellow(getGlyphs().warn) + ' ' + message); } type IndexResult = { success: boolean; filesIndexed: number; filesSkipped: number; filesErrored: number; nodesCreated: number; edgesCreated: number; errors: Array<{ message: string; filePath?: string; severity: string; code?: string }>; durationMs: number; }; /** * Print indexing results using clack log methods */ function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexResult, projectPath?: string): void { const hasErrors = result.filesErrored > 0; // Surface non-file-level failures (e.g. lock-acquisition failure // when another indexer is running) before the file-count branches. // Without this the CLI falls through to "No files found to index", // which is actively misleading — the index DID run, it just couldn't // get the lock. // // If success is false but no severity:'error' entry exists in // `result.errors` (degenerate case — shouldn't happen in practice // but worth guarding because the result shape is plumbed through // multiple call sites), fall back to a generic message rather than // continuing to the misleading "No files found" branch or throwing. if (!result.success && !hasErrors && result.filesIndexed === 0) { const generic = result.errors.find((e) => e.severity === 'error'); clack.log.error(generic?.message ?? `Indexing failed ${getGlyphs().dash} no further details available`); return; } if (result.filesIndexed > 0) { if (hasErrors) { clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files (${formatNumber(result.filesErrored)} could not be parsed)`); } else { clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files`); } clack.log.info(`${formatNumber(result.nodesCreated)} nodes, ${formatNumber(result.edgesCreated)} edges in ${formatDuration(result.durationMs)}`); } else if (hasErrors) { clack.log.error(`Indexing failed ${getGlyphs().dash} all ${formatNumber(result.filesErrored)} files had errors`); } else { clack.log.warn('No files found to index'); } if (hasErrors) { const errorsByCode = new Map(); for (const err of result.errors) { if (err.severity === 'error') { const code = err.code || 'unknown'; errorsByCode.set(code, (errorsByCode.get(code) || 0) + 1); } } const codeLabels: Record = { parse_error: 'files failed to parse', read_error: 'files could not be read', size_exceeded: 'files exceeded size limit', path_traversal: 'blocked paths', unsupported_language: 'unsupported language', parser_error: 'parser initialization failures', }; const breakdown = Array.from(errorsByCode) .map(([code, count]) => `${formatNumber(count)} ${codeLabels[code] || code}`) .join('\n'); clack.note(breakdown, 'Error breakdown'); if (projectPath) { writeErrorLog(projectPath, result.errors); clack.log.info('See .codegraph/errors.log for details'); } if (result.filesIndexed > 0) { clack.log.info(`The index is fully usable ${getGlyphs().dash} only the failed files are missing.`); } } else if (projectPath) { const logPath = path.join(projectPath, '.codegraph', 'errors.log'); if (fs.existsSync(logPath)) { fs.unlinkSync(logPath); } } } /** * Write detailed error log to .codegraph/errors.log */ function writeErrorLog(projectPath: string, errors: Array<{ message: string; filePath?: string; severity: string; code?: string }>): void { const cgDir = path.join(projectPath, '.codegraph'); if (!fs.existsSync(cgDir)) return; const logPath = path.join(cgDir, 'errors.log'); // Group errors by file path const errorsByFile = new Map>(); const noFileErrors: Array<{ message: string; code?: string }> = []; for (const err of errors) { if (err.severity !== 'error') continue; if (err.filePath) { let list = errorsByFile.get(err.filePath); if (!list) { list = []; errorsByFile.set(err.filePath, list); } list.push({ message: err.message, code: err.code }); } else { noFileErrors.push({ message: err.message, code: err.code }); } } const lines: string[] = [ `CodeGraph Error Log - ${new Date().toISOString()}`, `${errorsByFile.size} files with errors`, '', ]; for (const [filePath, fileErrors] of errorsByFile) { for (const err of fileErrors) { lines.push(`${filePath}: ${err.message}`); } } for (const err of noFileErrors) { lines.push(err.message); } fs.writeFileSync(logPath, lines.join('\n') + '\n'); } // ============================================================================= // Commands // ============================================================================= /** * codegraph init [path] */ program .command('init [path]') .description('Initialize CodeGraph in a project directory') .option('-i, --index', 'Run initial indexing after initialization') .option('-v, --verbose', 'Show detailed worker lifecycle and memory info') .action(async (pathArg: string | undefined, options: { index?: boolean; verbose?: boolean }) => { const projectPath = path.resolve(pathArg || process.cwd()); const clack = await importESM('@clack/prompts'); clack.intro('Initializing CodeGraph'); try { if (isInitialized(projectPath)) { clack.log.warn(`Already initialized in ${projectPath}`); clack.log.info('Use "codegraph index" to re-index or "codegraph sync" to update'); // Re-run agent surface wiring so re-running `init` is the // documented way to recover a project that's missing its // Cursor rules file (or future per-agent project surfaces). try { const { wireProjectSurfacesForGlobalAgents } = await import('../installer'); for (const { target, file } of wireProjectSurfacesForGlobalAgents()) { clack.log.success(`${target.displayName}: ${file.action} ${file.path}`); } } catch { /* non-fatal */ } try { const { offerWatchFallback } = await import('../installer'); await offerWatchFallback(clack, projectPath); } catch { /* non-fatal */ } clack.outro(''); return; } const { default: CodeGraph } = await loadCodeGraph(); const cg = await CodeGraph.init(projectPath, { index: false }); clack.log.success(`Initialized in ${projectPath}`); // Bootstrap project-local surfaces for any agent that's // configured globally (Cursor needs ./.cursor/rules/codegraph.mdc // to actually prefer codegraph over native grep). Silent when // there's nothing to write. try { const { wireProjectSurfacesForGlobalAgents } = await import('../installer'); for (const { target, file } of wireProjectSurfacesForGlobalAgents()) { clack.log.success(`${target.displayName}: ${file.action} ${file.path}`); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); clack.log.warn(`Skipped wiring project-local agent surfaces: ${msg}`); } if (options.index) { let result: IndexResult; if (options.verbose) { result = await cg.indexAll({ onProgress: createVerboseProgress(), verbose: true, }); } else { process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`); const progress = createShimmerProgress(); result = await cg.indexAll({ onProgress: progress.onProgress, }); await progress.stop(); } printIndexResult(clack, result, projectPath); } else { clack.log.info('Run "codegraph index" to index the project'); } try { const { offerWatchFallback } = await import('../installer'); await offerWatchFallback(clack, projectPath); } catch { /* non-fatal */ } clack.outro('Done'); cg.destroy(); } catch (err) { clack.log.error(`Failed: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); } }); /** * codegraph uninit [path] */ program .command('uninit [path]') .description('Remove CodeGraph from a project (deletes .codegraph/ directory)') .option('-f, --force', 'Skip confirmation prompt') .action(async (pathArg: string | undefined, options: { force?: boolean }) => { const projectPath = resolveProjectPath(pathArg); try { if (!isInitialized(projectPath)) { warn(`CodeGraph is not initialized in ${projectPath}`); return; } if (!options.force) { // Confirm with user const readline = await import('readline'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const answer = await new Promise((resolve) => { rl.question( chalk.yellow(`${getGlyphs().warn} This will permanently delete all CodeGraph data. Continue? (y/N) `), resolve ); }); rl.close(); if (answer.toLowerCase() !== 'y') { info('Cancelled'); return; } } const { default: CodeGraph } = await loadCodeGraph(); const cg = CodeGraph.openSync(projectPath); cg.uninitialize(); // Clean up any git sync hooks we installed (no-op if none / not a repo). try { const { removeGitSyncHook } = await import('../sync/git-hooks'); const removed = removeGitSyncHook(projectPath); if (removed.installed.length > 0) { info(`Removed git ${removed.installed.join(', ')} sync hook${removed.installed.length > 1 ? 's' : ''}`); } } catch { /* non-fatal */ } success(`Removed CodeGraph from ${projectPath}`); } catch (err) { error(`Failed to uninitialize: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); } }); /** * codegraph index [path] */ program .command('index [path]') .description('Index all files in the project') .option('-f, --force', 'Force full re-index even if already indexed') .option('-q, --quiet', 'Suppress progress output') .option('-v, --verbose', 'Show detailed worker lifecycle and memory info') .action(async (pathArg: string | undefined, options: { force?: boolean; quiet?: boolean; verbose?: boolean }) => { const projectPath = resolveProjectPath(pathArg); try { if (!isInitialized(projectPath)) { error(`CodeGraph not initialized in ${projectPath}`); info('Run "codegraph init" first'); process.exit(1); } const { default: CodeGraph } = await loadCodeGraph(); const cg = await CodeGraph.open(projectPath); if (options.quiet) { // Quiet mode: no UI, just run if (options.force) cg.clear(); const result = await cg.indexAll(); if (!result.success) process.exit(1); cg.destroy(); return; } const clack = await importESM('@clack/prompts'); clack.intro('Indexing project'); if (options.force) { cg.clear(); clack.log.info('Cleared existing index'); } let result: IndexResult; if (options.verbose) { result = await cg.indexAll({ onProgress: createVerboseProgress(), verbose: true, }); } else { process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`); const progress = createShimmerProgress(); result = await cg.indexAll({ onProgress: progress.onProgress, }); await progress.stop(); } printIndexResult(clack, result, projectPath); if (!result.success) { process.exit(1); } clack.outro('Done'); cg.destroy(); } catch (err) { error(`Failed to index: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); } }); /** * codegraph sync [path] */ program .command('sync [path]') .description('Sync changes since last index') .option('-q, --quiet', 'Suppress output (for git hooks)') .action(async (pathArg: string | undefined, options: { quiet?: boolean }) => { const projectPath = resolveProjectPath(pathArg); try { if (!isInitialized(projectPath)) { if (!options.quiet) { error(`CodeGraph not initialized in ${projectPath}`); } process.exit(1); } const { default: CodeGraph } = await loadCodeGraph(); const cg = await CodeGraph.open(projectPath); if (options.quiet) { await cg.sync(); cg.destroy(); return; } const clack = await importESM('@clack/prompts'); clack.intro('Syncing CodeGraph'); process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`); const progress = createShimmerProgress(); const result = await cg.sync({ onProgress: progress.onProgress, }); await progress.stop(); const totalChanges = result.filesAdded + result.filesModified + result.filesRemoved; if (totalChanges === 0) { clack.log.info('Already up to date'); } else { clack.log.success(`Synced ${formatNumber(totalChanges)} changed files`); const details: string[] = []; if (result.filesAdded > 0) details.push(`Added: ${result.filesAdded}`); if (result.filesModified > 0) details.push(`Modified: ${result.filesModified}`); if (result.filesRemoved > 0) details.push(`Removed: ${result.filesRemoved}`); clack.log.info(`${details.join(', ')} ${getGlyphs().dash} ${formatNumber(result.nodesUpdated)} nodes in ${formatDuration(result.durationMs)}`); } clack.outro('Done'); cg.destroy(); } catch (err) { if (!options.quiet) { error(`Failed to sync: ${err instanceof Error ? err.message : String(err)}`); } process.exit(1); } }); /** * codegraph status [path] */ program .command('status [path]') .description('Show index status and statistics') .option('-j, --json', 'Output as JSON') .action(async (pathArg: string | undefined, options: { json?: boolean }) => { const projectPath = resolveProjectPath(pathArg); try { if (!isInitialized(projectPath)) { if (options.json) { console.log(JSON.stringify({ initialized: false, projectPath })); return; } console.log(chalk.bold('\nCodeGraph Status\n')); info(`Project: ${projectPath}`); warn('Not initialized'); info('Run "codegraph init" to initialize'); return; } const { default: CodeGraph } = await loadCodeGraph(); const cg = await CodeGraph.open(projectPath); const stats = cg.getStats(); const changes = cg.getChangedFiles(); const backend = cg.getBackend(); const journalMode = cg.getJournalMode(); // JSON output mode if (options.json) { console.log(JSON.stringify({ initialized: true, projectPath, fileCount: stats.fileCount, nodeCount: stats.nodeCount, edgeCount: stats.edgeCount, dbSizeBytes: stats.dbSizeBytes, backend, journalMode, nodesByKind: stats.nodesByKind, languages: Object.entries(stats.filesByLanguage).filter(([, count]) => count > 0).map(([lang]) => lang), pendingChanges: { added: changes.added.length, modified: changes.modified.length, removed: changes.removed.length, }, })); cg.destroy(); return; } console.log(chalk.bold('\nCodeGraph Status\n')); // Project info console.log(chalk.cyan('Project:'), projectPath); console.log(); // Index stats console.log(chalk.bold('Index Statistics:')); console.log(` Files: ${formatNumber(stats.fileCount)}`); console.log(` Nodes: ${formatNumber(stats.nodeCount)}`); console.log(` Edges: ${formatNumber(stats.edgeCount)}`); console.log(` DB Size: ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`); // Surface the active SQLite backend (node:sqlite — Node's built-in real // SQLite, full WAL + FTS5, no native build). const backendLabel = chalk.green(`node:sqlite ${getGlyphs().dash} built-in (full WAL)`); console.log(` Backend: ${backendLabel}`); // Effective journal mode: 'wal' means concurrent reads never block on a // writer; anything else means they can ("database is locked"). node:sqlite // supports WAL everywhere, so a non-wal mode means the filesystem can't // (network mounts, WSL2 /mnt). See issue #238. const journalLabel = journalMode === 'wal' ? chalk.green('wal') : chalk.yellow(`${journalMode || 'unknown'} ${getGlyphs().dash} WAL inactive; reads can block on writes`); console.log(` Journal: ${journalLabel}`); console.log(); // Node breakdown console.log(chalk.bold('Nodes by Kind:')); const nodesByKind = Object.entries(stats.nodesByKind) .filter(([, count]) => count > 0) .sort((a, b) => b[1] - a[1]); for (const [kind, count] of nodesByKind) { console.log(` ${kind.padEnd(15)} ${formatNumber(count)}`); } console.log(); // Language breakdown console.log(chalk.bold('Files by Language:')); const filesByLang = Object.entries(stats.filesByLanguage) .filter(([, count]) => count > 0) .sort((a, b) => b[1] - a[1]); for (const [lang, count] of filesByLang) { console.log(` ${lang.padEnd(15)} ${formatNumber(count)}`); } console.log(); // Pending changes const totalChanges = changes.added.length + changes.modified.length + changes.removed.length; if (totalChanges > 0) { console.log(chalk.bold('Pending Changes:')); if (changes.added.length > 0) { console.log(` Added: ${changes.added.length} files`); } if (changes.modified.length > 0) { console.log(` Modified: ${changes.modified.length} files`); } if (changes.removed.length > 0) { console.log(` Removed: ${changes.removed.length} files`); } info('Run "codegraph sync" to update the index'); } else { success('Index is up to date'); } console.log(); cg.destroy(); } catch (err) { error(`Failed to get status: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); } }); /** * codegraph query */ program .command('query ') .description('Search for symbols in the codebase') .option('-p, --path ', 'Project path') .option('-l, --limit ', 'Maximum results', '10') .option('-k, --kind ', 'Filter by node kind (function, class, etc.)') .option('-j, --json', 'Output as JSON') .action(async (search: string, options: { path?: string; limit?: string; kind?: string; json?: boolean }) => { const projectPath = resolveProjectPath(options.path); try { if (!isInitialized(projectPath)) { error(`CodeGraph not initialized in ${projectPath}`); process.exit(1); } const { default: CodeGraph } = await loadCodeGraph(); const cg = await CodeGraph.open(projectPath); const limit = parseInt(options.limit || '10', 10); const results = cg.searchNodes(search, { limit, kinds: options.kind ? [options.kind as any] : undefined, }); if (options.json) { console.log(JSON.stringify(results, null, 2)); } else { if (results.length === 0) { info(`No results found for "${search}"`); } else { console.log(chalk.bold(`\nSearch Results for "${search}":\n`)); for (const result of results) { const node = result.node; const location = `${node.filePath}:${node.startLine}`; const score = chalk.dim(`(${(result.score * 100).toFixed(0)}%)`); console.log( chalk.cyan(node.kind.padEnd(12)) + chalk.white(node.name) + ' ' + score ); console.log(chalk.dim(` ${location}`)); if (node.signature) { console.log(chalk.dim(` ${node.signature}`)); } console.log(); } } } cg.destroy(); } catch (err) { error(`Search failed: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); } }); /** * codegraph files [path] */ program .command('files') .description('Show project file structure from the index') .option('-p, --path ', 'Project path') .option('--filter ', 'Filter to files under this directory') .option('--pattern ', 'Filter files matching this glob pattern') .option('--format ', 'Output format (tree, flat, grouped)', 'tree') .option('--max-depth ', 'Maximum directory depth for tree format') .option('--no-metadata', 'Hide file metadata (language, symbol count)') .option('-j, --json', 'Output as JSON') .action(async (options: { path?: string; filter?: string; pattern?: string; format?: string; maxDepth?: string; metadata?: boolean; json?: boolean; }) => { const projectPath = resolveProjectPath(options.path); try { if (!isInitialized(projectPath)) { error(`CodeGraph not initialized in ${projectPath}`); process.exit(1); } const { default: CodeGraph } = await loadCodeGraph(); const cg = await CodeGraph.open(projectPath); let files = cg.getFiles(); if (files.length === 0) { info('No files indexed. Run "codegraph index" first.'); cg.destroy(); return; } // Filter by path prefix if (options.filter) { const filter = options.filter; files = files.filter(f => f.path.startsWith(filter) || f.path.startsWith('./' + filter)); } // Filter by glob pattern if (options.pattern) { const regex = globToRegex(options.pattern); files = files.filter(f => regex.test(f.path)); } if (files.length === 0) { info('No files found matching the criteria.'); cg.destroy(); return; } // JSON output if (options.json) { const output = files.map(f => ({ path: f.path, language: f.language, nodeCount: f.nodeCount, size: f.size, })); console.log(JSON.stringify(output, null, 2)); cg.destroy(); return; } const includeMetadata = options.metadata !== false; const format = options.format || 'tree'; const maxDepth = options.maxDepth ? parseInt(options.maxDepth, 10) : undefined; // Format output switch (format) { case 'flat': console.log(chalk.bold(`\nFiles (${files.length}):\n`)); for (const file of files.sort((a, b) => a.path.localeCompare(b.path))) { if (includeMetadata) { console.log(` ${file.path} ${chalk.dim(`(${file.language}, ${file.nodeCount} symbols)`)}`); } else { console.log(` ${file.path}`); } } break; case 'grouped': console.log(chalk.bold(`\nFiles by Language (${files.length} total):\n`)); const byLang = new Map(); for (const file of files) { const existing = byLang.get(file.language) || []; existing.push(file); byLang.set(file.language, existing); } const sortedLangs = [...byLang.entries()].sort((a, b) => b[1].length - a[1].length); for (const [lang, langFiles] of sortedLangs) { console.log(chalk.cyan(`${lang} (${langFiles.length}):`)); for (const file of langFiles.sort((a, b) => a.path.localeCompare(b.path))) { if (includeMetadata) { console.log(` ${file.path} ${chalk.dim(`(${file.nodeCount} symbols)`)}`); } else { console.log(` ${file.path}`); } } console.log(); } break; case 'tree': default: console.log(chalk.bold(`\nProject Structure (${files.length} files):\n`)); printFileTree(files, includeMetadata, maxDepth, chalk); break; } console.log(); cg.destroy(); } catch (err) { error(`Failed to list files: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); } }); /** * Convert glob pattern to regex */ function globToRegex(pattern: string): RegExp { const escaped = pattern .replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/\*\*/g, '{{GLOBSTAR}}') .replace(/\*/g, '[^/]*') .replace(/\?/g, '[^/]') .replace(/\{\{GLOBSTAR\}\}/g, '.*'); return new RegExp(escaped); } /** * Print files as a tree */ function printFileTree( files: { path: string; language: string; nodeCount: number }[], includeMetadata: boolean, maxDepth: number | undefined, chalk: { dim: (s: string) => string; cyan: (s: string) => string } ): void { interface TreeNode { name: string; children: Map; file?: { language: string; nodeCount: number }; } const root: TreeNode = { name: '', children: new Map() }; for (const file of files) { const parts = file.path.split('/'); let current = root; for (let i = 0; i < parts.length; i++) { const part = parts[i]; if (!part) continue; if (!current.children.has(part)) { current.children.set(part, { name: part, children: new Map() }); } current = current.children.get(part)!; if (i === parts.length - 1) { current.file = { language: file.language, nodeCount: file.nodeCount }; } } } const renderNode = (node: TreeNode, prefix: string, isLast: boolean, depth: number): void => { if (maxDepth !== undefined && depth > maxDepth) return; const glyphs = getGlyphs(); const connector = isLast ? glyphs.treeLast : glyphs.treeBranch; const childPrefix = isLast ? ' ' : glyphs.treePipe; if (node.name) { let line = prefix + connector + node.name; if (node.file && includeMetadata) { line += chalk.dim(` (${node.file.language}, ${node.file.nodeCount} symbols)`); } console.log(line); } const children = [...node.children.values()]; children.sort((a, b) => { const aIsDir = a.children.size > 0 && !a.file; const bIsDir = b.children.size > 0 && !b.file; if (aIsDir !== bIsDir) return aIsDir ? -1 : 1; return a.name.localeCompare(b.name); }); for (let i = 0; i < children.length; i++) { const child = children[i]!; const nextPrefix = node.name ? prefix + childPrefix : prefix; renderNode(child, nextPrefix, i === children.length - 1, depth + 1); } }; renderNode(root, '', true, 0); } /** * codegraph context */ program .command('context ') .description('Build context for a task (outputs markdown)') .option('-p, --path ', 'Project path') .option('-n, --max-nodes ', 'Maximum nodes to include', '50') .option('-c, --max-code ', 'Maximum code blocks', '10') .option('--no-code', 'Exclude code blocks') .option('-f, --format ', 'Output format (markdown, json)', 'markdown') .action(async (task: string, options: { path?: string; maxNodes?: string; maxCode?: string; code?: boolean; format?: string; }) => { const projectPath = resolveProjectPath(options.path); try { if (!isInitialized(projectPath)) { error(`CodeGraph not initialized in ${projectPath}`); process.exit(1); } const { default: CodeGraph } = await loadCodeGraph(); const cg = await CodeGraph.open(projectPath); const context = await cg.buildContext(task, { maxNodes: parseInt(options.maxNodes || '50', 10), maxCodeBlocks: parseInt(options.maxCode || '10', 10), includeCode: options.code !== false, format: options.format as 'markdown' | 'json', }); // Output the context console.log(context); cg.destroy(); } catch (err) { error(`Failed to build context: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); } }); /** * codegraph serve */ program .command('serve') .description('Start CodeGraph as an MCP server for AI assistants') .option('-p, --path ', 'Project path (optional for MCP mode, uses rootUri from client)') .option('--mcp', 'Run as MCP server (stdio transport)') .option('--no-watch', 'Disable the file watcher (no auto-sync; useful on slow filesystems like WSL2 /mnt drives)') .action(async (options: { path?: string; mcp?: boolean; watch?: boolean }) => { const projectPath = options.path ? resolveProjectPath(options.path) : undefined; // Commander sets watch=false when --no-watch is passed. Route it through // the same env-var chokepoint the watcher and MCP server already honor. if (options.watch === false) { process.env.CODEGRAPH_NO_WATCH = '1'; } try { if (options.mcp) { // Start MCP server - it handles initialization lazily based on rootUri from client const { MCPServer } = await import('../mcp/index'); const server = new MCPServer(projectPath); await server.start(); // Server will run until terminated } else { // Default: show info about MCP mode. // Use stderr so stdout stays clean for any piped/stdio usage. console.error(chalk.bold('\nCodeGraph MCP Server\n')); console.error(chalk.blue(getGlyphs().info) + ' Use --mcp flag to start the MCP server'); console.error('\nTo use with Claude Code, add to your MCP configuration:'); console.error(chalk.dim(` { "mcpServers": { "codegraph": { "command": "codegraph", "args": ["serve", "--mcp"] } } } `)); console.error('Available tools:'); console.error(chalk.cyan(' codegraph_search') + ' - Search for code symbols'); console.error(chalk.cyan(' codegraph_context') + ' - Build context for a task'); console.error(chalk.cyan(' codegraph_callers') + ' - Find callers of a symbol'); console.error(chalk.cyan(' codegraph_callees') + ' - Find what a symbol calls'); console.error(chalk.cyan(' codegraph_impact') + ' - Analyze impact of changes'); console.error(chalk.cyan(' codegraph_node') + ' - Get symbol details'); console.error(chalk.cyan(' codegraph_files') + ' - Get project file structure'); console.error(chalk.cyan(' codegraph_status') + ' - Get index status'); } } catch (err) { error(`Failed to start server: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); } }); /** * codegraph unlock [path] */ program .command('unlock [path]') .description('Remove a stale lock file that is blocking indexing') .action(async (pathArg: string | undefined) => { const projectPath = resolveProjectPath(pathArg); try { if (!isInitialized(projectPath)) { error(`CodeGraph not initialized in ${projectPath}`); return; } const lockPath = path.join(getCodeGraphDir(projectPath), 'codegraph.lock'); if (!fs.existsSync(lockPath)) { info(`No lock file found ${getGlyphs().dash} nothing to do`); return; } fs.unlinkSync(lockPath); success('Removed lock file. You can now run indexing again.'); } catch (err) { error(`Failed to remove lock: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); } }); /** * codegraph callers * * CLI parity with the MCP graph tools (codegraph_callers/callees/impact) so the * traversal queries work in scripts, CI, and git hooks without a running MCP * server. */ program .command('callers ') .description('Find all functions/methods that call a specific symbol') .option('-p, --path ', 'Project path') .option('-l, --limit ', 'Maximum results', '20') .option('-j, --json', 'Output as JSON') .action(async (symbol: string, options: { path?: string; limit?: string; json?: boolean }) => { const projectPath = resolveProjectPath(options.path); try { if (!isInitialized(projectPath)) { error(`CodeGraph not initialized in ${projectPath}`); process.exit(1); } const { default: CodeGraph } = await loadCodeGraph(); const cg = await CodeGraph.open(projectPath); const limit = parseInt(options.limit || '20', 10); const matches = cg.searchNodes(symbol, { limit: 50 }); if (matches.length === 0) { info(`Symbol "${symbol}" not found`); cg.destroy(); return; } const seen = new Set(); const allCallers: Array<{ name: string; kind: string; filePath: string; startLine?: number }> = []; for (const match of matches) { const exactMatch = match.node.name === symbol || match.node.name.endsWith(`.${symbol}`) || match.node.name.endsWith(`::${symbol}`); if (!exactMatch && matches.length > 1) continue; for (const c of cg.getCallers(match.node.id)) { if (!seen.has(c.node.id)) { seen.add(c.node.id); allCallers.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine }); } } } // Fallback: if exact filter removed everything, use the top match if (allCallers.length === 0 && matches[0]) { for (const c of cg.getCallers(matches[0].node.id)) { if (!seen.has(c.node.id)) { seen.add(c.node.id); allCallers.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine }); } } } const limited = allCallers.slice(0, limit); if (options.json) { console.log(JSON.stringify({ symbol, callers: limited }, null, 2)); } else if (limited.length === 0) { info(`No callers found for "${symbol}"`); } else { console.log(chalk.bold(`\nCallers of "${symbol}" (${limited.length}):\n`)); for (const node of limited) { const loc = node.startLine ? `:${node.startLine}` : ''; console.log( chalk.cyan(node.kind.padEnd(12)) + chalk.white(node.name) ); console.log(chalk.dim(` ${node.filePath}${loc}`)); console.log(); } } cg.destroy(); } catch (err) { error(`callers failed: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); } }); /** * codegraph callees */ program .command('callees ') .description('Find all functions/methods that a specific symbol calls') .option('-p, --path ', 'Project path') .option('-l, --limit ', 'Maximum results', '20') .option('-j, --json', 'Output as JSON') .action(async (symbol: string, options: { path?: string; limit?: string; json?: boolean }) => { const projectPath = resolveProjectPath(options.path); try { if (!isInitialized(projectPath)) { error(`CodeGraph not initialized in ${projectPath}`); process.exit(1); } const { default: CodeGraph } = await loadCodeGraph(); const cg = await CodeGraph.open(projectPath); const limit = parseInt(options.limit || '20', 10); const matches = cg.searchNodes(symbol, { limit: 50 }); if (matches.length === 0) { info(`Symbol "${symbol}" not found`); cg.destroy(); return; } const seen = new Set(); const allCallees: Array<{ name: string; kind: string; filePath: string; startLine?: number }> = []; for (const match of matches) { const exactMatch = match.node.name === symbol || match.node.name.endsWith(`.${symbol}`) || match.node.name.endsWith(`::${symbol}`); if (!exactMatch && matches.length > 1) continue; for (const c of cg.getCallees(match.node.id)) { if (!seen.has(c.node.id)) { seen.add(c.node.id); allCallees.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine }); } } } if (allCallees.length === 0 && matches[0]) { for (const c of cg.getCallees(matches[0].node.id)) { if (!seen.has(c.node.id)) { seen.add(c.node.id); allCallees.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine }); } } } const limited = allCallees.slice(0, limit); if (options.json) { console.log(JSON.stringify({ symbol, callees: limited }, null, 2)); } else if (limited.length === 0) { info(`No callees found for "${symbol}"`); } else { console.log(chalk.bold(`\nCallees of "${symbol}" (${limited.length}):\n`)); for (const node of limited) { const loc = node.startLine ? `:${node.startLine}` : ''; console.log( chalk.cyan(node.kind.padEnd(12)) + chalk.white(node.name) ); console.log(chalk.dim(` ${node.filePath}${loc}`)); console.log(); } } cg.destroy(); } catch (err) { error(`callees failed: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); } }); /** * codegraph impact */ program .command('impact ') .description('Analyze what code is affected by changing a symbol') .option('-p, --path ', 'Project path') .option('-d, --depth ', 'Traversal depth', '2') .option('-j, --json', 'Output as JSON') .action(async (symbol: string, options: { path?: string; depth?: string; json?: boolean }) => { const projectPath = resolveProjectPath(options.path); try { if (!isInitialized(projectPath)) { error(`CodeGraph not initialized in ${projectPath}`); process.exit(1); } const { default: CodeGraph } = await loadCodeGraph(); const cg = await CodeGraph.open(projectPath); const depth = Math.min(Math.max(parseInt(options.depth || '2', 10), 1), 10); const matches = cg.searchNodes(symbol, { limit: 50 }); if (matches.length === 0) { info(`Symbol "${symbol}" not found`); cg.destroy(); return; } // Merge impact subgraphs across all exact-matching symbols const mergedNodes = new Map(); const seenEdges = new Set(); let edgeCount = 0; for (const match of matches) { const exactMatch = match.node.name === symbol || match.node.name.endsWith(`.${symbol}`) || match.node.name.endsWith(`::${symbol}`); if (!exactMatch && matches.length > 1) continue; const impact = cg.getImpactRadius(match.node.id, depth); for (const [id, n] of impact.nodes) { mergedNodes.set(id, { name: n.name, kind: n.kind, filePath: n.filePath, startLine: n.startLine }); } for (const e of impact.edges) { const key = `${e.source}->${e.target}:${e.kind}`; if (!seenEdges.has(key)) { seenEdges.add(key); edgeCount++; } } } // Fallback to top match if exact filter removed everything if (mergedNodes.size === 0 && matches[0]) { const impact = cg.getImpactRadius(matches[0].node.id, depth); for (const [id, n] of impact.nodes) { mergedNodes.set(id, { name: n.name, kind: n.kind, filePath: n.filePath, startLine: n.startLine }); } edgeCount = impact.edges.length; } if (options.json) { console.log(JSON.stringify({ symbol, depth, nodeCount: mergedNodes.size, edgeCount, affected: Array.from(mergedNodes.values()), }, null, 2)); } else if (mergedNodes.size === 0) { info(`No affected symbols found for "${symbol}"`); } else { console.log(chalk.bold(`\nImpact of changing "${symbol}" — ${mergedNodes.size} affected symbols:\n`)); // Group by file const byFile = new Map>(); for (const node of mergedNodes.values()) { const list = byFile.get(node.filePath) || []; list.push({ name: node.name, kind: node.kind, startLine: node.startLine }); byFile.set(node.filePath, list); } for (const [file, nodes] of byFile) { console.log(chalk.cyan(file)); for (const node of nodes) { const loc = node.startLine ? `:${node.startLine}` : ''; console.log(` ${chalk.dim(node.kind.padEnd(12))}${node.name}${chalk.dim(loc)}`); } console.log(); } } cg.destroy(); } catch (err) { error(`impact failed: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); } }); /** * codegraph affected [files...] * * Find test files affected by the given source files. * Traces dependency edges transitively to find test files that depend on changed code. * * Usage: * git diff --name-only | codegraph affected --stdin * codegraph affected src/lib/components/Editor.svelte src/routes/+page.svelte */ program .command('affected [files...]') .description('Find test files affected by changed source files') .option('-p, --path ', 'Project path') .option('--stdin', 'Read file list from stdin (one per line)') .option('-d, --depth ', 'Max dependency traversal depth', '5') .option('-f, --filter ', 'Custom glob filter for test files (e.g. "e2e/*.spec.ts")') .option('-j, --json', 'Output as JSON') .option('-q, --quiet', 'Only output file paths, no decoration') .action(async (fileArgs: string[], options: { path?: string; stdin?: boolean; depth?: string; filter?: string; json?: boolean; quiet?: boolean }) => { const projectPath = resolveProjectPath(options.path); try { if (!isInitialized(projectPath)) { error(`CodeGraph not initialized in ${projectPath}`); process.exit(1); } // Collect changed files from args or stdin let changedFiles: string[] = [...(fileArgs || [])]; if (options.stdin) { const stdinData = fs.readFileSync(0, 'utf-8'); const stdinFiles = stdinData.split('\n').map(f => f.trim()).filter(Boolean); changedFiles.push(...stdinFiles); } if (changedFiles.length === 0) { if (!options.quiet) info('No files provided. Use file arguments or --stdin.'); process.exit(0); } const { default: CodeGraph } = await loadCodeGraph(); const cg = await CodeGraph.open(projectPath); const maxDepth = parseInt(options.depth || '5', 10); // Common test file patterns const defaultTestPatterns = [ /\.spec\./, /\.test\./, /\/__tests__\//, /\/tests?\//, /\/e2e\//, /\/spec\//, ]; // Custom filter pattern let customFilter: RegExp | null = null; if (options.filter) { // Convert glob to regex: ** → .+, * → [^/]*, . → \. const regex = options.filter .replace(/[+[\]{}()^$|\\]/g, '\\$&') .replace(/\./g, '\\.') .replace(/\*\*/g, '.+') .replace(/\*/g, '[^/]*'); customFilter = new RegExp(regex); } function isTestFile(filePath: string): boolean { if (customFilter) return customFilter.test(filePath); return defaultTestPatterns.some(p => p.test(filePath)); } // BFS to find all transitive dependents of changed files, filtered to test files const affectedTests = new Set(); const allDependents = new Set(); for (const file of changedFiles) { // If the changed file is itself a test file, include it if (isTestFile(file)) { affectedTests.add(file); continue; } // BFS through dependents const queue: Array<{ file: string; depth: number }> = [{ file, depth: 0 }]; const visited = new Set(); visited.add(file); while (queue.length > 0) { const current = queue.shift()!; if (current.depth >= maxDepth) continue; const dependents = cg.getFileDependents(current.file); for (const dep of dependents) { if (visited.has(dep)) continue; visited.add(dep); allDependents.add(dep); if (isTestFile(dep)) { affectedTests.add(dep); } else { queue.push({ file: dep, depth: current.depth + 1 }); } } } } const sortedTests = Array.from(affectedTests).sort(); // Output if (options.json) { console.log(JSON.stringify({ changedFiles, affectedTests: sortedTests, totalDependentsTraversed: allDependents.size, }, null, 2)); } else if (options.quiet) { for (const t of sortedTests) console.log(t); } else { if (sortedTests.length === 0) { info('No test files affected by the changed files.'); } else { console.log(chalk.bold(`\nAffected test files (${sortedTests.length}):\n`)); for (const t of sortedTests) { console.log(' ' + chalk.cyan(t)); } console.log(); } } cg.destroy(); } catch (err) { error(`Affected analysis failed: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); } }); /** * codegraph install */ program .command('install') .description('Install codegraph MCP server into one or more agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent)') .option('-t, --target ', 'Target agent(s): comma-separated ids, or "auto"|"all"|"none". Default: prompt') .option('-l, --location ', 'Install location: "global" or "local". Default: prompt') .option('-y, --yes', 'Non-interactive: defaults to --location=global --target=auto, auto-allow on') .option('--no-permissions', 'Skip writing the auto-allow permissions list (Claude Code only)') .option('--print-config ', 'Print MCP config snippet for the named agent and exit (no file writes)') .action(async (opts: { target?: string; location?: string; yes?: boolean; permissions?: boolean; printConfig?: string; }) => { if (opts.printConfig) { const { getTarget, listTargetIds } = await import('../installer/targets/registry'); const target = getTarget(opts.printConfig); if (!target) { const known = listTargetIds().join(', '); error(`Unknown target "${opts.printConfig}". Known: ${known}.`); process.exit(1); } const loc = (opts.location === 'local' ? 'local' : 'global') as 'global' | 'local'; process.stdout.write(target.printConfig(loc)); return; } const { runInstallerWithOptions } = await import('../installer'); if (opts.location && opts.location !== 'global' && opts.location !== 'local') { error(`--location must be "global" or "local" (got "${opts.location}").`); process.exit(1); } try { // Commander's `--no-permissions` makes `opts.permissions === false`; // omitting the flag leaves it `true` (the positive-form default). // We MUST treat the default-true as "user did not override — let // the orchestrator prompt" and only forward an explicit `false` // (or `true` when --yes implies it). Otherwise the auto-allow // prompt is silently skipped on every interactive run. const explicitNoPermissions = opts.permissions === false; const autoAllow: boolean | undefined = explicitNoPermissions ? false : opts.yes ? true : undefined; await runInstallerWithOptions({ target: opts.target, location: opts.location as 'global' | 'local' | undefined, autoAllow, yes: opts.yes, }); } catch (err) { error(err instanceof Error ? err.message : String(err)); process.exit(1); } }); /** * codegraph uninstall * * Inverse of `install`. Removes the codegraph MCP server entry, * instructions block, and permissions from every agent (or a * `--target` subset). Prompts global-vs-local when not given. Does NOT * delete the `.codegraph/` index — that's `codegraph uninit`. */ program .command('uninstall') .description('Remove codegraph from your agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent)') .option('-t, --target ', 'Target agent(s): comma-separated ids, or "all". Default: all') .option('-l, --location ', 'Uninstall location: "global" or "local". Default: prompt') .option('-y, --yes', 'Non-interactive: defaults to --location=global --target=all') .action(async (opts: { target?: string; location?: string; yes?: boolean; }) => { const { runUninstaller } = await import('../installer'); if (opts.location && opts.location !== 'global' && opts.location !== 'local') { error(`--location must be "global" or "local" (got "${opts.location}").`); process.exit(1); } try { await runUninstaller({ target: opts.target, location: opts.location as 'global' | 'local' | undefined, yes: opts.yes, }); } catch (err) { error(err instanceof Error ? err.message : String(err)); process.exit(1); } }); // Parse and run program.parse(); } // end main()