codegraphcontext 0.5.3__py3-none-any.whl → 0.5.4__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 (77) hide show
  1. codegraphcontext/api/app.py +19 -5
  2. codegraphcontext/api/mcp_sse.py +49 -2
  3. codegraphcontext/cli/cli_helpers.py +123 -69
  4. codegraphcontext/cli/config_manager.py +3 -0
  5. codegraphcontext/cli/main.py +10 -5
  6. codegraphcontext/cli/setup_wizard.py +5 -0
  7. codegraphcontext/core/__init__.py +70 -48
  8. codegraphcontext/core/cgc_bundle.py +28 -3
  9. codegraphcontext/core/database_embedded_kuzu.py +12 -1
  10. codegraphcontext/core/database_falkordb.py +37 -10
  11. codegraphcontext/core/falkor_worker.py +16 -3
  12. codegraphcontext/core/watcher.py +10 -3
  13. codegraphcontext/server.py +1 -1
  14. codegraphcontext/tool_definitions.py +12 -2
  15. codegraphcontext/tools/advanced_language_query_tool.py +2 -0
  16. codegraphcontext/tools/code_finder.py +15 -12
  17. codegraphcontext/tools/graph_builder.py +11 -4
  18. codegraphcontext/tools/indexing/persistence/writer.py +32 -4
  19. codegraphcontext/tools/indexing/pipeline.py +41 -7
  20. codegraphcontext/tools/indexing/pre_scan.py +6 -0
  21. codegraphcontext/tools/indexing/resolution/calls.py +31 -7
  22. codegraphcontext/tools/indexing/resolution/inheritance.py +16 -2
  23. codegraphcontext/tools/indexing/resolution/post_resolution.py +13 -4
  24. codegraphcontext/tools/languages/sfc_common.py +240 -0
  25. codegraphcontext/tools/languages/solidity.py +1069 -0
  26. codegraphcontext/tools/languages/solidity_remappings.py +157 -0
  27. codegraphcontext/tools/languages/svelte.py +38 -0
  28. codegraphcontext/tools/languages/vue.py +38 -0
  29. codegraphcontext/tools/query_tool_languages/solidity_toolkit.py +70 -0
  30. codegraphcontext/tools/tree_sitter_parser.py +3 -0
  31. codegraphcontext/utils/cypher_readonly.py +27 -6
  32. codegraphcontext/utils/tree_sitter_manager.py +7 -0
  33. codegraphcontext/viz/dist/assets/__vite-browser-external-9wXp6ZBx.js +1 -0
  34. codegraphcontext/viz/dist/assets/function-calls-BtRHrqa2.png +0 -0
  35. codegraphcontext/viz/dist/assets/graph-total-D1fBAugo.png +0 -0
  36. codegraphcontext/viz/dist/assets/hero-graph-2voMJp2a.jpg +0 -0
  37. codegraphcontext/viz/dist/assets/hierarchy-DGADo0YT.png +0 -0
  38. codegraphcontext/viz/dist/assets/index-C-187lf0.js +5587 -0
  39. codegraphcontext/viz/dist/assets/index-fNAa6jgv.css +1 -0
  40. codegraphcontext/viz/dist/assets/parser-pyodide.worker-BgsDfaad.js +370 -0
  41. codegraphcontext/viz/dist/assets/parser.worker-_nvrecvj.js +233 -0
  42. codegraphcontext/viz/dist/assets/tree-sitter-qKYAACSa.wasm +0 -0
  43. codegraphcontext/viz/dist/cgcIcon.png +0 -0
  44. codegraphcontext/viz/dist/favicon.ico +0 -0
  45. codegraphcontext/viz/dist/index.html +32 -0
  46. codegraphcontext/viz/dist/logo-icon.svg +85 -0
  47. codegraphcontext/viz/dist/logo.svg +100 -0
  48. codegraphcontext/viz/dist/placeholder.svg +1 -0
  49. codegraphcontext/viz/dist/preview-image.png +0 -0
  50. codegraphcontext/viz/dist/robots.txt +14 -0
  51. codegraphcontext/viz/dist/wasm/tree-sitter-c.wasm +0 -0
  52. codegraphcontext/viz/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
  53. codegraphcontext/viz/dist/wasm/tree-sitter-core.js +1 -0
  54. codegraphcontext/viz/dist/wasm/tree-sitter-cpp.wasm +0 -0
  55. codegraphcontext/viz/dist/wasm/tree-sitter-dart.wasm +0 -0
  56. codegraphcontext/viz/dist/wasm/tree-sitter-go.wasm +0 -0
  57. codegraphcontext/viz/dist/wasm/tree-sitter-java.wasm +0 -0
  58. codegraphcontext/viz/dist/wasm/tree-sitter-javascript.wasm +0 -0
  59. codegraphcontext/viz/dist/wasm/tree-sitter-kotlin.wasm +0 -0
  60. codegraphcontext/viz/dist/wasm/tree-sitter-perl.wasm +1 -0
  61. codegraphcontext/viz/dist/wasm/tree-sitter-php.wasm +0 -0
  62. codegraphcontext/viz/dist/wasm/tree-sitter-python.wasm +0 -0
  63. codegraphcontext/viz/dist/wasm/tree-sitter-ruby.wasm +0 -0
  64. codegraphcontext/viz/dist/wasm/tree-sitter-rust.wasm +0 -0
  65. codegraphcontext/viz/dist/wasm/tree-sitter-swift.wasm +0 -0
  66. codegraphcontext/viz/dist/wasm/tree-sitter-tsx.wasm +0 -0
  67. codegraphcontext/viz/dist/wasm/tree-sitter-typescript.wasm +0 -0
  68. codegraphcontext/viz/dist/wasm/tree-sitter.wasm +0 -0
  69. codegraphcontext/viz/dist/wasm/web-tree-sitter.js +4007 -0
  70. codegraphcontext/viz/dist/wasm/web-tree-sitter.wasm +0 -0
  71. codegraphcontext/viz/server.py +34 -9
  72. {codegraphcontext-0.5.3.dist-info → codegraphcontext-0.5.4.dist-info}/METADATA +29 -3
  73. {codegraphcontext-0.5.3.dist-info → codegraphcontext-0.5.4.dist-info}/RECORD +77 -33
  74. {codegraphcontext-0.5.3.dist-info → codegraphcontext-0.5.4.dist-info}/WHEEL +0 -0
  75. {codegraphcontext-0.5.3.dist-info → codegraphcontext-0.5.4.dist-info}/entry_points.txt +0 -0
  76. {codegraphcontext-0.5.3.dist-info → codegraphcontext-0.5.4.dist-info}/licenses/LICENSE +0 -0
  77. {codegraphcontext-0.5.3.dist-info → codegraphcontext-0.5.4.dist-info}/top_level.txt +0 -0
