codegraphcontext 0.5.2__py3-none-any.whl → 0.5.3__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 (65) hide show
  1. codegraphcontext/api/app.py +6 -1
  2. codegraphcontext/api/auth.py +123 -0
  3. codegraphcontext/api/router.py +9 -5
  4. codegraphcontext/cli/cli_helpers.py +179 -20
  5. codegraphcontext/cli/config_manager.py +56 -14
  6. codegraphcontext/cli/hook_manager.py +26 -4
  7. codegraphcontext/cli/main.py +208 -16
  8. codegraphcontext/cli/project_config.py +218 -0
  9. codegraphcontext/cli/registry_commands.py +6 -6
  10. codegraphcontext/cli/simulator.py +343 -0
  11. codegraphcontext/core/bundle_registry.py +45 -6
  12. codegraphcontext/core/cgc_bundle.py +102 -31
  13. codegraphcontext/core/cgcignore.py +29 -11
  14. codegraphcontext/core/database.py +5 -1
  15. codegraphcontext/core/database_embedded_kuzu.py +37 -2
  16. codegraphcontext/core/database_falkordb.py +141 -30
  17. codegraphcontext/core/database_falkordb_remote.py +31 -11
  18. codegraphcontext/core/database_nornic.py +5 -1
  19. codegraphcontext/core/simulator.py +589 -0
  20. codegraphcontext/core/watcher.py +67 -11
  21. codegraphcontext/prompts.py +52 -2
  22. codegraphcontext/server.py +77 -4
  23. codegraphcontext/tool_definitions.py +171 -20
  24. codegraphcontext/tools/code_finder.py +39 -26
  25. codegraphcontext/tools/graph_builder.py +39 -10
  26. codegraphcontext/tools/handlers/analysis_handlers.py +28 -9
  27. codegraphcontext/tools/handlers/management_handlers.py +33 -7
  28. codegraphcontext/tools/handlers/query_handlers.py +10 -4
  29. codegraphcontext/tools/indexing/discovery.py +6 -2
  30. codegraphcontext/tools/indexing/embeddings.py +8 -0
  31. codegraphcontext/tools/indexing/persistence/writer.py +104 -137
  32. codegraphcontext/tools/indexing/pipeline.py +65 -17
  33. codegraphcontext/tools/indexing/pre_scan.py +14 -2
  34. codegraphcontext/tools/indexing/resolution/post_resolution.py +11 -20
  35. codegraphcontext/tools/indexing/schema.py +89 -47
  36. codegraphcontext/tools/indexing/schema_contract.py +1 -0
  37. codegraphcontext/tools/languages/c.py +1 -1
  38. codegraphcontext/tools/languages/cpp.py +1 -1
  39. codegraphcontext/tools/languages/css.py +1 -1
  40. codegraphcontext/tools/languages/dart.py +1 -1
  41. codegraphcontext/tools/languages/elisp.py +1 -1
  42. codegraphcontext/tools/languages/elixir.py +3 -3
  43. codegraphcontext/tools/languages/go.py +3 -3
  44. codegraphcontext/tools/languages/html.py +1 -1
  45. codegraphcontext/tools/languages/javascript.py +32 -27
  46. codegraphcontext/tools/languages/lua.py +1 -1
  47. codegraphcontext/tools/languages/perl.py +1 -1
  48. codegraphcontext/tools/languages/python.py +78 -44
  49. codegraphcontext/tools/languages/ruby.py +2 -2
  50. codegraphcontext/tools/languages/rust.py +1 -1
  51. codegraphcontext/tools/languages/typescript.py +31 -27
  52. codegraphcontext/tools/languages/typescriptjsx.py +3 -3
  53. codegraphcontext/tools/query_tool_languages/cpp_toolkit.py +22 -18
  54. codegraphcontext/tools/query_tool_languages/java_toolkit.py +1 -1
  55. codegraphcontext/tools/report_generator.py +7 -7
  56. codegraphcontext/tools/system.py +13 -16
  57. codegraphcontext/utils/cypher_ddl.py +80 -0
  58. codegraphcontext/utils/cypher_readonly.py +5 -0
  59. codegraphcontext/viz/server.py +20 -3
  60. {codegraphcontext-0.5.2.dist-info → codegraphcontext-0.5.3.dist-info}/METADATA +25 -5
  61. {codegraphcontext-0.5.2.dist-info → codegraphcontext-0.5.3.dist-info}/RECORD +65 -60
  62. {codegraphcontext-0.5.2.dist-info → codegraphcontext-0.5.3.dist-info}/WHEEL +0 -0
  63. {codegraphcontext-0.5.2.dist-info → codegraphcontext-0.5.3.dist-info}/entry_points.txt +0 -0
  64. {codegraphcontext-0.5.2.dist-info → codegraphcontext-0.5.3.dist-info}/licenses/LICENSE +0 -0
  65. {codegraphcontext-0.5.2.dist-info → codegraphcontext-0.5.3.dist-info}/top_level.txt +0 -0
