codegraph.ts 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474
  1. #!/usr/bin/env node
  2. /**
  3. * CodeGraph CLI
  4. *
  5. * Command-line interface for CodeGraph code intelligence.
  6. *
  7. * Usage:
  8. * codegraph Run interactive installer (when no args)
  9. * codegraph install Run interactive installer
  10. * codegraph init [path] Initialize CodeGraph in a project
  11. * codegraph uninit [path] Remove CodeGraph from a project
  12. * codegraph index [path] Index all files in the project
  13. * codegraph sync [path] Sync changes since last index
  14. * codegraph status [path] Show index status
  15. * codegraph query <search> Search for symbols
  16. * codegraph files [options] Show project file structure
  17. * codegraph context <task> Build context for a task
  18. * codegraph affected [files] Find test files affected by changes
  19. * codegraph mark-dirty [path] Mark project as needing sync (hooks)
  20. * codegraph sync-if-dirty [path] Sync if marked dirty (hooks)
  21. *
  22. * Note: Git hooks have been removed. CodeGraph sync is triggered automatically
  23. * through codegraph's Claude Code hooks integration.
  24. */
  25. import { Command } from 'commander';
  26. import * as path from 'path';
  27. import * as fs from 'fs';
  28. import { spawn } from 'child_process';
  29. import { getCodeGraphDir, findNearestCodeGraphRoot, isInitialized } from '../directory';
  30. // Lazy-load heavy modules (CodeGraph, runInstaller) to keep CLI startup fast.
  31. async function loadCodeGraph(): Promise<typeof import('../index')> {
  32. try {
  33. return await import('../index');
  34. } catch (err) {
  35. const msg = err instanceof Error ? err.message : String(err);
  36. console.error('\x1b[31m✗\x1b[0m Failed to load CodeGraph modules.');
  37. console.error(`\n Node: ${process.version} Platform: ${process.platform} ${process.arch}`);
  38. console.error(`\n Error: ${msg}`);
  39. console.error('\n Try reinstalling with: npm install -g @colbymchenry/codegraph\n');
  40. process.exit(1);
  41. }
  42. }
  43. type IndexProgress = import('../index').IndexProgress;
  44. // Dynamic import helper — tsc compiles import() to require() in CJS mode,
  45. // which fails for ESM-only packages. This bypasses the transformation.
  46. // eslint-disable-next-line @typescript-eslint/no-implied-eval
  47. const importESM = new Function('specifier', 'return import(specifier)') as
  48. (specifier: string) => Promise<typeof import('@clack/prompts')>;
  49. // Check if running with no arguments - run installer
  50. if (process.argv.length === 2) {
  51. import('../installer').then(({ runInstaller }) =>
  52. runInstaller()
  53. ).catch((err) => {
  54. console.error('Installation failed:', err instanceof Error ? err.message : String(err));
  55. process.exit(1);
  56. });
  57. } else {
  58. // Normal CLI flow
  59. main();
  60. }
  61. process.on('uncaughtException', (error) => {
  62. console.error('[CodeGraph] Uncaught exception:', error);
  63. });
  64. process.on('unhandledRejection', (reason) => {
  65. console.error('[CodeGraph] Unhandled rejection:', reason);
  66. });
  67. function main() {
  68. const program = new Command();
  69. // Version from package.json
  70. const packageJson = JSON.parse(
  71. fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf-8')
  72. );
  73. // =============================================================================
  74. // ANSI Color Helpers (avoid chalk ESM issues)
  75. // =============================================================================
  76. const colors = {
  77. reset: '\x1b[0m',
  78. bold: '\x1b[1m',
  79. dim: '\x1b[2m',
  80. red: '\x1b[31m',
  81. green: '\x1b[32m',
  82. yellow: '\x1b[33m',
  83. blue: '\x1b[34m',
  84. cyan: '\x1b[36m',
  85. white: '\x1b[37m',
  86. gray: '\x1b[90m',
  87. };
  88. const chalk = {
  89. bold: (s: string) => `${colors.bold}${s}${colors.reset}`,
  90. dim: (s: string) => `${colors.dim}${s}${colors.reset}`,
  91. red: (s: string) => `${colors.red}${s}${colors.reset}`,
  92. green: (s: string) => `${colors.green}${s}${colors.reset}`,
  93. yellow: (s: string) => `${colors.yellow}${s}${colors.reset}`,
  94. blue: (s: string) => `${colors.blue}${s}${colors.reset}`,
  95. cyan: (s: string) => `${colors.cyan}${s}${colors.reset}`,
  96. white: (s: string) => `${colors.white}${s}${colors.reset}`,
  97. gray: (s: string) => `${colors.gray}${s}${colors.reset}`,
  98. };
  99. program
  100. .name('codegraph')
  101. .description('Code intelligence and knowledge graph for any codebase')
  102. .version(packageJson.version);
  103. // =============================================================================
  104. // Helper Functions
  105. // =============================================================================
  106. /**
  107. * Resolve project path from argument or current directory
  108. * Walks up parent directories to find nearest initialized CodeGraph project
  109. * (must have .codegraph/codegraph.db, not just .codegraph/lessons.db)
  110. */
  111. function resolveProjectPath(pathArg?: string): string {
  112. const absolutePath = path.resolve(pathArg || process.cwd());
  113. // If exact path is initialized (has codegraph.db), use it
  114. if (isInitialized(absolutePath)) {
  115. return absolutePath;
  116. }
  117. // Walk up to find nearest parent with CodeGraph initialized
  118. // Note: findNearestCodeGraphRoot finds any .codegraph folder, but we need one with codegraph.db
  119. let current = absolutePath;
  120. const root = path.parse(current).root;
  121. while (current !== root) {
  122. const parent = path.dirname(current);
  123. if (parent === current) break;
  124. current = parent;
  125. if (isInitialized(current)) {
  126. return current;
  127. }
  128. }
  129. // Not found - return original path (will fail later with helpful error)
  130. return absolutePath;
  131. }
  132. /**
  133. * Format a number with commas
  134. */
  135. function formatNumber(n: number): string {
  136. return n.toLocaleString();
  137. }
  138. /**
  139. * Format duration in milliseconds to human readable
  140. */
  141. function formatDuration(ms: number): string {
  142. if (ms < 1000) {
  143. return `${ms}ms`;
  144. }
  145. const seconds = ms / 1000;
  146. if (seconds < 60) {
  147. return `${seconds.toFixed(1)}s`;
  148. }
  149. const minutes = Math.floor(seconds / 60);
  150. const remainingSeconds = seconds % 60;
  151. return `${minutes}m ${remainingSeconds.toFixed(0)}s`;
  152. }
  153. // =============================================================================
  154. // Shimmer Progress Renderer (devpit-style animation)
  155. // =============================================================================
  156. const SPINNER_GLYPHS = ['·', '✢', '✳', '✶', '✻', '✽'];
  157. const ANIM_INTERVAL = 150;
  158. const FRAMES_PER_GLYPH = 3;
  159. function lerp(a: number, b: number, t: number): number {
  160. return Math.round(a + (b - a) * t);
  161. }
  162. function shimmerColor(frame: number): string {
  163. const t = (Math.sin(frame * 2 * Math.PI / 13) + 1) / 2;
  164. const r = lerp(160, 251, t);
  165. const g = lerp(100, 191, t);
  166. const b = lerp(9, 36, t);
  167. return `\x1b[38;2;${r};${g};${b}m\x1b[1m`;
  168. }
  169. const PHASE_NAMES: Record<string, string> = {
  170. scanning: 'Scanning files',
  171. parsing: 'Parsing code',
  172. storing: 'Storing data',
  173. resolving: 'Resolving refs',
  174. };
  175. interface ShimmerProgress {
  176. onProgress: (progress: IndexProgress) => void;
  177. stop: () => void;
  178. }
  179. function createShimmerProgress(): ShimmerProgress {
  180. const startTime = Date.now();
  181. let interval: ReturnType<typeof setInterval> | null = null;
  182. let currentMessage = '';
  183. let currentPercent = -1;
  184. let currentCount = 0;
  185. let lastPhase = '';
  186. function animFrame(): number {
  187. return Math.floor((Date.now() - startTime) / ANIM_INTERVAL);
  188. }
  189. function spinnerGlyph(): string {
  190. const idx = Math.floor(animFrame() / FRAMES_PER_GLYPH) % SPINNER_GLYPHS.length;
  191. return SPINNER_GLYPHS[idx] ?? '·';
  192. }
  193. function renderBar(filled: number, empty: number): string {
  194. if (filled === 0) return `${colors.dim}${'░'.repeat(empty)}${colors.reset}`;
  195. // Shimmer sweeps left-to-right across the filled portion
  196. const cycleFrames = 24;
  197. const shimmerPos = ((animFrame() % cycleFrames) / cycleFrames) * (filled + 6) - 3;
  198. const shimmerWidth = 3;
  199. let bar = '';
  200. for (let i = 0; i < filled; i++) {
  201. const dist = Math.abs(i - shimmerPos);
  202. const t = Math.max(0, 1 - dist / shimmerWidth);
  203. const r = lerp(160, 251, t);
  204. const g = lerp(100, 191, t);
  205. const b = lerp(9, 36, t);
  206. bar += `\x1b[38;2;${r};${g};${b}m\x1b[1m█`;
  207. }
  208. bar += `${colors.reset}${colors.dim}${'░'.repeat(empty)}${colors.reset}`;
  209. return bar;
  210. }
  211. function render() {
  212. const glyph = spinnerGlyph();
  213. const frame = animFrame();
  214. const color = shimmerColor(frame);
  215. const rst = colors.reset;
  216. const dm = colors.dim;
  217. let line: string;
  218. if (currentPercent >= 0) {
  219. const barWidth = 25;
  220. const filled = Math.round(barWidth * currentPercent / 100);
  221. const empty = barWidth - filled;
  222. line = `${dm}│${rst} ${color}${glyph}${rst} ${currentMessage} ${renderBar(filled, empty)} ${currentPercent}%`;
  223. } else if (currentCount > 0) {
  224. line = `${dm}│${rst} ${color}${glyph}${rst} ${currentMessage}... ${formatNumber(currentCount)} found`;
  225. } else {
  226. line = `${dm}│${rst} ${color}${glyph}${rst} ${currentMessage}...`;
  227. }
  228. process.stdout.write(`\r\x1b[K${line}`);
  229. }
  230. function finishPhase() {
  231. if (!currentMessage) return;
  232. process.stdout.write(`\r\x1b[K`);
  233. let detail = '';
  234. if (currentPercent >= 0) detail = ' — done';
  235. else if (currentCount > 0) detail = ` — ${formatNumber(currentCount)} found`;
  236. process.stdout.write(`${colors.dim}│${colors.reset} ${colors.green}◆${colors.reset} ${currentMessage}${detail}\n`);
  237. }
  238. // Start animation loop
  239. interval = setInterval(render, ANIM_INTERVAL);
  240. return {
  241. onProgress(progress: IndexProgress) {
  242. const phaseName = PHASE_NAMES[progress.phase] || progress.phase;
  243. if (progress.phase !== lastPhase && lastPhase) {
  244. finishPhase();
  245. }
  246. lastPhase = progress.phase;
  247. currentMessage = phaseName;
  248. if (progress.total > 0) {
  249. currentPercent = Math.round((progress.current / progress.total) * 100);
  250. currentCount = 0;
  251. } else if (progress.current > 0) {
  252. currentPercent = -1;
  253. currentCount = progress.current;
  254. } else {
  255. currentPercent = -1;
  256. currentCount = 0;
  257. }
  258. // Render immediately — scanning is synchronous so setInterval can't fire
  259. render();
  260. },
  261. stop() {
  262. if (interval) {
  263. clearInterval(interval);
  264. interval = null;
  265. }
  266. finishPhase();
  267. },
  268. };
  269. }
  270. /**
  271. * Print success message
  272. */
  273. function success(message: string): void {
  274. console.log(chalk.green('✓') + ' ' + message);
  275. }
  276. /**
  277. * Print error message
  278. */
  279. function error(message: string): void {
  280. console.error(chalk.red('✗') + ' ' + message);
  281. }
  282. /**
  283. * Print info message
  284. */
  285. function info(message: string): void {
  286. console.log(chalk.blue('ℹ') + ' ' + message);
  287. }
  288. /**
  289. * Print warning message
  290. */
  291. function warn(message: string): void {
  292. console.log(chalk.yellow('⚠') + ' ' + message);
  293. }
  294. type IndexResult = {
  295. success: boolean;
  296. filesIndexed: number;
  297. filesSkipped: number;
  298. filesErrored: number;
  299. nodesCreated: number;
  300. edgesCreated: number;
  301. errors: Array<{ message: string; filePath?: string; severity: string; code?: string }>;
  302. durationMs: number;
  303. };
  304. /**
  305. * Print indexing results using clack log methods
  306. */
  307. function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexResult, projectPath?: string): void {
  308. const hasErrors = result.filesErrored > 0;
  309. if (result.filesIndexed > 0) {
  310. if (hasErrors) {
  311. clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files (${formatNumber(result.filesErrored)} could not be parsed)`);
  312. } else {
  313. clack.log.success(`Indexed ${formatNumber(result.filesIndexed)} files`);
  314. }
  315. clack.log.info(`${formatNumber(result.nodesCreated)} nodes, ${formatNumber(result.edgesCreated)} edges in ${formatDuration(result.durationMs)}`);
  316. } else if (hasErrors) {
  317. clack.log.error(`Indexing failed — all ${formatNumber(result.filesErrored)} files had errors`);
  318. } else {
  319. clack.log.warn('No files found to index');
  320. }
  321. if (hasErrors) {
  322. const errorsByCode = new Map<string, number>();
  323. for (const err of result.errors) {
  324. if (err.severity === 'error') {
  325. const code = err.code || 'unknown';
  326. errorsByCode.set(code, (errorsByCode.get(code) || 0) + 1);
  327. }
  328. }
  329. const codeLabels: Record<string, string> = {
  330. parse_error: 'files failed to parse',
  331. read_error: 'files could not be read',
  332. size_exceeded: 'files exceeded size limit',
  333. path_traversal: 'blocked paths',
  334. unsupported_language: 'unsupported language',
  335. parser_error: 'parser initialization failures',
  336. };
  337. const breakdown = Array.from(errorsByCode)
  338. .map(([code, count]) => `${formatNumber(count)} ${codeLabels[code] || code}`)
  339. .join('\n');
  340. clack.note(breakdown, 'Error breakdown');
  341. if (projectPath) {
  342. writeErrorLog(projectPath, result.errors);
  343. clack.log.info('See .codegraph/errors.log for details');
  344. }
  345. if (result.filesIndexed > 0) {
  346. clack.log.info('The index is fully usable — only the failed files are missing.');
  347. }
  348. } else if (projectPath) {
  349. const logPath = path.join(projectPath, '.codegraph', 'errors.log');
  350. if (fs.existsSync(logPath)) {
  351. fs.unlinkSync(logPath);
  352. }
  353. }
  354. }
  355. /**
  356. * Write detailed error log to .codegraph/errors.log
  357. */
  358. function writeErrorLog(projectPath: string, errors: Array<{ message: string; filePath?: string; severity: string; code?: string }>): void {
  359. const cgDir = path.join(projectPath, '.codegraph');
  360. if (!fs.existsSync(cgDir)) return;
  361. const logPath = path.join(cgDir, 'errors.log');
  362. // Group errors by file path
  363. const errorsByFile = new Map<string, Array<{ message: string; code?: string }>>();
  364. const noFileErrors: Array<{ message: string; code?: string }> = [];
  365. for (const err of errors) {
  366. if (err.severity !== 'error') continue;
  367. if (err.filePath) {
  368. let list = errorsByFile.get(err.filePath);
  369. if (!list) {
  370. list = [];
  371. errorsByFile.set(err.filePath, list);
  372. }
  373. list.push({ message: err.message, code: err.code });
  374. } else {
  375. noFileErrors.push({ message: err.message, code: err.code });
  376. }
  377. }
  378. const lines: string[] = [
  379. `CodeGraph Error Log — ${new Date().toISOString()}`,
  380. `${errorsByFile.size} files with errors`,
  381. '',
  382. ];
  383. for (const [filePath, fileErrors] of errorsByFile) {
  384. for (const err of fileErrors) {
  385. lines.push(`${filePath}: ${err.message}`);
  386. }
  387. }
  388. for (const err of noFileErrors) {
  389. lines.push(err.message);
  390. }
  391. fs.writeFileSync(logPath, lines.join('\n') + '\n');
  392. }
  393. // =============================================================================
  394. // Commands
  395. // =============================================================================
  396. /**
  397. * codegraph init [path]
  398. */
  399. program
  400. .command('init [path]')
  401. .description('Initialize CodeGraph in a project directory')
  402. .option('-i, --index', 'Run initial indexing after initialization')
  403. .action(async (pathArg: string | undefined, options: { index?: boolean }) => {
  404. const projectPath = path.resolve(pathArg || process.cwd());
  405. const clack = await importESM('@clack/prompts');
  406. clack.intro('Initializing CodeGraph');
  407. try {
  408. if (isInitialized(projectPath)) {
  409. clack.log.warn(`Already initialized in ${projectPath}`);
  410. clack.log.info('Use "codegraph index" to re-index or "codegraph sync" to update');
  411. clack.outro('');
  412. return;
  413. }
  414. const { default: CodeGraph } = await loadCodeGraph();
  415. const cg = await CodeGraph.init(projectPath, { index: false });
  416. clack.log.success(`Initialized in ${projectPath}`);
  417. if (options.index) {
  418. process.stdout.write(`${colors.dim}│${colors.reset}\n`);
  419. const progress = createShimmerProgress();
  420. const result = await cg.indexAll({
  421. onProgress: progress.onProgress,
  422. });
  423. progress.stop();
  424. printIndexResult(clack, result, projectPath);
  425. } else {
  426. clack.log.info('Run "codegraph index" to index the project');
  427. }
  428. clack.outro('Done');
  429. cg.destroy();
  430. } catch (err) {
  431. clack.log.error(`Failed: ${err instanceof Error ? err.message : String(err)}`);
  432. process.exit(1);
  433. }
  434. });
  435. /**
  436. * codegraph uninit [path]
  437. */
  438. program
  439. .command('uninit [path]')
  440. .description('Remove CodeGraph from a project (deletes .codegraph/ directory)')
  441. .option('-f, --force', 'Skip confirmation prompt')
  442. .action(async (pathArg: string | undefined, options: { force?: boolean }) => {
  443. const projectPath = resolveProjectPath(pathArg);
  444. try {
  445. if (!isInitialized(projectPath)) {
  446. warn(`CodeGraph is not initialized in ${projectPath}`);
  447. return;
  448. }
  449. if (!options.force) {
  450. // Confirm with user
  451. const readline = await import('readline');
  452. const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
  453. const answer = await new Promise<string>((resolve) => {
  454. rl.question(
  455. chalk.yellow('⚠ This will permanently delete all CodeGraph data. Continue? (y/N) '),
  456. resolve
  457. );
  458. });
  459. rl.close();
  460. if (answer.toLowerCase() !== 'y') {
  461. info('Cancelled');
  462. return;
  463. }
  464. }
  465. const { default: CodeGraph } = await loadCodeGraph();
  466. const cg = CodeGraph.openSync(projectPath);
  467. cg.uninitialize();
  468. success(`Removed CodeGraph from ${projectPath}`);
  469. } catch (err) {
  470. error(`Failed to uninitialize: ${err instanceof Error ? err.message : String(err)}`);
  471. process.exit(1);
  472. }
  473. });
  474. /**
  475. * codegraph index [path]
  476. */
  477. program
  478. .command('index [path]')
  479. .description('Index all files in the project')
  480. .option('-f, --force', 'Force full re-index even if already indexed')
  481. .option('-q, --quiet', 'Suppress progress output')
  482. .action(async (pathArg: string | undefined, options: { force?: boolean; quiet?: boolean }) => {
  483. const projectPath = resolveProjectPath(pathArg);
  484. try {
  485. if (!isInitialized(projectPath)) {
  486. error(`CodeGraph not initialized in ${projectPath}`);
  487. info('Run "codegraph init" first');
  488. process.exit(1);
  489. }
  490. const { default: CodeGraph } = await loadCodeGraph();
  491. const cg = await CodeGraph.open(projectPath);
  492. if (options.quiet) {
  493. // Quiet mode: no UI, just run
  494. if (options.force) cg.clear();
  495. const result = await cg.indexAll();
  496. if (!result.success) process.exit(1);
  497. cg.destroy();
  498. return;
  499. }
  500. const clack = await importESM('@clack/prompts');
  501. clack.intro('Indexing project');
  502. if (options.force) {
  503. cg.clear();
  504. clack.log.info('Cleared existing index');
  505. }
  506. process.stdout.write(`${colors.dim}│${colors.reset}\n`);
  507. const progress = createShimmerProgress();
  508. const result = await cg.indexAll({
  509. onProgress: progress.onProgress,
  510. });
  511. progress.stop();
  512. printIndexResult(clack, result, projectPath);
  513. if (!result.success) {
  514. process.exit(1);
  515. }
  516. clack.outro('Done');
  517. cg.destroy();
  518. } catch (err) {
  519. error(`Failed to index: ${err instanceof Error ? err.message : String(err)}`);
  520. process.exit(1);
  521. }
  522. });
  523. /**
  524. * codegraph sync [path]
  525. */
  526. program
  527. .command('sync [path]')
  528. .description('Sync changes since last index')
  529. .option('-q, --quiet', 'Suppress output (for git hooks)')
  530. .action(async (pathArg: string | undefined, options: { quiet?: boolean }) => {
  531. const projectPath = resolveProjectPath(pathArg);
  532. try {
  533. if (!isInitialized(projectPath)) {
  534. if (!options.quiet) {
  535. error(`CodeGraph not initialized in ${projectPath}`);
  536. }
  537. process.exit(1);
  538. }
  539. const { default: CodeGraph } = await loadCodeGraph();
  540. const cg = await CodeGraph.open(projectPath);
  541. if (options.quiet) {
  542. await cg.sync();
  543. cg.destroy();
  544. return;
  545. }
  546. const clack = await importESM('@clack/prompts');
  547. clack.intro('Syncing CodeGraph');
  548. process.stdout.write(`${colors.dim}│${colors.reset}\n`);
  549. const progress = createShimmerProgress();
  550. const result = await cg.sync({
  551. onProgress: progress.onProgress,
  552. });
  553. progress.stop();
  554. const totalChanges = result.filesAdded + result.filesModified + result.filesRemoved;
  555. if (totalChanges === 0) {
  556. clack.log.info('Already up to date');
  557. } else {
  558. clack.log.success(`Synced ${formatNumber(totalChanges)} changed files`);
  559. const details: string[] = [];
  560. if (result.filesAdded > 0) details.push(`Added: ${result.filesAdded}`);
  561. if (result.filesModified > 0) details.push(`Modified: ${result.filesModified}`);
  562. if (result.filesRemoved > 0) details.push(`Removed: ${result.filesRemoved}`);
  563. clack.log.info(`${details.join(', ')} — ${formatNumber(result.nodesUpdated)} nodes in ${formatDuration(result.durationMs)}`);
  564. }
  565. clack.outro('Done');
  566. cg.destroy();
  567. } catch (err) {
  568. if (!options.quiet) {
  569. error(`Failed to sync: ${err instanceof Error ? err.message : String(err)}`);
  570. }
  571. process.exit(1);
  572. }
  573. });
  574. /**
  575. * codegraph status [path]
  576. */
  577. program
  578. .command('status [path]')
  579. .description('Show index status and statistics')
  580. .option('-j, --json', 'Output as JSON')
  581. .action(async (pathArg: string | undefined, options: { json?: boolean }) => {
  582. const projectPath = resolveProjectPath(pathArg);
  583. try {
  584. if (!isInitialized(projectPath)) {
  585. if (options.json) {
  586. console.log(JSON.stringify({ initialized: false, projectPath }));
  587. return;
  588. }
  589. console.log(chalk.bold('\nCodeGraph Status\n'));
  590. info(`Project: ${projectPath}`);
  591. warn('Not initialized');
  592. info('Run "codegraph init" to initialize');
  593. return;
  594. }
  595. const { default: CodeGraph } = await loadCodeGraph();
  596. const cg = await CodeGraph.open(projectPath);
  597. const stats = cg.getStats();
  598. const changes = cg.getChangedFiles();
  599. // JSON output mode
  600. if (options.json) {
  601. console.log(JSON.stringify({
  602. initialized: true,
  603. projectPath,
  604. fileCount: stats.fileCount,
  605. nodeCount: stats.nodeCount,
  606. edgeCount: stats.edgeCount,
  607. dbSizeBytes: stats.dbSizeBytes,
  608. nodesByKind: stats.nodesByKind,
  609. languages: Object.entries(stats.filesByLanguage).filter(([, count]) => count > 0).map(([lang]) => lang),
  610. pendingChanges: {
  611. added: changes.added.length,
  612. modified: changes.modified.length,
  613. removed: changes.removed.length,
  614. },
  615. }));
  616. cg.destroy();
  617. return;
  618. }
  619. console.log(chalk.bold('\nCodeGraph Status\n'));
  620. // Project info
  621. console.log(chalk.cyan('Project:'), projectPath);
  622. console.log();
  623. // Index stats
  624. console.log(chalk.bold('Index Statistics:'));
  625. console.log(` Files: ${formatNumber(stats.fileCount)}`);
  626. console.log(` Nodes: ${formatNumber(stats.nodeCount)}`);
  627. console.log(` Edges: ${formatNumber(stats.edgeCount)}`);
  628. console.log(` DB Size: ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`);
  629. console.log();
  630. // Node breakdown
  631. console.log(chalk.bold('Nodes by Kind:'));
  632. const nodesByKind = Object.entries(stats.nodesByKind)
  633. .filter(([, count]) => count > 0)
  634. .sort((a, b) => b[1] - a[1]);
  635. for (const [kind, count] of nodesByKind) {
  636. console.log(` ${kind.padEnd(15)} ${formatNumber(count)}`);
  637. }
  638. console.log();
  639. // Language breakdown
  640. console.log(chalk.bold('Files by Language:'));
  641. const filesByLang = Object.entries(stats.filesByLanguage)
  642. .filter(([, count]) => count > 0)
  643. .sort((a, b) => b[1] - a[1]);
  644. for (const [lang, count] of filesByLang) {
  645. console.log(` ${lang.padEnd(15)} ${formatNumber(count)}`);
  646. }
  647. console.log();
  648. // Pending changes
  649. const totalChanges = changes.added.length + changes.modified.length + changes.removed.length;
  650. if (totalChanges > 0) {
  651. console.log(chalk.bold('Pending Changes:'));
  652. if (changes.added.length > 0) {
  653. console.log(` Added: ${changes.added.length} files`);
  654. }
  655. if (changes.modified.length > 0) {
  656. console.log(` Modified: ${changes.modified.length} files`);
  657. }
  658. if (changes.removed.length > 0) {
  659. console.log(` Removed: ${changes.removed.length} files`);
  660. }
  661. info('Run "codegraph sync" to update the index');
  662. } else {
  663. success('Index is up to date');
  664. }
  665. console.log();
  666. cg.destroy();
  667. } catch (err) {
  668. error(`Failed to get status: ${err instanceof Error ? err.message : String(err)}`);
  669. process.exit(1);
  670. }
  671. });
  672. /**
  673. * codegraph query <search>
  674. */
  675. program
  676. .command('query <search>')
  677. .description('Search for symbols in the codebase')
  678. .option('-p, --path <path>', 'Project path')
  679. .option('-l, --limit <number>', 'Maximum results', '10')
  680. .option('-k, --kind <kind>', 'Filter by node kind (function, class, etc.)')
  681. .option('-j, --json', 'Output as JSON')
  682. .action(async (search: string, options: { path?: string; limit?: string; kind?: string; json?: boolean }) => {
  683. const projectPath = resolveProjectPath(options.path);
  684. try {
  685. if (!isInitialized(projectPath)) {
  686. error(`CodeGraph not initialized in ${projectPath}`);
  687. process.exit(1);
  688. }
  689. const { default: CodeGraph } = await loadCodeGraph();
  690. const cg = await CodeGraph.open(projectPath);
  691. const limit = parseInt(options.limit || '10', 10);
  692. const results = cg.searchNodes(search, {
  693. limit,
  694. kinds: options.kind ? [options.kind as any] : undefined,
  695. });
  696. if (options.json) {
  697. console.log(JSON.stringify(results, null, 2));
  698. } else {
  699. if (results.length === 0) {
  700. info(`No results found for "${search}"`);
  701. } else {
  702. console.log(chalk.bold(`\nSearch Results for "${search}":\n`));
  703. for (const result of results) {
  704. const node = result.node;
  705. const location = `${node.filePath}:${node.startLine}`;
  706. const score = chalk.dim(`(${(result.score * 100).toFixed(0)}%)`);
  707. console.log(
  708. chalk.cyan(node.kind.padEnd(12)) +
  709. chalk.white(node.name) +
  710. ' ' + score
  711. );
  712. console.log(chalk.dim(` ${location}`));
  713. if (node.signature) {
  714. console.log(chalk.dim(` ${node.signature}`));
  715. }
  716. console.log();
  717. }
  718. }
  719. }
  720. cg.destroy();
  721. } catch (err) {
  722. error(`Search failed: ${err instanceof Error ? err.message : String(err)}`);
  723. process.exit(1);
  724. }
  725. });
  726. /**
  727. * codegraph files [path]
  728. */
  729. program
  730. .command('files')
  731. .description('Show project file structure from the index')
  732. .option('-p, --path <path>', 'Project path')
  733. .option('--filter <dir>', 'Filter to files under this directory')
  734. .option('--pattern <glob>', 'Filter files matching this glob pattern')
  735. .option('--format <format>', 'Output format (tree, flat, grouped)', 'tree')
  736. .option('--max-depth <number>', 'Maximum directory depth for tree format')
  737. .option('--no-metadata', 'Hide file metadata (language, symbol count)')
  738. .option('-j, --json', 'Output as JSON')
  739. .action(async (options: {
  740. path?: string;
  741. filter?: string;
  742. pattern?: string;
  743. format?: string;
  744. maxDepth?: string;
  745. metadata?: boolean;
  746. json?: boolean;
  747. }) => {
  748. const projectPath = resolveProjectPath(options.path);
  749. try {
  750. if (!isInitialized(projectPath)) {
  751. error(`CodeGraph not initialized in ${projectPath}`);
  752. process.exit(1);
  753. }
  754. const { default: CodeGraph } = await loadCodeGraph();
  755. const cg = await CodeGraph.open(projectPath);
  756. let files = cg.getFiles();
  757. if (files.length === 0) {
  758. info('No files indexed. Run "codegraph index" first.');
  759. cg.destroy();
  760. return;
  761. }
  762. // Filter by path prefix
  763. if (options.filter) {
  764. const filter = options.filter;
  765. files = files.filter(f => f.path.startsWith(filter) || f.path.startsWith('./' + filter));
  766. }
  767. // Filter by glob pattern
  768. if (options.pattern) {
  769. const regex = globToRegex(options.pattern);
  770. files = files.filter(f => regex.test(f.path));
  771. }
  772. if (files.length === 0) {
  773. info('No files found matching the criteria.');
  774. cg.destroy();
  775. return;
  776. }
  777. // JSON output
  778. if (options.json) {
  779. const output = files.map(f => ({
  780. path: f.path,
  781. language: f.language,
  782. nodeCount: f.nodeCount,
  783. size: f.size,
  784. }));
  785. console.log(JSON.stringify(output, null, 2));
  786. cg.destroy();
  787. return;
  788. }
  789. const includeMetadata = options.metadata !== false;
  790. const format = options.format || 'tree';
  791. const maxDepth = options.maxDepth ? parseInt(options.maxDepth, 10) : undefined;
  792. // Format output
  793. switch (format) {
  794. case 'flat':
  795. console.log(chalk.bold(`\nFiles (${files.length}):\n`));
  796. for (const file of files.sort((a, b) => a.path.localeCompare(b.path))) {
  797. if (includeMetadata) {
  798. console.log(` ${file.path} ${chalk.dim(`(${file.language}, ${file.nodeCount} symbols)`)}`);
  799. } else {
  800. console.log(` ${file.path}`);
  801. }
  802. }
  803. break;
  804. case 'grouped':
  805. console.log(chalk.bold(`\nFiles by Language (${files.length} total):\n`));
  806. const byLang = new Map<string, typeof files>();
  807. for (const file of files) {
  808. const existing = byLang.get(file.language) || [];
  809. existing.push(file);
  810. byLang.set(file.language, existing);
  811. }
  812. const sortedLangs = [...byLang.entries()].sort((a, b) => b[1].length - a[1].length);
  813. for (const [lang, langFiles] of sortedLangs) {
  814. console.log(chalk.cyan(`${lang} (${langFiles.length}):`));
  815. for (const file of langFiles.sort((a, b) => a.path.localeCompare(b.path))) {
  816. if (includeMetadata) {
  817. console.log(` ${file.path} ${chalk.dim(`(${file.nodeCount} symbols)`)}`);
  818. } else {
  819. console.log(` ${file.path}`);
  820. }
  821. }
  822. console.log();
  823. }
  824. break;
  825. case 'tree':
  826. default:
  827. console.log(chalk.bold(`\nProject Structure (${files.length} files):\n`));
  828. printFileTree(files, includeMetadata, maxDepth, chalk);
  829. break;
  830. }
  831. console.log();
  832. cg.destroy();
  833. } catch (err) {
  834. error(`Failed to list files: ${err instanceof Error ? err.message : String(err)}`);
  835. process.exit(1);
  836. }
  837. });
  838. /**
  839. * Convert glob pattern to regex
  840. */
  841. function globToRegex(pattern: string): RegExp {
  842. const escaped = pattern
  843. .replace(/[.+^${}()|[\]\\]/g, '\\$&')
  844. .replace(/\*\*/g, '{{GLOBSTAR}}')
  845. .replace(/\*/g, '[^/]*')
  846. .replace(/\?/g, '[^/]')
  847. .replace(/\{\{GLOBSTAR\}\}/g, '.*');
  848. return new RegExp(escaped);
  849. }
  850. /**
  851. * Print files as a tree
  852. */
  853. function printFileTree(
  854. files: { path: string; language: string; nodeCount: number }[],
  855. includeMetadata: boolean,
  856. maxDepth: number | undefined,
  857. chalk: { dim: (s: string) => string; cyan: (s: string) => string }
  858. ): void {
  859. interface TreeNode {
  860. name: string;
  861. children: Map<string, TreeNode>;
  862. file?: { language: string; nodeCount: number };
  863. }
  864. const root: TreeNode = { name: '', children: new Map() };
  865. for (const file of files) {
  866. const parts = file.path.split('/');
  867. let current = root;
  868. for (let i = 0; i < parts.length; i++) {
  869. const part = parts[i];
  870. if (!part) continue;
  871. if (!current.children.has(part)) {
  872. current.children.set(part, { name: part, children: new Map() });
  873. }
  874. current = current.children.get(part)!;
  875. if (i === parts.length - 1) {
  876. current.file = { language: file.language, nodeCount: file.nodeCount };
  877. }
  878. }
  879. }
  880. const renderNode = (node: TreeNode, prefix: string, isLast: boolean, depth: number): void => {
  881. if (maxDepth !== undefined && depth > maxDepth) return;
  882. const connector = isLast ? '└── ' : '├── ';
  883. const childPrefix = isLast ? ' ' : '│ ';
  884. if (node.name) {
  885. let line = prefix + connector + node.name;
  886. if (node.file && includeMetadata) {
  887. line += chalk.dim(` (${node.file.language}, ${node.file.nodeCount} symbols)`);
  888. }
  889. console.log(line);
  890. }
  891. const children = [...node.children.values()];
  892. children.sort((a, b) => {
  893. const aIsDir = a.children.size > 0 && !a.file;
  894. const bIsDir = b.children.size > 0 && !b.file;
  895. if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
  896. return a.name.localeCompare(b.name);
  897. });
  898. for (let i = 0; i < children.length; i++) {
  899. const child = children[i]!;
  900. const nextPrefix = node.name ? prefix + childPrefix : prefix;
  901. renderNode(child, nextPrefix, i === children.length - 1, depth + 1);
  902. }
  903. };
  904. renderNode(root, '', true, 0);
  905. }
  906. /**
  907. * codegraph context <task>
  908. */
  909. program
  910. .command('context <task>')
  911. .description('Build context for a task (outputs markdown)')
  912. .option('-p, --path <path>', 'Project path')
  913. .option('-n, --max-nodes <number>', 'Maximum nodes to include', '50')
  914. .option('-c, --max-code <number>', 'Maximum code blocks', '10')
  915. .option('--no-code', 'Exclude code blocks')
  916. .option('-f, --format <format>', 'Output format (markdown, json)', 'markdown')
  917. .action(async (task: string, options: {
  918. path?: string;
  919. maxNodes?: string;
  920. maxCode?: string;
  921. code?: boolean;
  922. format?: string;
  923. }) => {
  924. const projectPath = resolveProjectPath(options.path);
  925. try {
  926. if (!isInitialized(projectPath)) {
  927. error(`CodeGraph not initialized in ${projectPath}`);
  928. process.exit(1);
  929. }
  930. const { default: CodeGraph } = await loadCodeGraph();
  931. const cg = await CodeGraph.open(projectPath);
  932. const context = await cg.buildContext(task, {
  933. maxNodes: parseInt(options.maxNodes || '50', 10),
  934. maxCodeBlocks: parseInt(options.maxCode || '10', 10),
  935. includeCode: options.code !== false,
  936. format: options.format as 'markdown' | 'json',
  937. });
  938. // Output the context
  939. console.log(context);
  940. cg.destroy();
  941. } catch (err) {
  942. error(`Failed to build context: ${err instanceof Error ? err.message : String(err)}`);
  943. process.exit(1);
  944. }
  945. });
  946. /**
  947. * codegraph serve
  948. */
  949. program
  950. .command('serve')
  951. .description('Start CodeGraph as an MCP server for AI assistants')
  952. .option('-p, --path <path>', 'Project path (optional for MCP mode, uses rootUri from client)')
  953. .option('--mcp', 'Run as MCP server (stdio transport)')
  954. .action(async (options: { path?: string; mcp?: boolean }) => {
  955. const projectPath = options.path ? resolveProjectPath(options.path) : undefined;
  956. try {
  957. if (options.mcp) {
  958. // Start MCP server - it handles initialization lazily based on rootUri from client
  959. const { MCPServer } = await import('../mcp/index');
  960. const server = new MCPServer(projectPath);
  961. await server.start();
  962. // Server will run until terminated
  963. } else {
  964. // Default: show info about MCP mode.
  965. // Use stderr so stdout stays clean for any piped/stdio usage.
  966. console.error(chalk.bold('\nCodeGraph MCP Server\n'));
  967. console.error(chalk.blue('ℹ') + ' Use --mcp flag to start the MCP server');
  968. console.error('\nTo use with Claude Code, add to your MCP configuration:');
  969. console.error(chalk.dim(`
  970. {
  971. "mcpServers": {
  972. "codegraph": {
  973. "command": "codegraph",
  974. "args": ["serve", "--mcp"]
  975. }
  976. }
  977. }
  978. `));
  979. console.error('Available tools:');
  980. console.error(chalk.cyan(' codegraph_search') + ' - Search for code symbols');
  981. console.error(chalk.cyan(' codegraph_context') + ' - Build context for a task');
  982. console.error(chalk.cyan(' codegraph_callers') + ' - Find callers of a symbol');
  983. console.error(chalk.cyan(' codegraph_callees') + ' - Find what a symbol calls');
  984. console.error(chalk.cyan(' codegraph_impact') + ' - Analyze impact of changes');
  985. console.error(chalk.cyan(' codegraph_node') + ' - Get symbol details');
  986. console.error(chalk.cyan(' codegraph_files') + ' - Get project file structure');
  987. console.error(chalk.cyan(' codegraph_status') + ' - Get index status');
  988. }
  989. } catch (err) {
  990. error(`Failed to start server: ${err instanceof Error ? err.message : String(err)}`);
  991. process.exit(1);
  992. }
  993. });
  994. /**
  995. * codegraph visualize [path]
  996. */
  997. program
  998. .command('visualize [path]')
  999. .description('Open interactive graph visualization in your browser')
  1000. .option('-p, --port <port>', 'Port to listen on (default: auto)', parseInt)
  1001. .option('--no-open', 'Do not open browser automatically')
  1002. .action(async (pathArg: string | undefined, options: { port?: number; open?: boolean }) => {
  1003. const projectPath = resolveProjectPath(pathArg);
  1004. try {
  1005. if (!isInitialized(projectPath)) {
  1006. error(`CodeGraph not initialized in ${projectPath}`);
  1007. info('Run "codegraph init -i" first');
  1008. process.exit(1);
  1009. }
  1010. const { default: CodeGraph } = await loadCodeGraph();
  1011. const cg = await CodeGraph.open(projectPath);
  1012. const stats = cg.getStats();
  1013. console.log(chalk.bold('\n CodeGraph Explorer\n'));
  1014. info(`Project: ${projectPath}`);
  1015. info(`Indexed: ${formatNumber(stats.nodeCount)} nodes, ${formatNumber(stats.edgeCount)} edges, ${formatNumber(stats.fileCount)} files\n`);
  1016. const { VisualizerServer } = await import('../visualizer/server');
  1017. const server = new VisualizerServer(cg);
  1018. const { url } = await server.start({ port: options.port, openBrowser: options.open !== false });
  1019. success(`Visualizer running at ${chalk.cyan(url)}`);
  1020. console.log(chalk.dim(' Press Ctrl+C to stop\n'));
  1021. // Open browser
  1022. if (options.open !== false) {
  1023. const openCmd = process.platform === 'darwin' ? 'open' :
  1024. process.platform === 'win32' ? 'start' : 'xdg-open';
  1025. spawn(openCmd, [url], { detached: true, stdio: 'ignore' }).unref();
  1026. }
  1027. // Handle shutdown — force exit on second Ctrl+C
  1028. let shuttingDown = false;
  1029. const shutdown = () => {
  1030. if (shuttingDown) {
  1031. process.exit(1);
  1032. }
  1033. shuttingDown = true;
  1034. console.log(chalk.dim('\n Shutting down...'));
  1035. server.stop().then(() => {
  1036. cg.close();
  1037. process.exit(0);
  1038. }).catch(() => process.exit(1));
  1039. // Force exit after 2s if graceful shutdown hangs
  1040. setTimeout(() => process.exit(1), 2000).unref();
  1041. };
  1042. process.on('SIGINT', shutdown);
  1043. process.on('SIGTERM', shutdown);
  1044. } catch (err) {
  1045. error(`Failed to start visualizer: ${err instanceof Error ? err.message : String(err)}`);
  1046. process.exit(1);
  1047. }
  1048. });
  1049. /**
  1050. * codegraph mark-dirty [path]
  1051. *
  1052. * Touches .codegraph/.dirty to signal that files have changed.
  1053. * Used by Claude Code PostToolUse hooks to batch syncs.
  1054. * Runs silently and always exits 0.
  1055. */
  1056. program
  1057. .command('mark-dirty [path]')
  1058. .description('Mark project as needing sync (used by Claude Code hooks)')
  1059. .action(async (pathArg: string | undefined) => {
  1060. try {
  1061. const startPath = path.resolve(pathArg || process.cwd());
  1062. const projectRoot = findNearestCodeGraphRoot(startPath);
  1063. if (!projectRoot) {
  1064. // No .codegraph/ found — exit silently
  1065. process.exit(0);
  1066. }
  1067. const dirtyPath = path.join(getCodeGraphDir(projectRoot), '.dirty');
  1068. fs.writeFileSync(dirtyPath, Date.now().toString(), 'utf-8');
  1069. } catch {
  1070. // Never fail — this runs in the background during edits
  1071. }
  1072. process.exit(0);
  1073. });
  1074. /**
  1075. * codegraph sync-if-dirty [path]
  1076. *
  1077. * Checks if .codegraph/.dirty exists and, if so, spawns a detached
  1078. * background process to run `codegraph sync`. The hook process exits
  1079. * immediately so Claude Code's Stop hook never blocks.
  1080. *
  1081. * Removes the marker BEFORE spawning so edits during sync
  1082. * create a new marker for the next Stop event.
  1083. * Runs silently and always exits 0.
  1084. */
  1085. program
  1086. .command('sync-if-dirty [path]')
  1087. .description('Sync if project was marked dirty (used by Claude Code hooks)')
  1088. .action(async (pathArg: string | undefined) => {
  1089. try {
  1090. const startPath = path.resolve(pathArg || process.cwd());
  1091. const projectRoot = findNearestCodeGraphRoot(startPath);
  1092. if (!projectRoot) {
  1093. process.exit(0);
  1094. }
  1095. const dirtyPath = path.join(getCodeGraphDir(projectRoot!), '.dirty');
  1096. // No marker → nothing to do (sub-ms exit)
  1097. if (!fs.existsSync(dirtyPath)) {
  1098. process.exit(0);
  1099. }
  1100. // Remove marker FIRST so edits during sync create a new one
  1101. try { fs.unlinkSync(dirtyPath); } catch { /* ignore */ }
  1102. // If not fully initialized (no DB), exit
  1103. if (!isInitialized(projectRoot!)) {
  1104. process.exit(0);
  1105. }
  1106. // Spawn sync as a detached background process
  1107. // so this hook exits immediately and doesn't block Claude Code.
  1108. // Uses process.argv[0]/[1] (e.g. node /path/to/codegraph.js) so it
  1109. // works whether invoked via global install, npx, or directly.
  1110. const child = spawn(
  1111. process.argv[0]!,
  1112. [process.argv[1]!, 'sync', '--quiet', projectRoot!],
  1113. {
  1114. detached: true,
  1115. stdio: 'ignore',
  1116. windowsHide: true,
  1117. }
  1118. );
  1119. child.unref();
  1120. } catch {
  1121. // Never fail — this runs at the end of Claude responses
  1122. }
  1123. process.exit(0);
  1124. });
  1125. /**
  1126. * codegraph unlock [path]
  1127. */
  1128. program
  1129. .command('unlock [path]')
  1130. .description('Remove a stale lock file that is blocking indexing')
  1131. .action(async (pathArg: string | undefined) => {
  1132. const projectPath = resolveProjectPath(pathArg);
  1133. try {
  1134. if (!isInitialized(projectPath)) {
  1135. error(`CodeGraph not initialized in ${projectPath}`);
  1136. return;
  1137. }
  1138. const lockPath = path.join(getCodeGraphDir(projectPath), 'codegraph.lock');
  1139. if (!fs.existsSync(lockPath)) {
  1140. info('No lock file found — nothing to do');
  1141. return;
  1142. }
  1143. fs.unlinkSync(lockPath);
  1144. success('Removed lock file. You can now run indexing again.');
  1145. } catch (err) {
  1146. error(`Failed to remove lock: ${err instanceof Error ? err.message : String(err)}`);
  1147. process.exit(1);
  1148. }
  1149. });
  1150. /**
  1151. * codegraph affected [files...]
  1152. *
  1153. * Find test files affected by the given source files.
  1154. * Traces dependency edges transitively to find test files that depend on changed code.
  1155. *
  1156. * Usage:
  1157. * git diff --name-only | codegraph affected --stdin
  1158. * codegraph affected src/lib/components/Editor.svelte src/routes/+page.svelte
  1159. */
  1160. program
  1161. .command('affected [files...]')
  1162. .description('Find test files affected by changed source files')
  1163. .option('-p, --path <path>', 'Project path')
  1164. .option('--stdin', 'Read file list from stdin (one per line)')
  1165. .option('-d, --depth <number>', 'Max dependency traversal depth', '5')
  1166. .option('-f, --filter <glob>', 'Custom glob filter for test files (e.g. "e2e/*.spec.ts")')
  1167. .option('-j, --json', 'Output as JSON')
  1168. .option('-q, --quiet', 'Only output file paths, no decoration')
  1169. .action(async (fileArgs: string[], options: { path?: string; stdin?: boolean; depth?: string; filter?: string; json?: boolean; quiet?: boolean }) => {
  1170. const projectPath = resolveProjectPath(options.path);
  1171. try {
  1172. if (!isInitialized(projectPath)) {
  1173. error(`CodeGraph not initialized in ${projectPath}`);
  1174. process.exit(1);
  1175. }
  1176. // Collect changed files from args or stdin
  1177. let changedFiles: string[] = [...(fileArgs || [])];
  1178. if (options.stdin) {
  1179. const stdinData = fs.readFileSync(0, 'utf-8');
  1180. const stdinFiles = stdinData.split('\n').map(f => f.trim()).filter(Boolean);
  1181. changedFiles.push(...stdinFiles);
  1182. }
  1183. if (changedFiles.length === 0) {
  1184. if (!options.quiet) info('No files provided. Use file arguments or --stdin.');
  1185. process.exit(0);
  1186. }
  1187. const { default: CodeGraph } = await loadCodeGraph();
  1188. const cg = await CodeGraph.open(projectPath);
  1189. const maxDepth = parseInt(options.depth || '5', 10);
  1190. // Common test file patterns
  1191. const defaultTestPatterns = [
  1192. /\.spec\./,
  1193. /\.test\./,
  1194. /\/__tests__\//,
  1195. /\/tests?\//,
  1196. /\/e2e\//,
  1197. /\/spec\//,
  1198. ];
  1199. // Custom filter pattern
  1200. let customFilter: RegExp | null = null;
  1201. if (options.filter) {
  1202. // Convert glob to regex: ** → .+, * → [^/]*, . → \.
  1203. const regex = options.filter
  1204. .replace(/[+[\]{}()^$|\\]/g, '\\$&')
  1205. .replace(/\./g, '\\.')
  1206. .replace(/\*\*/g, '.+')
  1207. .replace(/\*/g, '[^/]*');
  1208. customFilter = new RegExp(regex);
  1209. }
  1210. function isTestFile(filePath: string): boolean {
  1211. if (customFilter) return customFilter.test(filePath);
  1212. return defaultTestPatterns.some(p => p.test(filePath));
  1213. }
  1214. // BFS to find all transitive dependents of changed files, filtered to test files
  1215. const affectedTests = new Set<string>();
  1216. const allDependents = new Set<string>();
  1217. for (const file of changedFiles) {
  1218. // If the changed file is itself a test file, include it
  1219. if (isTestFile(file)) {
  1220. affectedTests.add(file);
  1221. continue;
  1222. }
  1223. // BFS through dependents
  1224. const queue: Array<{ file: string; depth: number }> = [{ file, depth: 0 }];
  1225. const visited = new Set<string>();
  1226. visited.add(file);
  1227. while (queue.length > 0) {
  1228. const current = queue.shift()!;
  1229. if (current.depth >= maxDepth) continue;
  1230. const dependents = cg.getFileDependents(current.file);
  1231. for (const dep of dependents) {
  1232. if (visited.has(dep)) continue;
  1233. visited.add(dep);
  1234. allDependents.add(dep);
  1235. if (isTestFile(dep)) {
  1236. affectedTests.add(dep);
  1237. } else {
  1238. queue.push({ file: dep, depth: current.depth + 1 });
  1239. }
  1240. }
  1241. }
  1242. }
  1243. const sortedTests = Array.from(affectedTests).sort();
  1244. // Output
  1245. if (options.json) {
  1246. console.log(JSON.stringify({
  1247. changedFiles,
  1248. affectedTests: sortedTests,
  1249. totalDependentsTraversed: allDependents.size,
  1250. }, null, 2));
  1251. } else if (options.quiet) {
  1252. for (const t of sortedTests) console.log(t);
  1253. } else {
  1254. if (sortedTests.length === 0) {
  1255. info('No test files affected by the changed files.');
  1256. } else {
  1257. console.log(chalk.bold(`\nAffected test files (${sortedTests.length}):\n`));
  1258. for (const t of sortedTests) {
  1259. console.log(' ' + chalk.cyan(t));
  1260. }
  1261. console.log();
  1262. }
  1263. }
  1264. cg.destroy();
  1265. } catch (err) {
  1266. error(`Affected analysis failed: ${err instanceof Error ? err.message : String(err)}`);
  1267. process.exit(1);
  1268. }
  1269. });
  1270. /**
  1271. * codegraph install
  1272. */
  1273. program
  1274. .command('install')
  1275. .description('Run interactive installer for Claude Code integration')
  1276. .action(async () => {
  1277. const { runInstaller } = await import('../installer');
  1278. await runInstaller();
  1279. });
  1280. // Parse and run
  1281. program.parse();
  1282. } // end main()