codegraph-cli 2.1.0__py3-none-any.whl → 2.1.2__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 (42) hide show
  1. codegraph_cli/__init__.py +1 -1
  2. codegraph_cli/agents.py +59 -3
  3. codegraph_cli/chat_agent.py +58 -11
  4. codegraph_cli/cli.py +569 -54
  5. codegraph_cli/cli_chat.py +204 -94
  6. codegraph_cli/cli_diagnose.py +13 -2
  7. codegraph_cli/cli_docs.py +207 -0
  8. codegraph_cli/cli_explore.py +1053 -0
  9. codegraph_cli/cli_export.py +941 -0
  10. codegraph_cli/cli_groups.py +33 -0
  11. codegraph_cli/cli_health.py +316 -0
  12. codegraph_cli/cli_history.py +213 -0
  13. codegraph_cli/cli_onboard.py +380 -0
  14. codegraph_cli/cli_quickstart.py +256 -0
  15. codegraph_cli/cli_refactor.py +17 -3
  16. codegraph_cli/cli_setup.py +12 -12
  17. codegraph_cli/cli_suggestions.py +90 -0
  18. codegraph_cli/cli_test.py +17 -3
  19. codegraph_cli/cli_tui.py +210 -0
  20. codegraph_cli/cli_v2.py +24 -4
  21. codegraph_cli/cli_watch.py +158 -0
  22. codegraph_cli/cli_workflows.py +255 -0
  23. codegraph_cli/codegen_agent.py +15 -1
  24. codegraph_cli/config.py +18 -5
  25. codegraph_cli/context_manager.py +117 -15
  26. codegraph_cli/crew_agents.py +32 -8
  27. codegraph_cli/crew_chat.py +146 -13
  28. codegraph_cli/crew_tools.py +30 -2
  29. codegraph_cli/embeddings.py +95 -5
  30. codegraph_cli/llm.py +42 -55
  31. codegraph_cli/project_context.py +64 -1
  32. codegraph_cli/rag.py +282 -19
  33. codegraph_cli/storage.py +310 -14
  34. codegraph_cli/vector_store.py +110 -8
  35. {codegraph_cli-2.1.0.dist-info → codegraph_cli-2.1.2.dist-info}/METADATA +75 -21
  36. codegraph_cli-2.1.2.dist-info/RECORD +55 -0
  37. codegraph_cli-2.1.2.dist-info/entry_points.txt +2 -0
  38. codegraph_cli-2.1.0.dist-info/RECORD +0 -43
  39. codegraph_cli-2.1.0.dist-info/entry_points.txt +0 -2
  40. {codegraph_cli-2.1.0.dist-info → codegraph_cli-2.1.2.dist-info}/WHEEL +0 -0
  41. {codegraph_cli-2.1.0.dist-info → codegraph_cli-2.1.2.dist-info}/licenses/LICENSE +0 -0
  42. {codegraph_cli-2.1.0.dist-info → codegraph_cli-2.1.2.dist-info}/top_level.txt +0 -0
codegraph_cli/cli.py CHANGED
@@ -2,44 +2,49 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from difflib import get_close_matches
5
6
  from pathlib import Path
6
- from typing import Optional
7
+ from typing import Dict, Optional
7
8
 
8
9
  import typer
10
+ from rich.console import Console
11
+ from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn
9
12
 
10
13
  from . import __version__, config
11
14
  from .cli_chat import chat_app
15
+ from .cli_explore import explore_app
16
+ from .cli_export import export_app
17
+ from .cli_groups import analyze_grp, config_grp, project_grp
18
+ from .cli_health import health_app
19
+ from .cli_onboard import onboard
20
+ from .cli_quickstart import quickstart_app
12
21
  from .cli_setup import setup as setup_wizard, set_llm, unset_llm, show_llm
13
22
  from .cli_setup import set_embedding, unset_embedding, show_embedding
14
- from .cli_v2 import v2_app
23
+ from .cli_suggestions import show_next_steps
24
+ from .cli_watch import watch_app
15
25
  from .graph_export import export_dot, export_html
16
26
  from .orchestrator import MCPOrchestrator
17
27
  from .storage import GraphStore, ProjectManager
18
28
 
29
+ console = Console()
30
+
19
31
  app = typer.Typer(
20
32
  help="🧠 CodeGraph CLI — AI-powered code intelligence & multi-agent assistant.",
21
33
  no_args_is_help=True,
22
34
  rich_markup_mode="rich",
35
+ add_completion=False,
23
36
  )