@@ -25,6 +25,7 @@ from importlib.metadata import version as pkg_version, PackageNotFoundError
25
25
  from codegraphcontext.server import MCPServer
26
26
  from .setup_wizard import run_neo4j_setup_wizard, configure_mcp_client
27
27
  from . import config_manager
28
+ from . import project_config
28
29
  # Import the new helper functions
29
30
  from .cli_helpers import (
30
31
  index_helper,
@@ -82,6 +83,8 @@ app = typer.Typer(
82
83
  name="cgc",
83
84
  help="CodeGraphContext: An MCP server for AI-powered code analysis.",
84
85
  add_completion=True,
86
+ # `-h` is accepted as --help on every command, not just at the root.
87
+ context_settings={"help_option_names": ["-h", "--help"]},
85
88
  )
86
89
  console = Console(stderr=True)
87
90
 
@@ -576,7 +579,7 @@ def config_reset():
576
579
  console.print("[yellow]Reset cancelled[/yellow]")
577
580
 
578
581
  @config_app.command("db")
579
- def config_db(backend: str = typer.Argument(..., help="Database backend: 'neo4j', 'falkordb', 'falkordb-remote', 'kuzudb', or 'ladybugdb'")):
582
+ def config_db(backend: str = typer.Argument(..., help="Database backend: 'neo4j', 'falkordb', 'falkordb-remote', 'kuzudb', 'nornic', or 'ladybugdb'")):
580
583
  """
581
584
  Quickly switch the default database backend.
582
585
 
@@ -588,9 +591,9 @@ def config_db(backend: str = typer.Argument(..., help="Database backend: 'neo4j'
588
591
  cgc config db kuzudb
589
592
  """
590
593
  backend = backend.lower()
591
- if backend not in ['falkordb', 'falkordb-remote', 'neo4j', 'kuzudb', 'ladybugdb']:
594
+ if backend not in ['falkordb', 'falkordb-remote', 'neo4j', 'kuzudb', 'nornic', 'ladybugdb']:
592
595
  console.print(f"[bold red]Invalid backend: {backend}[/bold red]")
593
- console.print("Must be 'falkordb', 'falkordb-remote', 'neo4j', 'kuzudb', or 'ladybugdb'")
596
+ console.print("Must be 'falkordb', 'falkordb-remote', 'neo4j', 'kuzudb', 'nornic', or 'ladybugdb'")
594
597
  raise typer.Exit(code=1)
595
598
 
596
599
  updated = config_manager.set_config_value("DEFAULT_DATABASE", backend)
@@ -600,6 +603,99 @@ def config_db(backend: str = typer.Argument(..., help="Database backend: 'neo4j'
600
603
 
601
604
  console.print(f"[green]✔ Default database switched to {backend}[/green]")
602
605
 
606
+ # ============================================================================
607
+ # PROMPT COMMAND GROUP - Custom LLM Prompts
608
+ # ============================================================================
609
+
610
+ prompt_app = typer.Typer(help="Manage custom LLM prompt files")
611
+ app.add_typer(prompt_app, name="prompt")
612
+
613
+ @prompt_app.command("add")
614
+ def prompt_add(
615
+ path: str = typer.Argument(..., help="Path to the prompt file to register")
616
+ ):
617
+ """
618
+ Add a custom prompt file to the project.
619
+
620
+ Registers a prompt file that will be injected into the LLM system prompt.
621
+ The file path is stored relative to the project root.
622
+
623
+ Examples:
624
+ cgc prompt add skills.md
625
+ cgc prompt add docs/custom-instructions.txt
626
+ cgc prompt add /absolute/path/to/prompt.md
627
+ """
628
+ try:
629
+ success = project_config.add_prompt_file(path)
630
+ if not success:
631
+ raise typer.Exit(code=1)
632
+ except Exception as e:
633
+ console.print(f"[red]Error adding prompt file: {e}[/red]")
634
+ raise typer.Exit(code=1)
635
+
636
+ @prompt_app.command("list")
637
+ def prompt_list():
638
+ """
639
+ List all registered prompt files.
640
+
641
+ Shows all custom prompt files that will be injected into the LLM system prompt.
642
+ Files are shown in the order they will be prepended.
643
+ """
644
+ try:
645
+ prompts = project_config.list_prompt_files()
646
+
647
+ if not prompts:
648
+ console.print("[yellow]No custom prompt files registered.[/yellow]")
649
+ console.print("\nUse [cyan]cgc prompt add <path>[/cyan] to register a prompt file.")
650
+ return
651
+
652
+ console.print("[bold cyan]Registered Prompt Files:[/bold cyan]\n")
653
+
654
+ table = Table(show_header=True, header_style="bold magenta")
655
+ table.add_column("#", style="dim", width=3)
656
+ table.add_column("Path", style="green")
657
+ table.add_column("Status", style="cyan", width=10)
658
+
659
+ project_root = project_config.get_project_root()
660
+ for i, prompt_path in enumerate(prompts, 1):
661
+ # Check if file exists
662
+ prompt_file = Path(prompt_path)
663
+ if not prompt_file.is_absolute():
664
+ prompt_file = project_root / prompt_file
665
+
666
+ status = "✅ Found" if prompt_file.exists() else "⚠️ Missing"
667
+ table.add_row(str(i), prompt_path, status)
668
+
669
+ console.print(table)
670
+
671
+ config_file = project_config.get_project_config_file()
672
+ console.print(f"\n[dim]Config: {config_file}[/dim]")
673
+
674
+ except Exception as e:
675
+ console.print(f"[red]Error listing prompt files: {e}[/red]")
676
+ raise typer.Exit(code=1)
677
+
678
+ @prompt_app.command("remove")
679
+ def prompt_remove(
680
+ path: str = typer.Argument(..., help="Path to the prompt file to unregister")
681
+ ):
682
+ """
683
+ Remove a custom prompt file from the project.
684
+
685
+ Unregisters a prompt file so it will no longer be injected into the LLM system prompt.
686
+
687
+ Examples:
688
+ cgc prompt remove skills.md
689
+ cgc prompt remove docs/custom-instructions.txt
690
+ """
691
+ try:
692
+ success = project_config.remove_prompt_file(path)
693
+ if not success:
694
+ raise typer.Exit(code=1)
695
+ except Exception as e:
696
+ console.print(f"[red]Error removing prompt file: {e}[/red]")
697
+ raise typer.Exit(code=1)
698
+
603
699
  # ============================================================================
604
700
  # BUNDLE COMMAND GROUP - Pre-indexed Graph Snapshots
605
701
  # ============================================================================
@@ -735,6 +831,7 @@ def bundle_load(
735
831
  bundle_name: str = typer.Argument(..., help="Bundle name or path to load (e.g., 'numpy' or 'numpy.cgc')"),
736
832
  clear: bool = typer.Option(False, "--clear", help="Clear existing graph data before loading"),
737
833
  yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation when using --clear"),
834
+ context: Optional[str] = typer.Option(None, "--context", "-c", help="Specific context to use"),
738
835
  ):
739
836
  """
740
837
  Load a pre-indexed bundle (download if needed, then import).
@@ -755,7 +852,7 @@ def bundle_load(
755
852
 
756
853
  # If it's an absolute path or has .cgc extension and exists, use it directly
757
854
  if bundle_path.is_absolute() or (bundle_path.suffix == '.cgc' and bundle_path.exists()):
758
- bundle_import(str(bundle_path), clear=clear, yes=yes)
855
+ bundle_import(str(bundle_path), clear=clear, yes=yes, context=context)
759
856
  return
760
857
 
761
858
  # Add .cgc extension if not present
@@ -765,7 +862,7 @@ def bundle_load(
765
862
  # Check if exists locally
766
863
  if bundle_path.exists():
767
864
  console.print(f"[dim]Found local bundle: {bundle_path}[/dim]")
768
- bundle_import(str(bundle_path), clear=clear, yes=yes)
865
+ bundle_import(str(bundle_path), clear=clear, yes=yes, context=context)
769
866
  return
770
867
 
771
868
  # Try to download from registry
@@ -783,7 +880,7 @@ def bundle_load(
783
880
 
784
881
  if downloaded_path:
785
882
  # Import the downloaded bundle
786
- bundle_import(downloaded_path, clear=clear, yes=yes)
883
+ bundle_import(downloaded_path, clear=clear, yes=yes, context=context)
787
884
  else:
788
885
  console.print(f"[bold red]Failed to download bundle '{name}'[/bold red]")
789
886
  raise typer.Exit(code=1)
@@ -921,9 +1018,12 @@ def load_shortcut(
921
1018
  bundle_name: str = typer.Argument(..., help="Bundle name or path to load"),
922
1019
  clear: bool = typer.Option(False, "--clear", help="Clear existing graph data before loading"),
923
1020
  yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation when using --clear"),
1021
+ context: Optional[str] = typer.Option(None, "--context", "-c", help="Specific context to use"),
924
1022
  ):
925
1023
  """Shortcut for 'cgc bundle load'"""
926
- bundle_load(bundle_name, clear, yes=yes)
1024
+ # Must pass `context` explicitly: invoked as a plain function, Typer does not
1025
+ # resolve defaults, so an omitted parameter keeps its OptionInfo sentinel.
1026
+ bundle_load(bundle_name, clear, yes=yes, context=context)
927
1027
 
928
1028
  # ============================================================================
929
1029
  # REGISTRY COMMAND GROUP - Browse and Download Bundles
@@ -1024,7 +1124,7 @@ def registry_download(
1024
1124
 
1025
1125
  if load and bundle_path:
1026
1126
  console.print("\n[cyan]Loading bundle...[/cyan]")
1027
- bundle_import(bundle_path, clear=False)
1127
+ bundle_import(bundle_path, clear=False, yes=False, context=None)
1028
1128
 
1029
1129
  @registry_app.command("request")
1030
1130
  def registry_request(
@@ -1063,6 +1163,10 @@ def doctor():
1063
1163
  console.print("[bold cyan]🏥 Running CodeGraphContext Diagnostics...[/bold cyan]\n")
1064
1164
 
1065
1165
  all_checks_passed = True
1166
+ # Warnings are not failures, but printing "All diagnostics passed! System is
1167
+ # healthy." while a ⚠ is on screen is misleading — `doctor` is the command
1168
+ # people run when something is already wrong.
1169
+ warnings_found = False
1066
1170
 
1067
1171
  config_manager.ensure_first_run_bootstrap()
1068
1172
  config_manager.ensure_config_file()
@@ -1200,6 +1304,32 @@ def doctor():
1200
1304
 
1201
1305
  if is_falkordb_usable():
1202
1306
  console.print(" [green]✓[/green] FalkorDB Lite is installed")
1307
+ # An import probe is not a connection check. This section is
1308
+ # titled "Checking Database Connection" and the neo4j /
1309
+ # falkordb-remote branches genuinely connect, so the default
1310
+ # backend must too — otherwise `doctor` reports a healthy
1311
+ # system without ever touching the database.
1312
+ try:
1313
+ from codegraphcontext.core import get_database_manager
1314
+
1315
+ probe_manager = get_database_manager()
1316
+ try:
1317
+ with probe_manager.get_driver().session() as probe_session:
1318
+ probe_session.run("RETURN 1")
1319
+ console.print(" [green]✓[/green] FalkorDB Lite connection successful")
1320
+ backend_in_use = probe_manager.get_backend_type()
1321
+ if backend_in_use != "falkordb":
1322
+ console.print(
1323
+ f" [yellow]⚠[/yellow] Configured backend is 'falkordb' but "
1324
+ f"'{backend_in_use}' is actually active"
1325
+ )
1326
+ warnings_found = True
1327
+ finally:
1328
+ probe_manager.close_driver()
1329
+ except Exception as conn_error:
1330
+ console.print(" [red]✗[/red] FalkorDB Lite connection failed")
1331
+ console.print(f" Reason: {conn_error}")
1332
+ all_checks_passed = False
1203
1333
  else:
1204
1334
  raise ImportError("FalkorDB Lite is not available on this platform")
1205
1335
  except ImportError:
@@ -1210,6 +1340,7 @@ def doctor():
1210
1340
  all_checks_passed = False
1211
1341
  else:
1212
1342
  console.print(f" [yellow]⚠[/yellow] No connectivity probe for backend '{default_db}'")
1343
+ warnings_found = True
1213
1344
  except Exception as e:
1214
1345
  console.print(f" [red]✗[/red] Database check error: {e}")
1215
1346
  all_checks_passed = False
@@ -1239,6 +1370,7 @@ def doctor():
1239
1370
  console.print(f" [green]✓[/green] {len(available)}/{len(probe_langs)} probed parsers OK: {', '.join(available)}")
1240
1371
  if unavailable:
1241
1372
  console.print(f" [yellow]⚠[/yellow] Unavailable: {', '.join(unavailable)}")
1373
+ warnings_found = True
1242
1374
  except ImportError:
1243
1375
  console.print(" [red]✗[/red] tree-sitter-language-pack not installed")
1244
1376
  all_checks_passed = False
@@ -1264,6 +1396,7 @@ def doctor():
1264
1396
  all_checks_passed = False
1265
1397
  else:
1266
1398
  console.print(" [yellow]⚠[/yellow] Config directory doesn't exist, will be created on first use")
1399
+ warnings_found = True
1267
1400
  except Exception as e:
1268
1401
  console.print(f" [red]✗[/red] Permission check error: {e}")
1269
1402
  all_checks_passed = False
@@ -1276,11 +1409,17 @@ def doctor():
1276
1409
  console.print(f" [green]✓[/green] cgc command found at: {cgc_path}")
1277
1410
  else:
1278
1411
  console.print(" [yellow]⚠[/yellow] cgc command not in PATH (using python -m cgc)")
1412
+ warnings_found = True
1279
1413
 
1280
1414
  # Final summary
1281
1415
  console.print("\n" + "=" * 60)
1282
- if all_checks_passed:
1416
+ if all_checks_passed and not warnings_found:
1283
1417
  console.print("[bold green]✅ All diagnostics passed! System is healthy.[/bold green]")
1418
+ elif all_checks_passed:
1419
+ console.print(
1420
+ "[bold yellow]✅ No failures, but some checks reported warnings "
1421
+ "(⚠ above).[/bold yellow]"
1422
+ )
1284
1423
  else:
1285
1424
  console.print("[bold yellow]⚠️ Some issues detected. Please review the output above.[/bold yellow]")
1286
1425
  console.print("\n[cyan]Common fixes:[/cyan]")
@@ -1295,7 +1434,6 @@ def doctor():
1295
1434
 
1296
1435
 
1297
1436
 
1298
- @app.command()
1299
1437
  @app.command()
1300
1438
  def index(
1301
1439
  path: Optional[str] = typer.Argument(None, help="Path to the directory or file to index. Defaults to the current directory."),
@@ -1320,9 +1458,18 @@ def index(
1320
1458
  reindex_helper(path, context)
1321
1459
  else:
1322
1460
  index_helper(path, context)
1461
+ except typer.Exit:
1462
+ # typer.Exit subclasses RuntimeError and str() is empty, so the handler
1463
+ # below caught it, printed nothing, and returned 0 — every helper that
1464
+ # raised `typer.Exit(code=1)` (missing path, failed indexing) silently
1465
+ # became a success. Re-raise control flow before catching errors.
1466
+ raise
1323
1467
  except Exception as e:
1324
1468
  if str(e):
1325
1469
  console.print(f"[red]An error occurred during indexing: {e}[/red]")
1470
+ else:
1471
+ console.print("[red]An error occurred during indexing.[/red]")
1472
+ raise typer.Exit(code=1)
1326
1473
 
1327
1474
  if summarize:
1328
1475
  import os
@@ -1411,6 +1558,7 @@ def setup_scip():
1411
1558
  def delete(
1412
1559
  path: Optional[str] = typer.Argument(None, help="Path of the repository to delete from the code graph."),
1413
1560
  all_repos: bool = typer.Option(False, "--all", help="Delete all indexed repositories"),
1561
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt (for CI/non-interactive use)"),
1414
1562
  context: Optional[str] = typer.Option(None, "--context", "-c", help="Specific context to use")
1415
1563
  ):
1416
1564
  """
@@ -1508,6 +1656,22 @@ def delete(
1508
1656
  )
1509
1657
  raise typer.Exit(code=1)
1510
1658
 
1659
+ # `--all` demands a typer.confirm *and* typing "delete all", while a
1660
+ # single delete went straight through — irreversibly dropping a
1661
+ # repository's entire graph (potentially an hour of indexing) on a
1662
+ # typo, with no prompt, no --yes flag and no undo. The asymmetry was
1663
+ # actively misleading: anyone who had seen the heavy --all guardrails
1664
+ # would reasonably assume single deletes were guarded too.
1665
+ if not yes:
1666
+ resolved = Path(path).expanduser().resolve()
1667
+ console.print(
1668
+ f"[bold yellow]About to delete the graph for:[/bold yellow] {resolved}"
1669
+ )
1670
+ console.print("[dim]This is irreversible; re-indexing is the only way back.[/dim]")
1671
+ if not typer.confirm("Proceed?", default=False):
1672
+ console.print("[yellow]Deletion cancelled.[/yellow]")
1673
+ raise typer.Exit(code=1)
1674
+
1511
1675
  delete_helper(path, context)
1512
1676
 
1513
1677
 
@@ -1515,6 +1679,7 @@ def delete(
1515
1679
  def report(
1516
1680
  output: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path. Defaults to CGC_REPORT.md in the current directory."),
1517
1681
  java: bool = typer.Option(False, "--java", "-j", help="Include Spring/Maven Java sections."),
1682
+ repo: Optional[str] = typer.Option(None, "--repo", "-r", help="Repository root to scope the report to. Defaults to auto-detection from the current directory."),
1518
1683
  context: Optional[str] = typer.Option(None, "--context", "-c", help="Specific context to use"),
1519
1684
  ):
1520
1685
  """
@@ -1533,7 +1698,15 @@ def report(
1533
1698
  db_manager, _, _, _ = _initialize_services(context)
1534
1699
  try:
1535
1700
  from codegraphcontext.tools.report_generator import generate_report
1536
- report_text = generate_report(db_manager, output_path=output_path, include_java=java)
1701
+ # Without --repo the generator silently picks the repo with the most
1702
+ # indexed files, which is disclosed only in the report body.
1703
+ scoped_repo = Path(repo).expanduser().resolve().as_posix() if repo else None
1704
+ report_text = generate_report(
1705
+ db_manager,
1706
+ output_path=output_path,
1707
+ include_java=java,
1708
+ repo_path=scoped_repo,
1709
+ )
1537
1710
  console.print(f"[green]✓[/green] Report written to [bold]{output_path}[/bold]")
1538
1711
  # Print a short preview (first ~40 lines)
1539
1712
  preview_lines = report_text.splitlines()[:40]
@@ -1553,7 +1726,9 @@ def report(
1553
1726
  @app.command()
1554
1727
  def visualize(
1555
1728
  repo: Optional[str] = typer.Option(None, "--repo", "-r", help="Path to the repository to visualize."),
1556
- host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host interface to bind to."),
1729
+ # `-h` is --help everywhere else in the CLI; use -H so `cgc visualize -h`
1730
+ # doesn't fail with "Option '-h' requires an argument".
1731
+ host: str = typer.Option("127.0.0.1", "--host", "-H", help="Host interface to bind to."),
1557
1732
  port: int = typer.Option(8000, "--port", "-p", help="Port to run the visualizer server on."),
1558
1733
  context: Optional[str] = typer.Option(None, "--context", "-c", help="Specific context to use")
1559
1734
  ):
@@ -1642,7 +1817,11 @@ def unwatch(
1642
1817
  context: Optional[str] = typer.Option(None, "--context", "-c", help="Specific context to use"),
1643
1818
  ):
1644
1819
  """
1645
- Stop watching a directory for changes.
1820
+ [MCP only] Stop watching a directory for changes.
1821
+
1822
+ Not supported from the CLI: this cannot reach a watcher running in another
1823
+ process. Press Ctrl+C in the 'cgc watch' terminal, or use the
1824
+ 'unwatch_directory' MCP tool.
1646
1825
 
1647
1826
  Note: This command is primarily for MCP server mode.
1648
1827
  For CLI watch mode, simply press Ctrl+C in the watch terminal.
@@ -1658,7 +1837,10 @@ def watching(
1658
1837
  context: Optional[str] = typer.Option(None, "--context", "-c", help="Specific context to use"),
1659
1838
  ):
1660
1839
  """
1661
- List all directories currently being watched for changes.
1840
+ [MCP only] List all directories currently being watched for changes.
1841
+
1842
+ Not supported from the CLI: this cannot reach a watcher running in another
1843
+ process. Use the 'list_watched_paths' MCP tool.
1662
1844
 
1663
1845
  Note: This command is primarily for MCP server mode.
1664
1846
  For CLI watch mode, check the terminal where you ran 'cgc watch'.
@@ -2867,10 +3049,13 @@ def cypher_legacy(
2867
3049
  def index_abbrev(
2868
3050
  path: Optional[str] = typer.Argument(None, help="Path to index"),
2869
3051
  force: bool = typer.Option(False, "--force", "-f", help="Force re-index (delete existing and rebuild)"),
3052
+ summarize: bool = typer.Option(False, "--summarize", "-s", help="Display a codebase summary after indexing"),
2870
3053
  context: Optional[str] = typer.Option(None, "--context", "-c", help="Specific context to use")
2871
3054
  ):
2872
3055
  """Shortcut for 'cgc index'"""
2873
- index(path, force=force, context=context)
3056
+ # `summarize` must be passed explicitly: omitted, it keeps its OptionInfo
3057
+ # sentinel, which is truthy — so `cgc i` always printed the summary.
3058
+ index(path, force=force, summarize=summarize, context=context)
2874
3059
 
2875
3060
  @app.command("ls", rich_help_panel="Shortcuts")
2876
3061
  def list_abbrev(
@@ -2883,10 +3068,13 @@ def list_abbrev(
2883
3068
  def delete_abbrev(
2884
3069
  path: Optional[str] = typer.Argument(None, help="Path to delete"),
2885
3070
  all_repos: bool = typer.Option(False, "--all", help="Delete all indexed repositories"),
3071
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt (for CI/non-interactive use)"),
2886
3072
  context: Optional[str] = typer.Option(None, "--context", "-c", help="Specific context to use")
2887
3073
  ):
2888
3074
  """Shortcut for 'cgc delete'"""
2889
- delete(path, all_repos, context=context)
3075
+ # `yes` must be forwarded explicitly: omitted, it keeps its OptionInfo
3076
+ # sentinel, which is truthy — silently skipping the confirmation.
3077
+ delete(path, all_repos, yes=yes, context=context)
2890
3078
 
2891
3079
  @app.command("v", rich_help_panel="Shortcuts")
2892
3080
  def visualize_abbrev(
@@ -3161,5 +3349,9 @@ def _write_datasource_graph(ingested: dict, context: Optional[str] = None) -> No
3161
3349
  db_manager.close_driver()
3162
3350
 
3163
3351
 
3352
+ from codegraphcontext.cli.simulator import simulate_app
3353
+ app.add_typer(simulate_app, name="simulate")
3354
+
3355
+
3164
3356
  if __name__ == "__main__":
3165
3357
  app()
@@ -0,0 +1,218 @@
1
+ """
2
+ Project-level configuration management for CodeGraphContext.
3
+ Handles managing .cgc directory and project-specific settings like custom prompts.
4
+ """
5
+
6
+ import json
7
+ import logging
8
+ from pathlib import Path
9
+ from typing import List, Optional, Dict, Any
10
+ from rich.console import Console
11
+
12
+ console = Console()
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Project config directory and file
16
+ PROJECT_CONFIG_DIR_NAME = ".cgc"
17
+ PROJECT_CONFIG_FILE_NAME = "config.json"
18
+
19
+
20
+ def get_project_root() -> Path:
21
+ """Get the current working directory as project root."""
22
+ return Path.cwd()
23
+
24
+
25
+ def get_project_config_dir(project_root: Optional[Path] = None) -> Path:
26
+ """Get the .cgc directory path for the project."""
27
+ if project_root is None:
28
+ project_root = get_project_root()
29
+ return project_root / PROJECT_CONFIG_DIR_NAME
30
+
31
+
32
+ def get_project_config_file(project_root: Optional[Path] = None) -> Path:
33
+ """Get the config.json file path within .cgc directory."""
34
+ return get_project_config_dir(project_root) / PROJECT_CONFIG_FILE_NAME
35
+
36
+
37
+ def ensure_project_config_dir(project_root: Optional[Path] = None) -> Path:
38
+ """Ensure .cgc directory exists and return its path."""
39
+ config_dir = get_project_config_dir(project_root)
40
+ config_dir.mkdir(parents=True, exist_ok=True)
41
+ return config_dir
42
+
43
+
44
+ def load_project_config(project_root: Optional[Path] = None) -> Dict[str, Any]:
45
+ """
46
+ Load project configuration from .cgc/config.json.
47
+ Returns an empty config structure if file doesn't exist.
48
+ """
49
+ config_file = get_project_config_file(project_root)
50
+
51
+ if not config_file.exists():
52
+ return {"prompts": []}
53
+
54
+ try:
55
+ with open(config_file, "r", encoding="utf-8") as f:
56
+ config = json.load(f)
57
+ # Ensure prompts field exists
58
+ if "prompts" not in config:
59
+ config["prompts"] = []
60
+ return config
61
+ except json.JSONDecodeError as e:
62
+ console.print(f"[yellow]Warning: Invalid JSON in {config_file}: {e}[/yellow]")
63
+ return {"prompts": []}
64
+ except Exception as e:
65
+ console.print(f"[yellow]Warning: Could not load config: {e}[/yellow]")
66
+ return {"prompts": []}
67
+
68
+
69
+ def save_project_config(config: Dict[str, Any], project_root: Optional[Path] = None):
70
+ """Save project configuration to .cgc/config.json."""
71
+ ensure_project_config_dir(project_root)
72
+ config_file = get_project_config_file(project_root)
73
+
74
+ try:
75
+ with open(config_file, "w", encoding="utf-8") as f:
76
+ json.dump(config, f, indent=2)
77
+ except Exception as e:
78
+ console.print(f"[red]Error saving project config: {e}[/red]")
79
+ raise
80
+
81
+
82
+ def add_prompt_file(prompt_path: str, project_root: Optional[Path] = None) -> bool:
83
+ """
84
+ Add a custom prompt file to the project configuration.
85
+ Validates that the file exists and prevents duplicates.
86
+ Stores paths relative to project root.
87
+
88
+ Returns True if successful, False otherwise.
89
+ """
90
+ if project_root is None:
91
+ project_root = get_project_root()
92
+
93
+ # Convert to Path object and resolve
94
+ prompt_file = Path(prompt_path)
95
+
96
+ # Check if file exists
97
+ if not prompt_file.exists():
98
+ console.print(f"[red]❌ File not found: {prompt_path}[/red]")
99
+ return False
100
+
101
+ if not prompt_file.is_file():
102
+ console.print(f"[red]❌ Not a file: {prompt_path}[/red]")
103
+ return False
104
+
105
+ # Make path relative to project root if it's absolute and within project
106
+ try:
107
+ if prompt_file.is_absolute():
108
+ try:
109
+ relative_path = prompt_file.relative_to(project_root)
110
+ prompt_path_str = str(relative_path).replace("\\", "/")
111
+ except ValueError:
112
+ # File is outside project root, store as absolute
113
+ prompt_path_str = str(prompt_file.resolve()).replace("\\", "/")
114
+ else:
115
+ prompt_path_str = str(prompt_file).replace("\\", "/")
116
+ except Exception:
117
+ prompt_path_str = str(prompt_file).replace("\\", "/")
118
+
119
+ # Load current config
120
+ config = load_project_config(project_root)
121
+
122
+ # Check for duplicates
123
+ if prompt_path_str in config["prompts"]:
124
+ console.print(f"[yellow]⚠ Prompt file already registered: {prompt_path_str}[/yellow]")
125
+ return False
126
+
127
+ # Add to config
128
+ config["prompts"].append(prompt_path_str)
129
+ save_project_config(config, project_root)
130
+
131
+ console.print(f"[green]✅ Added prompt file: {prompt_path_str}[/green]")
132
+ return True
133
+
134
+
135
+ def list_prompt_files(project_root: Optional[Path] = None) -> List[str]:
136
+ """
137
+ Get list of registered prompt files.
138
+ Returns list of prompt file paths.
139
+ """
140
+ config = load_project_config(project_root)
141
+ return config.get("prompts", [])
142
+
143
+
144
+ def remove_prompt_file(prompt_path: str, project_root: Optional[Path] = None) -> bool:
145
+ """
146
+ Remove a prompt file from the project configuration.
147
+
148
+ Returns True if successful, False otherwise.
149
+ """
150
+ if project_root is None:
151
+ project_root = get_project_root()
152
+
153
+ # Normalize the path for comparison
154
+ prompt_file = Path(prompt_path)
155
+
156
+ # Try both relative and absolute versions
157
+ try:
158
+ if prompt_file.is_absolute():
159
+ try:
160
+ relative_path = prompt_file.relative_to(project_root)
161
+ prompt_path_normalized = str(relative_path).replace("\\", "/")
162
+ except ValueError:
163
+ prompt_path_normalized = str(prompt_file.resolve()).replace("\\", "/")
164
+ else:
165
+ prompt_path_normalized = str(prompt_file).replace("\\", "/")
166
+ except Exception:
167
+ prompt_path_normalized = str(prompt_file).replace("\\", "/")
168
+
169
+ # Load current config
170
+ config = load_project_config(project_root)
171
+
172
+ # Try to remove - check both the normalized path and original
173
+ original_length = len(config["prompts"])
174
+ config["prompts"] = [p for p in config["prompts"] if p not in (prompt_path, prompt_path_normalized)]
175
+
176
+ if len(config["prompts"]) == original_length:
177
+ console.print(f"[yellow]⚠ Prompt file not found in config: {prompt_path}[/yellow]")
178
+ return False
179
+
180
+ save_project_config(config, project_root)
181
+ console.print(f"[green]✅ Removed prompt file: {prompt_path}[/green]")
182
+ return True
183
+
184
+
185
+ def get_prompt_file_contents(project_root: Optional[Path] = None) -> List[str]:
186
+ """
187
+ Load contents of all registered prompt files.
188
+ Skips files that don't exist and logs warnings.
189
+
190
+ Returns list of prompt file contents in order.
191
+ """
192
+ if project_root is None:
193
+ project_root = get_project_root()
194
+
195
+ prompt_files = list_prompt_files(project_root)
196
+ contents = []
197
+
198
+ for prompt_path in prompt_files:
199
+ # Resolve path relative to project root
200
+ prompt_file = Path(prompt_path)
201
+ if not prompt_file.is_absolute():
202
+ prompt_file = project_root / prompt_file
203
+
204
+ # Try to read file
205
+ try:
206
+ if not prompt_file.exists():
207
+ logger.warning(f"Skipping missing prompt file: {prompt_path}")
208
+ continue
209
+
210
+ with open(prompt_file, "r", encoding="utf-8") as f:
211
+ content = f.read().strip()
212
+ if content:
213
+ contents.append(content)
214
+ except Exception as e:
215
+ logger.warning(f"Error reading prompt file {prompt_path}: {e}")
216
+ continue
217
+
218
+ return contents
@@ -112,7 +112,7 @@ def list_bundles(verbose: bool = False, unique: bool = False):
112
112
  table.add_column("Download URL", style="blue", no_wrap=False)
113
113
 
114
114
  # Sort by full_name to group versions together
115
- bundles.sort(key=lambda b: (b.get('name', ''), b.get('full_name', '')))
115
+ bundles.sort(key=lambda b: ((b.get('name') or ''), (b.get('full_name') or '')))
116
116
 
117
117
  for bundle in bundles:
118
118
  # Use full_name for display (includes version info)
@@ -163,10 +163,10 @@ def search_bundles(query: str):
163
163
  query_lower = query.lower()
164
164
  matching_bundles = [
165
165
  b for b in bundles
166
- if query_lower in b.get('name', '').lower() or
167
- query_lower in b.get('full_name', '').lower() or
168
- query_lower in b.get('repo', '').lower() or
169
- query_lower in b.get('description', '').lower()
166
+ if query_lower in (b.get('name') or '').lower() or
167
+ query_lower in (b.get('full_name') or '').lower() or
168
+ query_lower in (b.get('repo') or '').lower() or
169
+ query_lower in (b.get('description') or '').lower()
170
170
  ]
171
171
 
172
172
  if not matching_bundles:
@@ -407,7 +407,7 @@ def load_bundle_command(bundle_name: str, clear_existing: bool = False):
407
407
  if not all(services):
408
408
  return (False, "Failed to initialize database services", {})
409
409
 
410
- db_manager, _, _ = services
410
+ db_manager, _, _, _ = services
411
411
 
412
412
  # Check if bundle exists locally
413
413
  bundle_path = Path(bundle_name)