@@ -1,10 +1,10 @@
1
1
  # src/codegraphcontext/api/app.py
2
2
  import os
3
- from fastapi import FastAPI
3
+ from fastapi import Depends, FastAPI
4
4
  from fastapi.responses import HTMLResponse
5
5
  from fastapi.middleware.cors import CORSMiddleware
6
6
  from .router import router
7
- from .auth import log_auth_status
7
+ from .auth import log_auth_status, require_api_key
8
8
  from .mcp_sse import handle_sse, handle_messages
9
9
 
10
10
  def create_app() -> FastAPI:
@@ -37,9 +37,23 @@ def create_app() -> FastAPI:
37
37
  """Liveness probe for load balancers and k8s."""
38
38
  return {"status": "ok"}
39
39
 
40
- # MCP-over-SSE Endpoints
41
- app.add_api_route("/api/v1/mcp/sse", handle_sse, methods=["GET"])
42
- app.add_api_route("/api/v1/mcp/messages", handle_messages, methods=["POST"])
40
+ # MCP-over-SSE Endpoints. These dispatch to the same tools as the REST
41
+ # router (execute_cypher_query, add_code_to_graph, delete_repository), so
42
+ # they need the same API-key dependency the router applies — without it,
43
+ # setting CGC_API_KEY left the SSE transport as an unauthenticated path to
44
+ # every tool.
45
+ app.add_api_route(
46
+ "/api/v1/mcp/sse",
47
+ handle_sse,
48
+ methods=["GET"],
49
+ dependencies=[Depends(require_api_key)],
50
+ )
51
+ app.add_api_route(
52
+ "/api/v1/mcp/messages",
53
+ handle_messages,
54
+ methods=["POST"],
55
+ dependencies=[Depends(require_api_key)],
56
+ )
43
57
 
44
58
  @app.get("/", response_class=HTMLResponse)
45
59
  async def root():