24
37
 
25
- # Register v2 commands
26
- app.add_typer(v2_app, name="v2")
27
-
28
- # Register chat commands
29
- app.add_typer(chat_app, name="chat")
30
-
31
- # Register setup wizard as direct command
32
- app.command("setup")(setup_wizard)
33
-
34
- # Register LLM management commands
35
- app.command("set-llm")(set_llm)
36
- app.command("unset-llm")(unset_llm)
37
- app.command("show-llm")(show_llm)
38
+ # ── Top-level groups ─────────────────────────────────────────────
39
+ app.add_typer(config_grp, name="config", help="Configuration management")
40
+ app.add_typer(project_grp, name="project", help="Project management")
41
+ app.add_typer(analyze_grp, name="analyze", help="Code analysis")
42
+ app.add_typer(chat_app, name="chat", help="Interactive chat with AI agents")
43
+ app.add_typer(explore_app, name="explore", help="Visual code explorer in browser")
44
+ app.add_typer(export_app, name="export", help="Export project documentation")
38
45
 
39
- # Register embedding management commands
40
- app.command("set-embedding")(set_embedding)
41
- app.command("unset-embedding")(unset_embedding)
42
- app.command("show-embedding")(show_embedding)
46
+ # Top-level standalone commands
47
+ app.command("onboard")(onboard)
43
48
 
44
49
 
45
50
  def version_callback(value: bool):
@@ -58,7 +63,7 @@ def main(
58
63
  help="Show version and exit.",
59
64
  callback=version_callback,
60
65
  is_eager=True,
61
- )
66
+ ),
62
67
  ):
63
68
  """CodeGraph CLI: Local-first code intelligence with AI-powered impact analysis."""
64
69
  pass
@@ -78,7 +83,6 @@ def _open_current_store(pm: ProjectManager) -> GraphStore:
78
83
  return GraphStore(project_dir)
79
84
 
80
85
 
