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.
- codegraphcontext/api/app.py +6 -1
- codegraphcontext/api/auth.py +123 -0
- codegraphcontext/api/router.py +9 -5
- codegraphcontext/cli/cli_helpers.py +179 -20
- codegraphcontext/cli/config_manager.py +56 -14
- codegraphcontext/cli/hook_manager.py +26 -4
- codegraphcontext/cli/main.py +208 -16
- codegraphcontext/cli/project_config.py +218 -0
- codegraphcontext/cli/registry_commands.py +6 -6
- codegraphcontext/cli/simulator.py +343 -0
- codegraphcontext/core/bundle_registry.py +45 -6
- codegraphcontext/core/cgc_bundle.py +102 -31
- codegraphcontext/core/cgcignore.py +29 -11
- codegraphcontext/core/database.py +5 -1
- codegraphcontext/core/database_embedded_kuzu.py +37 -2
- codegraphcontext/core/database_falkordb.py +141 -30
- codegraphcontext/core/database_falkordb_remote.py +31 -11
- codegraphcontext/core/database_nornic.py +5 -1
- codegraphcontext/core/simulator.py +589 -0
- codegraphcontext/core/watcher.py +67 -11
- codegraphcontext/prompts.py +52 -2
- codegraphcontext/server.py +77 -4
- codegraphcontext/tool_definitions.py +171 -20
- codegraphcontext/tools/code_finder.py +39 -26
- codegraphcontext/tools/graph_builder.py +39 -10
- codegraphcontext/tools/handlers/analysis_handlers.py +28 -9
- codegraphcontext/tools/handlers/management_handlers.py +33 -7
- codegraphcontext/tools/handlers/query_handlers.py +10 -4
- codegraphcontext/tools/indexing/discovery.py +6 -2
- codegraphcontext/tools/indexing/embeddings.py +8 -0
- codegraphcontext/tools/indexing/persistence/writer.py +104 -137
- codegraphcontext/tools/indexing/pipeline.py +65 -17
- codegraphcontext/tools/indexing/pre_scan.py +14 -2
- codegraphcontext/tools/indexing/resolution/post_resolution.py +11 -20
- codegraphcontext/tools/indexing/schema.py +89 -47
- codegraphcontext/tools/indexing/schema_contract.py +1 -0
- codegraphcontext/tools/languages/c.py +1 -1
- codegraphcontext/tools/languages/cpp.py +1 -1
- codegraphcontext/tools/languages/css.py +1 -1
- codegraphcontext/tools/languages/dart.py +1 -1
- codegraphcontext/tools/languages/elisp.py +1 -1
- codegraphcontext/tools/languages/elixir.py +3 -3
- codegraphcontext/tools/languages/go.py +3 -3
- codegraphcontext/tools/languages/html.py +1 -1
- codegraphcontext/tools/languages/javascript.py +32 -27
- codegraphcontext/tools/languages/lua.py +1 -1
- codegraphcontext/tools/languages/perl.py +1 -1
- codegraphcontext/tools/languages/python.py +78 -44
- codegraphcontext/tools/languages/ruby.py +2 -2
- codegraphcontext/tools/languages/rust.py +1 -1
- codegraphcontext/tools/languages/typescript.py +31 -27
- codegraphcontext/tools/languages/typescriptjsx.py +3 -3
- codegraphcontext/tools/query_tool_languages/cpp_toolkit.py +22 -18
- codegraphcontext/tools/query_tool_languages/java_toolkit.py +1 -1
- codegraphcontext/tools/report_generator.py +7 -7
- codegraphcontext/tools/system.py +13 -16
- codegraphcontext/utils/cypher_ddl.py +80 -0
- codegraphcontext/utils/cypher_readonly.py +5 -0
- codegraphcontext/viz/server.py +20 -3
- {codegraphcontext-0.5.2.dist-info → codegraphcontext-0.5.3.dist-info}/METADATA +25 -5
- {codegraphcontext-0.5.2.dist-info → codegraphcontext-0.5.3.dist-info}/RECORD +65 -60
- {codegraphcontext-0.5.2.dist-info → codegraphcontext-0.5.3.dist-info}/WHEEL +0 -0
- {codegraphcontext-0.5.2.dist-info → codegraphcontext-0.5.3.dist-info}/entry_points.txt +0 -0
- {codegraphcontext-0.5.2.dist-info → codegraphcontext-0.5.3.dist-info}/licenses/LICENSE +0 -0
- {codegraphcontext-0.5.2.dist-info → codegraphcontext-0.5.3.dist-info}/top_level.txt +0 -0
codegraphcontext/api/app.py
CHANGED
|
@@ -4,9 +4,14 @@ from fastapi import 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
8
|
from .mcp_sse import handle_sse, handle_messages
|
|
8
9
|
|
|
9
10
|
def create_app() -> FastAPI:
|
|
11
|
+
# Log whether API-key auth is active. Emits a prominent security warning
|
|
12
|
+
# when the gateway is running unauthenticated (CGC_API_KEY unset).
|
|
13
|
+
log_auth_status()
|
|
14
|
+
|
|
10
15
|
app = FastAPI(
|
|
11
16
|
title="CodeGraphContext Gateway",
|
|
12
17
|
description="HTTP API gateway for CodeGraphContext MCP server. Enables integration with ChatGPT Actions, Claude, and web frontends.",
|
|
@@ -61,7 +66,7 @@ def create_app() -> FastAPI:
|
|
|
61
66
|
<a href="/docs" class="btn">View API Docs</a>
|
|
62
67
|
<div class="links">
|
|
63
68
|
<a href="/openapi.json">OpenAPI Spec</a>
|
|
64
|
-
<a href="https://github.com/
|
|
69
|
+
<a href="https://github.com/CodeGraphContext/CodeGraphContext" target="_blank">GitHub</a>
|
|
65
70
|
</div>
|
|
66
71
|
</div>
|
|
67
72
|
</body>
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# src/codegraphcontext/api/auth.py
|
|
2
|
+
"""Optional API-key authentication for the CodeGraphContext HTTP API.
|
|
3
|
+
|
|
4
|
+
Backward-compatible and opt-in:
|
|
5
|
+
|
|
6
|
+
* When **no** key is configured, the API behaves exactly as it did before
|
|
7
|
+
(every endpoint is unauthenticated) but a prominent security warning is
|
|
8
|
+
logged at startup so operators know the gateway is wide open.
|
|
9
|
+
* When a key **is** configured (via the ``CGC_API_KEY`` environment variable
|
|
10
|
+
or the standard config mechanism), every protected router endpoint requires
|
|
11
|
+
it, supplied as either ``Authorization: Bearer <key>`` or ``X-API-Key: <key>``.
|
|
12
|
+
Missing/incorrect keys receive an HTTP ``401``.
|
|
13
|
+
|
|
14
|
+
The comparison uses :func:`secrets.compare_digest` to avoid timing side
|
|
15
|
+
channels.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import secrets
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
from fastapi import Header, HTTPException
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# Config/env key that holds the API key. Reused as the config_manager key so
|
|
28
|
+
# ``cgc config set CGC_API_KEY <value>`` works as well.
|
|
29
|
+
API_KEY_ENV = "CGC_API_KEY"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_configured_api_key() -> Optional[str]:
|
|
33
|
+
"""Return the configured API key, or ``None`` when auth is disabled.
|
|
34
|
+
|
|
35
|
+
Resolution order (highest priority first):
|
|
36
|
+
|
|
37
|
+
1. The ``CGC_API_KEY`` environment variable (read directly so auth works
|
|
38
|
+
even when no config file is present).
|
|
39
|
+
2. The value stored via the existing config mechanism
|
|
40
|
+
(:func:`codegraphcontext.cli.config_manager.get_config_value`).
|
|
41
|
+
|
|
42
|
+
Empty / whitespace-only values are treated as "not configured".
|
|
43
|
+
"""
|
|
44
|
+
env_val = os.getenv(API_KEY_ENV)
|
|
45
|
+
if env_val and env_val.strip():
|
|
46
|
+
return env_val.strip()
|
|
47
|
+
|
|
48
|
+
# Fall back to the existing config mechanism (global/local .env, defaults).
|
|
49
|
+
try:
|
|
50
|
+
from codegraphcontext.cli.config_manager import get_config_value
|
|
51
|
+
|
|
52
|
+
cfg_val = get_config_value(API_KEY_ENV)
|
|
53
|
+
if cfg_val and cfg_val.strip():
|
|
54
|
+
return cfg_val.strip()
|
|
55
|
+
except Exception: # pragma: no cover - config lookup is best-effort here
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _extract_provided_key(
|
|
62
|
+
authorization: Optional[str], x_api_key: Optional[str]
|
|
63
|
+
) -> Optional[str]:
|
|
64
|
+
"""Pull the caller-supplied key from the Authorization or X-API-Key header."""
|
|
65
|
+
if authorization:
|
|
66
|
+
value = authorization.strip()
|
|
67
|
+
if value.lower().startswith("bearer "):
|
|
68
|
+
return value[len("bearer ") :].strip()
|
|
69
|
+
# Accept a bare token in the Authorization header as a convenience.
|
|
70
|
+
return value or None
|
|
71
|
+
if x_api_key and x_api_key.strip():
|
|
72
|
+
return x_api_key.strip()
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _keys_match(provided: str, configured: str) -> bool:
|
|
77
|
+
"""Constant-time comparison that never raises on non-ASCII input."""
|
|
78
|
+
try:
|
|
79
|
+
return secrets.compare_digest(
|
|
80
|
+
provided.encode("utf-8"), configured.encode("utf-8")
|
|
81
|
+
)
|
|
82
|
+
except (TypeError, AttributeError):
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def require_api_key(
|
|
87
|
+
authorization: Optional[str] = Header(default=None),
|
|
88
|
+
x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
|
|
89
|
+
) -> None:
|
|
90
|
+
"""FastAPI dependency that enforces the API key when one is configured.
|
|
91
|
+
|
|
92
|
+
No-op (backward compatible) when no key is set; otherwise requires a valid
|
|
93
|
+
key via ``Authorization: Bearer <key>`` or ``X-API-Key: <key>``, returning
|
|
94
|
+
HTTP ``401`` on a missing or incorrect key.
|
|
95
|
+
"""
|
|
96
|
+
configured = get_configured_api_key()
|
|
97
|
+
if not configured:
|
|
98
|
+
# Auth disabled -> preserve the pre-existing unauthenticated behavior.
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
provided = _extract_provided_key(authorization, x_api_key)
|
|
102
|
+
if not provided or not _keys_match(provided, configured):
|
|
103
|
+
raise HTTPException(
|
|
104
|
+
status_code=401,
|
|
105
|
+
detail="Invalid or missing API key",
|
|
106
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def log_auth_status() -> None:
|
|
111
|
+
"""Log the authentication status at app startup.
|
|
112
|
+
|
|
113
|
+
Emits a prominent WARNING when the API is unauthenticated so the insecure
|
|
114
|
+
default is impossible to miss in logs.
|
|
115
|
+
"""
|
|
116
|
+
if get_configured_api_key():
|
|
117
|
+
logger.info("API key authentication is ENABLED (CGC_API_KEY is set).")
|
|
118
|
+
else:
|
|
119
|
+
logger.warning(
|
|
120
|
+
"⚠️ CodeGraphContext API is running WITHOUT authentication. "
|
|
121
|
+
"Anyone who can reach this server can index code, run Cypher queries "
|
|
122
|
+
"and call tools. Set CGC_API_KEY to require a key."
|
|
123
|
+
)
|
codegraphcontext/api/router.py
CHANGED
|
@@ -5,17 +5,21 @@ from typing import Dict, Any, List
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
7
|
from .schemas import (
|
|
8
|
-
IndexRequest,
|
|
9
|
-
QueryRequest,
|
|
10
|
-
SearchRequest,
|
|
11
|
-
ToolCallRequest,
|
|
8
|
+
IndexRequest,
|
|
9
|
+
QueryRequest,
|
|
10
|
+
SearchRequest,
|
|
11
|
+
ToolCallRequest,
|
|
12
12
|
ApiResponse
|
|
13
13
|
)
|
|
14
|
+
from .auth import require_api_key
|
|
14
15
|
from codegraphcontext.server import MCPServer
|
|
15
16
|
|
|
16
17
|
import socket
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
# Optional API-key auth is enforced on every route below via require_api_key.
|
|
20
|
+
# It is a no-op (backward compatible) unless CGC_API_KEY is configured, in
|
|
21
|
+
# which case each request must supply the key. See codegraphcontext.api.auth.
|
|
22
|
+
router = APIRouter(dependencies=[Depends(require_api_key)])
|
|
19
23
|
|
|
20
24
|
# Global server instance (initialized on startup)
|
|
21
25
|
_server_instance: MCPServer = None
|
|
@@ -112,6 +112,9 @@ def _print_index_execution_summary(graph_builder: GraphBuilder) -> None:
|
|
|
112
112
|
"Files by extension",
|
|
113
113
|
_format_extension_counts(summary.get("files_by_extension", {})),
|
|
114
114
|
)
|
|
115
|
+
failed_files = summary.get("failed_files", 0)
|
|
116
|
+
if failed_files:
|
|
117
|
+
table.add_row("[red]Files failed to parse[/red]", f"[red]{failed_files}[/red]")
|
|
115
118
|
table.add_row("Function nodes", str(summary.get("function_nodes", 0)))
|
|
116
119
|
table.add_row("Class nodes", str(summary.get("class_nodes", 0)))
|
|
117
120
|
table.add_row("CALLS edges", str(summary.get("call_edges", 0)))
|
|
@@ -353,7 +356,7 @@ def index_helper(path: str, context: Optional[str] = None):
|
|
|
353
356
|
elapsed = time_end - time_start
|
|
354
357
|
_print_call_resolution_diagnostics(graph_builder)
|
|
355
358
|
_print_index_execution_summary(graph_builder)
|
|
356
|
-
console.print(f"[green]Successfully finished indexing: {
|
|
359
|
+
console.print(f"[green]Successfully finished indexing: {path_obj} in {elapsed:.2f} seconds[/green]")
|
|
357
360
|
|
|
358
361
|
# Check if auto-watch is enabled
|
|
359
362
|
try:
|
|
@@ -454,8 +457,13 @@ def delete_helper(repo_path: str, context: Optional[str] = None):
|
|
|
454
457
|
else:
|
|
455
458
|
console.print(f"[yellow]Repository not found in graph: {repo_path}[/yellow]")
|
|
456
459
|
console.print("[dim]Tip: Use 'cgc list' to see available repositories.[/dim]")
|
|
460
|
+
raise typer.Exit(code=1)
|
|
461
|
+
except typer.Exit:
|
|
462
|
+
# Control flow, not an error — let it through so the exit code survives.
|
|
463
|
+
raise
|
|
457
464
|
except Exception as e:
|
|
458
465
|
console.print(f"[bold red]An error occurred:[/bold red] {e}")
|
|
466
|
+
raise typer.Exit(code=1)
|
|
459
467
|
finally:
|
|
460
468
|
db_manager.close_driver()
|
|
461
469
|
|
|
@@ -519,7 +527,13 @@ def cypher_helper(query: str, context: Optional[str] = None):
|
|
|
519
527
|
raise typer.Exit(code=1)
|
|
520
528
|
|
|
521
529
|
backend = getattr(db_manager, "get_backend_type", lambda: "neo4j")()
|
|
522
|
-
|
|
530
|
+
# Neo4j honours default_access_mode natively; FalkorDB routes READ sessions
|
|
531
|
+
# through GRAPH.RO_QUERY for server-enforced read-only execution.
|
|
532
|
+
session_kwargs = (
|
|
533
|
+
{"default_access_mode": "READ"}
|
|
534
|
+
if backend in ("neo4j", "falkordb", "falkordb-remote")
|
|
535
|
+
else {}
|
|
536
|
+
)
|
|
523
537
|
|
|
524
538
|
try:
|
|
525
539
|
with db_manager.get_driver().session(**session_kwargs) as session:
|
|
@@ -564,6 +578,119 @@ import uvicorn
|
|
|
564
578
|
import urllib.parse
|
|
565
579
|
from ..viz.server import run_server, set_db_manager
|
|
566
580
|
|
|
581
|
+
_OFFLINE_VIZ_DEFAULT_QUERY = """
|
|
582
|
+
MATCH (n)-[r]->(m)
|
|
583
|
+
RETURN n, r, m
|
|
584
|
+
LIMIT 300
|
|
585
|
+
"""
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _render_offline_visualization(
|
|
589
|
+
db_manager,
|
|
590
|
+
repo_path: Optional[str] = None,
|
|
591
|
+
cypher_query: Optional[str] = None,
|
|
592
|
+
output_path: Optional[str] = None,
|
|
593
|
+
) -> None:
|
|
594
|
+
"""Render the graph with the dependency-free single-file HTML renderer.
|
|
595
|
+
|
|
596
|
+
The React frontend in ``viz/dist`` is not shipped in the wheel, which made
|
|
597
|
+
every visualization entry point a hard failure on a plain ``pip install``.
|
|
598
|
+
``utils/visualize_graph.py`` is a complete, zero-dependency renderer that
|
|
599
|
+
was sitting in the package with no callers — this wires it up so the
|
|
600
|
+
command degrades to a working (simpler) view instead of exiting 1.
|
|
601
|
+
"""
|
|
602
|
+
from ..utils.visualize_graph import open_in_browser, build_graph_data
|
|
603
|
+
|
|
604
|
+
query = cypher_query or _OFFLINE_VIZ_DEFAULT_QUERY
|
|
605
|
+
nodes: Dict[Any, Dict[str, Any]] = {}
|
|
606
|
+
edges: List[Dict[str, Any]] = []
|
|
607
|
+
|
|
608
|
+
def _ident(value) -> Optional[str]:
|
|
609
|
+
return None if value is None else str(value)
|
|
610
|
+
|
|
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.
|
|
614
|
+
def _is_relationship(value) -> bool:
|
|
615
|
+
if hasattr(value, "labels"):
|
|
616
|
+
return False
|
|
617
|
+
if hasattr(value, "type"):
|
|
618
|
+
return True
|
|
619
|
+
return isinstance(value, dict) and "_src" in value and "_dst" in value
|
|
620
|
+
|
|
621
|
+
def _is_node(value) -> bool:
|
|
622
|
+
if hasattr(value, "labels"):
|
|
623
|
+
return True
|
|
624
|
+
if hasattr(value, "type"):
|
|
625
|
+
return False
|
|
626
|
+
# Kùzu relationships also carry _label, so _src/_dst is what
|
|
627
|
+
# distinguishes them from nodes.
|
|
628
|
+
return isinstance(value, dict) and "_label" in value and "_src" not in value
|
|
629
|
+
|
|
630
|
+
def _node_payload(value) -> Dict[str, Any]:
|
|
631
|
+
if hasattr(value, "labels"):
|
|
632
|
+
props = dict(value)
|
|
633
|
+
labels = list(getattr(value, "labels", []) or [])
|
|
634
|
+
label = labels[0] if labels else "Node"
|
|
635
|
+
node_id = getattr(value, "element_id", None) or getattr(value, "id", None)
|
|
636
|
+
else:
|
|
637
|
+
props, label = dict(value), value.get("_label", "Node")
|
|
638
|
+
node_id = value.get("_id")
|
|
639
|
+
if node_id is None:
|
|
640
|
+
node_id = props.get("path") or props.get("name")
|
|
641
|
+
return {
|
|
642
|
+
"id": _ident(node_id),
|
|
643
|
+
"label": label,
|
|
644
|
+
"name": props.get("name") or props.get("path") or "",
|
|
645
|
+
"file_path": props.get("path", ""),
|
|
646
|
+
"line_number": props.get("line_number"),
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
def _edge_payload(value) -> Optional[Dict[str, Any]]:
|
|
650
|
+
start = getattr(value, "start_node", None)
|
|
651
|
+
end = getattr(value, "end_node", None)
|
|
652
|
+
if start is not None and end is not None:
|
|
653
|
+
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"),
|
|
657
|
+
}
|
|
658
|
+
if isinstance(value, dict):
|
|
659
|
+
return {
|
|
660
|
+
"source": _ident(value.get("_src")),
|
|
661
|
+
"target": _ident(value.get("_dst")),
|
|
662
|
+
"type": value.get("_label", "RELATED"),
|
|
663
|
+
}
|
|
664
|
+
return None
|
|
665
|
+
|
|
666
|
+
with db_manager.get_driver().session() as session:
|
|
667
|
+
for record in session.run(query):
|
|
668
|
+
values = list(record.values()) if hasattr(record, "values") else list(record)
|
|
669
|
+
for value in values:
|
|
670
|
+
if _is_node(value):
|
|
671
|
+
payload = _node_payload(value)
|
|
672
|
+
if payload["id"] is not None:
|
|
673
|
+
nodes[payload["id"]] = payload
|
|
674
|
+
elif _is_relationship(value):
|
|
675
|
+
edge = _edge_payload(value)
|
|
676
|
+
if edge and edge["source"] and edge["target"]:
|
|
677
|
+
edges.append(edge)
|
|
678
|
+
|
|
679
|
+
if not nodes:
|
|
680
|
+
console.print(
|
|
681
|
+
"[yellow]No graph data returned — index a repository first "
|
|
682
|
+
"with 'cgc index <path>'.[/yellow]"
|
|
683
|
+
)
|
|
684
|
+
raise typer.Exit(code=1)
|
|
685
|
+
|
|
686
|
+
title = f"CGC Code Graph — {Path(repo_path).name}" if repo_path else "CGC Code Graph"
|
|
687
|
+
graph_data = build_graph_data(list(nodes.values()), edges)
|
|
688
|
+
path = open_in_browser(graph_data, title=title, output_path=output_path)
|
|
689
|
+
console.print(
|
|
690
|
+
f"[green]Rendered {len(nodes)} nodes and {len(edges)} edges to[/green] {path}"
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
|
|
567
694
|
def visualize_helper(
|
|
568
695
|
repo_path: Optional[str] = None,
|
|
569
696
|
host: str = "127.0.0.1",
|
|
@@ -625,8 +752,15 @@ def visualize_helper(
|
|
|
625
752
|
"[dim]then sync[/dim] [cyan]website/dist[/cyan] [dim]→[/dim] "
|
|
626
753
|
"[cyan]src/codegraphcontext/viz/dist[/cyan][dim].[/dim]"
|
|
627
754
|
)
|
|
628
|
-
|
|
629
|
-
|
|
755
|
+
console.print(
|
|
756
|
+
"\n[yellow]Falling back to the built-in offline renderer.[/yellow]"
|
|
757
|
+
)
|
|
758
|
+
try:
|
|
759
|
+
return _render_offline_visualization(
|
|
760
|
+
db_manager, repo_path=repo_path, cypher_query=cypher_query
|
|
761
|
+
)
|
|
762
|
+
finally:
|
|
763
|
+
db_manager.close_driver()
|
|
630
764
|
|
|
631
765
|
index_html = static_dir / "index.html"
|
|
632
766
|
if not index_html.is_file():
|
|
@@ -815,17 +949,23 @@ def stats_helper(path: str = None, context: Optional[str] = None):
|
|
|
815
949
|
return
|
|
816
950
|
|
|
817
951
|
# Get stats
|
|
818
|
-
# Get stats using separate queries to handle depth and avoid Cartesian products
|
|
952
|
+
# Get stats using separate queries to handle depth and avoid Cartesian products.
|
|
953
|
+
# `count(x)` counts *rows*, and a variable-length CONTAINS* match
|
|
954
|
+
# yields one row per distinct path to the node: a method is
|
|
955
|
+
# reachable as Repo->..->File->Function and again as
|
|
956
|
+
# Repo->..->File->Class->Function, a nested function three ways.
|
|
957
|
+
# count(DISTINCT x) is required or the totals are inflated —
|
|
958
|
+
# functions were over-reported by ~55% on CGC's own repo.
|
|
819
959
|
# 1. Files
|
|
820
|
-
file_query = "MATCH (r:Repository {path: $path})-[:CONTAINS*]->(f:File) RETURN count(f) as c"
|
|
960
|
+
file_query = "MATCH (r:Repository {path: $path})-[:CONTAINS*]->(f:File) RETURN count(DISTINCT f) as c"
|
|
821
961
|
file_count = session.run(file_query, path=repo_path_str).single()["c"]
|
|
822
|
-
|
|
962
|
+
|
|
823
963
|
# 2. Functions (including methods in classes)
|
|
824
|
-
func_query = "MATCH (r:Repository {path: $path})-[:CONTAINS*]->(func:Function) RETURN count(func) as c"
|
|
964
|
+
func_query = "MATCH (r:Repository {path: $path})-[:CONTAINS*]->(func:Function) RETURN count(DISTINCT func) as c"
|
|
825
965
|
func_count = session.run(func_query, path=repo_path_str).single()["c"]
|
|
826
|
-
|
|
966
|
+
|
|
827
967
|
# 3. Classes
|
|
828
|
-
class_query = "MATCH (r:Repository {path: $path})-[:CONTAINS*]->(c:Class) RETURN count(c) as c"
|
|
968
|
+
class_query = "MATCH (r:Repository {path: $path})-[:CONTAINS*]->(c:Class) RETURN count(DISTINCT c) as c"
|
|
829
969
|
class_count = session.run(class_query, path=repo_path_str).single()["c"]
|
|
830
970
|
|
|
831
971
|
# 4. Modules (imported) - Note: Module nodes are outside the repo structure usually, connected via IMPORTS
|
|
@@ -1017,19 +1157,38 @@ def watch_helper(
|
|
|
1017
1157
|
|
|
1018
1158
|
|
|
1019
1159
|
def unwatch_helper(path: str):
|
|
1020
|
-
"""
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1160
|
+
"""Report that unwatching is not available from the CLI.
|
|
1161
|
+
|
|
1162
|
+
This command touches no watcher state at all — it never has. It used to
|
|
1163
|
+
print a note, echo the requested path back as "Path specified:" (which
|
|
1164
|
+
reads like confirmation that something happened, even for a path that was
|
|
1165
|
+
never watched and does not exist) and exit 0.
|
|
1166
|
+
"""
|
|
1167
|
+
console.print(
|
|
1168
|
+
"[bold red]Error:[/bold red] 'cgc unwatch' is not supported from the CLI — "
|
|
1169
|
+
"it cannot reach a watcher started in another process."
|
|
1170
|
+
)
|
|
1171
|
+
console.print("[dim]For CLI watch mode, press Ctrl+C in the terminal running 'cgc watch'.[/dim]")
|
|
1172
|
+
console.print(
|
|
1173
|
+
"[dim]For MCP mode, call the 'unwatch_directory' tool from your IDE.[/dim]"
|
|
1174
|
+
)
|
|
1175
|
+
raise typer.Exit(code=1)
|
|
1024
1176
|
|
|
1025
1177
|
|
|
1026
1178
|
def list_watching_helper():
|
|
1027
|
-
"""
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
console.print(
|
|
1179
|
+
"""Report that listing watched paths is not available from the CLI.
|
|
1180
|
+
|
|
1181
|
+
Same as unwatch: no watcher state is consulted, so exiting 0 advertised a
|
|
1182
|
+
successful listing of nothing.
|
|
1183
|
+
"""
|
|
1184
|
+
console.print(
|
|
1185
|
+
"[bold red]Error:[/bold red] 'cgc watching' is not supported from the CLI — "
|
|
1186
|
+
"it cannot reach a watcher started in another process."
|
|
1187
|
+
)
|
|
1188
|
+
console.print("[dim]For CLI watch mode, check the terminal where you ran 'cgc watch'.[/dim]")
|
|
1189
|
+
console.print("[dim]For MCP mode: start 'cgc mcp start', then call the "
|
|
1190
|
+
"'list_watched_paths' tool from your IDE.[/dim]")
|
|
1191
|
+
raise typer.Exit(code=1)
|
|
1033
1192
|
|
|
1034
1193
|
|
|
1035
1194
|
def setup_scip_helper() -> None:
|
|
@@ -130,6 +130,11 @@ DEFAULT_CONFIG = {
|
|
|
130
130
|
# Default LLM model names used for graph queries when no value is explicitly configured
|
|
131
131
|
"OPENAI_MODEL": "gpt-4o",
|
|
132
132
|
"ANTHROPIC_MODEL": "claude-3-5-sonnet-20241022",
|
|
133
|
+
# Optional API key for the HTTP API gateway. Empty = auth disabled
|
|
134
|
+
# (backward compatible). When set, HTTP endpoints require the key via
|
|
135
|
+
# `Authorization: Bearer <key>` or `X-API-Key`. May also be set via the
|
|
136
|
+
# CGC_API_KEY environment variable (which takes priority).
|
|
137
|
+
"CGC_API_KEY": "",
|
|
133
138
|
}
|
|
134
139
|
|
|
135
140
|
# Configuration key descriptions
|
|
@@ -208,6 +213,12 @@ CONFIG_DESCRIPTIONS = {
|
|
|
208
213
|
"Requires ANTHROPIC_API_KEY environment variable. "
|
|
209
214
|
"Default: claude-3-5-sonnet-20241022"
|
|
210
215
|
),
|
|
216
|
+
"CGC_API_KEY": (
|
|
217
|
+
"Optional API key protecting the HTTP API gateway. Empty = "
|
|
218
|
+
"unauthenticated (backward compatible). When set, HTTP endpoints "
|
|
219
|
+
"require it via `Authorization: Bearer <key>` or the `X-API-Key` "
|
|
220
|
+
"header. The CGC_API_KEY environment variable overrides this value."
|
|
221
|
+
),
|
|
211
222
|
}
|
|
212
223
|
|
|
213
224
|
# Valid values for each config key
|
|
@@ -327,10 +338,13 @@ def load_config() -> Dict[str, str]:
|
|
|
327
338
|
"""
|
|
328
339
|
Load configuration with priority support.
|
|
329
340
|
Priority order (highest to lowest):
|
|
330
|
-
1. Environment variables
|
|
331
|
-
2. Local .env file (in
|
|
341
|
+
1. Environment variables (always highest priority)
|
|
342
|
+
2. Local .env file (ONLY in per-repo mode or when CGC_LOAD_PROJECT_ENV=1)
|
|
332
343
|
3. Global ~/.codegraphcontext/.env
|
|
333
344
|
|
|
345
|
+
BUG FIX: In global/named context mode, local repo .env files are now properly ignored
|
|
346
|
+
to prevent silent database redirection when working inside cloned repositories.
|
|
347
|
+
|
|
334
348
|
Note: Does NOT create config directory - caller must call ensure_config_dir() first if needed.
|
|
335
349
|
"""
|
|
336
350
|
# Start with defaults
|
|
@@ -348,7 +362,8 @@ def load_config() -> Dict[str, str]:
|
|
|
348
362
|
except Exception as e:
|
|
349
363
|
console.print(f"[red]Error loading global config: {e}[/red]")
|
|
350
364
|
|
|
351
|
-
# Load local .env file if
|
|
365
|
+
# Load local .env file ONLY if should_apply_project_dotenv() returns True
|
|
366
|
+
# This respects context mode (per-repo vs global/named) and environment overrides
|
|
352
367
|
local_env = find_local_env()
|
|
353
368
|
if local_env and local_env.exists():
|
|
354
369
|
try:
|
|
@@ -358,18 +373,33 @@ def load_config() -> Dict[str, str]:
|
|
|
358
373
|
if line and not line.startswith("#") and "=" in line:
|
|
359
374
|
key, value = line.split("=", 1)
|
|
360
375
|
key = key.strip()
|
|
361
|
-
|
|
376
|
+
value = value.strip()
|
|
377
|
+
|
|
378
|
+
# In per-repo mode: allow all config keys to be overridden
|
|
379
|
+
# In global/named mode: local .env should not be loaded at all (find_local_env returns None)
|
|
380
|
+
# But if it somehow gets through, only allow non-DB-path keys for safety
|
|
381
|
+
if key in DB_PATH_ENV_KEYS:
|
|
382
|
+
# CRITICAL: Never let local .env override DB paths in global mode
|
|
383
|
+
# This prevents silent database redirection
|
|
384
|
+
continue
|
|
385
|
+
|
|
362
386
|
if key in DEFAULT_CONFIG or key in DATABASE_CREDENTIAL_KEYS:
|
|
363
|
-
config[key] = value
|
|
387
|
+
config[key] = value
|
|
364
388
|
except Exception as e:
|
|
365
389
|
console.print(f"[yellow]Warning: Error loading local .env: {e}[/yellow]")
|
|
366
390
|
|
|
367
|
-
# Environment variables have highest priority
|
|
391
|
+
# Environment variables have highest priority - always override everything
|
|
368
392
|
for key in DEFAULT_CONFIG.keys():
|
|
369
393
|
env_value = os.getenv(key)
|
|
370
394
|
if env_value is not None:
|
|
371
395
|
config[key] = env_value
|
|
372
396
|
|
|
397
|
+
# Also check for database credential environment variables
|
|
398
|
+
for key in DATABASE_CREDENTIAL_KEYS:
|
|
399
|
+
env_value = os.getenv(key)
|
|
400
|
+
if env_value is not None:
|
|
401
|
+
config[key] = env_value
|
|
402
|
+
|
|
373
403
|
return config
|
|
374
404
|
|
|
375
405
|
|
|
@@ -755,13 +785,17 @@ def show_config():
|
|
|
755
785
|
for key in sorted(config_settings.keys()):
|
|
756
786
|
value = config_settings[key]
|
|
757
787
|
description = CONFIG_DESCRIPTIONS.get(key, "")
|
|
758
|
-
|
|
788
|
+
|
|
789
|
+
# Never print secret-like values (e.g. the HTTP API key) in plaintext.
|
|
790
|
+
if "API_KEY" in key.upper() and value:
|
|
791
|
+
value = "********"
|
|
792
|
+
|
|
759
793
|
# Highlight non-default values
|
|
760
794
|
if value != DEFAULT_CONFIG.get(key):
|
|
761
795
|
value_style = "[bold yellow]" + value + "[/bold yellow]"
|
|
762
796
|
else:
|
|
763
797
|
value_style = value
|
|
764
|
-
|
|
798
|
+
|
|
765
799
|
table.add_row(key, value_style, description)
|
|
766
800
|
|
|
767
801
|
console.print(table)
|
|
@@ -897,12 +931,20 @@ def save_context_config(cfg: ContextConfig) -> None:
|
|
|
897
931
|
"cgcignore_path": ctx.cgcignore_path,
|
|
898
932
|
}
|
|
899
933
|
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
934
|
+
# Read-merge the existing file first so that unrelated top-level sections
|
|
935
|
+
# (e.g. ``workspace_mappings``) are preserved instead of being wiped out.
|
|
936
|
+
raw: Dict[str, Any] = {}
|
|
937
|
+
if CONTEXT_CONFIG_FILE.exists():
|
|
938
|
+
try:
|
|
939
|
+
with open(CONTEXT_CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
940
|
+
raw = yaml.safe_load(f) or {}
|
|
941
|
+
except Exception:
|
|
942
|
+
raw = {}
|
|
943
|
+
|
|
944
|
+
raw["version"] = cfg.version
|
|
945
|
+
raw["mode"] = cfg.mode
|
|
946
|
+
raw["default_context"] = cfg.default_context
|
|
947
|
+
raw["contexts"] = contexts_raw
|
|
906
948
|
|
|
907
949
|
try:
|
|
908
950
|
_atomic_write_text(
|
|
@@ -202,17 +202,39 @@ def _configure_merge_driver(repo_root: Path) -> None:
|
|
|
202
202
|
_git_config(repo_root, "merge.cgc-bundle.driver", "cgc bundle merge %O %A %B")
|
|
203
203
|
|
|
204
204
|
|
|
205
|
+
def _run_git(repo_root: Path, args: list[str], *, check: bool, **kwargs):
|
|
206
|
+
"""Run a git command, translating a missing/failed git into HookError.
|
|
207
|
+
|
|
208
|
+
Callers only handle ``HookError``; without this a missing ``git`` binary
|
|
209
|
+
(``FileNotFoundError``) or a failed ``config`` call (``CalledProcessError``)
|
|
210
|
+
would crash the ``cgc hook`` commands with an uncaught exception.
|
|
211
|
+
"""
|
|
212
|
+
try:
|
|
213
|
+
return subprocess.run(
|
|
214
|
+
["git", "-C", str(repo_root), *args],
|
|
215
|
+
check=check,
|
|
216
|
+
**kwargs,
|
|
217
|
+
)
|
|
218
|
+
except FileNotFoundError as exc:
|
|
219
|
+
raise HookError(
|
|
220
|
+
"Git executable not found. Install Git and ensure it is on PATH."
|
|
221
|
+
) from exc
|
|
222
|
+
except subprocess.CalledProcessError as exc:
|
|
223
|
+
raise HookError(f"git {' '.join(args)} failed: {exc}") from exc
|
|
224
|
+
|
|
225
|
+
|
|
205
226
|
def _git_config(repo_root: Path, key: str, value: str) -> None:
|
|
206
|
-
|
|
227
|
+
_run_git(repo_root, ["config", key, value], check=True)
|
|
207
228
|
|
|
208
229
|
|
|
209
230
|
def _unset_git_config(repo_root: Path, key: str) -> None:
|
|
210
|
-
|
|
231
|
+
_run_git(repo_root, ["config", "--unset-all", key], check=False)
|
|
211
232
|
|
|
212
233
|
|
|
213
234
|
def _has_git_config(repo_root: Path, key: str) -> bool:
|
|
214
|
-
result =
|
|
215
|
-
|
|
235
|
+
result = _run_git(
|
|
236
|
+
repo_root,
|
|
237
|
+
["config", "--get", key],
|
|
216
238
|
check=False,
|
|
217
239
|
stdout=subprocess.DEVNULL,
|
|
218
240
|
stderr=subprocess.DEVNULL,
|