codegraphcontext 0.4.16__py3-none-any.whl → 0.4.17__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 (51) hide show
  1. codegraphcontext/api/router.py +78 -23
  2. codegraphcontext/cli/cli_helpers.py +65 -46
  3. codegraphcontext/cli/config_manager.py +56 -21
  4. codegraphcontext/cli/main.py +156 -130
  5. codegraphcontext/cli/registry_commands.py +34 -11
  6. codegraphcontext/cli/visualizer.py +154 -40
  7. codegraphcontext/core/__init__.py +33 -19
  8. codegraphcontext/core/bundle_registry.py +18 -4
  9. codegraphcontext/core/cgc_bundle.py +241 -182
  10. codegraphcontext/core/cgcignore.py +3 -3
  11. codegraphcontext/core/database_falkordb.py +45 -21
  12. codegraphcontext/core/database_kuzu.py +38 -23
  13. codegraphcontext/core/database_ladybug.py +25 -14
  14. codegraphcontext/core/falkor_worker.py +39 -3
  15. codegraphcontext/core/jobs.py +1 -0
  16. codegraphcontext/core/watcher.py +45 -4
  17. codegraphcontext/server.py +57 -21
  18. codegraphcontext/tools/code_finder.py +16 -13
  19. codegraphcontext/tools/graph_builder.py +10 -7
  20. codegraphcontext/tools/handlers/analysis_handlers.py +4 -2
  21. codegraphcontext/tools/handlers/indexing_handlers.py +13 -36
  22. codegraphcontext/tools/handlers/management_handlers.py +68 -16
  23. codegraphcontext/tools/handlers/query_handlers.py +24 -37
  24. codegraphcontext/tools/handlers/watcher_handlers.py +38 -27
  25. codegraphcontext/tools/indexing/constants.py +4 -0
  26. codegraphcontext/tools/indexing/persistence/writer.py +72 -133
  27. codegraphcontext/tools/indexing/pipeline.py +20 -0
  28. codegraphcontext/tools/indexing/resolution/calls.py +14 -1
  29. codegraphcontext/tools/languages/c.py +13 -3
  30. codegraphcontext/tools/languages/dart.py +13 -3
  31. codegraphcontext/tools/languages/elisp.py +13 -3
  32. codegraphcontext/tools/languages/elixir.py +13 -3
  33. codegraphcontext/tools/languages/go.py +13 -3
  34. codegraphcontext/tools/languages/javascript.py +13 -3
  35. codegraphcontext/tools/languages/lua.py +13 -3
  36. codegraphcontext/tools/languages/perl.py +13 -3
  37. codegraphcontext/tools/languages/python.py +77 -35
  38. codegraphcontext/tools/languages/ruby.py +13 -3
  39. codegraphcontext/tools/languages/swift.py +13 -3
  40. codegraphcontext/tools/languages/typescript.py +13 -3
  41. codegraphcontext/tools/report_generator.py +80 -15
  42. codegraphcontext/tools/scip_indexer.py +6 -1
  43. codegraphcontext/utils/cypher_readonly.py +65 -0
  44. codegraphcontext/utils/path_sandbox.py +90 -0
  45. codegraphcontext/viz/server.py +57 -37
  46. {codegraphcontext-0.4.16.dist-info → codegraphcontext-0.4.17.dist-info}/METADATA +82 -2
  47. {codegraphcontext-0.4.16.dist-info → codegraphcontext-0.4.17.dist-info}/RECORD +51 -49
  48. {codegraphcontext-0.4.16.dist-info → codegraphcontext-0.4.17.dist-info}/WHEEL +0 -0
  49. {codegraphcontext-0.4.16.dist-info → codegraphcontext-0.4.17.dist-info}/entry_points.txt +0 -0
  50. {codegraphcontext-0.4.16.dist-info → codegraphcontext-0.4.17.dist-info}/licenses/LICENSE +0 -0
  51. {codegraphcontext-0.4.16.dist-info → codegraphcontext-0.4.17.dist-info}/top_level.txt +0 -0
