codegraph-py 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. codegraph/__init__.py +12 -0
  2. codegraph/__main__.py +6 -0
  3. codegraph/cli.py +911 -0
  4. codegraph/codegraph.py +618 -0
  5. codegraph/context/__init__.py +216 -0
  6. codegraph/db/__init__.py +11 -0
  7. codegraph/db/connection.py +163 -0
  8. codegraph/db/queries.py +752 -0
  9. codegraph/db/schema.sql +151 -0
  10. codegraph/directory.py +160 -0
  11. codegraph/errors.py +101 -0
  12. codegraph/extraction/__init__.py +956 -0
  13. codegraph/extraction/languages/__init__.py +64 -0
  14. codegraph/extraction/languages/base.py +132 -0
  15. codegraph/extraction/languages/c_cfg.py +53 -0
  16. codegraph/extraction/languages/cpp_cfg.py +60 -0
  17. codegraph/extraction/languages/dart_cfg.py +53 -0
  18. codegraph/extraction/languages/go_cfg.py +70 -0
  19. codegraph/extraction/languages/java_cfg.py +62 -0
  20. codegraph/extraction/languages/javascript_cfg.py +84 -0
  21. codegraph/extraction/languages/kotlin_cfg.py +52 -0
  22. codegraph/extraction/languages/lua_cfg.py +65 -0
  23. codegraph/extraction/languages/python_cfg.py +75 -0
  24. codegraph/extraction/languages/ruby_cfg.py +48 -0
  25. codegraph/extraction/languages/rust_cfg.py +68 -0
  26. codegraph/extraction/languages/scala_cfg.py +59 -0
  27. codegraph/extraction/languages/swift_cfg.py +64 -0
  28. codegraph/extraction/languages/typescript_cfg.py +89 -0
  29. codegraph/extraction/tree_sitter_extractor.py +689 -0
  30. codegraph/graph/__init__.py +685 -0
  31. codegraph/installer/__init__.py +6 -0
  32. codegraph/mcp/__init__.py +668 -0
  33. codegraph/project_config.py +191 -0
  34. codegraph/resolution/__init__.py +337 -0
  35. codegraph/search/__init__.py +653 -0
  36. codegraph/sync/__init__.py +204 -0
  37. codegraph/types.py +334 -0
  38. codegraph/ui/__init__.py +11 -0
  39. codegraph/utils.py +218 -0
  40. codegraph_py-1.0.0.dist-info/METADATA +238 -0
  41. codegraph_py-1.0.0.dist-info/RECORD +44 -0
  42. codegraph_py-1.0.0.dist-info/WHEEL +5 -0
  43. codegraph_py-1.0.0.dist-info/entry_points.txt +2 -0
  44. codegraph_py-1.0.0.dist-info/top_level.txt +1 -0
