cortexcode 0.1.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.
cortexcode/cli.py ADDED
@@ -0,0 +1,845 @@
1
+ """CLI - Command-line interface for CortexCode."""
2
+
3
+ import sys
4
+ import time
5
+ from pathlib import Path
6
+
7
+ import click
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn, TimeRemainingColumn
11
+ from rich.prompt import Confirm, Prompt
12
+ from rich.panel import Panel
13
+ from rich import box
14
+
15
+ from cortexcode import indexer
16
+ from cortexcode.context import get_context, calculate_token_savings
17
+ from cortexcode.git_diff import get_diff_context
18
+ from cortexcode.docs import generate_all_docs
19
+ from cortexcode.watcher import start_watcher
20
+
21
+
22
+ console = Console()
23
+
24
+
25
+ @click.group()
26
+ @click.version_option(version="0.1.0", prog_name="cortexcode")
27
+ def main():
28
+ """CortexCode - Lightweight code indexing for AI assistants."""
29
+ pass
30
+
31
+
32
+ @main.command()
33
+ @click.argument("path", default=".", type=click.Path(exists=True))
34
+ @click.option("-o", "--output", default=".cortexcode/index.json", help="Output file path")
35
+ @click.option("-v", "--verbose", is_flag=True, help="Show verbose output")
36
+ @click.option("-w", "--watch", is_flag=True, help="Start watching after indexing")
37
+ @click.option("-i", "--incremental", is_flag=True, help="Only re-index changed files")
38
+ def index(path, output, verbose, watch, incremental):
39
+ """Index a directory and save the code graph."""
40
+ path = Path(path).resolve()
41
+ output = Path(output)
42
+
43
+ console.print(Panel.fit(
44
+ f"[bold cyan]⚡ CortexCode Indexer[/bold cyan]\n"
45
+ f"[dim]Scanning: {path}[/dim]" + (" (incremental)" if incremental else ""),
46
+ border_style="cyan"
47
+ ))
48
+
49
+ with Progress(
50
+ SpinnerColumn(),
51
+ TextColumn("[progress.description]{task.description}"),
52
+ BarColumn(bar_width=40),
53
+ TaskProgressColumn(),
54
+ TimeRemainingColumn(),
55
+ console=console,
56
+ ) as progress:
57
+ task = progress.add_task("[cyan]Indexing files...", total=None)
58
+
59
+ start_time = time.time()
60
+ index_data = indexer.index_directory(path, incremental=incremental)
61
+ elapsed = time.time() - start_time
62
+
63
+ progress.update(task, completed=True)
64
+
65
+ indexer.save_index(index_data, output)
66
+
67
+ file_count = len(index_data.get("files", {}))
68
+ symbol_count = sum(len(s.get("symbols", [])) if isinstance(s, dict) else len(s) for s in index_data.get("files", {}).values())
69
+ languages = index_data.get("languages", [])
70
+
71
+ table = Table(box=box.ROUNDED, show_header=False)
72
+ table.add_column("Key", style="cyan")
73
+ table.add_column("Value", style="white")
74
+
75
+ table.add_row("Files", f"[bold]{file_count}[/bold]")
76
+ table.add_row("Symbols", f"[bold]{symbol_count}[/bold]")
77
+ table.add_row("Languages", ", ".join(languages) if languages else "N/A")
78
+ table.add_row("Time", f"{elapsed:.2f}s")
79
+ table.add_row("Output", str(output))
80
+ if incremental:
81
+ table.add_row("Mode", "[yellow]Incremental[/yellow]")
82
+
83
+ console.print()
84
+ console.print(Panel(
85
+ table,
86
+ title="[bold green]✓ Indexing Complete[/bold green]",
87
+ border_style="green"
88
+ ))
89
+
90
+ if verbose:
91
+ _show_index_summary(index_data)
92
+
93
+ if watch:
94
+ console.print(f"\n[yellow]Starting watcher...[/yellow]")
95
+ console.print("[dim]Press Ctrl+C to stop[/dim]")
96
+ start_watcher(path, verbose=verbose)
97
+
98
+
99
+ @main.command()
100
+ @click.argument("path", default=".", type=click.Path(exists=True))
101
+ @click.option("-v", "--verbose", is_flag=True, help="Show file change events")
102
+ def watch(path, verbose):
103
+ """Watch for file changes and auto-reindex."""
104
+ path = Path(path).resolve()
105
+
106
+ console.print(f"[bold blue]Watching:[/bold blue] {path}")
107
+ console.print("[dim]Press Ctrl+C to stop[/dim]")
108
+
109
+ start_watcher(path, verbose=verbose)
110
+
111
+
112
+ @main.command()
113
+ @click.argument("query", required=False)
114
+ @click.option("-n", "--num-results", default=5, help="Number of results to return")
115
+ @click.option("-f", "--format", "output_format", default="text", type=click.Choice(["text", "json"]))
116
+ @click.option("--tokens", is_flag=True, help="Show token savings estimate")
117
+ def context(query, num_results, output_format, tokens):
118
+ """Get relevant context for AI assistants."""
119
+ index_path = Path(".cortexcode/index.json")
120
+
121
+ if not index_path.exists():
122
+ console.print("[bold red]Error:[/bold red] No index found. Run 'cortexcode index' first.")
123
+ sys.exit(1)
124
+
125
+ result = get_context(index_path, query, num_results)
126
+
127
+ if output_format == "json":
128
+ import json
129
+ console.print(json.dumps(result, indent=2))
130
+ else:
131
+ _print_context(result)
132
+
133
+ if tokens:
134
+ savings = calculate_token_savings(index_path, query, num_results)
135
+ _print_token_savings(savings)
136
+
137
+
138
+ @main.command()
139
+ @click.argument("query")
140
+ @click.option("-t", "--type", "sym_type", default=None, help="Filter by type (function, class, method, interface)")
141
+ @click.option("-f", "--file", "file_filter", default=None, help="Filter by file path")
142
+ @click.option("-n", "--limit", default=20, help="Max results")
143
+ def search(query, sym_type, file_filter, limit):
144
+ """Search indexed symbols (grep-like)."""
145
+ index_path = Path(".cortexcode/index.json")
146
+
147
+ if not index_path.exists():
148
+ console.print("[bold red]Error:[/bold red] No index found. Run 'cortexcode index' first.")
149
+ sys.exit(1)
150
+
151
+ import json as _json
152
+ index = _json.loads(index_path.read_text(encoding="utf-8"))
153
+ files = index.get("files", {})
154
+ call_graph = index.get("call_graph", {})
155
+ query_lower = query.lower()
156
+
157
+ results = []
158
+ for rel_path, file_data in files.items():
159
+ if file_filter and file_filter.lower() not in rel_path.lower():
160
+ continue
161
+
162
+ symbols = file_data.get("symbols", []) if isinstance(file_data, dict) else file_data
163
+ for sym in symbols:
164
+ name = sym.get("name", "").lower()
165
+ if query_lower in name:
166
+ if sym_type and sym.get("type") != sym_type:
167
+ continue
168
+ results.append({**sym, "file": rel_path})
169
+
170
+ if not results:
171
+ console.print(f"[yellow]No symbols matching '{query}'[/yellow]")
172
+ return
173
+
174
+ console.print(f"\n[bold]Found {len(results)} symbols matching '{query}':[/bold]\n")
175
+
176
+ table = Table(box=box.SIMPLE, show_header=True)
177
+ table.add_column("Type", style="dim", width=10)
178
+ table.add_column("Name", style="cyan")
179
+ table.add_column("File", style="dim")
180
+ table.add_column("Line", justify="right")
181
+ table.add_column("Calls", style="dim")
182
+
183
+ for sym in results[:limit]:
184
+ calls = sym.get("calls", [])
185
+ calls_str = ", ".join(calls[:3]) + ("..." if len(calls) > 3 else "") if calls else ""
186
+ table.add_row(
187
+ sym.get("type", "?"),
188
+ sym.get("name", "?"),
189
+ sym.get("file", "?"),
190
+ str(sym.get("line", "?")),
191
+ calls_str,
192
+ )
193
+
194
+ console.print(table)
195
+
196
+ if len(results) > limit:
197
+ console.print(f"\n[dim]Showing {limit}/{len(results)} results. Use -n to see more.[/dim]")
198
+
199
+
200
+ @main.command()
201
+ @click.option("--ref", default="HEAD", help="Git ref to compare against (default: HEAD)")
202
+ @click.option("-f", "--format", "output_format", default="text", type=click.Choice(["text", "json"]))
203
+ def diff(ref, output_format):
204
+ """Show changed symbols since last commit (git diff-aware context)."""
205
+ index_path = Path(".cortexcode/index.json")
206
+
207
+ if not index_path.exists():
208
+ console.print("[bold red]Error:[/bold red] No index found. Run 'cortexcode index' first.")
209
+ sys.exit(1)
210
+
211
+ result = get_diff_context(index_path, ref)
212
+
213
+ if output_format == "json":
214
+ import json as _json
215
+ console.print(_json.dumps(result, indent=2))
216
+ return
217
+
218
+ if result["changed_files"] == 0:
219
+ console.print("[green]No changes detected.[/green]")
220
+ return
221
+
222
+ console.print(Panel.fit(
223
+ f"[bold cyan]Git Diff Context[/bold cyan]\n"
224
+ f"[dim]Comparing against: {ref}[/dim]",
225
+ border_style="cyan"
226
+ ))
227
+
228
+ console.print(f"\n[bold]{result['changed_files']}[/bold] files changed\n")
229
+
230
+ # Changed symbols
231
+ changed = result.get("changed_symbols", [])
232
+ if changed:
233
+ table = Table(title="Changed Symbols", box=box.SIMPLE, show_header=True)
234
+ table.add_column("", width=3)
235
+ table.add_column("Type", style="dim", width=10)
236
+ table.add_column("Name", style="cyan")
237
+ table.add_column("File", style="dim")
238
+ table.add_column("Line", justify="right")
239
+
240
+ for sym in changed:
241
+ marker = "[red]*[/red]" if sym.get("changed") else "[dim]~[/dim]"
242
+ table.add_row(
243
+ marker,
244
+ sym.get("type", "?"),
245
+ sym.get("name", "?"),
246
+ sym.get("file", "?"),
247
+ str(sym.get("line", "?")),
248
+ )
249
+
250
+ console.print(table)
251
+ console.print("[dim] [red]*[/red] = in changed lines [dim]~[/dim] = in changed file[/dim]\n")
252
+
253
+ # Affected symbols (callers of changed code)
254
+ affected = result.get("affected_symbols", [])
255
+ if affected:
256
+ console.print("[bold yellow]Potentially affected symbols:[/bold yellow]\n")
257
+ for sym in affected:
258
+ calls_changed = ", ".join(sym.get("calls_changed", []))
259
+ console.print(f" [yellow]{sym['name']}[/yellow] ({sym.get('type', '?')}) in {sym.get('file', '?')}")
260
+ console.print(f" [dim]calls changed: {calls_changed}[/dim]")
261
+
262
+
263
+ @main.command()
264
+ @click.argument("path", default=".", type=click.Path(exists=True))
265
+ @click.option("-o", "--output", default=".cortexcode/docs", help="Output directory")
266
+ @click.option("--open", "open_browser", is_flag=True, help="Open HTML docs in browser")
267
+ def docs(path, output, open_browser):
268
+ """Generate project documentation."""
269
+ path = Path(path).resolve()
270
+ output = Path(output)
271
+
272
+ console.print(Panel.fit(
273
+ f"[bold cyan]📄 Documentation Generator[/bold cyan]\n"
274
+ f"[dim]Source: {path}[/dim]",
275
+ border_style="cyan"
276
+ ))
277
+
278
+ index_path = path / ".cortexcode" / "index.json"
279
+ if not index_path.exists():
280
+ console.print("[bold red]Error:[/bold red] No index found. Run 'cortexcode index' first.")
281
+ sys.exit(1)
282
+
283
+ with Progress(
284
+ SpinnerColumn(),
285
+ TextColumn("[progress.description]{task.description}"),
286
+ console=console,
287
+ ) as progress:
288
+ task = progress.add_task("[cyan]Generating documentation...", total=None)
289
+ generate_all_docs(index_path, output)
290
+ progress.update(task, completed=True)
291
+
292
+ console.print()
293
+ console.print(Panel(
294
+ f"[bold green]✓[/bold green] Documentation generated successfully!\n\n"
295
+ f"[cyan]Output:[/cyan] {output}\n"
296
+ f"[cyan]HTML:[/cyan] {output}/index.html\n\n"
297
+ f"[dim]Open index.html in a browser to view interactive documentation.[/dim]",
298
+ title="[bold]Documentation Complete[/bold]",
299
+ border_style="green"
300
+ ))
301
+
302
+ if open_browser:
303
+ import webbrowser
304
+ html_path = (output / "index.html").resolve()
305
+ webbrowser.open(f"file:///{html_path}")
306
+
307
+
308
+ def _show_index_summary(index_data: dict) -> None:
309
+ """Show a summary table of the index."""
310
+ table = Table(title="Index Summary")
311
+ table.add_column("Metric", style="cyan")
312
+ table.add_column("Value", style="green")
313
+
314
+ table.add_row("Files", str(len(index_data.get("files", {}))))
315
+ table.add_row("Symbols", str(len(index_data.get("call_graph", {}))))
316
+ table.add_row("Last Indexed", index_data.get("last_indexed", "N/A"))
317
+
318
+ console.print(table)
319
+
320
+
321
+ def _print_context(result: dict) -> None:
322
+ """Print context in human-readable format."""
323
+ if not result.get("symbols"):
324
+ console.print("[yellow]No matching symbols found.[/yellow]")
325
+ return
326
+
327
+ console.print("\n[bold]Relevant Symbols:[/bold]\n")
328
+
329
+ for sym in result["symbols"]:
330
+ console.print(f" [cyan]{sym['name']}[/cyan] ({sym.get('type', 'unknown')})")
331
+ console.print(f" File: {sym.get('file', 'unknown')}:{sym.get('line', '?')}")
332
+ if sym.get("params"):
333
+ console.print(f" Params: {', '.join(sym['params'])}")
334
+ if sym.get("calls"):
335
+ console.print(f" Calls: {', '.join(sym['calls'])}")
336
+ if sym.get("called_by"):
337
+ console.print(f" Called by: {', '.join(sym['called_by'])}")
338
+ console.print()
339
+
340
+
341
+ def _print_token_savings(savings: dict) -> None:
342
+ """Print token savings analysis."""
343
+ console.print()
344
+ table = Table(
345
+ title="[bold]Token Savings Analysis[/bold]",
346
+ box=box.ROUNDED,
347
+ show_header=False,
348
+ )
349
+ table.add_column("Metric", style="cyan")
350
+ table.add_column("Value", style="white", justify="right")
351
+
352
+ raw = savings["raw_project_tokens"]
353
+ ctx = savings["context_tokens"]
354
+ idx = savings["index_tokens"]
355
+
356
+ table.add_row("Source files", f"{savings['file_count']} files")
357
+ table.add_row("Raw project tokens", f"[red]{raw:,}[/red]")
358
+ table.add_row("Full index tokens", f"[yellow]{idx:,}[/yellow]")
359
+ table.add_row("Context query tokens", f"[green]{ctx:,}[/green]")
360
+ table.add_row("", "")
361
+ table.add_row("Tokens saved", f"[bold green]{savings['savings_tokens']:,}[/bold green]")
362
+ table.add_row("Savings", f"[bold green]{savings['savings_percent']}%[/bold green]")
363
+ table.add_row("Compression ratio", f"[bold]{savings['compression_ratio']}x[/bold]")
364
+
365
+ console.print(Panel(table, border_style="green"))
366
+
367
+ # Cost estimate (GPT-4 pricing ~$30/1M input tokens)
368
+ cost_raw = raw / 1_000_000 * 30
369
+ cost_ctx = ctx / 1_000_000 * 30
370
+ cost_saved = cost_raw - cost_ctx
371
+
372
+ if cost_saved > 0.001:
373
+ console.print(
374
+ f" [dim]Estimated cost per query (GPT-4 rates):[/dim]\n"
375
+ f" [red]Without CortexCode:[/red] ${cost_raw:.4f}\n"
376
+ f" [green]With CortexCode:[/green] ${cost_ctx:.4f}\n"
377
+ f" [bold green]Saved per query:[/bold green] ${cost_saved:.4f}\n"
378
+ )
379
+
380
+
381
+ @main.command()
382
+ def stats():
383
+ """Show project index statistics and token savings."""
384
+ index_path = Path(".cortexcode/index.json")
385
+
386
+ if not index_path.exists():
387
+ console.print("[bold red]Error:[/bold red] No index found. Run 'cortexcode index' first.")
388
+ sys.exit(1)
389
+
390
+ console.print(Panel.fit(
391
+ "[bold cyan]CortexCode Stats[/bold cyan]",
392
+ border_style="cyan"
393
+ ))
394
+
395
+ savings = calculate_token_savings(index_path)
396
+ _print_token_savings(savings)
397
+
398
+ # Also show per-query savings for common queries
399
+ import json
400
+ index = json.loads(index_path.read_text(encoding="utf-8"))
401
+ call_graph = index.get("call_graph", {})
402
+
403
+ # Pick top 3 symbols by call count
404
+ top_symbols = sorted(
405
+ call_graph.items(),
406
+ key=lambda x: len(x[1]) if isinstance(x[1], list) else 0,
407
+ reverse=True
408
+ )[:3]
409
+
410
+ if top_symbols:
411
+ console.print("\n[bold]Per-Query Savings (top symbols):[/bold]\n")
412
+ query_table = Table(box=box.SIMPLE, show_header=True)
413
+ query_table.add_column("Query", style="cyan")
414
+ query_table.add_column("Context Tokens", justify="right")
415
+ query_table.add_column("vs Raw", justify="right", style="green")
416
+
417
+ for name, _ in top_symbols:
418
+ q_savings = calculate_token_savings(index_path, name, 5)
419
+ query_table.add_row(
420
+ name,
421
+ f"{q_savings['context_tokens']:,}",
422
+ f"{q_savings['savings_percent']}% saved"
423
+ )
424
+
425
+ console.print(query_table)
426
+
427
+
428
+ @main.command()
429
+ def mcp():
430
+ """Start MCP server for AI agent integration (stdin/stdout)."""
431
+ from cortexcode.mcp_server import run_stdio_server
432
+
433
+ console.print("[dim]CortexCode MCP server started (stdin/stdout)[/dim]")
434
+ run_stdio_server()
435
+
436
+
437
+ @main.command()
438
+ def lsp():
439
+ """Start Language Server Protocol server (stdin/stdout)."""
440
+ from cortexcode.lsp_server import run_lsp_server
441
+ run_lsp_server()
442
+
443
+
444
+ @main.command(name="find")
445
+ @click.argument("query")
446
+ @click.option("-n", "--limit", default=10, help="Max results")
447
+ def semantic_find(query, limit):
448
+ """Semantic search — find symbols by meaning, not just name.
449
+
450
+ Examples: cortexcode find "authentication handler"
451
+ cortexcode find "database models"
452
+ cortexcode find "user login flow"
453
+ """
454
+ from cortexcode.semantic_search import semantic_search
455
+
456
+ index_path = Path(".cortexcode/index.json")
457
+ if not index_path.exists():
458
+ console.print("[bold red]Error:[/bold red] No index found. Run 'cortexcode index' first.")
459
+ sys.exit(1)
460
+
461
+ result = semantic_search(index_path, query, limit)
462
+ results = result.get("results", [])
463
+
464
+ if not results:
465
+ console.print(f"[yellow]No results for '{query}'[/yellow]")
466
+ return
467
+
468
+ console.print(f"\n[bold]Semantic search: \"{query}\"[/bold] ({len(results)} results from {result['total_symbols']} symbols)\n")
469
+
470
+ table = Table(box=box.SIMPLE, show_header=True)
471
+ table.add_column("Score", justify="right", width=6)
472
+ table.add_column("Type", style="dim", width=10)
473
+ table.add_column("Name", style="cyan")
474
+ table.add_column("File", style="dim")
475
+ table.add_column("Line", justify="right", width=5)
476
+
477
+ for r in results:
478
+ score_color = "green" if r["score"] > 0.3 else "yellow" if r["score"] > 0.1 else "dim"
479
+ table.add_row(
480
+ f"[{score_color}]{r['score']:.2f}[/{score_color}]",
481
+ r.get("type", "?"),
482
+ r.get("name", "?"),
483
+ r.get("file", "?"),
484
+ str(r.get("line", "?")),
485
+ )
486
+
487
+ console.print(table)
488
+
489
+
490
+ @main.command()
491
+ @click.argument("path", default=".", type=click.Path(exists=True))
492
+ def scan(path):
493
+ """Scan dependencies for known issues and security warnings."""
494
+ from cortexcode.vuln_scan import scan_dependencies
495
+
496
+ path = Path(path).resolve()
497
+
498
+ console.print(Panel.fit(
499
+ f"[bold cyan]Dependency Scanner[/bold cyan]\n"
500
+ f"[dim]Scanning: {path}[/dim]",
501
+ border_style="cyan"
502
+ ))
503
+
504
+ result = scan_dependencies(path)
505
+
506
+ if not result["scanned_files"]:
507
+ console.print("[yellow]No dependency files found.[/yellow]")
508
+ return
509
+
510
+ console.print(f"\n[bold]Scanned:[/bold] {', '.join(result['scanned_files'])}")
511
+ console.print(f"[bold]Dependencies found:[/bold] {result['total_dependencies']}")
512
+
513
+ warnings = result.get("warnings", [])
514
+ if warnings:
515
+ console.print(f"\n[bold yellow]Warnings ({len(warnings)}):[/bold yellow]\n")
516
+ for w in warnings:
517
+ severity_color = {"high": "red", "medium": "yellow", "low": "dim"}.get(w["severity"], "white")
518
+ console.print(f" [{severity_color}][{w['severity'].upper()}][/{severity_color}] {w['package']}: {w['message']}")
519
+ else:
520
+ console.print("\n[green]No warnings found.[/green]")
521
+
522
+
523
+ @main.command("dead-code")
524
+ @click.argument("path", default=".", type=click.Path(exists=True))
525
+ def dead_code(path):
526
+ """Detect potentially unused symbols (dead code)."""
527
+ from cortexcode.analysis import detect_dead_code
528
+
529
+ path = Path(path).resolve()
530
+ index_path = path / ".cortexcode" / "index.json"
531
+ if not index_path.exists():
532
+ console.print("[red]No index found. Run `cortexcode index` first.[/red]")
533
+ return
534
+
535
+ import json as _json
536
+ index = _json.loads(index_path.read_text(encoding="utf-8"))
537
+ dead = detect_dead_code(index)
538
+
539
+ if not dead:
540
+ console.print("[green]No dead code detected — all symbols are referenced.[/green]")
541
+ return
542
+
543
+ console.print(Panel.fit(
544
+ f"[bold yellow]Potentially Unused Symbols[/bold yellow]\n"
545
+ f"[dim]Found {len(dead)} symbols that are never called or imported[/dim]",
546
+ border_style="yellow"
547
+ ))
548
+
549
+ table = Table(box=box.ROUNDED)
550
+ table.add_column("Type", width=10)
551
+ table.add_column("Name", style="cyan")
552
+ table.add_column("File")
553
+ table.add_column("Line", justify="right", width=5)
554
+
555
+ for d in dead[:50]:
556
+ table.add_row(d["type"], d["name"], d["file"], str(d["line"]))
557
+
558
+ console.print(table)
559
+ if len(dead) > 50:
560
+ console.print(f"[dim]... and {len(dead) - 50} more[/dim]")
561
+
562
+
563
+ @main.command()
564
+ @click.argument("path", default=".", type=click.Path(exists=True))
565
+ @click.option("--top", "-n", default=20, help="Show top N most complex functions")
566
+ @click.option("--min-score", default=0, help="Minimum complexity score to show")
567
+ def complexity(path, top, min_score):
568
+ """Analyze code complexity metrics for all functions."""
569
+ from cortexcode.analysis import compute_complexity
570
+
571
+ path = Path(path).resolve()
572
+ index_path = path / ".cortexcode" / "index.json"
573
+ if not index_path.exists():
574
+ console.print("[red]No index found. Run `cortexcode index` first.[/red]")
575
+ return
576
+
577
+ import json as _json
578
+ index = _json.loads(index_path.read_text(encoding="utf-8"))
579
+ results = compute_complexity(index, project_root=str(path))
580
+
581
+ if min_score > 0:
582
+ results = [r for r in results if r.get("score", 0) >= min_score]
583
+
584
+ if not results:
585
+ console.print("[green]No complex functions found.[/green]")
586
+ return
587
+
588
+ # Summary stats
589
+ ratings = {"low": 0, "medium": 0, "high": 0, "critical": 0}
590
+ for r in results:
591
+ ratings[r.get("rating", "low")] += 1
592
+
593
+ console.print(Panel.fit(
594
+ f"[bold cyan]Complexity Analysis[/bold cyan]\n"
595
+ f"[green]Low: {ratings['low']}[/green] [yellow]Medium: {ratings['medium']}[/yellow] "
596
+ f"[red]High: {ratings['high']}[/red] [bold red]Critical: {ratings['critical']}[/bold red]",
597
+ border_style="cyan"
598
+ ))
599
+
600
+ table = Table(title=f"Top {top} Most Complex Functions", box=box.ROUNDED)
601
+ table.add_column("Score", justify="right", width=6)
602
+ table.add_column("Rating", width=8)
603
+ table.add_column("Name", style="cyan")
604
+ table.add_column("Lines", justify="right", width=6)
605
+ table.add_column("CC", justify="right", width=4)
606
+ table.add_column("Nest", justify="right", width=4)
607
+ table.add_column("Params", justify="right", width=6)
608
+ table.add_column("File")
609
+
610
+ for r in results[:top]:
611
+ rating = r.get("rating", "low")
612
+ color = {"low": "green", "medium": "yellow", "high": "red", "critical": "bold red"}[rating]
613
+ table.add_row(
614
+ str(r.get("score", 0)),
615
+ f"[{color}]{rating}[/{color}]",
616
+ r["name"],
617
+ str(r.get("lines", "?")),
618
+ str(r.get("cyclomatic", "?")),
619
+ str(r.get("max_nesting", "?")),
620
+ str(r.get("params_count", 0)),
621
+ r["file"],
622
+ )
623
+
624
+ console.print(table)
625
+
626
+
627
+ @main.command()
628
+ @click.argument("symbol")
629
+ @click.argument("path", default=".", type=click.Path(exists=True))
630
+ def impact(symbol, path):
631
+ """Analyze change impact — what breaks if a symbol is modified."""
632
+ from cortexcode.analysis import analyze_change_impact
633
+
634
+ path = Path(path).resolve()
635
+ index_path = path / ".cortexcode" / "index.json"
636
+ if not index_path.exists():
637
+ console.print("[red]No index found. Run `cortexcode index` first.[/red]")
638
+ return
639
+
640
+ import json as _json
641
+ index = _json.loads(index_path.read_text(encoding="utf-8"))
642
+ result = analyze_change_impact(index, symbol)
643
+
644
+ risk_color = {"low": "green", "medium": "yellow", "high": "red"}[result["risk"]]
645
+
646
+ console.print(Panel.fit(
647
+ f"[bold cyan]Change Impact Analysis[/bold cyan]\n"
648
+ f"Symbol: [bold]{symbol}[/bold]\n"
649
+ f"Risk: [{risk_color}]{result['risk'].upper()}[/{risk_color}] | "
650
+ f"Total impact: {result['total_impact']} symbols",
651
+ border_style="cyan"
652
+ ))
653
+
654
+ if result["direct_callers"]:
655
+ console.print(f"\n[bold]Direct callers ({len(result['direct_callers'])}):[/bold]")
656
+ for c in result["direct_callers"]:
657
+ console.print(f" [cyan]{c}[/cyan]")
658
+
659
+ if result["indirect_callers"]:
660
+ console.print(f"\n[bold]Indirect callers ({len(result['indirect_callers'])}):[/bold]")
661
+ for c in result["indirect_callers"]:
662
+ console.print(f" [dim]{c}[/dim]")
663
+
664
+ if result["affected_files"]:
665
+ console.print(f"\n[bold]Affected files ({len(result['affected_files'])}):[/bold]")
666
+ for f in result["affected_files"]:
667
+ console.print(f" {f}")
668
+
669
+ if result["affected_tests"]:
670
+ console.print(f"\n[bold yellow]Tests to update ({len(result['affected_tests'])}):[/bold yellow]")
671
+ for f in result["affected_tests"]:
672
+ console.print(f" {f}")
673
+
674
+ if result["importing_files"]:
675
+ console.print(f"\n[bold]Files importing affected modules ({len(result['importing_files'])}):[/bold]")
676
+ for f in result["importing_files"]:
677
+ console.print(f" [dim]{f}[/dim]")
678
+
679
+
680
+ @main.group()
681
+ def workspace():
682
+ """Multi-repo workspace management."""
683
+ pass
684
+
685
+
686
+ @workspace.command("init")
687
+ @click.argument("path", default=".", type=click.Path(exists=True))
688
+ def workspace_init(path):
689
+ """Initialize a workspace at the given path."""
690
+ from cortexcode.workspace import Workspace
691
+
692
+ ws = Workspace(Path(path).resolve())
693
+ if ws.load_config():
694
+ console.print("[yellow]Workspace already exists here.[/yellow]")
695
+ return
696
+ ws.save_config()
697
+ console.print(f"[green]Workspace initialized at {ws.workspace_root}[/green]")
698
+
699
+
700
+ @workspace.command("add")
701
+ @click.argument("repo_path", type=click.Path(exists=True))
702
+ @click.option("--alias", "-a", default=None, help="Short alias for the repo")
703
+ def workspace_add(repo_path, alias):
704
+ """Add a repo to the workspace."""
705
+ from cortexcode.workspace import Workspace
706
+
707
+ ws = Workspace(Path(".").resolve())
708
+ if not ws.load_config():
709
+ console.print("[red]No workspace found. Run `cortexcode workspace init` first.[/red]")
710
+ return
711
+ try:
712
+ repo = ws.add_repo(repo_path, alias)
713
+ console.print(f"[green]Added {repo['alias']} → {repo['path']}[/green]")
714
+ except ValueError as e:
715
+ console.print(f"[red]{e}[/red]")
716
+
717
+
718
+ @workspace.command("remove")
719
+ @click.argument("alias_or_path")
720
+ def workspace_remove(alias_or_path):
721
+ """Remove a repo from the workspace."""
722
+ from cortexcode.workspace import Workspace
723
+
724
+ ws = Workspace(Path(".").resolve())
725
+ if not ws.load_config():
726
+ console.print("[red]No workspace found.[/red]")
727
+ return
728
+ if ws.remove_repo(alias_or_path):
729
+ console.print(f"[green]Removed {alias_or_path}[/green]")
730
+ else:
731
+ console.print(f"[yellow]Not found: {alias_or_path}[/yellow]")
732
+
733
+
734
+ @workspace.command("list")
735
+ def workspace_list():
736
+ """List all repos in the workspace."""
737
+ from cortexcode.workspace import Workspace
738
+
739
+ ws = Workspace(Path(".").resolve())
740
+ if not ws.load_config():
741
+ console.print("[red]No workspace found. Run `cortexcode workspace init` first.[/red]")
742
+ return
743
+
744
+ repos = ws.list_repos()
745
+ if not repos:
746
+ console.print("[dim]No repos in workspace. Use `cortexcode workspace add <path>`[/dim]")
747
+ return
748
+
749
+ table = Table(title="Workspace Repos", box=box.ROUNDED)
750
+ table.add_column("Alias", style="cyan")
751
+ table.add_column("Path")
752
+ table.add_column("Indexed", justify="center")
753
+
754
+ for r in repos:
755
+ table.add_row(r["alias"], r["path"], "✓" if r["indexed"] else "✗")
756
+ console.print(table)
757
+
758
+
759
+ @workspace.command("index")
760
+ @click.option("--full", is_flag=True, help="Full re-index (not incremental)")
761
+ def workspace_index(full):
762
+ """Index all repos in the workspace."""
763
+ from cortexcode.workspace import Workspace
764
+
765
+ ws = Workspace(Path(".").resolve())
766
+ if not ws.load_config():
767
+ console.print("[red]No workspace found.[/red]")
768
+ return
769
+
770
+ with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), console=console) as progress:
771
+ progress.add_task("Indexing workspace repos...", total=None)
772
+ results = ws.index_all(incremental=not full)
773
+
774
+ table = Table(title="Workspace Index Results", box=box.ROUNDED)
775
+ table.add_column("Repo", style="cyan")
776
+ table.add_column("Symbols", justify="right")
777
+
778
+ for alias, count in results.items():
779
+ color = "green" if count >= 0 else "red"
780
+ table.add_row(alias, f"[{color}]{count}[/{color}]")
781
+ console.print(table)
782
+
783
+
784
+ @workspace.command("search")
785
+ @click.argument("query")
786
+ def workspace_search(query):
787
+ """Search symbols across all workspace repos."""
788
+ from cortexcode.workspace import Workspace
789
+
790
+ ws = Workspace(Path(".").resolve())
791
+ if not ws.load_config():
792
+ console.print("[red]No workspace found.[/red]")
793
+ return
794
+
795
+ results = ws.search_across_repos(query)
796
+ if not results:
797
+ console.print(f"[dim]No results for '{query}'[/dim]")
798
+ return
799
+
800
+ table = Table(title=f"Results for '{query}'", box=box.ROUNDED)
801
+ table.add_column("Repo", style="dim", width=12)
802
+ table.add_column("Type", width=10)
803
+ table.add_column("Name", style="cyan")
804
+ table.add_column("File")
805
+
806
+ for r in results:
807
+ table.add_row(r.get("repo", "?"), r.get("type", "?"), r.get("name", "?"), r.get("file", "?"))
808
+ console.print(table)
809
+
810
+
811
+ @main.command()
812
+ @click.argument("path", default=".", type=click.Path(exists=True))
813
+ @click.option("--port", "-p", default=8787, help="Port to serve the dashboard")
814
+ def dashboard(path, port):
815
+ """Launch a live web dashboard with auto-refresh on index changes."""
816
+ from cortexcode.dashboard import DashboardServer
817
+
818
+ path = Path(path).resolve()
819
+ index_path = path / ".cortexcode" / "index.json"
820
+
821
+ if not index_path.exists():
822
+ console.print("[yellow]No index found. Indexing first...[/yellow]")
823
+ idx = indexer.CodeIndexer()
824
+ index = idx.index_directory(path)
825
+ output_dir = path / ".cortexcode"
826
+ output_dir.mkdir(exist_ok=True)
827
+ import json as _json
828
+ index_path.write_text(_json.dumps(index, indent=2, default=str), encoding="utf-8")
829
+
830
+ docs_dir = path / ".cortexcode" / "docs"
831
+ console.print(Panel.fit(
832
+ f"[bold cyan]CortexCode Live Dashboard[/bold cyan]\n"
833
+ f"[dim]Serving: {docs_dir}[/dim]\n"
834
+ f"[bold green]http://localhost:{port}[/bold green]\n"
835
+ f"[dim]Auto-refreshes when index changes[/dim]",
836
+ border_style="cyan"
837
+ ))
838
+ console.print("[dim]Press Ctrl+C to stop[/dim]")
839
+
840
+ server = DashboardServer(path, port)
841
+ server.start(open_browser=True)
842
+
843
+
844
+ if __name__ == "__main__":
845
+ main()