81
- @app.command("index")
82
86
  def index_project(
83
87
  project_path: Path = typer.Argument(..., exists=True, file_okay=False, help="Path to source project."),
84
88
  project_name: Optional[str] = typer.Option(None, "--name", "-n", help="Explicit memory name for project."),
@@ -86,9 +90,15 @@ def index_project(
86
90
  llm_provider: str = typer.Option("ollama", help="LLM provider: ollama, groq, openai, anthropic."),
87
91
  llm_api_key: Optional[str] = typer.Option(None, help="API key for cloud LLM providers."),
88
92
  ):
89
- """Parse and index a project into local semantic memory."""
93
+ """📦 Parse and index a project into local semantic memory.
94
+
95
+ Example:
96
+ cg index ./my-project
97
+ cg index ./backend --name my-api
98
+ """
90
99
  from datetime import datetime
91
-
100
+ import time as _time
101
+
92
102
  pm = ProjectManager()
93
103
  resolved_path = project_path.resolve()
94
104
  name = project_name or _project_name_from_path(resolved_path)
@@ -101,26 +111,46 @@ def index_project(
101
111
  llm_provider=llm_provider,
102
112
  llm_api_key=llm_api_key,
103
113
  )
104
- stats = orchestrator.index(resolved_path)
105
-
106
- # Store project metadata including source path
114
+
115
+ start_t = _time.time()
116
+ with Progress(
117
+ SpinnerColumn(),
118
+ TextColumn("[progress.description]{task.description}"),
119
+ BarColumn(),
120
+ TaskProgressColumn(),
121
+ console=console,
122
+ ) as progress:
123
+ task = progress.add_task("[cyan]Indexing project...", total=None)
124
+ stats = orchestrator.index(resolved_path)
125
+ progress.update(task, completed=100, total=100, description="[green]Indexing complete")
126
+ elapsed = _time.time() - start_t
127
+
128
+ # Store project metadata including source path and embedding info
129
+ emb_model_key = getattr(orchestrator.embedding_model, 'model_key', 'hash')
130
+ emb_dim = getattr(orchestrator.embedding_model, 'dim', 256)
107
131
  store.set_metadata({
108
132
  **store.get_metadata(),
109
133
  "project_name": name,
110
134
  "source_path": str(resolved_path),
111
- "indexed_at": datetime.now().isoformat()
135
+ "indexed_at": datetime.now().isoformat(),
136
+ "embedding_model": emb_model_key,
137
+ "embedding_dim": emb_dim,
112
138
  })
113
-
139
+
114
140
  pm.set_current_project(name)
115
141
  store.close()
116
142
 
117
- typer.echo(f"Indexed '{resolved_path}' as project '{name}'.")
118
- typer.echo(f"Nodes: {stats['nodes']} | Edges: {stats['edges']}")
143
+ console.print(f"[green]✓[/green] Indexed [bold]'{resolved_path}'[/bold] as project [bold]'{name}'[/bold] in {elapsed:.1f}s")
144
+ console.print(f" Nodes: {stats['nodes']} | Edges: {stats['edges']}")
145
+ show_next_steps("index")
119
146
 
120
147
 
121
- @app.command("list-projects")
122
148
  def list_projects():
123
- """List all persisted project memories."""
149
+ """📋 List all persisted project memories.
150
+
151
+ Example:
152
+ cg list-projects
153
+ """
124
154
  pm = ProjectManager()
125
155
  projects = pm.list_projects()
126
156
  current = pm.get_current_project()
@@ -134,9 +164,12 @@ def list_projects():
134
164
  typer.echo(f"{marker} {p}")
135
165
 
136
166
 
137
- @app.command("load-project")
138
167
  def load_project(project_name: str = typer.Argument(..., help="Name of project memory to load.")):
139
- """Switch active project memory."""
168
+ """🔄 Switch active project memory.
169
+
170
+ Example:
171
+ cg load-project my-api
172
+ """
140
173
  pm = ProjectManager()
141
174
  if project_name not in pm.list_projects():
142
175
  raise typer.BadParameter(f"Project '{project_name}' not found.")
@@ -144,7 +177,6 @@ def load_project(project_name: str = typer.Argument(..., help="Name of project m
144
177
  typer.echo(f"Loaded project '{project_name}'.")
145
178
 
146
179
 
147
- @app.command("unload-project")
148
180
  def unload_project():
149
181
  """Unload active project memory without deleting data."""
150
182
  pm = ProjectManager()
@@ -152,9 +184,12 @@ def unload_project():
152
184
  typer.echo("Unloaded active project.")
153
185
 
154
186
 
155
- @app.command("delete-project")
156
187
  def delete_project(project_name: str = typer.Argument(..., help="Project memory to delete.")):
157
- """Delete persisted project memory."""
188
+ """🗑️ Delete persisted project memory.
189
+
190
+ Example:
191
+ cg delete-project old-project
192
+ """
158
193
  pm = ProjectManager()
159
194
  deleted = pm.delete_project(project_name)
160
195
  if not deleted:
@@ -164,12 +199,15 @@ def delete_project(project_name: str = typer.Argument(..., help="Project memory
164
199
  typer.echo(f"Deleted project '{project_name}'.")
165
200
 
166
201
 
167
- @app.command("merge-projects")
168
202
  def merge_projects(
169
203
  source_project: str = typer.Argument(..., help="Project to merge from."),
170
204
  target_project: str = typer.Argument(..., help="Project to merge into."),
171
205
  ):
172
- """Merge one project memory into another."""
206
+ """🔀 Merge one project memory into another.
207
+
208
+ Example:
209
+ cg merge-projects frontend backend
210
+ """
173
211
  pm = ProjectManager()
174
212
  if source_project not in pm.list_projects() or target_project not in pm.list_projects():
175
213
  raise typer.BadParameter("Both source and target projects must exist.")
@@ -183,19 +221,26 @@ def merge_projects(
183
221
  typer.echo(f"Merged '{source_project}' into '{target_project}'.")
184
222
 
185
223
 
186
- @app.command("search")
187
224
  def search(
188
225
  query: str = typer.Argument(..., help="Semantic query for code discovery."),
189
226
  top_k: int = typer.Option(5, min=1, max=30, help="Maximum number of matches."),
190
227
  ):
191
- """Run semantic search across currently loaded project memory."""
228
+ """🔍 Semantic search across your codebase (alias: find).
229
+
230
+ Example:
231
+ cg search "database migration logic"
232
+ cg search "JWT token validation"
233
+ cg search "authentication" --top-k 10
234
+ """
192
235
  pm = ProjectManager()
193
236
  store = _open_current_store(pm)
194
237
  orchestrator = MCPOrchestrator(store)
195
- results = orchestrator.search(query, top_k=top_k)
238
+
239
+ with console.status("[cyan]Searching...[/cyan]"):
240
+ results = orchestrator.search(query, top_k=top_k)
196
241
 
197
242
  if not results:
198
- typer.echo("No semantic matches found.")
243
+ console.print("[yellow]No semantic matches found.[/yellow]")
199
244
  store.close()
200
245
  raise typer.Exit(code=0)
201
246
 
@@ -207,9 +252,9 @@ def search(
207
252
  typer.echo(f" {snippet[0][:120]}")
208
253
 
209
254
  store.close()
255
+ show_next_steps("search")
210
256
 
211
257
 
212
- @app.command("impact")
213
258
  def impact(
214
259
  symbol: str = typer.Argument(..., help="Function/class/module symbol to analyze."),
215
260
  hops: int = typer.Option(2, min=1, max=6, help="Dependency traversal depth."),
@@ -227,7 +272,12 @@ def impact(
227
272
  help="LLM model name.",
228
273
  ),
229
274
  ):
230
- """Run multi-hop impact analysis using graph + RAG + local LLM."""
275
+ """📊 Multi-hop impact analysis using graph + RAG + local LLM.
276
+
277
+ Example:
278
+ cg impact "UserService.authenticate"
279
+ cg impact "process_payment" --hops 3
280
+ """
231
281
  pm = ProjectManager()
232
282
  store = _open_current_store(pm)
233
283
  orchestrator = MCPOrchestrator(
@@ -270,12 +320,16 @@ def impact(
270
320
  store.close()
271
321
 
272
322
 
273
- @app.command("graph")
274
323
  def graph(
275
324
  symbol: str = typer.Argument(..., help="Function/class/module symbol to inspect."),
276
325
  depth: int = typer.Option(2, min=1, max=6, help="Traversal depth."),
277
326
  ):
278
- """Show lightweight ASCII dependency graph around a symbol."""
327
+ """🕸️ Show lightweight ASCII dependency graph around a symbol.
328
+
329
+ Example:
330
+ cg graph "UserService"
331
+ cg graph "main" --depth 4
332
+ """
279
333
  pm = ProjectManager()
280
334
  store = _open_current_store(pm)
281
335
  orchestrator = MCPOrchestrator(store)
@@ -284,13 +338,18 @@ def graph(
284
338
  store.close()
285
339
 
286
340
 
287
- @app.command("export-graph")
288
341
  def export_graph(
289
342
  symbol: str = typer.Argument("", help="Optional focus symbol to export local subgraph."),
290
343
  fmt: str = typer.Option("html", "--format", "-f", help="Export format: html or dot."),
291
344
  output: Optional[Path] = typer.Option(None, "--output", "-o", help="Output file path."),
292
345
  ):
293
- """Export graph to standalone HTML or Graphviz DOT."""
346
+ """📤 Export graph to standalone HTML or Graphviz DOT.
347
+
348
+ Example:
349
+ cg export-graph
350
+ cg export-graph UserService --format dot
351
+ cg export-graph --output my_graph.html
352
+ """
294
353
  fmt = fmt.lower()
295
354
  if fmt not in {"html", "dot"}:
296
355
  raise typer.BadParameter("Format must be one of: html, dot")
@@ -311,20 +370,27 @@ def export_graph(
311
370
  store.close()
312
371
 
313
372
 
314
- @app.command("current-project")
315
373
  def current_project():
316
- """Print active project memory name."""
374
+ """📌 Print active project memory name.
375
+
376
+ Example:
377
+ cg current-project
378
+ """
317
379
  pm = ProjectManager()
318
380
  current = pm.get_current_project()
319
381
  typer.echo(current or "No project loaded")
320
382
 
321
383
 
322
- @app.command("rag-context")
323
384
  def rag_context(
324
385
  query: str = typer.Argument(..., help="Query to retrieve code context without analysis."),
325
386
  top_k: int = typer.Option(6, min=1, max=30, help="Number of snippets to fetch."),
326
387
  ):
327
- """Retrieve top semantic snippets to inspect RAG context directly."""
388
+ """📄 Retrieve top semantic snippets to inspect RAG context directly.
389
+
390
+ Example:
391
+ cg rag-context "authentication flow"
392
+ cg rag-context "database models" --top-k 10
393
+ """
328
394
  pm = ProjectManager()
329
395
  store = _open_current_store(pm)
330
396
  orchestrator = MCPOrchestrator(store)
@@ -332,5 +398,454 @@ def rag_context(
332
398
  store.close()
333
399
 
334
400
 
401
+ # ===================================================================
402
+ # Tree command - Show project structure
403
+ # ===================================================================
404
+
405
+ def _build_tree_structure(store: "GraphStore", full: bool = False) -> Dict:
406
+ """Build a nested tree structure from indexed nodes.
407
+
408
+ Args:
409
+ store: GraphStore instance with indexed nodes
410
+ full: If True, include functions/classes breakdown
411
+
412
+ Returns:
413
+ Nested dict representing the tree structure
414
+ """
415
+ from collections import defaultdict
416
+
417
+ # Get all nodes grouped by file
418
+ nodes_by_file = store.all_by_file()
419
+
420
+ # Build tree structure
421
+ tree: Dict = defaultdict(lambda: {"type": "directory", "children": {}, "functions": [], "classes": []})
422
+
423
+ for file_path, nodes in nodes_by_file.items():
424
+ # Split file path into parts
425
+ parts = Path(file_path).parts
426
+ current = tree
427
+
428
+ # Navigate/create directory structure
429
+ for part in parts[:-1]:
430
+ if part not in current:
431
+ current[part] = {"type": "directory", "children": {}, "functions": [], "classes": []}
432
+ current = current[part]["children"]
433
+
434
+ # Add file node
435
+ filename = parts[-1] if parts else file_path
436
+ file_node = {
437
+ "type": "file",
438
+ "path": file_path,
439
+ "functions": [],
440
+ "classes": [],
441
+ "other": [],
442
+ }
443
+
444
+ if full:
445
+ # Categorize nodes by type
446
+ for node in nodes:
447
+ node_type = node.get("node_type", "unknown")
448
+ name = node.get("name", "unknown")
449
+ qualname = node.get("qualname", name)
450
+ start_line = node.get("start_line", 0)
451
+ end_line = node.get("end_line", 0)
452
+
453
+ node_info = {
454
+ "name": name,
455
+ "qualname": qualname,
456
+ "lines": f"L{start_line}-{end_line}",
457
+ }
458
+
459
+ if node_type == "function":
460
+ file_node["functions"].append(node_info)
461
+ elif node_type == "class":
462
+ file_node["classes"].append(node_info)
463
+ else:
464
+ file_node["other"].append(node_info)
465
+
466
+ current[filename] = file_node
467
+
468
+ return dict(tree)
469
+
470
+
471
+ def _render_tree(tree: Dict, prefix: str = "", full: bool = False, is_last: bool = True) -> str:
472
+ """Render tree structure as ASCII art.
473
+
474
+ Args:
475
+ tree: Nested dict representing the tree
476
+ prefix: Current line prefix for indentation
477
+ full: If True, show functions/classes breakdown
478
+ is_last: Whether this is the last item at current level
479
+
480
+ Returns:
481
+ ASCII tree string
482
+ """
483
+ lines = []
484
+ entries = sorted(tree.keys())
485
+
486
+ for i, name in enumerate(entries):
487
+ node = tree[name]
488
+ is_last_item = (i == len(entries) - 1)
489
+
490
+ # Choose connector
491
+ connector = " " if is_last_item else " |"
492
+ child_prefix = prefix + connector
493
+
494
+ # Determine node type indicator
495
+ if isinstance(node, dict):
496
+ node_type = node.get("type", "unknown")
497
+
498
+ if node_type == "directory":
499
+ # Directory - show with trailing slash
500
+ lines.append(f"{prefix}{'`-- ' if is_last_item else '|-- '}{name}/")
501
+ # Recursively render children
502
+ children = node.get("children", {})
503
+ if children:
504
+ lines.append(_render_tree(children, child_prefix, full, is_last_item))
505
+
506
+ elif node_type == "file":
507
+ # File - show filename
508
+ lines.append(f"{prefix}{'`-- ' if is_last_item else '|-- '}{name}")
509
+
510
+ if full:
511
+ # Show functions
512
+ functions = node.get("functions", [])
513
+ for j, func in enumerate(functions):
514
+ is_last_func = (j == len(functions) - 1) and not node.get("classes")
515
+ func_connector = " " if is_last_func else " |"
516
+ lines.append(f"{child_prefix}{'`-- ' if is_last_func else '|-- '}[fn] {func['name']} ({func['lines']})")
517
+
518
+ # Show classes
519
+ classes = node.get("classes", [])
520
+ for j, cls in enumerate(classes):
521
+ is_last_cls = (j == len(classes) - 1) and not node.get("other")
522
+ cls_connector = " " if is_last_cls else " |"
523
+ lines.append(f"{child_prefix}{'`-- ' if is_last_cls else '|-- '}[cls] {cls['name']} ({cls['lines']})")
524
+
525
+ # Show other nodes (modules, imports, etc.)
526
+ other = node.get("other", [])
527
+ for j, item in enumerate(other):
528
+ is_last_other = j == len(other) - 1
529
+ lines.append(f"{child_prefix}{'`-- ' if is_last_other else '|-- '}[{item.get('name', 'unknown')}]")
530
+ else:
531
+ # Simple entry
532
+ lines.append(f"{prefix}{'`-- ' if is_last_item else '|-- '}{name}")
533
+
534
+ return "\n".join(lines)
535
+
536
+
537
+ def tree_command(
538
+ full: bool = typer.Option(
539
+ False,
540
+ "--full",
541
+ "-f",
542
+ help="Show full breakdown including functions and classes within each file.",
543
+ ),
544
+ path: Optional[Path] = typer.Option(
545
+ None,
546
+ "--path",
547
+ "-p",
548
+ help="Filter tree to show only files under a specific path prefix.",
549
+ ),
550
+ ):
551
+ """Show the directory tree structure of the currently indexed project.
552
+
553
+ By default, shows files and directories. Use --full to see functions
554
+ and classes within each file.
555
+
556
+ Examples:
557
+ cg tree # Show basic file/directory tree
558
+ cg tree --full # Show tree with functions/classes breakdown
559
+ cg tree -p src/ # Show only files under src/ directory
560
+ """
561
+ pm = ProjectManager()
562
+ store = _open_current_store(pm)
563
+
564
+ # Get project metadata for display
565
+ metadata = store.get_metadata()
566
+ project_name = metadata.get("project_name", pm.get_current_project() or "unknown")
567
+ source_path = metadata.get("source_path", "unknown")
568
+
569
+ typer.echo(f"Project: {project_name}")
570
+ typer.echo(f"Source: {source_path}")
571
+ typer.echo("")
572
+
573
+ # Build and render tree
574
+ tree = _build_tree_structure(store, full=full)
575
+
576
+ if not tree:
577
+ typer.echo("No files indexed in this project.")
578
+ store.close()
579
+ raise typer.Exit(code=0)
580
+
581
+ # Filter by path if specified
582
+ if path:
583
+ path_str = str(path).rstrip("/")
584
+ # Navigate to the specified sub-tree
585
+ parts = Path(path_str).parts
586
+ current = tree
587
+ for part in parts:
588
+ if part in current:
589
+ node = current[part]
590
+ if isinstance(node, dict) and node.get("type") == "directory":
591
+ current = node.get("children", {})
592
+ else:
593
+ current = {part: node}
594
+ break
595
+ else:
596
+ typer.echo(f"Path '{path}' not found in indexed project.")
597
+ store.close()
598
+ raise typer.Exit(code=1)
599
+ tree = current
600
+
601
+ # Render the tree
602
+ tree_output = _render_tree(tree, full=full)
603
+ typer.echo(tree_output)
604
+
605
+ # Show summary stats
606
+ nodes = store.get_nodes()
607
+ files = set(n["file_path"] for n in nodes)
608
+ functions = sum(1 for n in nodes if n["node_type"] == "function")
609
+ classes = sum(1 for n in nodes if n["node_type"] == "class")
610
+
611
+ typer.echo("")
612
+ typer.echo(f"Summary: {len(files)} files, {functions} functions, {classes} classes, {len(nodes)} total nodes")
613
+
614
+ # Detect unindexed files and warn
615
+ source = metadata.get("source_path") or metadata.get("project_root")
616
+ if source:
617
+ unindexed = _detect_unindexed_files(Path(source), files)
618
+ if unindexed:
619
+ typer.echo("")
620
+ typer.echo(
621
+ typer.style(
622
+ f"⚠ {len(unindexed)} file(s) on disk not in index:",
623
+ fg=typer.colors.YELLOW,
624
+ )
625
+ )
626
+ for f in sorted(unindexed)[:10]:
627
+ typer.echo(f" + {f}")
628
+ if len(unindexed) > 10:
629
+ typer.echo(f" … and {len(unindexed) - 10} more")
630
+ typer.echo(
631
+ f"\nRun {typer.style('cg analyze sync', fg=typer.colors.CYAN)} to incrementally index new/changed files."
632
+ )
633
+
634
+ store.close()
635
+
636
+
637
+ # ===================================================================
638
+ # Helpers: detect out-of-sync files
639
+ # ===================================================================
640
+
641
+ def _detect_unindexed_files(
642
+ source_root: Path, indexed_files: set[str],
643
+ ) -> list[str]:
644
+ """Return relative paths of parseable files on disk that are NOT in the index."""
645
+ from .parser import LANGUAGE_MAP, SKIP_DIRS
646
+
647
+ unindexed: list[str] = []
648
+ if not source_root.is_dir():
649
+ return unindexed
650
+
651
+ for ext in LANGUAGE_MAP:
652
+ for fp in sorted(source_root.rglob(f"*{ext}")):
653
+ if any(part in SKIP_DIRS for part in fp.parts):
654
+ continue
655
+ rel = str(fp.relative_to(source_root))
656
+ if rel not in indexed_files:
657
+ unindexed.append(rel)
658
+ return unindexed
659
+
660
+
661
+ # ===================================================================
662
+ # Sync command — incremental index of new / changed files
663
+ # ===================================================================
664
+
665
+ def sync_command(
666
+ dry_run: bool = typer.Option(
667
+ False, "--dry-run", "-n",
668
+ help="Only list what would be synced; don't modify the index.",
669
+ ),
670
+ ):
671
+ """Incrementally sync the index with the source directory.
672
+
673
+ Detects new and deleted files relative to the current index and
674
+ updates accordingly — much faster than a full re-index.
675
+
676
+ Examples:
677
+ cg analyze sync # sync new/deleted files
678
+ cg analyze sync --dry-run # preview changes only
679
+ """
680
+ from .embeddings import get_embedder
681
+ from .parser import LANGUAGE_MAP, SKIP_DIRS
682
+
683
+ pm = ProjectManager()
684
+ store = _open_current_store(pm)
685
+ metadata = store.get_metadata()
686
+ source = metadata.get("source_path") or metadata.get("project_root")
687
+
688
+ if not source:
689
+ typer.echo("❌ No source path recorded for this project. Re-index with: cg project index <path>")
690
+ store.close()
691
+ raise typer.Exit(1)
692
+
693
+ source_root = Path(source)
694
+ if not source_root.is_dir():
695
+ typer.echo(f"❌ Source path no longer exists: {source}")
696
+ store.close()
697
+ raise typer.Exit(1)
698
+
699
+ # Gather indexed files
700
+ nodes = store.get_nodes()
701
+ indexed_files = set(n["file_path"] for n in nodes)
702
+
703
+ # Gather files on disk
704
+ disk_files: set[str] = set()
705
+ for ext in LANGUAGE_MAP:
706
+ for fp in sorted(source_root.rglob(f"*{ext}")):
707
+ if any(part in SKIP_DIRS for part in fp.parts):
708
+ continue
709
+ disk_files.add(str(fp.relative_to(source_root)))
710
+
711
+ new_files = sorted(disk_files - indexed_files)
712
+ deleted_files = sorted(indexed_files - disk_files)
713
+
714
+ if not new_files and not deleted_files:
715
+ typer.echo("✅ Index is already in sync — no changes detected.")
716
+ store.close()
717
+ return
718
+
719
+ # Report
720
+ if new_files:
721
+ typer.echo(typer.style(f"\n📄 {len(new_files)} new file(s):", bold=True))
722
+ for f in new_files:
723
+ typer.echo(f" + {f}")
724
+
725
+ if deleted_files:
726
+ typer.echo(typer.style(f"\n🗑 {len(deleted_files)} deleted file(s):", bold=True))
727
+ for f in deleted_files:
728
+ typer.echo(f" - {f}")
729
+
730
+ if dry_run:
731
+ typer.echo(f"\n(dry run — no changes made)")
732
+ store.close()
733
+ return
734
+
735
+ # Apply changes
736
+ embedder = get_embedder()
737
+ model_key = getattr(embedder, "model_key", "hash")
738
+
739
+ added_nodes = 0
740
+ removed_nodes = 0
741
+
742
+ with Progress(
743
+ SpinnerColumn(),
744
+ TextColumn("[progress.description]{task.description}"),
745
+ BarColumn(),
746
+ TaskProgressColumn(),
747
+ console=console,
748
+ ) as progress:
749
+ total = len(new_files) + len(deleted_files)
750
+ task = progress.add_task("Syncing...", total=total)
751
+
752
+ for f in deleted_files:
753
+ removed_nodes += store.remove_nodes_for_file(f)
754
+ progress.advance(task)
755
+
756
+ for f in new_files:
757
+ fp = source_root / f
758
+ added_nodes += store.index_single_file(
759
+ file_path=fp,
760
+ project_root=source_root,
761
+ embedder=embedder,
762
+ model_key=model_key,
763
+ )
764
+ progress.advance(task)
765
+
766
+ typer.echo(
767
+ f"\n✅ Sync complete: "
768
+ f"+{added_nodes} nodes ({len(new_files)} files), "
769
+ f"-{removed_nodes} nodes ({len(deleted_files)} files)"
770
+ )
771
+ store.close()
772
+
773
+
774
+ # ===================================================================
775
+ # Wire functions into groups (the ONLY way to reach these commands)
776
+ # ===================================================================
777
+
778
+ # Config group: setup, LLM, and embedding management
779
+ config_grp.command("setup")(setup_wizard)
780
+ config_grp.command("set-llm")(set_llm)
781
+ config_grp.command("unset-llm")(unset_llm)
782
+ config_grp.command("show-llm")(show_llm)
783
+ config_grp.command("set-embedding")(set_embedding)
784
+ config_grp.command("unset-embedding")(unset_embedding)
785
+ config_grp.command("show-embedding")(show_embedding)
786
+
787
+ # Project group: index, load, list, delete, merge, current, init, watch
788
+ project_grp.command("index")(index_project)
789
+ project_grp.command("list")(list_projects)
790
+ project_grp.command("load")(load_project)
791
+ project_grp.command("unload")(unload_project)
792
+ project_grp.command("delete")(delete_project)
793
+ project_grp.command("merge")(merge_projects)
794
+ project_grp.command("current")(current_project)
795
+ project_grp.add_typer(quickstart_app, name="init", help="🚀 Quick-start wizard")
796
+ project_grp.add_typer(watch_app, name="watch", help="👀 Auto-reindex on file changes")
797
+
798
+ # Analyze group: search, impact, graph, export-graph, rag-context, tree, health
799
+ analyze_grp.command("search")(search)
800
+ analyze_grp.command("impact")(impact)
801
+ analyze_grp.command("graph")(graph)
802
+ analyze_grp.command("export-graph")(export_graph)
803
+ analyze_grp.command("rag-context")(rag_context)
804
+ analyze_grp.command("tree")(tree_command)
805
+ analyze_grp.command("sync")(sync_command)
806
+ analyze_grp.add_typer(health_app, name="health", help="🏥 Project health dashboard")
807
+
808
+
809
+ # ===================================================================
810
+ # Fuzzy command matching for typos
811
+ # ===================================================================
812
+
813
+
814
+ def _get_all_command_names() -> list[str]:
815
+ """Collect all registered command names including groups."""
816
+ names = []
817
+ for cmd in app.registered_commands:
818
+ if cmd.name:
819
+ names.append(cmd.name)
820
+ for group in app.registered_groups:
821
+ if group.name:
822
+ names.append(group.name)
823
+ return names
824
+
825
+
826
+ def cli_main() -> None:
827
+ """Entry point with fuzzy command matching on unknown commands.
828
+
829
+ This wraps the Typer app invocation to catch unknown-command errors
830
+ (exit code 2) and suggest similar commands using difflib.
831
+ """
832
+ import sys
833
+
834
+ try:
835
+ app()
836
+ except SystemExit as e:
837
+ if e.code == 2 and len(sys.argv) > 1:
838
+ unknown = sys.argv[1]
839
+ if not unknown.startswith("-"):
840
+ all_commands = _get_all_command_names()
841
+ suggestions = get_close_matches(unknown, all_commands, n=3, cutoff=0.5)
842
+ if suggestions:
843
+ console.print(f"\n[yellow]Unknown command '[bold]{unknown}[/bold]'. Did you mean:[/yellow]")
844
+ for s in suggestions:
845
+ console.print(f" [cyan]cg {s}[/cyan]")
846
+ console.print()
847
+ raise
848
+
849
+
335
850
  if __name__ == "__main__":
336
- app()
851
+ cli_main()