codegraph/cli.py ADDED
@@ -0,0 +1,911 @@
1
+ """
2
+ CodeGraph CLI
3
+
4
+ Command-line interface for CodeGraph code intelligence.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import sys
12
+ import time
13
+ from typing import Optional, List, Dict
14
+
15
+ import click
16
+
17
+ from codegraph.codegraph import CodeGraph
18
+ from codegraph.directory import (
19
+ is_initialized, find_nearest_codegraph_root,
20
+ unsafe_index_root_reason, derive_project_name_tokens,
21
+ )
22
+ from codegraph.types import SearchOptions, GraphStats
23
+
24
+
25
+ # =============================================================================
26
+ # CLI Colors & Helpers
27
+ # =============================================================================
28
+
29
+ class Colors:
30
+ """ANSI color helpers."""
31
+ RESET = '\x1b[0m'
32
+ BOLD = '\x1b[1m'
33
+ DIM = '\x1b[2m'
34
+ RED = '\x1b[31m'
35
+ GREEN = '\x1b[32m'
36
+ YELLOW = '\x1b[33m'
37
+ BLUE = '\x1b[34m'
38
+ CYAN = '\x1b[36m'
39
+ GRAY = '\x1b[90m'
40
+
41
+
42
+ def bold(s: str) -> str:
43
+ return f'{Colors.BOLD}{s}{Colors.RESET}'
44
+
45
+
46
+ def dim(s: str) -> str:
47
+ return f'{Colors.DIM}{s}{Colors.RESET}'
48
+
49
+
50
+ def red(s: str) -> str:
51
+ return f'{Colors.RED}{s}{Colors.RESET}'
52
+
53
+
54
+ def green(s: str) -> str:
55
+ return f'{Colors.GREEN}{s}{Colors.RESET}'
56
+
57
+
58
+ def yellow(s: str) -> str:
59
+ return f'{Colors.YELLOW}{s}{Colors.RESET}'
60
+
61
+
62
+ def blue(s: str) -> str:
63
+ return f'{Colors.BLUE}{s}{Colors.RESET}'
64
+
65
+
66
+ def format_number(n: int) -> str:
67
+ """Format a number with commas."""
68
+ return f'{n:,}'
69
+
70
+
71
+ def format_duration(ms: float) -> str:
72
+ """Format duration in milliseconds to human readable."""
73
+ if ms < 1000:
74
+ return f'{ms:.0f}ms'
75
+ seconds = ms / 1000
76
+ if seconds < 60:
77
+ return f'{seconds:.1f}s'
78
+ minutes = int(seconds / 60)
79
+ remaining = seconds % 60
80
+ return f'{minutes}m {remaining:.0f}s'
81
+
82
+
83
+ def resolve_project_path(path_arg: Optional[str] = None) -> str:
84
+ """Resolve project path from argument or current directory."""
85
+ absolute = os.path.abspath(path_arg or os.getcwd())
86
+
87
+ if is_initialized(absolute):
88
+ return absolute
89
+
90
+ # Walk up to find nearest parent with CodeGraph initialized
91
+ current = absolute
92
+ root = os.path.splitdrive(absolute)[0] + os.sep
93
+
94
+ while current != root:
95
+ parent = os.path.dirname(current)
96
+ if parent == current:
97
+ break
98
+ current = parent
99
+ if is_initialized(current):
100
+ return current
101
+
102
+ return absolute
103
+
104
+
105
+ # =============================================================================
106
+ # CLI Commands
107
+ # =============================================================================
108
+
109
+ @click.group()
110
+ @click.version_option(version='1.0.0', prog_name='codegraph')
111
+ def main():
112
+ """CodeGraph - Semantic code intelligence for AI coding agents.
113
+
114
+ Build a knowledge graph of your codebase for surgical context,
115
+ fewer tool calls, and faster answers.
116
+ """
117
+ pass
118
+
119
+
120
+ @main.command()
121
+ @click.argument('path', required=False, default=None)
122
+ @click.option('-f', '--force', is_flag=True, help='Initialize even if path looks unsafe')
123
+ @click.option('-v', '--verbose', is_flag=True, help='Show detailed progress')
124
+ def init(path: Optional[str], force: bool, verbose: bool):
125
+ """Initialize CodeGraph in a project directory and build initial index."""
126
+ project_path = os.path.abspath(path or os.getcwd())
127
+
128
+ # Check unsafe paths
129
+ unsafe = unsafe_index_root_reason(project_path)
130
+ if unsafe and not force:
131
+ click.echo(red(
132
+ f'Refusing to initialize in {project_path} — it looks like {unsafe}.'
133
+ ))
134
+ click.echo('Run this inside a specific project directory, or pass --force.')
135
+ return
136
+
137
+ if is_initialized(project_path):
138
+ click.echo(yellow(f'Already initialized in {project_path}'))
139
+ click.echo('Use "codegraph index" to re-index or "codegraph sync" to update')
140
+ return
141
+
142
+ click.echo(bold('\nInitializing CodeGraph...\n'))
143
+
144
+ try:
145
+ cg = CodeGraph.init_sync(project_path)
146
+ click.echo(green(f'āœ“ Initialized in {project_path}'))
147
+
148
+ # Run initial index
149
+ click.echo('Indexing project...')
150
+
151
+ result = cg.index_all(verbose=verbose)
152
+
153
+ if result.success and result.files_indexed > 0:
154
+ click.echo(green(
155
+ f'āœ“ Indexed {format_number(result.files_indexed)} files'
156
+ ))
157
+ click.echo(
158
+ f'{format_number(result.nodes_created)} nodes, '
159
+ f'{format_number(result.edges_created)} edges '
160
+ f'in {format_duration(result.duration_ms)}'
161
+ )
162
+ elif result.files_errored > 0:
163
+ click.echo(yellow(
164
+ f'Indexed {format_number(result.files_indexed)} files '
165
+ f'({format_number(result.files_errored)} errors)'
166
+ ))
167
+ else:
168
+ click.echo(yellow('No files found to index'))
169
+
170
+ cg.destroy()
171
+ click.echo(green('\nDone! Run "codegraph status" to see the results.'))
172
+
173
+ except Exception as e:
174
+ click.echo(red(f'Failed: {str(e)}'))
175
+ sys.exit(1)
176
+
177
+
178
+ @main.command()
179
+ @click.argument('path', required=False, default=None)
180
+ def uninit(path: Optional[str]):
181
+ """Remove CodeGraph from a project (deletes .codegraph/ directory)."""
182
+ project_path = resolve_project_path(path)
183
+
184
+ if not is_initialized(project_path):
185
+ click.echo(yellow(f'CodeGraph is not initialized in {project_path}'))
186
+ return
187
+
188
+ click.echo(yellow(
189
+ f'This will permanently delete all CodeGraph data from {project_path}'
190
+ ))
191
+ if not click.confirm('Continue?'):
192
+ click.echo('Cancelled')
193
+ return
194
+
195
+ try:
196
+ cg = CodeGraph.open_sync(project_path)
197
+ cg.uninitialize()
198
+ click.echo(green(f'Removed CodeGraph from {project_path}'))
199
+ except Exception as e:
200
+ click.echo(red(f'Failed: {str(e)}'))
201
+ sys.exit(1)
202
+
203
+
204
+ @main.command()
205
+ @click.argument('path', required=False, default=None)
206
+ @click.option('-f', '--force', is_flag=True, help='Index even if path looks unsafe')
207
+ @click.option('-q', '--quiet', is_flag=True, help='Suppress progress output')
208
+ @click.option('-v', '--verbose', is_flag=True, help='Show detailed progress')
209
+ def index(path: Optional[str], force: bool, quiet: bool, verbose: bool):
210
+ """Rebuild the full index from scratch."""
211
+ project_path = resolve_project_path(path)
212
+
213
+ # Check unsafe paths
214
+ unsafe = unsafe_index_root_reason(project_path)
215
+ if unsafe and not force:
216
+ click.echo(red(
217
+ f'Refusing to index {project_path} — it looks like {unsafe}. '
218
+ f'Pass --force to override.'
219
+ ))
220
+ return
221
+
222
+ if not is_initialized(project_path):
223
+ click.echo(red(f'CodeGraph not initialized in {project_path}'))
224
+ click.echo('Run "codegraph init" first')
225
+ return
226
+
227
+ click.echo(bold('\nIndexing project...\n'))
228
+
229
+ try:
230
+ # Recreate database for fresh index
231
+ cg = CodeGraph.open_sync(project_path)
232
+
233
+ result = cg.index_all(verbose=verbose)
234
+
235
+ if not quiet:
236
+ if result.success and result.files_indexed > 0:
237
+ click.echo(green(
238
+ f'āœ“ Indexed {format_number(result.files_indexed)} files'
239
+ ))
240
+ click.echo(
241
+ f'{format_number(result.nodes_created)} nodes, '
242
+ f'{format_number(result.edges_created)} edges '
243
+ f'in {format_duration(result.duration_ms)}'
244
+ )
245
+ elif result.files_errored > 0:
246
+ click.echo(yellow(
247
+ f'Indexed {format_number(result.files_indexed)} files '
248
+ f'({format_number(result.files_errored)} errors)'
249
+ ))
250
+ else:
251
+ click.echo(yellow('No files found to index'))
252
+
253
+ cg.destroy()
254
+ click.echo(green('\nDone!'))
255
+
256
+ except Exception as e:
257
+ click.echo(red(f'Failed: {str(e)}'))
258
+ sys.exit(1)
259
+
260
+
261
+ @main.command()
262
+ @click.argument('path', required=False, default=None)
263
+ @click.option('-q', '--quiet', is_flag=True, help='Suppress output')
264
+ def sync(path: Optional[str], quiet: bool):
265
+ """Sync changes since last index."""
266
+ project_path = resolve_project_path(path)
267
+
268
+ if not is_initialized(project_path):
269
+ if not quiet:
270
+ click.echo(red(f'CodeGraph not initialized in {project_path}'))
271
+ return
272
+
273
+ try:
274
+ cg = CodeGraph.open_sync(project_path)
275
+
276
+ result = cg.sync()
277
+
278
+ if not quiet:
279
+ total = result.files_added + result.files_modified + result.files_removed
280
+ if total == 0:
281
+ click.echo('Already up to date')
282
+ else:
283
+ click.echo(green(f'Synced {format_number(total)} changed files'))
284
+ details = []
285
+ if result.files_added > 0:
286
+ details.append(f'Added: {result.files_added}')
287
+ if result.files_modified > 0:
288
+ details.append(f'Modified: {result.files_modified}')
289
+ if result.files_removed > 0:
290
+ details.append(f'Removed: {result.files_removed}')
291
+ click.echo(
292
+ f'{", ".join(details)} — '
293
+ f'{format_number(result.nodes_updated)} nodes '
294
+ f'in {format_duration(result.duration_ms)}'
295
+ )
296
+
297
+ cg.destroy()
298
+ if not quiet:
299
+ click.echo(green('Done!'))
300
+
301
+ except Exception as e:
302
+ if not quiet:
303
+ click.echo(red(f'Failed: {str(e)}'))
304
+ sys.exit(1)
305
+
306
+
307
+ @main.command()
308
+ @click.argument('path', required=False, default=None)
309
+ @click.option('-j', '--json', 'json_output', is_flag=True, help='Output as JSON')
310
+ def status(path: Optional[str], json_output: bool):
311
+ """Show index status and statistics."""
312
+ project_path = resolve_project_path(path)
313
+
314
+ if not is_initialized(project_path):
315
+ if json_output:
316
+ click.echo(json.dumps({
317
+ 'initialized': False,
318
+ 'version': '1.0.0',
319
+ 'projectPath': project_path,
320
+ }))
321
+ return
322
+
323
+ click.echo(bold('\nCodeGraph Status\n'))
324
+ click.echo(f'Project: {project_path}')
325
+ click.echo(yellow('Not initialized'))
326
+ click.echo('Run "codegraph init" to initialize')
327
+ return
328
+
329
+ try:
330
+ cg = CodeGraph.open_sync(project_path)
331
+ stats = cg.get_stats()
332
+ changes = cg.get_changed_files()
333
+ build_info = cg.get_index_build_info()
334
+
335
+ if json_output:
336
+ click.echo(json.dumps({
337
+ 'initialized': True,
338
+ 'version': '1.0.0',
339
+ 'projectPath': project_path,
340
+ 'fileCount': stats.file_count,
341
+ 'nodeCount': stats.node_count,
342
+ 'edgeCount': stats.edge_count,
343
+ 'dbSizeBytes': stats.db_size_bytes,
344
+ 'backend': 'sqlite3',
345
+ 'nodesByKind': stats.nodes_by_kind,
346
+ 'languages': [k for k, v in stats.files_by_language.items() if v > 0],
347
+ 'pendingChanges': {
348
+ 'added': len(changes.get('added', [])),
349
+ 'modified': len(changes.get('modified', [])),
350
+ 'removed': len(changes.get('removed', [])),
351
+ },
352
+ }, indent=2))
353
+ cg.destroy()
354
+ return
355
+
356
+ click.echo(bold('\nCodeGraph Status\n'))
357
+ click.echo(f'{blue("Project:")} {project_path}')
358
+ click.echo()
359
+
360
+ # Index stats
361
+ click.echo(bold('Index Statistics:'))
362
+ click.echo(f' Files: {format_number(stats.file_count)}')
363
+ click.echo(f' Nodes: {format_number(stats.node_count)}')
364
+ click.echo(f' Edges: {format_number(stats.edge_count)}')
365
+ click.echo(f' DB Size: {stats.db_size_bytes / 1024 / 1024:.2f} MB')
366
+ click.echo(f' Backend: {green("sqlite3 — built-in (full WAL)")}')
367
+ click.echo()
368
+
369
+ # Node breakdown
370
+ if stats.nodes_by_kind:
371
+ click.echo(bold('Nodes by Kind:'))
372
+ for kind, count in sorted(
373
+ stats.nodes_by_kind.items(),
374
+ key=lambda x: -x[1]
375
+ ):
376
+ click.echo(f' {kind:15} {format_number(count)}')
377
+ click.echo()
378
+
379
+ # Pending changes
380
+ total = (
381
+ len(changes.get('added', []))
382
+ + len(changes.get('modified', []))
383
+ + len(changes.get('removed', []))
384
+ )
385
+ if total > 0:
386
+ click.echo(bold('Pending Changes:'))
387
+ if changes.get('added'):
388
+ click.echo(f' Added: {len(changes["added"])} files')
389
+ if changes.get('modified'):
390
+ click.echo(f' Modified: {len(changes["modified"])} files')
391
+ if changes.get('removed'):
392
+ click.echo(f' Removed: {len(changes["removed"])} files')
393
+ click.echo(blue('Run "codegraph sync" to update the index'))
394
+ else:
395
+ click.echo(green('Index is up to date'))
396
+ click.echo()
397
+
398
+ cg.destroy()
399
+
400
+ except Exception as e:
401
+ click.echo(red(f'Failed: {str(e)}'))
402
+ sys.exit(1)
403
+
404
+
405
+ @main.command()
406
+ @click.argument('search')
407
+ @click.option('-p', '--path', 'path_arg', help='Project path')
408
+ @click.option('-l', '--limit', default=10, type=int, help='Maximum results')
409
+ @click.option('-k', '--kind', help='Filter by node kind (function, class, etc.)')
410
+ @click.option('-j', '--json', 'json_output', is_flag=True, help='Output as JSON')
411
+ def query(search: str, path_arg: Optional[str], limit: int,
412
+ kind: Optional[str], json_output: bool):
413
+ """Search for symbols in the codebase."""
414
+ project_path = resolve_project_path(path_arg)
415
+
416
+ if not is_initialized(project_path):
417
+ click.echo(red(f'CodeGraph not initialized in {project_path}'))
418
+ return
419
+
420
+ try:
421
+ cg = CodeGraph.open_sync(project_path)
422
+
423
+ opts = SearchOptions(limit=limit)
424
+ if kind:
425
+ opts.kinds = [kind]
426
+
427
+ results = cg.search_nodes(search, opts)
428
+
429
+ if json_output:
430
+ output = []
431
+ for r in results:
432
+ output.append({
433
+ 'node': {
434
+ 'id': r.node.id,
435
+ 'kind': r.node.kind,
436
+ 'name': r.node.name,
437
+ 'qualifiedName': r.node.qualified_name,
438
+ 'filePath': r.node.file_path,
439
+ 'startLine': r.node.start_line,
440
+ 'endLine': r.node.end_line,
441
+ 'signature': r.node.signature,
442
+ },
443
+ 'score': r.score,
444
+ })
445
+ click.echo(json.dumps(output, indent=2))
446
+ cg.destroy()
447
+ return
448
+
449
+ if not results:
450
+ click.echo(f'No results found for "{search}"')
451
+ else:
452
+ click.echo(bold(f'\nSearch Results for "{search}":\n'))
453
+ for r in results:
454
+ node = r.node
455
+ location = f'{node.file_path}:{node.start_line}'
456
+ click.echo(f' {blue(node.kind.ljust(12))} {bold(node.name)}')
457
+ click.echo(f' {dim(" " + location)}')
458
+ if node.signature:
459
+ click.echo(f' {dim(" " + node.signature)}')
460
+ click.echo()
461
+
462
+ cg.destroy()
463
+
464
+ except Exception as e:
465
+ click.echo(red(f'Search failed: {str(e)}'))
466
+ sys.exit(1)
467
+
468
+
469
+ @main.command()
470
+ @click.argument('query_parts', nargs=-1, required=True)
471
+ @click.option('-p', '--path', 'path_arg', help='Project path')
472
+ @click.option('--max-files', type=int, default=12, help='Maximum files to include')
473
+ def explore(query_parts: List[str], path_arg: Optional[str], max_files: int):
474
+ """Explore code: relevant symbols' source + call paths in one shot.
475
+
476
+ Same output as the codegraph_explore MCP tool.
477
+ """
478
+ query = ' '.join(query_parts)
479
+ project_path = resolve_project_path(path_arg)
480
+
481
+ if not is_initialized(project_path):
482
+ click.echo(red(
483
+ f'CodeGraph not initialized in {project_path}. '
484
+ f'Run "codegraph init" first.'
485
+ ))
486
+ return
487
+
488
+ try:
489
+ cg = CodeGraph.open_sync(project_path)
490
+ results = cg.search_nodes(query, SearchOptions(limit=max_files))
491
+
492
+ if not results:
493
+ click.echo(f'No results found for "{query}"')
494
+ cg.destroy()
495
+ return
496
+
497
+ # Group by file
498
+ from collections import defaultdict
499
+ files: Dict[str, List] = defaultdict(list)
500
+ for r in results:
501
+ files[r.node.file_path].append(r.node)
502
+
503
+ for filepath, nodes in files.items():
504
+ click.echo(bold(f'\n### {filepath}'))
505
+ for node in nodes:
506
+ sig = f' {node.signature}' if node.signature else ''
507
+ click.echo(
508
+ f' {blue(node.kind.ljust(12))} '
509
+ f'{bold(node.name)}{sig} '
510
+ f'({dim(f"line {node.start_line}")})'
511
+ )
512
+ # Show docstring if present
513
+ if node.docstring:
514
+ doc = node.docstring.strip()[:150]
515
+ click.echo(f' {dim(" " + doc)}')
516
+
517
+ cg.destroy()
518
+
519
+ except Exception as e:
520
+ click.echo(red(f'Explore failed: {str(e)}'))
521
+ sys.exit(1)
522
+
523
+
524
+ @main.command()
525
+ @click.argument('symbol')
526
+ @click.option('-p', '--path', 'path_arg', help='Project path')
527
+ @click.option('-d', '--depth', default=1, type=int, help='Traversal depth')
528
+ def callers(symbol: str, path_arg: Optional[str], depth: int):
529
+ """Find what calls a function/method."""
530
+ project_path = resolve_project_path(path_arg)
531
+
532
+ if not is_initialized(project_path):
533
+ click.echo(red(f'CodeGraph not initialized in {project_path}'))
534
+ return
535
+
536
+ try:
537
+ cg = CodeGraph.open_sync(project_path)
538
+ nodes = cg.get_nodes_by_name(symbol)
539
+
540
+ if not nodes:
541
+ click.echo(yellow(f'Symbol not found: {symbol}'))
542
+ cg.destroy()
543
+ return
544
+
545
+ callers_list = cg.get_callers(nodes[0].id, depth)
546
+
547
+ if not callers_list:
548
+ click.echo(f'No callers found for {symbol}')
549
+ else:
550
+ click.echo(bold(f'\nCallers of "{symbol}":\n'))
551
+ seen = set()
552
+ for caller_node, edge in callers_list:
553
+ key = f'{caller_node.file_path}:{caller_node.start_line}'
554
+ if key in seen:
555
+ continue
556
+ seen.add(key)
557
+ location = f'{caller_node.file_path}:{caller_node.start_line}'
558
+ click.echo(f' {blue(caller_node.kind.ljust(12))} {bold(caller_node.name)}')
559
+ click.echo(f' {dim(" " + location)}')
560
+ click.echo()
561
+
562
+ cg.destroy()
563
+
564
+ except Exception as e:
565
+ click.echo(red(f'Failed: {str(e)}'))
566
+ sys.exit(1)
567
+
568
+
569
+ @main.command()
570
+ @click.argument('symbol')
571
+ @click.option('-p', '--path', 'path_arg', help='Project path')
572
+ @click.option('-d', '--depth', default=1, type=int, help='Traversal depth')
573
+ def callees(symbol: str, path_arg: Optional[str], depth: int):
574
+ """Find what a function/method calls."""
575
+ project_path = resolve_project_path(path_arg)
576
+
577
+ if not is_initialized(project_path):
578
+ click.echo(red(f'CodeGraph not initialized in {project_path}'))
579
+ return
580
+
581
+ try:
582
+ cg = CodeGraph.open_sync(project_path)
583
+ nodes = cg.get_nodes_by_name(symbol)
584
+
585
+ if not nodes:
586
+ click.echo(yellow(f'Symbol not found: {symbol}'))
587
+ cg.destroy()
588
+ return
589
+
590
+ callees_list = cg.get_callees(nodes[0].id, depth)
591
+
592
+ if not callees_list:
593
+ click.echo(f'No callees found for {symbol}')
594
+ else:
595
+ click.echo(bold(f'\nCallees of "{symbol}":\n'))
596
+ seen = set()
597
+ for callee_node, edge in callees_list:
598
+ key = f'{callee_node.file_path}:{callee_node.start_line}'
599
+ if key in seen:
600
+ continue
601
+ seen.add(key)
602
+ location = f'{callee_node.file_path}:{callee_node.start_line}'
603
+ click.echo(f' {blue(callee_node.kind.ljust(12))} {bold(callee_node.name)}')
604
+ click.echo(f' {dim(" " + location)}')
605
+ click.echo()
606
+
607
+ cg.destroy()
608
+
609
+ except Exception as e:
610
+ click.echo(red(f'Failed: {str(e)}'))
611
+ sys.exit(1)
612
+
613
+
614
+ @main.command()
615
+ @click.argument('symbol')
616
+ @click.option('-p', '--path', 'path_arg', help='Project path')
617
+ @click.option('-d', '--depth', default=3, type=int, help='Traversal depth')
618
+ @click.option('-j', '--json', 'json_output', is_flag=True, help='Output as JSON')
619
+ def impact(symbol: str, path_arg: Optional[str], depth: int, json_output: bool):
620
+ """Analyze what code is affected by changing a symbol."""
621
+ project_path = resolve_project_path(path_arg)
622
+
623
+ if not is_initialized(project_path):
624
+ click.echo(red(f'CodeGraph not initialized in {project_path}'))
625
+ return
626
+
627
+ try:
628
+ cg = CodeGraph.open_sync(project_path)
629
+ nodes = cg.get_nodes_by_name(symbol)
630
+
631
+ if not nodes:
632
+ click.echo(yellow(f'Symbol not found: {symbol}'))
633
+ cg.destroy()
634
+ return
635
+
636
+ subgraph = cg.get_impact_radius(nodes[0].id, depth)
637
+
638
+ if json_output:
639
+ output = {
640
+ 'symbol': symbol,
641
+ 'nodeCount': len(subgraph.nodes),
642
+ 'edgeCount': len(subgraph.edges),
643
+ 'files': list(set(n.file_path for n in subgraph.nodes.values())),
644
+ 'nodes': [
645
+ {
646
+ 'id': n.id,
647
+ 'kind': n.kind,
648
+ 'name': n.name,
649
+ 'filePath': n.file_path,
650
+ 'startLine': n.start_line,
651
+ }
652
+ for n in subgraph.nodes.values()
653
+ ],
654
+ }
655
+ click.echo(json.dumps(output, indent=2))
656
+ cg.destroy()
657
+ return
658
+
659
+ files = set(n.file_path for n in subgraph.nodes.values())
660
+ click.echo(bold(f'\nImpact analysis for "{symbol}":\n'))
661
+ click.echo(f' {len(subgraph.nodes)} symbols affected')
662
+ click.echo(f' {len(subgraph.edges)} relationships')
663
+ click.echo(f' {len(files)} files involved')
664
+ click.echo()
665
+
666
+ if subgraph.nodes:
667
+ click.echo(bold('Affected symbols:'))
668
+ for n in subgraph.nodes.values():
669
+ if n.id != nodes[0].id:
670
+ location = f'{n.file_path}:{n.start_line}'
671
+ click.echo(f' {blue(n.kind.ljust(12))} {bold(n.name)}')
672
+ click.echo(f' {dim(" " + location)}')
673
+
674
+ cg.destroy()
675
+
676
+ except Exception as e:
677
+ click.echo(red(f'Failed: {str(e)}'))
678
+ sys.exit(1)
679
+
680
+
681
+ @main.command()
682
+ @click.argument('files', nargs=-1)
683
+ @click.option('-p', '--path', 'path_arg', help='Project path')
684
+ def affected(files: List[str], path_arg: Optional[str]):
685
+ """Find test files affected by changes."""
686
+ project_path = resolve_project_path(path_arg)
687
+
688
+ if not is_initialized(project_path):
689
+ click.echo(red(f'CodeGraph not initialized in {project_path}'))
690
+ return
691
+
692
+ try:
693
+ cg = CodeGraph.open_sync(project_path)
694
+
695
+ affected_files: set = set()
696
+ for filepath in files:
697
+ # Get nodes in the changed file
698
+ nodes = cg.get_nodes_by_file(filepath)
699
+ for node in nodes:
700
+ # Find what depends on these nodes
701
+ dependents = cg.get_impact_radius(node.id, depth=2)
702
+ for n in dependents.nodes.values():
703
+ if n.file_path.endswith('_test.py') or n.file_path.endswith('test_'):
704
+ affected_files.add(n.file_path)
705
+
706
+ if not affected_files:
707
+ click.echo('No affected test files found')
708
+ else:
709
+ click.echo(bold('\nAffected test files:\n'))
710
+ for f in sorted(affected_files):
711
+ click.echo(f' {f}')
712
+
713
+ cg.destroy()
714
+
715
+ except Exception as e:
716
+ click.echo(red(f'Failed: {str(e)}'))
717
+ sys.exit(1)
718
+
719
+
720
+ @main.command()
721
+ @click.argument('name', required=True)
722
+ @click.option('--path', '-p', 'path_arg', default=None, help='Project path')
723
+ def node(name: str, path_arg: Optional[str]):
724
+ """View details about a specific symbol node (source code, metadata)."""
725
+ project_path = resolve_project_path(path_arg)
726
+
727
+ if not is_initialized(project_path):
728
+ click.echo(red(f'CodeGraph not initialized in {project_path}'))
729
+ click.echo('Run "codegraph init" first')
730
+ return
731
+
732
+ try:
733
+ cg = CodeGraph(project_path)
734
+
735
+ # Search for nodes by name
736
+ results = cg.search(name)
737
+ if not results:
738
+ click.echo(yellow(f'No nodes found matching "{name}"'))
739
+ return
740
+
741
+ # If exact match, show details
742
+ exact_matches = [r for r in results if r.node.name == name]
743
+ if exact_matches:
744
+ target = exact_matches[0].node
745
+ else:
746
+ target = results[0].node
747
+
748
+ click.echo(bold(f'\nšŸ“¦ {target.kind}: {target.name}'))
749
+ click.echo(f' {dim("ID:")} {target.id}')
750
+ click.echo(f' {dim("File:")} {target.file_path}')
751
+ click.echo(f' {dim("Lines:")} {target.start_line}-{target.end_line}')
752
+ if target.signature:
753
+ click.echo(f' {dim("Signature:")} {target.signature}')
754
+ if target.docstring:
755
+ click.echo(f' {dim("Docstring:")} {target.docstring}')
756
+ if target.qualified_name:
757
+ click.echo(f' {dim("QName:")} {target.qualified_name}')
758
+ click.echo(f' {dim("Exported:")} {green("yes") if target.is_exported else "no"}')
759
+
760
+ # Show callers
761
+ callers = cg.get_callers(target.id)
762
+ if callers:
763
+ click.echo(bold(f'\n Callers ({len(callers)}):'))
764
+ for c in callers:
765
+ click.echo(f' ← {c.name} ({c.file_path}:{c.start_line})')
766
+
767
+ # Show callees
768
+ callees = cg.get_callees(target.id)
769
+ if callees:
770
+ click.echo(bold(f'\n Callees ({len(callees)}):'))
771
+ for c in callees:
772
+ click.echo(f' → {c.name} ({c.file_path}:{c.start_line})')
773
+
774
+ cg.destroy()
775
+
776
+ except Exception as e:
777
+ click.echo(red(f'Failed: {str(e)}'))
778
+ sys.exit(1)
779
+
780
+
781
+ @main.command()
782
+ @click.option('--path', '-p', 'path_arg', default=None, help='Project path')
783
+ @click.option('--json-output', 'json_output', is_flag=True, help='Output as JSON')
784
+ def files(path_arg: Optional[str], json_output: bool):
785
+ """List indexed files with language and symbol statistics."""
786
+ project_path = resolve_project_path(path_arg)
787
+
788
+ if not is_initialized(project_path):
789
+ click.echo(red(f'CodeGraph not initialized in {project_path}'))
790
+ click.echo('Run "codegraph init" first')
791
+ return
792
+
793
+ try:
794
+ cg = CodeGraph(project_path)
795
+ stats = cg.get_stats()
796
+ all_files = cg.db.get_all_file_paths()
797
+
798
+ if json_output:
799
+ result = {
800
+ 'total_files': len(all_files),
801
+ 'total_nodes': stats.node_count,
802
+ 'total_edges': stats.edge_count,
803
+ 'files': [],
804
+ }
805
+ for f in all_files:
806
+ nodes = cg.db.get_nodes_by_file(f)
807
+ langs = set(n.language for n in nodes if n.language)
808
+ result['files'].append({
809
+ 'path': f,
810
+ 'node_count': len(nodes),
811
+ 'languages': list(langs),
812
+ })
813
+ click.echo(json.dumps(result, indent=2))
814
+ else:
815
+ click.echo(bold(f'\nšŸ“ Indexed Files ({len(all_files)} total)\n'))
816
+ for f in sorted(all_files):
817
+ nodes = cg.db.get_nodes_by_file(f)
818
+ lang = ''
819
+ for n in nodes:
820
+ if n.language and n.language != 'unknown':
821
+ lang = n.language
822
+ break
823
+ kinds = {}
824
+ for n in nodes:
825
+ k = n.kind
826
+ kinds[k] = kinds.get(k, 0) + 1
827
+ kind_str = ', '.join(f'{v} {k}' for k, v in sorted(kinds.items()) if k != 'file')
828
+ if kind_str:
829
+ click.echo(f' {dim(lang+" ") if lang else ""}{f} {dim("("+kind_str+")")}')
830
+ else:
831
+ click.echo(f' {f}')
832
+
833
+ cg.destroy()
834
+
835
+ except Exception as e:
836
+ click.echo(red(f'Failed: {str(e)}'))
837
+ sys.exit(1)
838
+
839
+
840
+ @main.command()
841
+ @click.option('--path', '-p', 'path_arg', default=None, help='Project path')
842
+ def install(path_arg: Optional[str]):
843
+ """Install shell command integration (alias, completion)."""
844
+ project_path = resolve_project_path(path_arg)
845
+ click.echo(bold('\nšŸ”§ CodeGraph Shell Integration'))
846
+ click.echo()
847
+ click.echo('To add CodeGraph to your shell, add one of the following to your')
848
+ click.echo('shell configuration file (~/.bashrc, ~/.zshrc, etc.):')
849
+ click.echo()
850
+ click.echo(dim(' # Bash/Zsh'))
851
+ click.echo(f' alias cg="codegraph"')
852
+ click.echo()
853
+ click.echo(dim(' # Or for click shell completion:'))
854
+ click.echo(' eval "$(_CODEGRAPH_COMPLETE=bash_source codegraph)" # Bash')
855
+ click.echo(' eval "$(_CODEGRAPH_COMPLETE=zsh_source codegraph)" # Zsh')
856
+ click.echo(' codegraph completion install # Fish')
857
+ click.echo()
858
+ click.echo(green(' CodeGraph is ready at: ' + project_path))
859
+ click.echo()
860
+
861
+
862
+ @main.command()
863
+ @click.argument('path', required=False, default=None)
864
+ @click.option('-w', '--watch', is_flag=True, help='Watch for file changes and auto-sync')
865
+ @click.option('--verbose', '-v', is_flag=True, help='Log sync info to stderr')
866
+ def serve(path: Optional[str], watch: bool, verbose: bool):
867
+ """Start MCP server for AI agent integration.
868
+
869
+ Listens on stdin/stdout using the Model Context Protocol.
870
+ AI assistants connect to this server to query the code graph.
871
+ """
872
+ project_path = resolve_project_path(path)
873
+
874
+ if not is_initialized(project_path):
875
+ click.echo(red(f'CodeGraph not initialized in {project_path}'))
876
+ click.echo('Run "codegraph init" first')
877
+ return
878
+
879
+ click.echo(f'Starting CodeGraph MCP server for {project_path}...')
880
+ if watch:
881
+ click.echo(green(' File watching enabled — auto-syncing on changes'))
882
+
883
+ from codegraph.mcp import MCPServer
884
+ server = MCPServer(project_path, watch=watch)
885
+
886
+ import asyncio
887
+ async def run():
888
+ await server.start()
889
+
890
+ try:
891
+ asyncio.run(run())
892
+ except KeyboardInterrupt:
893
+ click.echo('\nServer stopped')
894
+
895
+
896
+ @main.command()
897
+ @click.argument('path', required=False, default=None)
898
+ def unlock(path: Optional[str]):
899
+ """Remove stale lock file."""
900
+ project_path = resolve_project_path(path)
901
+ lock_path = os.path.join(project_path, '.codegraph', 'codegraph.lock')
902
+
903
+ if os.path.isfile(lock_path):
904
+ os.remove(lock_path)
905
+ click.echo(green('Lock file removed'))
906
+ else:
907
+ click.echo('No lock file found')
908
+
909
+
910
+ if __name__ == '__main__':
911
+ main()