@@ -48,11 +48,41 @@ async def handle_call_tool(name: str, arguments: dict | None) -> list[TextConten
48
48
  # Create the SSE transport.
49
49
  sse = SseServerTransport("/api/v1/mcp/messages")
50
50
 
51
+
52
+ class _AlreadySentResponse(Response):
53
+ """Placeholder for a response the MCP transport wrote itself.
54
+
55
+ Both handlers below hand the raw ASGI ``send`` to the SDK, which emits the
56
+ complete response. FastAPI still does ``await endpoint(request)`` followed
57
+ by ``await response(...)``, so returning ``None`` raises ``TypeError:
58
+ 'NoneType' object is not callable`` and returning a real ``Response`` starts
59
+ a second response on a finished ASGI cycle. This satisfies FastAPI while
60
+ putting nothing on the wire.
61
+ """
62
+
63
+ async def __call__(self, scope, receive, send) -> None:
64
+ return
65
+
66
+
67
+ class _SendTracker:
68
+ """ASGI ``send`` wrapper that records whether a response was started."""
69
+
70
+ def __init__(self, send):
71
+ self._send = send
72
+ self.response_started = False
73
+
74
+ async def __call__(self, message) -> None:
75
+ if message.get("type") == "http.response.start":
76
+ self.response_started = True
77
+ await self._send(message)
78
+
79
+
51
80
  async def handle_sse(request: Request):
52
81
  """Entry point for the SSE connection."""
53
82
  logger.info("SSE client connected")
83
+ sender = _SendTracker(request._send)
54
84
  try:
55
- async with sse.connect_sse(request.scope, request.receive, request._send) as (read_stream, write_stream):
85
+ async with sse.connect_sse(request.scope, request.receive, sender) as (read_stream, write_stream):
56
86
  await mcp_server.run(
57
87
  read_stream,
58
88
  write_stream,
@@ -70,6 +100,10 @@ async def handle_sse(request: Request):
70
100
  logger.debug("SSE connection closed: %s", type(exc).__name__)
71
101
  finally:
72
102
  logger.info("SSE client disconnected — handler exited, resources freed")
103
+ if sender.response_started:
104
+ return _AlreadySentResponse()
105
+ # connect_sse bailed out before writing anything (e.g. rejected origin).
106
+ return Response(status_code=500, content="SSE connection could not be established")
73
107
 
74
108
 
75
109
  async def handle_messages(request: Request):
@@ -99,7 +133,20 @@ async def handle_messages(request: Request):
99
133
  media_type="application/json"
100
134
  )
101
135
 
136
+ # `handle_post_message` builds its own Request and awaits `.body()` again.
137
+ # Starlette caches a body on the Request instance, not in the scope, so
138
+ # handing it the raw `receive` would make it wait for a body that has
139
+ # already been drained above — the POST hangs until the client gives up and
140
+ # the message never reaches the session. Replay the buffered bytes instead.
141
+ async def replay_receive() -> dict:
142
+ return {"type": "http.request", "body": raw_body, "more_body": False}
143
+
144
+ sender = _SendTracker(request._send)
102
145
  try:
103
- await sse.handle_post_message(request.scope, request.receive, request._send)
146
+ await sse.handle_post_message(request.scope, replay_receive, sender)
104
147
  except Exception as exc:
105
148
  logger.debug("Message handler closed: %s", type(exc).__name__)
149
+
150
+ if sender.response_started:
151
+ return _AlreadySentResponse()
152
+ return Response(status_code=202, content="Accepted")
@@ -239,65 +239,78 @@ def _initialize_services(
239
239
  return db_manager, graph_builder, code_finder, ctx
240
240
 
241
241
 
242
- async def _run_index_with_progress(graph_builder: GraphBuilder, path_obj: Path, is_dependency: bool = False, cgcignore_path: str = None):
242
+ async def _run_index_with_progress(graph_builder: GraphBuilder, path_obj: Path, is_dependency: bool = False, cgcignore_path: str = None, disable_progress: bool = False):
243
243
  """Internal helper to run indexing with a Live progress bar."""
244
244
  job_id = graph_builder.job_manager.create_job(str(path_obj), is_dependency=is_dependency)
245
245
 
246
- # Create the progress bar
247
- with Progress(
248
- SpinnerColumn(),
249
- TextColumn("[progress.description]{task.description}"),
250
- BarColumn(),
251
- TaskProgressColumn(),
252
- MofNCompleteColumn(),
253
- TimeRemainingColumn(),
254
- TextColumn("[dim]{task.fields[filename]}"),
255
- console=console,
256
- transient=True,
257
- ) as progress:
258
-
259
- task_id = progress.add_task(
260
- "Indexing...",
261
- total=None, # Will be updated once file discovery is done
262
- filename=""
263
- )
246
+ disable_progress = (
247
+ disable_progress
248
+ or not console.is_terminal
249
+ or os.environ.get("CI", "").lower() == "true"
250
+ )
264
251
 
265
- indexing_task = asyncio.create_task(
266
- graph_builder.build_graph_from_path_async(path_obj, is_dependency=is_dependency, job_id=job_id, cgcignore_path=cgcignore_path)
267
- )
252
+ if not disable_progress:
253
+ os.environ["CGC_ACTIVE_PROGRESS_BAR"] = "1"
268
254
 
269
- from ..core.jobs import JobStatus
255
+ try:
256
+ # Create the progress bar
257
+ with Progress(
258
+ SpinnerColumn(),
259
+ TextColumn("[progress.description]{task.description}"),
260
+ BarColumn(),
261
+ TaskProgressColumn(),
262
+ MofNCompleteColumn(),
263
+ TimeRemainingColumn(),
264
+ TextColumn("[dim]{task.fields[filename]}"),
265
+ console=console,
266
+ transient=True,
267
+ disable=disable_progress,
268
+ ) as progress:
270
269
 
271
- # Poll for updates
272
- while not indexing_task.done():
273
- job = graph_builder.job_manager.get_job(job_id)
274
- if job:
275
- if job.total_files > 0:
276
- progress.update(task_id, total=job.total_files, completed=job.processed_files)
277
-
278
- # Prefer post-processing status over the last parsed file path
279
- current_file = job.status_message or job.current_file or ""
280
- if len(current_file) > 40:
281
- current_file = "..." + current_file[-37:]
282
- progress.update(task_id, filename=current_file)
270
+ task_id = progress.add_task(
271
+ "Indexing...",
272
+ total=None, # Will be updated once file discovery is done
273
+ filename=""
274
+ )
283
275
 
284
- if job.status in [JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED]:
285
- break
276
+ indexing_task = asyncio.create_task(
277
+ graph_builder.build_graph_from_path_async(path_obj, is_dependency=is_dependency, job_id=job_id, cgcignore_path=cgcignore_path)
278
+ )
279
+
280
+ from ..core.jobs import JobStatus
286
281
 
287
- await asyncio.sleep(0.1)
282
+ # Poll for updates
283
+ while not indexing_task.done():
284
+ job = graph_builder.job_manager.get_job(job_id)
285
+ if job:
286
+ if job.total_files > 0:
287
+ progress.update(task_id, total=job.total_files, completed=job.processed_files)
288
+
289
+ # Prefer post-processing status over the last parsed file path
290
+ current_file = job.status_message or job.current_file or ""
291
+ if len(current_file) > 40:
292
+ current_file = "..." + current_file[-37:]
293
+ progress.update(task_id, filename=current_file)
294
+
295
+ if job.status in [JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED]:
296
+ break
297
+
298
+ await asyncio.sleep(0.1)
288
299
 
289
- # Wait for actual completion and handle final state
290
- try:
291
- await indexing_task
292
- job = graph_builder.job_manager.get_job(job_id)
293
- if job and job.status == JobStatus.FAILED:
294
- error_msg = job.errors[0] if job.errors else "Unknown error"
295
- raise RuntimeError(error_msg)
296
- except Exception as e:
297
- raise e
300
+ # Wait for actual completion and handle final state
301
+ try:
302
+ await indexing_task
303
+ job = graph_builder.job_manager.get_job(job_id)
304
+ if job and job.status == JobStatus.FAILED:
305
+ error_msg = job.errors[0] if job.errors else "Unknown error"
306
+ raise RuntimeError(error_msg)
307
+ except Exception as e:
308
+ raise e
309
+ finally:
310
+ os.environ.pop("CGC_ACTIVE_PROGRESS_BAR", None)
298
311
 
299
312
 
300
- def index_helper(path: str, context: Optional[str] = None):
313
+ def index_helper(path: str, context: Optional[str] = None, no_progress: bool = False):
301
314
  """Synchronously indexes a repository in a given context."""
302
315
  time_start = time.time()
303
316
  path_obj = Path(path).resolve()
@@ -334,10 +347,16 @@ def index_helper(path: str, context: Optional[str] = None):
334
347
  file_count = record["file_count"] if record else 0
335
348
 
336
349
  if file_count > 0:
337
- console.print(f"[yellow]Repository '{path}' is already indexed with {file_count} files. Skipping.[/yellow]")
338
- console.print("[dim]💡 Tip: Use 'cgc index --force' to re-index[/dim]")
339
- db_manager.close_driver()
340
- return
350
+ expected = graph_builder.estimate_processing_time(path_obj) if path_obj.is_dir() else None
351
+ expected_file_count = expected[0] if expected else None
352
+ if expected_file_count is None or file_count >= expected_file_count:
353
+ console.print(f"[yellow]Repository '{path}' is already indexed with {file_count} files. Skipping.[/yellow]")
354
+ console.print("[dim]💡 Tip: Use 'cgc index --force' to re-index[/dim]")
355
+ db_manager.close_driver()
356
+ return
357
+ console.print(
358
+ f"[yellow]Repository '{path}' has only {file_count} of {expected_file_count} files indexed. Continuing.[/yellow]"
359
+ )
341
360
  else:
342
361
  console.print(f"[yellow]Repository '{path}' exists but has no files (likely interrupted). Re-indexing...[/yellow]")
343
362
  except Exception as e:
@@ -351,7 +370,7 @@ def index_helper(path: str, context: Optional[str] = None):
351
370
  console.print(f"Starting indexing for: {path_obj}")
352
371
 
353
372
  try:
354
- asyncio.run(_run_index_with_progress(graph_builder, path_obj, is_dependency=False, cgcignore_path=ctx.cgcignore_path))
373
+ asyncio.run(_run_index_with_progress(graph_builder, path_obj, is_dependency=False, cgcignore_path=ctx.cgcignore_path, disable_progress=no_progress))
355
374
  time_end = time.time()
356
375
  elapsed = time_end - time_start
357
376
  _print_call_resolution_diagnostics(graph_builder)
@@ -608,34 +627,56 @@ def _render_offline_visualization(
608
627
  def _ident(value) -> Optional[str]:
609
628
  return None if value is None else str(value)
610
629
 
611
- # Neo4j/Falkor return driver objects carrying .labels / .type; Kùzu and
612
- # Ladybug return plain dicts carrying _label plus _src/_dst. Check the
613
- # driver attributes first — a driver object may also be dict-like.
630
+ # Kùzu spells the internal record keys in lowercase (_label/_src/_dst/_id);
631
+ # Ladybug returns the same fields uppercased (_LABEL/_SRC/_DST/_ID). Look
632
+ # up both spellings so either backend reaches the offline renderer (#1458).
633
+ def _meta(d: dict, key: str, default=None):
634
+ if key in d:
635
+ return d[key]
636
+ return d.get(key.upper(), default)
637
+
638
+ def _has_meta(d: dict, key: str) -> bool:
639
+ return key in d or key.upper() in d
640
+
641
+ # Neo4j returns driver objects carrying .labels / .type that support
642
+ # dict()/Mapping access. FalkorDB's own driver objects also carry
643
+ # .labels on nodes, but are NOT dict-convertible — their properties live
644
+ # in a plain `.properties` dict, and its Edge exposes `.relation` /
645
+ # `.src_node` / `.dest_node` instead of `.type` / `.start_node` /
646
+ # `.end_node`. Kùzu and Ladybug return plain dicts carrying _label plus
647
+ # _src/_dst. Check the driver attributes first — a driver object may
648
+ # also be dict-like.
614
649
  def _is_relationship(value) -> bool:
615
650
  if hasattr(value, "labels"):
616
651
  return False
617
652
  if hasattr(value, "type"):
618
653
  return True
619
- return isinstance(value, dict) and "_src" in value and "_dst" in value
654
+ if hasattr(value, "relation") and hasattr(value, "src_node"):
655
+ return True
656
+ return isinstance(value, dict) and _has_meta(value, "_src") and _has_meta(value, "_dst")
620
657
 
621
658
  def _is_node(value) -> bool:
622
659
  if hasattr(value, "labels"):
623
660
  return True
624
661
  if hasattr(value, "type"):
625
662
  return False
663
+ if hasattr(value, "relation") and hasattr(value, "src_node"):
664
+ return False
626
665
  # Kùzu relationships also carry _label, so _src/_dst is what
627
666
  # distinguishes them from nodes.
628
- return isinstance(value, dict) and "_label" in value and "_src" not in value
667
+ return isinstance(value, dict) and _has_meta(value, "_label") and not _has_meta(value, "_src")
629
668
 
630
669
  def _node_payload(value) -> Dict[str, Any]:
631
670
  if hasattr(value, "labels"):
632
- props = dict(value)
671
+ # FalkorDB's Node keeps properties in `.properties` and is not
672
+ # itself iterable; Neo4j's Node supports dict() via Mapping.
673
+ props = dict(value.properties) if hasattr(value, "properties") else dict(value)
633
674
  labels = list(getattr(value, "labels", []) or [])
634
675
  label = labels[0] if labels else "Node"
635
676
  node_id = getattr(value, "element_id", None) or getattr(value, "id", None)
636
677
  else:
637
- props, label = dict(value), value.get("_label", "Node")
638
- node_id = value.get("_id")
678
+ props, label = dict(value), _meta(value, "_label", "Node")
679
+ node_id = _meta(value, "_id")
639
680
  if node_id is None:
640
681
  node_id = props.get("path") or props.get("name")
641
682
  return {
@@ -646,20 +687,33 @@ def _render_offline_visualization(
646
687
  "line_number": props.get("line_number"),
647
688
  }
648
689
 
690
+ def _node_ref_id(ref) -> Any:
691
+ # Neo4j's start_node/end_node are full Node objects; FalkorDB's
692
+ # src_node/dest_node are already the bare node id (an int).
693
+ if isinstance(ref, (int, str)):
694
+ return ref
695
+ return getattr(ref, "element_id", None) or getattr(ref, "id", None)
696
+
649
697
  def _edge_payload(value) -> Optional[Dict[str, Any]]:
698
+ # Neo4j: .start_node/.end_node/.type. FalkorDB: .src_node/.dest_node/
699
+ # .relation.
650
700
  start = getattr(value, "start_node", None)
701
+ if start is None:
702
+ start = getattr(value, "src_node", None)
651
703
  end = getattr(value, "end_node", None)
704
+ if end is None:
705
+ end = getattr(value, "dest_node", None)
652
706
  if start is not None and end is not None:
653
707
  return {
654
- "source": _ident(getattr(start, "element_id", None) or getattr(start, "id", None)),
655
- "target": _ident(getattr(end, "element_id", None) or getattr(end, "id", None)),
656
- "type": getattr(value, "type", "RELATED"),
708
+ "source": _ident(_node_ref_id(start)),
709
+ "target": _ident(_node_ref_id(end)),
710
+ "type": getattr(value, "type", None) or getattr(value, "relation", "RELATED"),
657
711
  }
658
712
  if isinstance(value, dict):
659
713
  return {
660
- "source": _ident(value.get("_src")),
661
- "target": _ident(value.get("_dst")),
662
- "type": value.get("_label", "RELATED"),
714
+ "source": _ident(_meta(value, "_src")),
715
+ "target": _ident(_meta(value, "_dst")),
716
+ "type": _meta(value, "_label", "RELATED"),
663
717
  }
664
718
  return None
665
719
 
@@ -803,7 +857,7 @@ def visualize_helper(
803
857
  db_manager.close_driver()
804
858
 
805
859
 
806
- def reindex_helper(path: str, context: Optional[str] = None):
860
+ def reindex_helper(path: str, context: Optional[str] = None, no_progress: bool = False):
807
861
  """Force re-index by deleting and rebuilding the repository."""
808
862
  time_start = time.time()
809
863
  path_obj = Path(path).resolve()
@@ -836,7 +890,7 @@ def reindex_helper(path: str, context: Optional[str] = None):
836
890
  console.print(f"[cyan]Re-indexing: {path_obj}[/cyan]")
837
891
 
838
892
  try:
839
- asyncio.run(_run_index_with_progress(graph_builder, path_obj, is_dependency=False, cgcignore_path=ctx.cgcignore_path))
893
+ asyncio.run(_run_index_with_progress(graph_builder, path_obj, is_dependency=False, cgcignore_path=ctx.cgcignore_path, disable_progress=no_progress))
840
894
  time_end = time.time()
841
895
  elapsed = time_end - time_start
842
896
  _print_call_resolution_diagnostics(graph_builder)
@@ -87,6 +87,8 @@ DEFAULT_CONFIG = {
87
87
  "DEFAULT_DATABASE": "falkordb",
88
88
  "FALKORDB_PATH": str(CONFIG_DIR / "global" / "db" / "falkordb"),
89
89
  "FALKORDB_SOCKET_PATH": str(CONFIG_DIR / "global" / "db" / "falkordb.sock"),
90
+ # Empty selects a deterministic port derived from each embedded DB socket.
91
+ "FALKORDB_PORT": "",
90
92
  "LADYBUGDB_PATH": str(CONFIG_DIR / "global" / "db" / "ladybugdb"),
91
93
  "KUZUDB_PATH": str(CONFIG_DIR / "global" / "db" / "kuzudb"),
92
94
  "INDEX_VARIABLES": "true",
@@ -142,6 +144,7 @@ CONFIG_DESCRIPTIONS = {
142
144
  "DEFAULT_DATABASE": "Default database backend (neo4j|falkordb|falkordb-remote|kuzudb|nornic|ladybugdb)",
143
145
  "FALKORDB_PATH": "Path to FalkorDB database file",
144
146
  "FALKORDB_SOCKET_PATH": "Path to FalkorDB Unix socket",
147
+ "FALKORDB_PORT": "Optional FalkorDB port override (empty = per-database port)",
145
148
  "LADYBUGDB_PATH": "Path to LadybugDB database directory",
146
149
  "KUZUDB_PATH": "Path to KuzuDB database directory",
147
150
  "INDEX_VARIABLES": "Index variable nodes in the graph (lighter graph if false)",
@@ -1440,6 +1440,7 @@ def index(
1440
1440
  force: bool = typer.Option(False, "--force", "-f", help="Force re-index (delete existing and rebuild)"),
1441
1441
  context: Optional[str] = typer.Option(None, "--context", "-c", help="Specific context to use (overrides mode/default)"),
1442
1442
  summarize: bool = typer.Option(False, "--summarize", "-s", help="Show a summary of the indexed codebase after indexing"),
1443
+ no_progress: bool = typer.Option(False, "--no-progress", help="Disable live progress rendering during indexing."),
1443
1444
  ):
1444
1445
  """
1445
1446
  Indexes a directory or file by adding it to the code graph.
@@ -1455,9 +1456,9 @@ def index(
1455
1456
  try:
1456
1457
  if force:
1457
1458
  console.print("[yellow]Force re-indexing (--force flag detected)[/yellow]")
1458
- reindex_helper(path, context)
1459
+ reindex_helper(path, context, no_progress=no_progress)
1459
1460
  else:
1460
- index_helper(path, context)
1461
+ index_helper(path, context, no_progress=no_progress)
1461
1462
  except typer.Exit:
1462
1463
  # typer.Exit subclasses RuntimeError and str() is empty, so the handler
1463
1464
  # below caught it, printed nothing, and returned 0 — every helper that
@@ -3050,12 +3051,13 @@ def index_abbrev(
3050
3051
  path: Optional[str] = typer.Argument(None, help="Path to index"),
3051
3052
  force: bool = typer.Option(False, "--force", "-f", help="Force re-index (delete existing and rebuild)"),
3052
3053
  summarize: bool = typer.Option(False, "--summarize", "-s", help="Display a codebase summary after indexing"),
3053
- context: Optional[str] = typer.Option(None, "--context", "-c", help="Specific context to use")
3054
+ context: Optional[str] = typer.Option(None, "--context", "-c", help="Specific context to use"),
3055
+ no_progress: bool = typer.Option(False, "--no-progress", help="Disable live progress rendering during indexing.")
3054
3056
  ):
3055
3057
  """Shortcut for 'cgc index'"""
3056
3058
  # `summarize` must be passed explicitly: omitted, it keeps its OptionInfo
3057
3059
  # sentinel, which is truthy — so `cgc i` always printed the summary.
3058
- index(path, force=force, summarize=summarize, context=context)
3060
+ index(path, force=force, summarize=summarize, context=context, no_progress=no_progress)
3059
3061
 
3060
3062
  @app.command("ls", rich_help_panel="Shortcuts")
3061
3063
  def list_abbrev(
@@ -3084,7 +3086,10 @@ def visualize_abbrev(
3084
3086
  ):
3085
3087
  """Shortcut for 'cgc visualize'"""
3086
3088
  _load_credentials()
3087
- visualize_helper(repo, port, context=context)
3089
+ # `port` must be passed by keyword: visualize_helper's second positional
3090
+ # parameter is `host`, so the int landed there and the server tried to bind
3091
+ # to host=8000 while ignoring --port entirely.
3092
+ visualize_helper(repo, port=port, context=context)
3088
3093
 
3089
3094
  @app.command("w", rich_help_panel="Shortcuts")
3090
3095
  def watch_abbrev(
@@ -184,6 +184,11 @@ def find_jetbrains_mcp_config():
184
184
  configs.append(mcp_file)
185
185
  print(mcp_file)
186
186
  return configs
187
+ # Always return a list: callers store this directly in the config_paths
188
+ # mapping and iterate it, so returning None (no JetBrains install, or no
189
+ # mcpServer.xml yet) raised a TypeError instead of falling through to the
190
+ # "configure manually" path.
191
+ return configs
187
192
 
188
193
 
189
194
  def convert_mcp_json_to_yaml():
@@ -20,7 +20,9 @@ from pathlib import Path
20
20
  from typing import Union, Optional
21
21
  import importlib.util
22
22
 
23
- # Set when FalkorDB Lite fails in-process so we skip repeated startup/retry storms.
23
+ # Retained for compatibility with callers that inspect this module attribute.
24
+ # FalkorDB startup failures are now tracked by FalkorDBManager per database
25
+ # configuration, rather than disabling the backend for the whole process.
24
26
  _FALKORDB_DISABLED = False
25
27
 
26
28
 
@@ -48,15 +50,56 @@ def _fallback_db_path_for(db_path: Optional[str], target_backend: str) -> Option
48
50
  return db_path
49
51
 
50
52
 
53
+ def _try_fallback_backends(db_path: Optional[str], candidates, *, reason: str):
54
+ """
55
+ Return the first available backend from ``candidates``, naming it in the log.
56
+
57
+ Every fallback path funnels through here so the backend a user actually ends
58
+ up on is always recorded. A silent switch is effectively undebuggable: the
59
+ only trace left is that the *requested* backend was requested, so an
60
+ interpreter where FalkorDB Lite cannot load looks identical to one where it
61
+ loaded fine, and queries run against a different (often empty) database.
62
+
63
+ Returns ``None`` when no candidate is available, so callers keep control of
64
+ the error they raise.
65
+ """
66
+ from codegraphcontext.utils.debug_log import warning_logger
67
+
68
+ for name in candidates:
69
+ if name == 'kuzudb' and _is_kuzudb_available():
70
+ from .database_kuzu import KuzuDBManager
71
+ path = _fallback_db_path_for(db_path, 'kuzudb')
72
+ warning_logger(
73
+ f"Database backend fallback: {reason} "
74
+ f"Now using KùzuDB at {path or 'default path'}."
75
+ )
76
+ return KuzuDBManager(db_path=path)
77
+ if name == 'ladybugdb' and _is_ladybugdb_available():
78
+ from .database_ladybug import LadybugDBManager
79
+ path = _fallback_db_path_for(db_path, 'ladybugdb')
80
+ warning_logger(
81
+ f"Database backend fallback: {reason} "
82
+ f"Now using LadybugDB at {path or 'default path'}."
83
+ )
84
+ return LadybugDBManager(db_path=path)
85
+ if name == 'neo4j' and _is_neo4j_configured():
86
+ from .database import DatabaseManager
87
+ warning_logger(f"Database backend fallback: {reason} Now using Neo4j Server.")
88
+ return DatabaseManager()
89
+ if name == 'nornic' and _is_nornic_configured():
90
+ from .database_nornic import NornicDBManager
91
+ warning_logger(f"Database backend fallback: {reason} Now using Nornic DB.")
92
+ return NornicDBManager()
93
+ return None
94
+
95
+
51
96
  def mark_falkordb_unavailable() -> None:
52
- """Remember that FalkorDB Lite cannot run in this process."""
53
- global _FALKORDB_DISABLED
54
- _FALKORDB_DISABLED = True
97
+ """Compatibility hook; startup failures are scoped by FalkorDBManager."""
55
98
 
56
99
 
57
100
  def is_falkordb_usable() -> bool:
58
- """True when FalkorDB Lite is installed and has not failed this session."""
59
- return _is_falkordb_available() and not _FALKORDB_DISABLED
101
+ """True when FalkorDB Lite is available on this system."""
102
+ return _is_falkordb_available()
60
103
 
61
104
  def _is_kuzudb_available() -> bool:
62
105
  """Check if KùzuDB is installed."""
@@ -124,17 +167,13 @@ def get_database_manager(db_path: Optional[str] = None) -> Union['DatabaseManage
124
167
  db_type = db_type.lower()
125
168
  if db_type == 'kuzudb':
126
169
  if not _is_kuzudb_available():
127
- info_logger("Kùzu is not installed. Falling back to an available configured backend.")
128
- if _is_ladybugdb_available():
129
- from .database_ladybug import LadybugDBManager
130
- return LadybugDBManager(db_path=db_path)
131
- if _is_neo4j_configured():
132
- from .database import DatabaseManager
133
- info_logger("Using Neo4j Server (fallback)")
134
- return DatabaseManager()
135
- if _is_nornic_configured():
136
- from .database_nornic import NornicDBManager
137
- return NornicDBManager()
170
+ mgr = _try_fallback_backends(
171
+ db_path,
172
+ ('ladybugdb', 'neo4j', 'nornic'),
173
+ reason="database was set to 'kuzudb' but Kùzu is not installed.",
174
+ )
175
+ if mgr is not None:
176
+ return mgr
138
177
  raise ValueError("Database set to 'kuzudb' but Kùzu is not installed.\nRun 'pip install kuzu'")
139
178
  from .database_kuzu import KuzuDBManager
140
179
  info_logger(f"Using KùzuDB (explicit) at {db_path or 'default path'}")
@@ -142,23 +181,13 @@ def get_database_manager(db_path: Optional[str] = None) -> Union['DatabaseManage
142
181
 
143
182
  elif db_type == 'falkordb':
144
183
  if not is_falkordb_usable():
145
- if _FALKORDB_DISABLED:
146
- info_logger("FalkorDB Lite disabled for this process after earlier failure. Falling back to an available backend.")
147
- else:
148
- info_logger("FalkorDB Lite is not supported or not installed. Falling back to an available backend.")
149
- if _is_kuzudb_available():
150
- from .database_kuzu import KuzuDBManager
151
- return KuzuDBManager(db_path=_fallback_db_path_for(db_path, 'kuzudb'))
152
- if _is_ladybugdb_available():
153
- from .database_ladybug import LadybugDBManager
154
- return LadybugDBManager(db_path=_fallback_db_path_for(db_path, 'ladybugdb'))
155
- if _is_neo4j_configured():
156
- from .database import DatabaseManager
157
- info_logger("Using Neo4j Server (fallback)")
158
- return DatabaseManager()
159
- if _is_nornic_configured():
160
- from .database_nornic import NornicDBManager
161
- return NornicDBManager()
184
+ mgr = _try_fallback_backends(
185
+ db_path,
186
+ ('kuzudb', 'ladybugdb', 'neo4j', 'nornic'),
187
+ reason="FalkorDB Lite is not supported or not installed here.",
188
+ )
189
+ if mgr is not None:
190
+ return mgr
162
191
  raise ValueError(
163
192
  "Database set to 'falkordb' but FalkorDB Lite is not installed or not supported on this OS.\n"
164
193
  "Install 'falkordblite' or configure a supported alternative such as KùzuDB or Neo4j."
@@ -172,20 +201,13 @@ def get_database_manager(db_path: Optional[str] = None) -> Union['DatabaseManage
172
201
  return mgr
173
202
  except FalkorDBUnavailableError as falkor_err:
174
203
  mark_falkordb_unavailable()
175
- info_logger(f"FalkorDB Lite not functional ({falkor_err}). Falling back to available backend.")
176
- if _is_kuzudb_available():
177
- from .database_kuzu import KuzuDBManager
178
- return KuzuDBManager(db_path=_fallback_db_path_for(db_path, 'kuzudb'))
179
- if _is_ladybugdb_available():
180
- from .database_ladybug import LadybugDBManager
181
- return LadybugDBManager(db_path=_fallback_db_path_for(db_path, 'ladybugdb'))
182
- if _is_neo4j_configured():
183
- from .database import DatabaseManager
184
- info_logger("Using Neo4j Server (fallback)")
185
- return DatabaseManager()
186
- if _is_nornic_configured():
187
- from .database_nornic import NornicDBManager
188
- return NornicDBManager()
204
+ mgr = _try_fallback_backends(
205
+ db_path,
206
+ ('kuzudb', 'ladybugdb', 'neo4j', 'nornic'),
207
+ reason=f"FalkorDB Lite was requested but is not functional ({falkor_err}).",
208
+ )
209
+ if mgr is not None:
210
+ return mgr
189
211
  raise
190
212
 
191
213
  elif db_type == 'falkordb-remote':