| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392 |
- #!/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 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> Search for symbols
- * codegraph files [options] Show project file structure
- * codegraph context <task> Build context for a task
- * codegraph affected [files] Find test files affected by changes
- * codegraph mark-dirty [path] Mark project as needing sync (hooks)
- * codegraph sync-if-dirty [path] Sync if marked dirty (hooks)
- *
- * Note: Git hooks have been removed. CodeGraph sync is triggered automatically
- * through codegraph's Claude Code hooks integration.
- */
- import { Command } from 'commander';
- import * as path from 'path';
- import * as fs from 'fs';
- import { spawn } from 'child_process';
- import { getCodeGraphDir, findNearestCodeGraphRoot, isInitialized } from '../directory';
- import { createShimmerProgress } from '../ui/shimmer-progress';
- // Lazy-load heavy modules (CodeGraph, runInstaller) to keep CLI startup fast.
- async function loadCodeGraph(): Promise<typeof import('../index')> {
- try {
- return await import('../index');
- } catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- console.error('\x1b[31m✗\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<typeof import('@clack/prompts')>;
- // 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 ? ` — ${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('✓') + ' ' + message);
- }
- /**
- * Print error message
- */
- function error(message: string): void {
- console.error(chalk.red('✗') + ' ' + message);
- }
- /**
- * Print info message
- */
- function info(message: string): void {
- console.log(chalk.blue('ℹ') + ' ' + message);
- }
- /**
- * Print warning message
- */
- function warn(message: string): void {
- console.log(chalk.yellow('⚠') + ' ' + 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;
- 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 — all ${formatNumber(result.filesErrored)} files had errors`);
- } else {
- clack.log.warn('No files found to index');
- }
- if (hasErrors) {
- const errorsByCode = new Map<string, number>();
- 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<string, string> = {
- 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 — 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<string, Array<{ message: string; code?: string }>>();
- 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');
- clack.outro('');
- return;
- }
- const { default: CodeGraph } = await loadCodeGraph();
- const cg = await CodeGraph.init(projectPath, { index: false });
- clack.log.success(`Initialized in ${projectPath}`);
- if (options.index) {
- let result: IndexResult;
- if (options.verbose) {
- result = await cg.indexAll({
- onProgress: createVerboseProgress(),
- verbose: true,
- });
- } else {
- process.stdout.write(`${colors.dim}│${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');
- }
- 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<string>((resolve) => {
- rl.question(
- chalk.yellow('⚠ 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();
- 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}│${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}│${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(', ')} — ${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();
- // 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,
- 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`);
- 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 <search>
- */
- program
- .command('query <search>')
- .description('Search for symbols in the codebase')
- .option('-p, --path <path>', 'Project path')
- .option('-l, --limit <number>', 'Maximum results', '10')
- .option('-k, --kind <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 <path>', 'Project path')
- .option('--filter <dir>', 'Filter to files under this directory')
- .option('--pattern <glob>', 'Filter files matching this glob pattern')
- .option('--format <format>', 'Output format (tree, flat, grouped)', 'tree')
- .option('--max-depth <number>', '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<string, typeof files>();
- 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<string, TreeNode>;
- 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 connector = isLast ? '└── ' : '├── ';
- const childPrefix = isLast ? ' ' : '│ ';
- 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 <task>
- */
- program
- .command('context <task>')
- .description('Build context for a task (outputs markdown)')
- .option('-p, --path <path>', 'Project path')
- .option('-n, --max-nodes <number>', 'Maximum nodes to include', '50')
- .option('-c, --max-code <number>', 'Maximum code blocks', '10')
- .option('--no-code', 'Exclude code blocks')
- .option('-f, --format <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 <path>', 'Project path (optional for MCP mode, uses rootUri from client)')
- .option('--mcp', 'Run as MCP server (stdio transport)')
- .action(async (options: { path?: string; mcp?: boolean }) => {
- const projectPath = options.path ? resolveProjectPath(options.path) : undefined;
- 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('ℹ') + ' 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 visualize [path]
- */
- program
- .command('visualize [path]')
- .description('Open interactive graph visualization in your browser')
- .option('-p, --port <port>', 'Port to listen on (default: auto)', parseInt)
- .option('--no-open', 'Do not open browser automatically')
- .action(async (pathArg: string | undefined, options: { port?: number; open?: boolean }) => {
- const projectPath = resolveProjectPath(pathArg);
- try {
- if (!isInitialized(projectPath)) {
- error(`CodeGraph not initialized in ${projectPath}`);
- info('Run "codegraph init -i" first');
- process.exit(1);
- }
- const { default: CodeGraph } = await loadCodeGraph();
- const cg = await CodeGraph.open(projectPath);
- const stats = cg.getStats();
- console.log(chalk.bold('\n CodeGraph Explorer\n'));
- info(`Project: ${projectPath}`);
- info(`Indexed: ${formatNumber(stats.nodeCount)} nodes, ${formatNumber(stats.edgeCount)} edges, ${formatNumber(stats.fileCount)} files\n`);
- const { VisualizerServer } = await import('../visualizer/server');
- const server = new VisualizerServer(cg);
- const { url } = await server.start({ port: options.port, openBrowser: options.open !== false });
- success(`Visualizer running at ${chalk.cyan(url)}`);
- console.log(chalk.dim(' Press Ctrl+C to stop\n'));
- // Open browser
- if (options.open !== false) {
- const openCmd = process.platform === 'darwin' ? 'open' :
- process.platform === 'win32' ? 'start' : 'xdg-open';
- spawn(openCmd, [url], { detached: true, stdio: 'ignore' }).unref();
- }
- // Handle shutdown — force exit on second Ctrl+C
- let shuttingDown = false;
- const shutdown = () => {
- if (shuttingDown) {
- process.exit(1);
- }
- shuttingDown = true;
- console.log(chalk.dim('\n Shutting down...'));
- server.stop().then(() => {
- cg.close();
- process.exit(0);
- }).catch(() => process.exit(1));
- // Force exit after 2s if graceful shutdown hangs
- setTimeout(() => process.exit(1), 2000).unref();
- };
- process.on('SIGINT', shutdown);
- process.on('SIGTERM', shutdown);
- } catch (err) {
- error(`Failed to start visualizer: ${err instanceof Error ? err.message : String(err)}`);
- process.exit(1);
- }
- });
- /**
- * codegraph mark-dirty [path]
- *
- * Touches .codegraph/.dirty to signal that files have changed.
- * Used by Claude Code PostToolUse hooks to batch syncs.
- * Runs silently and always exits 0.
- */
- program
- .command('mark-dirty [path]')
- .description('Mark project as needing sync (used by Claude Code hooks)')
- .action(async (pathArg: string | undefined) => {
- try {
- const startPath = path.resolve(pathArg || process.cwd());
- const projectRoot = findNearestCodeGraphRoot(startPath);
- if (!projectRoot) {
- // No .codegraph/ found — exit silently
- process.exit(0);
- }
- const dirtyPath = path.join(getCodeGraphDir(projectRoot), '.dirty');
- fs.writeFileSync(dirtyPath, Date.now().toString(), 'utf-8');
- } catch {
- // Never fail — this runs in the background during edits
- }
- process.exit(0);
- });
- /**
- * codegraph sync-if-dirty [path]
- *
- * Checks if .codegraph/.dirty exists and, if so, spawns a detached
- * background process to run `codegraph sync`. The hook process exits
- * immediately so Claude Code's Stop hook never blocks.
- *
- * Removes the marker BEFORE spawning so edits during sync
- * create a new marker for the next Stop event.
- * Runs silently and always exits 0.
- */
- program
- .command('sync-if-dirty [path]')
- .description('Sync if project was marked dirty (used by Claude Code hooks)')
- .action(async (pathArg: string | undefined) => {
- try {
- const startPath = path.resolve(pathArg || process.cwd());
- const projectRoot = findNearestCodeGraphRoot(startPath);
- if (!projectRoot) {
- process.exit(0);
- }
- const dirtyPath = path.join(getCodeGraphDir(projectRoot!), '.dirty');
- // No marker → nothing to do (sub-ms exit)
- if (!fs.existsSync(dirtyPath)) {
- process.exit(0);
- }
- // Remove marker FIRST so edits during sync create a new one
- try { fs.unlinkSync(dirtyPath); } catch { /* ignore */ }
- // If not fully initialized (no DB), exit
- if (!isInitialized(projectRoot!)) {
- process.exit(0);
- }
- // Spawn sync as a detached background process
- // so this hook exits immediately and doesn't block Claude Code.
- // Uses process.argv[0]/[1] (e.g. node /path/to/codegraph.js) so it
- // works whether invoked via global install, npx, or directly.
- const child = spawn(
- process.argv[0]!,
- [process.argv[1]!, 'sync', '--quiet', projectRoot!],
- {
- detached: true,
- stdio: 'ignore',
- windowsHide: true,
- }
- );
- child.unref();
- } catch {
- // Never fail — this runs at the end of Claude responses
- }
- process.exit(0);
- });
- /**
- * 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 — 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 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 <path>', 'Project path')
- .option('--stdin', 'Read file list from stdin (one per line)')
- .option('-d, --depth <number>', 'Max dependency traversal depth', '5')
- .option('-f, --filter <glob>', '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<string>();
- const allDependents = new Set<string>();
- 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<string>();
- 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('Run interactive installer for Claude Code integration')
- .action(async () => {
- const { runInstaller } = await import('../installer');
- await runInstaller();
- });
- // Parse and run
- program.parse();
- } // end main()
|