@@ -13,6 +13,8 @@ from .schemas import (
13
13
  )
14
14
  from codegraphcontext.server import MCPServer
15
15
 
16
+ import socket
17
+
16
18
  router = APIRouter()
17
19
 
18
20
  # Global server instance (initialized on startup)
@@ -25,9 +27,19 @@ def get_server() -> MCPServer:
25
27
  _server_instance = MCPServer(cwd=Path.cwd())
26
28
  return _server_instance
27
29
 
30
+ def raise_service_unavailable(exc: Exception):
31
+ raise HTTPException(
32
+ status_code=503,
33
+ detail="Database service unavailable",
34
+ ) from exc
35
+
28
36
  @router.get("/status", response_model=ApiResponse)
29
37
  async def get_status(server: MCPServer = Depends(get_server)):
30
- status = server.db_manager.is_connected()
38
+ try:
39
+ status = server.db_manager.is_connected()
40
+ except (OSError, socket.error) as exc:
41
+ raise_service_unavailable(exc)
42
+
31
43
  return ApiResponse(
32
44
  status="ok",
33
45
  message="Connected" if status else "Disconnected",
@@ -43,16 +55,35 @@ async def list_tools(server: MCPServer = Depends(get_server)):
43
55
 
44
56
  @router.post("/tools/call", response_model=ApiResponse)
45
57
  async def call_tool(
46
- request: ToolCallRequest,
58
+ request: ToolCallRequest,
47
59
  server: MCPServer = Depends(get_server)
48
60
  ):
49
61
  try:
50
- result = await server.handle_tool_call(request.name, request.arguments)
62
+ result = await server.handle_tool_call(
63
+ request.name,
64
+ request.arguments
65
+ )
66
+
51
67
  if "error" in result:
52
- return ApiResponse(status="error", error=result["error"])
53
- return ApiResponse(status="ok", data=result)
68
+ return ApiResponse(
69
+ status="error",
70
+ error=result["error"]
71
+ )
72
+
73
+ return ApiResponse(
74
+ status="ok",
75
+ data=result
76
+ )
77
+
78
+ except (OSError, socket.error) as exc:
79
+ raise_service_unavailable(exc)
80
+
54
81
  except Exception as e:
55
- return ApiResponse(status="error", error=str(e))
82
+ return ApiResponse(
83
+ status="error",
84
+ error=str(e)
85
+ )
86
+
56
87
 
57
88
  @router.post("/index", response_model=ApiResponse)
58
89
  async def index_repository(
@@ -60,21 +91,27 @@ async def index_repository(
60
91
  background_tasks: BackgroundTasks,
61
92
  server: MCPServer = Depends(get_server)
62
93
  ):
63
- # Map to add_code_to_graph tool
64
94
  args = {
65
95
  "path": request.path,
66
96
  "repo_name": request.repo_name,
67
97
  "branch": request.branch,
68
98
  "force": request.force
69
99
  }
70
-
71
- # We call handle_tool_call which is async
72
- # But add_code_to_graph starts a background job anyway
73
- result = await server.handle_tool_call("add_code_to_graph", args)
74
-
100
+
101
+ try:
102
+ result = await server.handle_tool_call(
103
+ "add_code_to_graph",
104
+ args
105
+ )
106
+ except (OSError, socket.error) as exc:
107
+ raise_service_unavailable(exc)
108
+
75
109
  if "error" in result:
76
- raise HTTPException(status_code=400, detail=result["error"])
77
-
110
+ raise HTTPException(
111
+ status_code=400,
112
+ detail=result["error"]
113
+ )
114
+
78
115
  return ApiResponse(
79
116
  status="ok",
80
117
  message="Indexing job started",
@@ -86,17 +123,35 @@ async def execute_query(
86
123
  request: QueryRequest,
87
124
  server: MCPServer = Depends(get_server)
88
125
  ):
89
- result = await server.handle_tool_call("execute_cypher_query", {
90
- "cypher_query": request.query,
91
- "params": request.params
92
- })
93
-
126
+ try:
127
+ result = await server.handle_tool_call(
128
+ "execute_cypher_query",
129
+ {
130
+ "cypher_query": request.query,
131
+ "params": request.params,
132
+ },
133
+ )
134
+ except (OSError, socket.error) as exc:
135
+ raise_service_unavailable(exc)
136
+
94
137
  if "error" in result:
95
138
  return ApiResponse(status="error", error=result["error"])
96
-
139
+
97
140
  return ApiResponse(status="ok", data=result)
98
141
 
99
142
  @router.get("/repositories", response_model=ApiResponse)
100
- async def list_repositories(server: MCPServer = Depends(get_server)):
101
- result = await server.handle_tool_call("list_indexed_repositories", {})
102
- return ApiResponse(status="ok", data=result)
143
+ async def list_repositories(
144
+ server: MCPServer = Depends(get_server)
145
+ ):
146
+ try:
147
+ result = await server.handle_tool_call(
148
+ "list_indexed_repositories",
149
+ {}
150
+ )
151
+ except (OSError, socket.error) as exc:
152
+ raise_service_unavailable(exc)
153
+
154
+ return ApiResponse(
155
+ status="ok",
156
+ data=result
157
+ )
@@ -29,7 +29,13 @@ from ..tools.package_resolver import get_local_package_path
29
29
  from ..utils.debug_log import info_logger, warning_logger
30
30
  from ..core.database import Neo4jConnectionError
31
31
  from ..utils.repo_path import any_repo_matches_path
32
- from .config_manager import resolve_context, ResolvedContext, register_repo_in_context, ensure_first_run_bootstrap
32
+ from .config_manager import (
33
+ resolve_context,
34
+ ResolvedContext,
35
+ register_repo_in_context,
36
+ ensure_first_run_bootstrap,
37
+ ContextNotFoundError,
38
+ )
33
39
 
34
40
  console = Console()
35
41
 
@@ -41,6 +47,9 @@ def _fail_services_init() -> None:
41
47
 
42
48
  def _kuzu_fallback_path(ctx: ResolvedContext) -> Optional[str]:
43
49
  """Derive a KùzuDB directory when falling back from another backend."""
50
+ runtime = os.getenv("CGC_RUNTIME_DB_PATH")
51
+ if runtime:
52
+ return str(Path(runtime).expanduser().resolve())
44
53
  if ctx.db_path:
45
54
  return str(Path(ctx.db_path).parent / "kuzudb")
46
55
  try:
@@ -85,7 +94,11 @@ def _initialize_services(
85
94
  """
86
95
  ensure_first_run_bootstrap()
87
96
  console.print("[dim]Resolving context...[/dim]")
88
- ctx = resolve_context(cli_context_flag, cwd=cwd)
97
+ try:
98
+ ctx = resolve_context(cli_context_flag, cwd=cwd)
99
+ except ContextNotFoundError as exc:
100
+ console.print(f"[bold red]Error:[/bold red] {exc}")
101
+ raise typer.Exit(code=1)
89
102
 
90
103
  # Let the user know what context we're operating in
91
104
  if ctx.mode == "named":
@@ -120,6 +133,8 @@ def _initialize_services(
120
133
  # Check if this is a FalkorDB failure that should trigger a KùzuDB fallback
121
134
  from ..core.database_falkordb import FalkorDBUnavailableError
122
135
  if isinstance(e, FalkorDBUnavailableError):
136
+ from ..core import mark_falkordb_unavailable
137
+ mark_falkordb_unavailable()
123
138
  console.print(f"[yellow]⚠ FalkorDB Lite is not functional in this environment: {e}[/yellow]")
124
139
  console.print("[cyan]Falling back to KùzuDB for a reliable experience...[/cyan]")
125
140
 
@@ -219,8 +234,8 @@ async def _run_index_with_progress(graph_builder: GraphBuilder, path_obj: Path,
219
234
  if job.total_files > 0:
220
235
  progress.update(task_id, total=job.total_files, completed=job.processed_files)
221
236
 
222
- # Update the current filename in the UI
223
- current_file = job.current_file or ""
237
+ # Prefer post-processing status over the last parsed file path
238
+ current_file = job.status_message or job.current_file or ""
224
239
  if len(current_file) > 40:
225
240
  current_file = "..." + current_file[-37:]
226
241
  progress.update(task_id, filename=current_file)
@@ -283,9 +298,10 @@ def index_helper(path: str, context: Optional[str] = None):
283
298
  except Exception as e:
284
299
  console.print(f"[yellow]Warning: Could not check file count: {e}. Proceeding with indexing...[/yellow]")
285
300
 
286
- # Auto-register the repo into the named context (auto-creates if needed)
287
301
  if context and ctx.mode == "named":
288
- register_repo_in_context(context, str(path_obj), auto_create=True)
302
+ if not register_repo_in_context(context, str(path_obj), auto_create=False):
303
+ db_manager.close_driver()
304
+ raise typer.Exit(code=1)
289
305
 
290
306
  console.print(f"Starting indexing for: {path_obj}")
291
307
 
@@ -327,7 +343,7 @@ def add_package_helper(package_name: str, language: str, context: Optional[str]
327
343
  if not package_path_str:
328
344
  console.print(f"[red]Error: Could not find package '{package_name}' for language '{language}'.[/red]")
329
345
  db_manager.close_driver()
330
- return
346
+ raise typer.Exit(code=1)
331
347
 
332
348
  package_path = Path(package_path_str)
333
349
 
@@ -345,6 +361,7 @@ def add_package_helper(package_name: str, language: str, context: Optional[str]
345
361
  console.print(f"[green]Successfully finished indexing package: {package_name}[/green]")
346
362
  except Exception as e:
347
363
  console.print(f"[bold red]An error occurred during package indexing:[/bold red] {e}")
364
+ raise typer.Exit(code=1)
348
365
  finally:
349
366
  db_manager.close_driver()
350
367
 
@@ -406,23 +423,26 @@ def cypher_helper(query: str, context: Optional[str] = None):
406
423
  _fail_services_init()
407
424
 
408
425
  db_manager, _, _, ctx = services
409
-
410
- # Replicating safety checks from MCPServer (using word boundaries to avoid false positives like 'createEmail')
411
- import re
412
- forbidden_keywords = ['CREATE', 'MERGE', 'DELETE', 'SET', 'REMOVE', 'DROP', 'CALL apoc']
413
- pattern = r'\b(' + '|'.join(forbidden_keywords) + r')\b'
414
- if re.search(pattern, query, re.IGNORECASE):
415
- console.print("[bold red]Error: This command only supports read-only queries.[/bold red]")
426
+
427
+ from ..utils.cypher_readonly import is_read_only_cypher, read_only_rejection_message
428
+
429
+ if not is_read_only_cypher(query):
430
+ console.print(f"[bold red]Error:[/bold red] {read_only_rejection_message()}")
416
431
  db_manager.close_driver()
417
432
  raise typer.Exit(code=1)
418
433
 
434
+ backend = getattr(db_manager, "get_backend_type", lambda: "neo4j")()
435
+ session_kwargs = {"default_access_mode": "READ"} if backend == "neo4j" else {}
436
+
419
437
  try:
420
- with db_manager.get_driver().session() as session:
438
+ with db_manager.get_driver().session(**session_kwargs) as session:
421
439
  result = session.run(query)
422
440
  records = [record.data() for record in result]
423
441
  console.print(json.dumps(records, indent=2))
424
442
  except Exception as e:
425
443
  console.print(f"[bold red]An error occurred while executing query:[/bold red] {e}")
444
+ db_manager.close_driver()
445
+ raise typer.Exit(code=1)
426
446
  finally:
427
447
  db_manager.close_driver()
428
448
 
@@ -430,34 +450,25 @@ def cypher_helper(query: str, context: Optional[str] = None):
430
450
  def cypher_helper_visual(query: str, context: Optional[str] = None):
431
451
  """Executes a read-only Cypher query and visualizes the results."""
432
452
  from .visualizer import visualize_cypher_results
433
-
453
+ from ..utils.cypher_readonly import is_read_only_cypher, read_only_rejection_message
454
+
434
455
  services = _initialize_services(context)
435
456
  if not all(services[:3]):
436
457
  _fail_services_init()
437
458
 
438
459
  db_manager, _, _, ctx = services
439
-
440
- # Replicating safety checks from MCPServer (using word boundaries to avoid false positives like 'createEmail')
441
- import re
442
- forbidden_keywords = ['CREATE', 'MERGE', 'DELETE', 'SET', 'REMOVE', 'DROP', 'CALL apoc']
443
- pattern = r'\b(' + '|'.join(forbidden_keywords) + r')\b'
444
- if re.search(pattern, query, re.IGNORECASE):
445
- console.print("[bold red]Error: This command only supports read-only queries.[/bold red]")
460
+
461
+ if not is_read_only_cypher(query):
462
+ console.print(f"[bold red]Error:[/bold red] {read_only_rejection_message()}")
446
463
  db_manager.close_driver()
447
464
  raise typer.Exit(code=1)
448
465
 
449
466
  try:
450
- with db_manager.get_driver().session() as session:
451
- result = session.run(query)
452
- records = [record.data() for record in result]
453
-
454
- if not records:
455
- console.print("[yellow]No results to visualize.[/yellow]")
456
- return # finally block will close driver
457
-
458
- visualize_cypher_results(records, query)
467
+ visualize_cypher_results(query)
459
468
  except Exception as e:
460
469
  console.print(f"[bold red]An error occurred while executing query:[/bold red] {e}")
470
+ db_manager.close_driver()
471
+ raise typer.Exit(code=1)
461
472
  finally:
462
473
  db_manager.close_driver()
463
474
 
@@ -466,7 +477,12 @@ import uvicorn
466
477
  import urllib.parse
467
478
  from ..viz.server import run_server, set_db_manager
468
479
 
469
- def visualize_helper(repo_path: Optional[str] = None, port: int = 8000, context: Optional[str] = None):
480
+ def visualize_helper(
481
+ repo_path: Optional[str] = None,
482
+ port: int = 8000,
483
+ context: Optional[str] = None,
484
+ cypher_query: Optional[str] = None,
485
+ ):
470
486
  """Generates an interactive visualization using the Playground UI."""
471
487
  services = _initialize_services(context)
472
488
  if not all(services[:3]):
@@ -537,7 +553,9 @@ def visualize_helper(repo_path: Optional[str] = None, port: int = 8000, context:
537
553
  params = {"backend": backend_url}
538
554
  if repo_path:
539
555
  params["repo_path"] = str(Path(repo_path).resolve())
540
-
556
+ if cypher_query:
557
+ params["cypher_query"] = cypher_query
558
+
541
559
  query_string = urllib.parse.urlencode(params)
542
560
  visualization_url = f"{backend_url}/explore?{query_string}"
543
561
 
@@ -558,6 +576,7 @@ def visualize_helper(repo_path: Optional[str] = None, port: int = 8000, context:
558
576
  run_server(host="127.0.0.1", port=port, static_dir=str(static_dir))
559
577
  except Exception as e:
560
578
  console.print(f"[bold red]An error occurred while running the server:[/bold red] {e}")
579
+ raise typer.Exit(code=1)
561
580
  finally:
562
581
  db_manager.close_driver()
563
582
 
@@ -565,17 +584,18 @@ def visualize_helper(repo_path: Optional[str] = None, port: int = 8000, context:
565
584
  def reindex_helper(path: str, context: Optional[str] = None):
566
585
  """Force re-index by deleting and rebuilding the repository."""
567
586
  time_start = time.time()
568
- services = _initialize_services(context)
587
+ path_obj = Path(path).resolve()
588
+ index_cwd = path_obj if path_obj.is_dir() else path_obj.parent
589
+ services = _initialize_services(context, cwd=index_cwd)
569
590
  if not all(services[:3]):
570
591
  _fail_services_init()
571
592
 
572
593
  db_manager, graph_builder, code_finder, ctx = services
573
- path_obj = Path(path).resolve()
574
594
 
575
595
  if not path_obj.exists():
576
596
  console.print(f"[red]Error: Path does not exist: {path_obj}[/red]")
577
597
  db_manager.close_driver()
578
- return
598
+ raise typer.Exit(code=1)
579
599
 
580
600
  # Check if already indexed
581
601
  indexed_repos = code_finder.list_indexed_repositories()
@@ -589,7 +609,7 @@ def reindex_helper(path: str, context: Optional[str] = None):
589
609
  except Exception as e:
590
610
  console.print(f"[red]Error deleting old index: {e}[/red]")
591
611
  db_manager.close_driver()
592
- return
612
+ raise typer.Exit(code=1)
593
613
 
594
614
  console.print(f"[cyan]Re-indexing: {path_obj}[/cyan]")
595
615
 
@@ -627,14 +647,13 @@ def clean_helper(context: Optional[str] = None):
627
647
  batch_size = 500
628
648
 
629
649
  with db_manager.get_driver().session() as session:
630
- # Layer-by-layer deletion: iteratively delete nodes that lost
631
- # their CONTAINS parent. Each pass peels one layer of the
632
- # Repository → File → Class/Function → Variable hierarchy.
650
+ # Delete nodes with no incoming relationships (true orphans).
651
+ # Parameters (HAS_PARAMETER), import Modules (IMPORTS), etc. are kept.
633
652
  while True:
634
653
  result = session.run("""
635
654
  MATCH (n)
636
655
  WHERE NOT n:Repository
637
- AND NOT ()-[:CONTAINS]->(n)
656
+ AND NOT ()-[]->(n)
638
657
  WITH n LIMIT $batch_size
639
658
  DETACH DELETE n
640
659
  RETURN count(n) as deleted
@@ -762,7 +781,7 @@ def stats_helper(path: str = None, context: Optional[str] = None):
762
781
  db_manager.close_driver()
763
782
 
764
783
 
765
- def watch_helper(path: str, context: Optional[str] = None):
784
+ def watch_helper(path: str, context: Optional[str] = None, use_polling: Optional[bool] = None):
766
785
  """Watch a directory for changes and auto-update the graph (blocking mode)."""
767
786
  import logging
768
787
  from ..core.watcher import CodeWatcher
@@ -782,12 +801,12 @@ def watch_helper(path: str, context: Optional[str] = None):
782
801
  if not path_obj.exists():
783
802
  console.print(f"[red]Error: Path does not exist: {path_obj}[/red]")
784
803
  db_manager.close_driver()
785
- return
804
+ raise typer.Exit(code=1)
786
805
 
787
806
  if not path_obj.is_dir():
788
807
  console.print(f"[red]Error: Path must be a directory: {path_obj}[/red]")
789
808
  db_manager.close_driver()
790
- return
809
+ raise typer.Exit(code=1)
791
810
 
792
811
  console.print(f"[bold cyan]🔍 Watching {path_obj} for changes...[/bold cyan]")
793
812
 
@@ -817,7 +836,7 @@ def watch_helper(path: str, context: Optional[str] = None):
817
836
 
818
837
  # Create watcher instance
819
838
  job_manager = JobManager()
820
- watcher = CodeWatcher(graph_builder, job_manager)
839
+ watcher = CodeWatcher(graph_builder, job_manager, use_polling=use_polling)
821
840
 
822
841
  try:
823
842
  # Start the observer thread
@@ -204,11 +204,12 @@ def normalize_config_path(value: str, *, absolute: bool = False, base_dir: Optio
204
204
  return str(path_obj)
205
205
 
206
206
 
207
- def ensure_config_dir(path: Path = CONFIG_DIR):
207
+ def ensure_config_dir(path: Optional[Path] = None):
208
208
  """
209
209
  Ensure that the configuration directory exists.
210
210
  Creates the directory and a logs subdirectory if they do not already exist.
211
211
  """
212
+ path = path or CONFIG_DIR
212
213
  path.mkdir(parents=True, exist_ok=True)
213
214
  (path / "logs").mkdir(parents=True, exist_ok=True)
214
215
 
@@ -275,11 +276,31 @@ def load_config() -> Dict[str, str]:
275
276
  return config
276
277
 
277
278
 
279
+ def should_apply_project_dotenv() -> bool:
280
+ """True when cwd-local ``.codegraphcontext/.env`` should merge with global config.
281
+
282
+ Skips project env when ``HOME`` is isolated (e.g. E2E) but ``cwd`` is an unrelated
283
+ checkout, unless ``CGC_LOAD_PROJECT_ENV=1``. Set ``CGC_IGNORE_PROJECT_ENV=1`` to force skip.
284
+ """
285
+ if os.getenv("CGC_IGNORE_PROJECT_ENV", "").strip().lower() in ("1", "true", "yes"):
286
+ return False
287
+ if os.getenv("CGC_LOAD_PROJECT_ENV", "").strip().lower() in ("1", "true", "yes"):
288
+ return True
289
+ try:
290
+ Path.cwd().resolve().relative_to(Path.home().resolve())
291
+ return True
292
+ except ValueError:
293
+ return False
294
+
295
+
278
296
  def find_local_env() -> Optional[Path]:
279
297
  """
280
298
  Find a local .env file by searching current directory and parents.
281
299
  Returns the first .env file found, or None.
282
300
  """
301
+ if not should_apply_project_dotenv():
302
+ return None
303
+
283
304
  current = Path.cwd()
284
305
 
285
306
  # Search up to 5 levels up
@@ -484,6 +505,11 @@ def get_config_value(key: str) -> Optional[str]:
484
505
  return config.get(key)
485
506
 
486
507
 
508
+ def is_db_deletion_allowed() -> bool:
509
+ """True when destructive delete/clear operations are permitted."""
510
+ return str(get_config_value("ALLOW_DB_DELETION") or "false").strip().lower() == "true"
511
+
512
+
487
513
  def set_config_value(key: str, value: str) -> bool:
488
514
  """Set a configuration value. Returns True if successful.
489
515
 
@@ -513,7 +539,15 @@ def set_config_value(key: str, value: str) -> bool:
513
539
 
514
540
  def reset_config():
515
541
  """Reset configuration to defaults (preserves database credentials)."""
542
+ import shutil
543
+ from datetime import datetime
544
+
516
545
  ensure_config_dir()
546
+ if CONFIG_FILE.exists():
547
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
548
+ backup = CONFIG_FILE.with_name(f"{CONFIG_FILE.name}.{stamp}.bak")
549
+ shutil.copy2(CONFIG_FILE, backup)
550
+ console.print(f"[dim]Backed up current config to {backup}[/dim]")
517
551
  save_config(DEFAULT_CONFIG.copy(), preserve_db_credentials=True)
518
552
  console.print("[green]✅ Configuration reset to defaults[/green]")
519
553
  console.print("[cyan]Note: Database credentials were preserved[/cyan]")
@@ -538,22 +572,21 @@ def _print_welcome_banner() -> None:
538
572
  console.print()
539
573
 
540
574
 
541
- def ensure_first_run_bootstrap() -> bool:
575
+ def ensure_first_run_bootstrap(show_welcome: bool = False) -> bool:
542
576
  """Run one-time setup for brand-new installs.
543
577
 
544
- Creates default config files, the global .cgcignore, and prints a
545
- welcome banner. Returns True when bootstrap was performed.
578
+ Creates default config files and the global .cgcignore silently.
579
+ Returns True when bootstrap was performed.
546
580
  """
547
581
  if _FIRST_RUN_MARKER.exists():
548
582
  return False
549
583
 
550
584
  ensure_config_dir()
551
585
  ensure_global_cgcignore()
552
- # Ensure config.yaml exists (triggers creation with defaults)
553
586
  load_context_config()
554
- _print_welcome_banner()
587
+ if show_welcome:
588
+ _print_welcome_banner()
555
589
 
556
- # Stamp so we don't repeat
557
590
  _FIRST_RUN_MARKER.parent.mkdir(parents=True, exist_ok=True)
558
591
  _FIRST_RUN_MARKER.write_text("1")
559
592
  return True
@@ -792,6 +825,10 @@ def find_local_cgc_dir(start: Optional[Path] = None) -> Optional[Path]:
792
825
  return None
793
826
 
794
827
 
828
+ class ContextNotFoundError(ValueError):
829
+ """Raised when --context names an unregistered workspace."""
830
+
831
+
795
832
  def resolve_context(
796
833
  cli_context: Optional[str] = None,
797
834
  cwd: Optional[Path] = None,
@@ -811,23 +848,21 @@ def resolve_context(
811
848
  # --- 1. Explicit CLI flag ---
812
849
  if cli_context:
813
850
  ctx = cfg.contexts.get(cli_context)
814
- db = ctx.database if ctx else "falkordb"
815
- db_path = ctx.db_path if ctx else _default_db_path(cli_context, db)
816
- cgcignore = (
817
- ctx.cgcignore_path
818
- if ctx
819
- else str(CONFIG_DIR / "contexts" / cli_context / ".cgcignore")
820
- )
851
+ if ctx is None:
852
+ raise ContextNotFoundError(
853
+ f"Context '{cli_context}' is not registered. "
854
+ f"Create it with: cgc context create {cli_context}"
855
+ )
821
856
  return ResolvedContext(
822
857
  mode="named",
823
858
  context_name=cli_context,
824
- database=db,
825
- db_path=db_path,
826
- cgcignore_path=cgcignore,
859
+ database=ctx.database,
860
+ db_path=ctx.db_path,
861
+ cgcignore_path=ctx.cgcignore_path,
827
862
  )
828
863
 
829
- # --- 2. Local .codegraphcontext/ in repo ---
830
- local_cgc = find_local_cgc_dir(cwd)
864
+ # --- 2. Local .codegraphcontext/ in repo (per-repo mode only) ---
865
+ local_cgc = find_local_cgc_dir(cwd) if cfg.mode == "per-repo" else None
831
866
 
832
867
  # If we are in per-repo mode and no local folder was found, create it in CWD
833
868
  if local_cgc is None and cfg.mode == "per-repo":
@@ -864,8 +899,8 @@ def resolve_context(
864
899
  is_local=True,
865
900
  )
866
901
 
867
- # --- 2b. Saved workspace mapping (CWD -> child .codegraphcontext/) ---
868
- mapping = get_workspace_mapping(cwd)
902
+ # --- 2b. Saved workspace mapping (per-repo mode only) ---
903
+ mapping = get_workspace_mapping(cwd) if cfg.mode == "per-repo" else None
869
904
  if mapping:
870
905
  mapped_ctx_path = Path(mapping["context_path"])
871
906
  if mapped_ctx_path.exists() and mapped_ctx_path.is_dir():