codegraphcontext 0.4.17__py3-none-any.whl → 0.4.18__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 (29) hide show
  1. codegraphcontext/api/app.py +5 -0
  2. codegraphcontext/cli/cli_helpers.py +52 -4
  3. codegraphcontext/cli/config_manager.py +33 -8
  4. codegraphcontext/cli/hook_manager.py +220 -0
  5. codegraphcontext/cli/main.py +121 -15
  6. codegraphcontext/cli/setup_wizard.py +96 -10
  7. codegraphcontext/core/cgc_bundle.py +14 -4
  8. codegraphcontext/core/database_kuzu.py +10 -0
  9. codegraphcontext/core/watcher.py +48 -5
  10. codegraphcontext/tools/code_finder.py +1 -1
  11. codegraphcontext/tools/graph_builder.py +38 -178
  12. codegraphcontext/tools/handlers/management_handlers.py +6 -2
  13. codegraphcontext/tools/handlers/watcher_handlers.py +7 -2
  14. codegraphcontext/tools/indexing/discovery.py +1 -2
  15. codegraphcontext/tools/indexing/persistence/utils.py +46 -0
  16. codegraphcontext/tools/indexing/persistence/writer.py +264 -93
  17. codegraphcontext/tools/indexing/pipeline.py +27 -13
  18. codegraphcontext/tools/indexing/resolution/calls.py +21 -10
  19. codegraphcontext/tools/indexing/resolution/post_resolution.py +18 -6
  20. codegraphcontext/tools/indexing/scip_pipeline.py +38 -3
  21. codegraphcontext/tools/languages/python.py +10 -11
  22. codegraphcontext/tools/report_generator.py +11 -11
  23. codegraphcontext/utils/path_sandbox.py +2 -1
  24. {codegraphcontext-0.4.17.dist-info → codegraphcontext-0.4.18.dist-info}/METADATA +5 -5
  25. {codegraphcontext-0.4.17.dist-info → codegraphcontext-0.4.18.dist-info}/RECORD +29 -27
  26. {codegraphcontext-0.4.17.dist-info → codegraphcontext-0.4.18.dist-info}/WHEEL +0 -0
  27. {codegraphcontext-0.4.17.dist-info → codegraphcontext-0.4.18.dist-info}/entry_points.txt +0 -0
  28. {codegraphcontext-0.4.17.dist-info → codegraphcontext-0.4.18.dist-info}/licenses/LICENSE +0 -0
  29. {codegraphcontext-0.4.17.dist-info → codegraphcontext-0.4.18.dist-info}/top_level.txt +0 -0
@@ -24,6 +24,11 @@ def create_app() -> FastAPI:
24
24
 
25
25
  app.include_router(router, prefix="/api/v1")
26
26
 
27
+ @app.get("/health")
28
+ async def health():
29
+ """Liveness probe for load balancers and k8s."""
30
+ return {"status": "ok"}
31
+
27
32
  # MCP-over-SSE Endpoints
28
33
  app.add_api_route("/api/v1/mcp/sse", handle_sse, methods=["GET"])
29
34
  app.add_api_route("/api/v1/mcp/messages", handle_messages, methods=["POST"])
@@ -260,6 +260,10 @@ def index_helper(path: str, context: Optional[str] = None):
260
260
  """Synchronously indexes a repository in a given context."""
261
261
  time_start = time.time()
262
262
  path_obj = Path(path).resolve()
263
+ # Normalize to forward slashes for cross-platform DB consistency.
264
+ # The graph DB always stores paths via Path.resolve().as_posix(),
265
+ # so Cypher queries must also use forward slashes on Windows.
266
+ repo_path_str = path_obj.as_posix()
263
267
  index_cwd = path_obj if path_obj.is_dir() else path_obj.parent
264
268
  services = _initialize_services(context, cwd=index_cwd)
265
269
  if not all(services[:3]):
@@ -283,7 +287,7 @@ def index_helper(path: str, context: Optional[str] = None):
283
287
  with db_manager.get_driver().session() as session:
284
288
  result = session.run(
285
289
  "MATCH (r:Repository {path: $path})-[:CONTAINS*]->(f:File) RETURN count(DISTINCT f) as file_count",
286
- path=str(path_obj)
290
+ path=repo_path_str
287
291
  )
288
292
  record = result.single()
289
293
  file_count = record["file_count"] if record else 0
@@ -415,6 +419,49 @@ def delete_helper(repo_path: str, context: Optional[str] = None):
415
419
  finally:
416
420
  db_manager.close_driver()
417
421
 
422
+ def _print_query_exception(e: Exception, query: str) -> None:
423
+ """
424
+ Pretty-print a database query exception, surfacing the raw driver
425
+ error message so Cypher syntax problems are clearly visible.
426
+ """
427
+ import traceback
428
+
429
+ error_type = type(e).__name__
430
+ error_module = type(e).__module__ or ""
431
+
432
+ # Neo4j: CypherSyntaxError and other ClientError subclasses carry
433
+ # a .message and .code attribute with the full server-side detail.
434
+ if "neo4j" in error_module:
435
+ code = getattr(e, "code", None)
436
+ msg = getattr(e, "message", None) or str(e)
437
+ console.print(f"[bold red]Query Error ({error_type}):[/bold red]")
438
+ if code:
439
+ console.print(f" [yellow]Code:[/yellow] {code}")
440
+ console.print(f" [yellow]Message:[/yellow] {msg}")
441
+
442
+ # FalkorDB: ResponseError / exceptions in falkordb or redis packages
443
+ elif "falkordb" in error_module or "redis" in error_module:
444
+ console.print(f"[bold red]Query Error ({error_type}):[/bold red]")
445
+ console.print(f" [yellow]Database message:[/yellow] {e}")
446
+
447
+ # KuzuDB: RuntimeError from the kuzu extension
448
+ # KuzuDB: RuntimeError from the kuzu extension or database_kuzu wrapper
449
+ elif "kuzu" in error_module or (
450
+ error_type == "RuntimeError" and "Parser exception" in str(e)
451
+ ):
452
+ console.print(f"[bold red]Query Error ({error_type}):[/bold red]")
453
+ console.print(f" [yellow]Database message:[/yellow] {e}")
454
+
455
+ else:
456
+ # Fallback: unknown backend — print type + message + traceback
457
+ console.print(f"[bold red]An error occurred while executing query ({error_type}):[/bold red]")
458
+ console.print(f" [yellow]Message:[/yellow] {e}")
459
+ console.print("[dim]--- Traceback ---[/dim]")
460
+ console.print(f"[dim]{traceback.format_exc()}[/dim]")
461
+
462
+ console.print(f"\n[dim]Failed query:[/dim]")
463
+ console.print(f"[dim] {query}[/dim]")
464
+
418
465
 
419
466
  def cypher_helper(query: str, context: Optional[str] = None):
420
467
  """Executes a read-only Cypher query."""
@@ -440,7 +487,7 @@ def cypher_helper(query: str, context: Optional[str] = None):
440
487
  records = [record.data() for record in result]
441
488
  console.print(json.dumps(records, indent=2))
442
489
  except Exception as e:
443
- console.print(f"[bold red]An error occurred while executing query:[/bold red] {e}")
490
+ _print_query_exception(e, query)
444
491
  db_manager.close_driver()
445
492
  raise typer.Exit(code=1)
446
493
  finally:
@@ -466,7 +513,7 @@ def cypher_helper_visual(query: str, context: Optional[str] = None):
466
513
  try:
467
514
  visualize_cypher_results(query)
468
515
  except Exception as e:
469
- console.print(f"[bold red]An error occurred while executing query:[/bold red] {e}")
516
+ _print_query_exception(e, query)
470
517
  db_manager.close_driver()
471
518
  raise typer.Exit(code=1)
472
519
  finally:
@@ -844,10 +891,11 @@ def watch_helper(path: str, context: Optional[str] = None, use_polling: Optional
844
891
 
845
892
  # Add the directory to watch
846
893
  if is_indexed:
847
- console.print("[green]✓[/green] Already indexed (no initial scan needed)")
894
+ console.print("[green]✓[/green] Already indexed. Synchronizing current files...")
848
895
  watcher.watch_directory(
849
896
  str(path_obj),
850
897
  perform_initial_scan=False,
898
+ sync_on_start=True,
851
899
  cgcignore_path=ctx.cgcignore_path,
852
900
  )
853
901
  else:
@@ -160,6 +160,14 @@ CONFIG_VALIDATORS = {
160
160
  "CGC_EMBEDDING_MODEL": ["local", "openai"],
161
161
  "FUZZY_SEARCH": ["true", "false"],
162
162
  }
163
+
164
+ SUPPORTED_DATABASES: List[str] = CONFIG_VALIDATORS["DEFAULT_DATABASE"]
165
+ DATABASE_CLI_HELP = (
166
+ "Database backend ("
167
+ + "|".join(SUPPORTED_DATABASES)
168
+ + "). Defaults to DEFAULT_DATABASE from config."
169
+ )
170
+
163
171
  DEFAULT_CGCIGNORE_PATTERNS = """\
164
172
  # Default .cgcignore patterns
165
173
  # Lines starting with # are comments; blank lines are ignored.
@@ -279,18 +287,25 @@ def load_config() -> Dict[str, str]:
279
287
  def should_apply_project_dotenv() -> bool:
280
288
  """True when cwd-local ``.codegraphcontext/.env`` should merge with global config.
281
289
 
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.
290
+ Project env is loaded only in **per-repo** context mode (or when
291
+ ``CGC_LOAD_PROJECT_ENV=1``). In **global** / **named** mode, ``~/.codegraphcontext/.env``
292
+ wins so clones with a checked-in ``.codegraphcontext/.env`` do not hijack config.
293
+
294
+ Set ``CGC_IGNORE_PROJECT_ENV=1`` to force skip; ``CGC_LOAD_PROJECT_ENV=1`` to force load.
284
295
  """
285
296
  if os.getenv("CGC_IGNORE_PROJECT_ENV", "").strip().lower() in ("1", "true", "yes"):
286
297
  return False
287
298
  if os.getenv("CGC_LOAD_PROJECT_ENV", "").strip().lower() in ("1", "true", "yes"):
288
299
  return True
300
+ cfg = load_context_config()
301
+ if cfg.mode != "per-repo":
302
+ return False
289
303
  try:
290
304
  Path.cwd().resolve().relative_to(Path.home().resolve())
291
305
  return True
292
306
  except ValueError:
293
- return False
307
+ # Per-repo indexing from /tmp with an isolated HOME (common in E2E/CI).
308
+ return True
294
309
 
295
310
 
296
311
  def find_local_env() -> Optional[Path]:
@@ -869,23 +884,33 @@ def resolve_context(
869
884
  local_cgc = cwd / ".codegraphcontext"
870
885
  local_cgc.mkdir(parents=True, exist_ok=True)
871
886
  (local_cgc / "db").mkdir(exist_ok=True)
872
-
887
+
888
+ inherited_db = load_config().get("DEFAULT_DATABASE", "falkordb")
889
+
873
890
  # Copy global .env into local context for easy per-repo tweaking
874
891
  import shutil
875
892
  if CONFIG_FILE.exists():
876
893
  shutil.copy2(CONFIG_FILE, local_cgc / ".env")
877
-
878
- console.print(f"[dim]Auto-initialized per-repo context at {local_cgc}[/dim]")
894
+
895
+ local_yaml = local_cgc / "config.yaml"
896
+ if not local_yaml.exists():
897
+ with open(local_yaml, "w", encoding="utf-8") as f:
898
+ yaml.safe_dump({"database": inherited_db}, f)
899
+
900
+ console.print(
901
+ f"[dim]Auto-initialized per-repo context at {local_cgc} "
902
+ f"(Database: {inherited_db})[/dim]"
903
+ )
879
904
 
880
905
  if local_cgc is not None:
881
906
  # Read local config.yaml if present
882
907
  local_yaml = local_cgc / "config.yaml"
883
- local_db = "falkordb"
908
+ local_db = load_config().get("DEFAULT_DATABASE", "falkordb")
884
909
  if local_yaml.exists():
885
910
  try:
886
911
  with open(local_yaml, encoding="utf-8") as f:
887
912
  local_raw = yaml.safe_load(f) or {}
888
- local_db = local_raw.get("database", "falkordb")
913
+ local_db = local_raw.get("database", local_db)
889
914
  except Exception:
890
915
  pass
891
916
  db_path = str(local_cgc / "db" / local_db)
@@ -0,0 +1,220 @@
1
+ """Git hook management for keeping CGC indexes in sync."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ import stat
8
+ import subprocess
9
+
10
+
11
+ MANAGED_MARKER = "# CGC_MANAGED_HOOK"
12
+ HOOK_NAMES = ("post-commit", "post-checkout")
13
+ GITATTRIBUTES_ENTRY = "*.cgc merge=cgc-bundle"
14
+
15
+
16
+ class HookError(RuntimeError):
17
+ """Raised when hook installation cannot proceed safely."""
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class GitRepository:
22
+ root: Path
23
+ git_dir: Path
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class HookStatus:
28
+ repo_root: Path
29
+ git_dir: Path
30
+ installed_hooks: tuple[str, ...]
31
+ unmanaged_hooks: tuple[str, ...]
32
+ has_merge_driver: bool
33
+ has_gitattributes_entry: bool
34
+
35
+ @property
36
+ def installed(self) -> bool:
37
+ return (
38
+ bool(self.installed_hooks)
39
+ and self.has_merge_driver
40
+ and self.has_gitattributes_entry
41
+ )
42
+
43
+
44
+ def find_git_repository(start: Path | str | None = None) -> GitRepository:
45
+ """Find the nearest Git repository root and git directory."""
46
+ current = Path(start or Path.cwd()).resolve()
47
+ if current.is_file():
48
+ current = current.parent
49
+
50
+ for candidate in (current, *current.parents):
51
+ git_path = candidate / ".git"
52
+ if git_path.is_dir():
53
+ return GitRepository(root=candidate, git_dir=git_path)
54
+ if git_path.is_file():
55
+ target = _read_worktree_gitdir(git_path)
56
+ return GitRepository(root=candidate, git_dir=target)
57
+
58
+ raise HookError("No Git repository found from the current directory.")
59
+
60
+
61
+ def install_hooks(start: Path | str | None = None, *, force: bool = False) -> HookStatus:
62
+ repo = find_git_repository(start)
63
+ hooks_dir = repo.git_dir / "hooks"
64
+ hooks_dir.mkdir(parents=True, exist_ok=True)
65
+
66
+ script = _hook_script(repo.root)
67
+ for hook_name in HOOK_NAMES:
68
+ hook_path = hooks_dir / hook_name
69
+ if hook_path.exists() and not _is_managed_hook(hook_path) and not force:
70
+ raise HookError(
71
+ f"{hook_name} already exists and is not managed by CGC. "
72
+ "Re-run with --force to replace it."
73
+ )
74
+ hook_path.write_text(script, encoding="utf-8")
75
+ mode = hook_path.stat().st_mode
76
+ hook_path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
77
+
78
+ _ensure_gitattributes(repo.root)
79
+ _configure_merge_driver(repo.root)
80
+ return get_hook_status(repo.root)
81
+
82
+
83
+ def uninstall_hooks(start: Path | str | None = None) -> HookStatus:
84
+ repo = find_git_repository(start)
85
+ hooks_dir = repo.git_dir / "hooks"
86
+
87
+ for hook_name in HOOK_NAMES:
88
+ hook_path = hooks_dir / hook_name
89
+ if hook_path.exists() and _is_managed_hook(hook_path):
90
+ hook_path.unlink()
91
+
92
+ _remove_gitattributes_entry(repo.root)
93
+ _unset_git_config(repo.root, "merge.cgc-bundle.name")
94
+ _unset_git_config(repo.root, "merge.cgc-bundle.driver")
95
+ return get_hook_status(repo.root)
96
+
97
+
98
+ def get_hook_status(start: Path | str | None = None) -> HookStatus:
99
+ repo = find_git_repository(start)
100
+ hooks_dir = repo.git_dir / "hooks"
101
+ installed_hooks: list[str] = []
102
+ unmanaged_hooks: list[str] = []
103
+
104
+ for hook_name in HOOK_NAMES:
105
+ hook_path = hooks_dir / hook_name
106
+ if not hook_path.exists():
107
+ continue
108
+ if _is_managed_hook(hook_path):
109
+ installed_hooks.append(hook_name)
110
+ else:
111
+ unmanaged_hooks.append(hook_name)
112
+
113
+ return HookStatus(
114
+ repo_root=repo.root,
115
+ git_dir=repo.git_dir,
116
+ installed_hooks=tuple(installed_hooks),
117
+ unmanaged_hooks=tuple(unmanaged_hooks),
118
+ has_merge_driver=_has_git_config(repo.root, "merge.cgc-bundle.driver"),
119
+ has_gitattributes_entry=_has_gitattributes_entry(repo.root),
120
+ )
121
+
122
+
123
+ def _read_worktree_gitdir(git_file: Path) -> Path:
124
+ content = git_file.read_text(encoding="utf-8").strip()
125
+ prefix = "gitdir:"
126
+ if not content.lower().startswith(prefix):
127
+ raise HookError(f"Unsupported .git file format at {git_file}")
128
+
129
+ raw_path = content[len(prefix):].strip()
130
+ git_dir = Path(raw_path)
131
+ if not git_dir.is_absolute():
132
+ git_dir = (git_file.parent / git_dir).resolve()
133
+ return git_dir
134
+
135
+
136
+ def _hook_script(repo_root: Path) -> str:
137
+ repo_root_value = _sh_quote(str(repo_root))
138
+ return (
139
+ "#!/bin/sh\n"
140
+ f"{MANAGED_MARKER}: CodeGraphContext auto-update hook\n"
141
+ f"CGC_REPO_ROOT={repo_root_value}\n"
142
+ "if command -v cgc >/dev/null 2>&1; then\n"
143
+ ' cgc update "$CGC_REPO_ROOT" --quiet\n'
144
+ "else\n"
145
+ ' python -m codegraphcontext update "$CGC_REPO_ROOT" --quiet\n'
146
+ "fi\n"
147
+ )
148
+
149
+
150
+ def _sh_quote(value: str) -> str:
151
+ return "'" + value.replace("'", "'\"'\"'") + "'"
152
+
153
+
154
+ def _is_managed_hook(path: Path) -> bool:
155
+ try:
156
+ return MANAGED_MARKER in path.read_text(encoding="utf-8", errors="ignore")
157
+ except OSError:
158
+ return False
159
+
160
+
161
+ def _ensure_gitattributes(repo_root: Path) -> None:
162
+ path = repo_root / ".gitattributes"
163
+ existing = path.read_text(encoding="utf-8") if path.exists() else ""
164
+ lines = existing.splitlines()
165
+ if any(line.strip() == GITATTRIBUTES_ENTRY for line in lines):
166
+ return
167
+
168
+ prefix = existing
169
+ if prefix and not prefix.endswith("\n"):
170
+ prefix += "\n"
171
+ path.write_text(f"{prefix}{GITATTRIBUTES_ENTRY}\n", encoding="utf-8")
172
+
173
+
174
+ def _remove_gitattributes_entry(repo_root: Path) -> None:
175
+ path = repo_root / ".gitattributes"
176
+ if not path.exists():
177
+ return
178
+
179
+ lines = [
180
+ line
181
+ for line in path.read_text(encoding="utf-8").splitlines()
182
+ if line.strip() != GITATTRIBUTES_ENTRY
183
+ ]
184
+ if lines:
185
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
186
+ else:
187
+ path.unlink()
188
+
189
+
190
+ def _has_gitattributes_entry(repo_root: Path) -> bool:
191
+ path = repo_root / ".gitattributes"
192
+ if not path.exists():
193
+ return False
194
+ return any(
195
+ line.strip() == GITATTRIBUTES_ENTRY
196
+ for line in path.read_text(encoding="utf-8").splitlines()
197
+ )
198
+
199
+
200
+ def _configure_merge_driver(repo_root: Path) -> None:
201
+ _git_config(repo_root, "merge.cgc-bundle.name", "CodeGraphContext bundle merge driver")
202
+ _git_config(repo_root, "merge.cgc-bundle.driver", "cgc bundle merge %O %A %B")
203
+
204
+
205
+ def _git_config(repo_root: Path, key: str, value: str) -> None:
206
+ subprocess.run(["git", "-C", str(repo_root), "config", key, value], check=True)
207
+
208
+
209
+ def _unset_git_config(repo_root: Path, key: str) -> None:
210
+ subprocess.run(["git", "-C", str(repo_root), "config", "--unset-all", key], check=False)
211
+
212
+
213
+ def _has_git_config(repo_root: Path, key: str) -> bool:
214
+ result = subprocess.run(
215
+ ["git", "-C", str(repo_root), "config", "--get", key],
216
+ check=False,
217
+ stdout=subprocess.DEVNULL,
218
+ stderr=subprocess.DEVNULL,
219
+ )
220
+ return result.returncode == 0
@@ -9,6 +9,7 @@ Commands:
9
9
  - help: Displays help information.
10
10
  - version: Show the installed version.
11
11
  """
12
+ import sys
12
13
  import typer
13
14
  from rich.console import Console
14
15
  from rich.table import Table
@@ -34,6 +35,7 @@ from .cli_helpers import (
34
35
  cypher_helper_visual,
35
36
  visualize_helper,
36
37
  reindex_helper,
38
+ update_helper,
37
39
  clean_helper,
38
40
  stats_helper,
39
41
  _initialize_services,
@@ -42,6 +44,7 @@ from .cli_helpers import (
42
44
  list_watching_helper,
43
45
  setup_scip_helper,
44
46
  )
47
+ from .hook_manager import HookError, get_hook_status, install_hooks, uninstall_hooks
45
48
 
46
49
  # Set the log level for the noisy neo4j, asyncio, and urllib3 loggers to keep the output clean.
47
50
  # Get the log level from config, defaulting to WARNING
@@ -249,7 +252,7 @@ def context_list():
249
252
  @context_app.command("create")
250
253
  def context_create(
251
254
  name: str = typer.Argument(..., help="Name of the new context"),
252
- database: str = typer.Option(None, "--database", "--db", "-db", "-d", help="Database backend (falkordb, kuzudb, neo4j). Defaults to DEFAULT_DATABASE from config."),
255
+ database: str = typer.Option(None, "--database", "--db", "-db", "-d", help=config_manager.DATABASE_CLI_HELP),
253
256
  db_path: str = typer.Option(None, "--db-path", help="Explicit path for the DB (defaults to ~/.codegraphcontext/contexts/<name>/db)"),
254
257
  ):
255
258
  """Create a new logical context."""
@@ -290,7 +293,7 @@ def _load_credentials(cli_context_flag: Optional[str] = None):
290
293
  Uses per-variable precedence - each variable is loaded from the highest priority source.
291
294
  Priority order (highest to lowest):
292
295
  1. Runtime environment variables (shell/CI)
293
- 2. Local `.codegraphcontext/.env` and `.env` in the current project directory (project-specific overrides)
296
+ 2. Local `.codegraphcontext/.env` and `.env` in the current project directory (per-repo mode only)
294
297
  3. Global `~/.codegraphcontext/.env` (user defaults, including `cgc config set`)
295
298
  4. Local `mcp.json` env vars (project defaults)
296
299
 
@@ -653,10 +656,30 @@ def bundle_export(
653
656
  finally:
654
657
  db_manager.close_driver()
655
658
 
659
+ def _confirm_bundle_clear(clear: bool, yes: bool) -> bool:
660
+ """Return True if import may proceed; False if user cancelled."""
661
+ if not clear:
662
+ return True
663
+ console.print("[yellow]⚠️ Warning: This will clear all existing graph data![/yellow]")
664
+ if yes:
665
+ return True
666
+ if not sys.stdin.isatty():
667
+ console.print(
668
+ "[bold red]Refusing to clear graph in non-interactive mode. "
669
+ "Pass --yes / -y to confirm.[/bold red]"
670
+ )
671
+ raise typer.Exit(code=1)
672
+ if not typer.confirm("Are you sure you want to continue?", default=False):
673
+ console.print("[yellow]Import cancelled[/yellow]")
674
+ return False
675
+ return True
676
+
677
+
656
678
  @bundle_app.command("import")
657
679
  def bundle_import(
658
680
  bundle_file: str = typer.Argument(..., help="Path to the .cgc bundle file to import"),
659
681
  clear: bool = typer.Option(False, "--clear", help="Clear existing graph data before importing"),
682
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation when using --clear"),
660
683
  context: Optional[str] = typer.Option(None, "--context", "-c", help="Specific context to use"),
661
684
  ):
662
685
  """
@@ -667,7 +690,7 @@ def bundle_import(
667
690
 
668
691
  Examples:
669
692
  cgc bundle import numpy.cgc
670
- cgc bundle import my-project.cgc --clear
693
+ cgc bundle import my-project.cgc --clear --yes
671
694
  """
672
695
  _load_credentials()
673
696
  from codegraphcontext.core.cgc_bundle import CGCBundle
@@ -684,11 +707,8 @@ def bundle_import(
684
707
  console.print(f"[bold red]Bundle file not found: {bundle_path}[/bold red]")
685
708
  raise typer.Exit(code=1)
686
709
 
687
- if clear:
688
- console.print("[yellow]⚠️ Warning: This will clear all existing graph data![/yellow]")
689
- if not typer.confirm("Are you sure you want to continue?", default=False):
690
- console.print("[yellow]Import cancelled[/yellow]")
691
- return
710
+ if not _confirm_bundle_clear(clear, yes):
711
+ return
692
712
 
693
713
  console.print(f"[cyan]Importing bundle from {bundle_path}...[/cyan]")
694
714
 
@@ -710,7 +730,8 @@ def bundle_import(
710
730
  @bundle_app.command("load")
711
731
  def bundle_load(
712
732
  bundle_name: str = typer.Argument(..., help="Bundle name or path to load (e.g., 'numpy' or 'numpy.cgc')"),
713
- clear: bool = typer.Option(False, "--clear", help="Clear existing graph data before loading")
733
+ clear: bool = typer.Option(False, "--clear", help="Clear existing graph data before loading"),
734
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation when using --clear"),
714
735
  ):
715
736
  """
716
737
  Load a pre-indexed bundle (download if needed, then import).
@@ -731,7 +752,7 @@ def bundle_load(
731
752
 
732
753
  # If it's an absolute path or has .cgc extension and exists, use it directly
733
754
  if bundle_path.is_absolute() or (bundle_path.suffix == '.cgc' and bundle_path.exists()):
734
- bundle_import(str(bundle_path), clear=clear)
755
+ bundle_import(str(bundle_path), clear=clear, yes=yes)
735
756
  return
736
757
 
737
758
  # Add .cgc extension if not present
@@ -741,7 +762,7 @@ def bundle_load(
741
762
  # Check if exists locally
742
763
  if bundle_path.exists():
743
764
  console.print(f"[dim]Found local bundle: {bundle_path}[/dim]")
744
- bundle_import(str(bundle_path), clear=clear)
765
+ bundle_import(str(bundle_path), clear=clear, yes=yes)
745
766
  return
746
767
 
747
768
  # Try to download from registry
@@ -759,7 +780,7 @@ def bundle_load(
759
780
 
760
781
  if downloaded_path:
761
782
  # Import the downloaded bundle
762
- bundle_import(downloaded_path, clear=clear)
783
+ bundle_import(downloaded_path, clear=clear, yes=yes)
763
784
  else:
764
785
  console.print(f"[bold red]Failed to download bundle '{name}'[/bold red]")
765
786
  raise typer.Exit(code=1)
@@ -769,6 +790,71 @@ def bundle_load(
769
790
  console.print("[dim]Use 'cgc registry list' to see available bundles[/dim]")
770
791
  raise typer.Exit(code=1)
771
792
 
793
+ # ============================================================================
794
+ # HOOK COMMAND GROUP - Git integration
795
+ # ============================================================================
796
+
797
+ hook_app = typer.Typer(help="Install Git hooks that keep the CGC graph in sync")
798
+ app.add_typer(hook_app, name="hook")
799
+
800
+
801
+ @hook_app.command("install")
802
+ def hook_install(
803
+ path: str = typer.Argument(".", help="Path inside the Git repository"),
804
+ force: bool = typer.Option(False, "--force", "-f", help="Replace existing non-CGC hook files"),
805
+ ):
806
+ """
807
+ Install CGC-managed Git hooks in the nearest repository.
808
+
809
+ The installed hooks run `cgc update <repo> --quiet` after commits and
810
+ checkouts. Existing non-CGC hooks are preserved unless --force is used.
811
+ """
812
+ try:
813
+ status = install_hooks(path, force=force)
814
+ except HookError as exc:
815
+ console.print(f"[bold red]Hook install failed:[/bold red] {exc}")
816
+ raise typer.Exit(code=1)
817
+
818
+ console.print(f"[green]✓[/green] Installed CGC hooks in [bold]{status.repo_root}[/bold]")
819
+ console.print(f"[dim]Git directory: {status.git_dir}[/dim]")
820
+
821
+
822
+ @hook_app.command("uninstall")
823
+ def hook_uninstall(
824
+ path: str = typer.Argument(".", help="Path inside the Git repository"),
825
+ ):
826
+ """Remove CGC-managed Git hooks and local merge-driver config."""
827
+ try:
828
+ status = uninstall_hooks(path)
829
+ except HookError as exc:
830
+ console.print(f"[bold red]Hook uninstall failed:[/bold red] {exc}")
831
+ raise typer.Exit(code=1)
832
+
833
+ console.print(f"[green]✓[/green] Removed CGC hooks from [bold]{status.repo_root}[/bold]")
834
+
835
+
836
+ @hook_app.command("status")
837
+ def hook_status(
838
+ path: str = typer.Argument(".", help="Path inside the Git repository"),
839
+ ):
840
+ """Show whether CGC-managed Git hooks are installed."""
841
+ try:
842
+ status = get_hook_status(path)
843
+ except HookError as exc:
844
+ console.print(f"[bold red]Hook status failed:[/bold red] {exc}")
845
+ raise typer.Exit(code=1)
846
+
847
+ table = Table(title="CGC Git Hook Status", show_header=True, header_style="bold magenta")
848
+ table.add_column("Check", style="cyan")
849
+ table.add_column("Status")
850
+ table.add_row("Repository", str(status.repo_root))
851
+ table.add_row("Git directory", str(status.git_dir))
852
+ table.add_row("Managed hooks", ", ".join(status.installed_hooks) or "none")
853
+ table.add_row("Unmanaged hooks", ", ".join(status.unmanaged_hooks) or "none")
854
+ table.add_row("Merge driver", "installed" if status.has_merge_driver else "missing")
855
+ table.add_row(".gitattributes", "installed" if status.has_gitattributes_entry else "missing")
856
+ console.print(table)
857
+
772
858
  # Shortcut commands at root level
773
859
  @app.command("export", rich_help_panel="Bundle Shortcuts")
774
860
  def export_shortcut(
@@ -783,10 +869,11 @@ def export_shortcut(
783
869
  @app.command("load", rich_help_panel="Bundle Shortcuts")
784
870
  def load_shortcut(
785
871
  bundle_name: str = typer.Argument(..., help="Bundle name or path to load"),
786
- clear: bool = typer.Option(False, "--clear", help="Clear existing graph data before loading")
872
+ clear: bool = typer.Option(False, "--clear", help="Clear existing graph data before loading"),
873
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation when using --clear"),
787
874
  ):
788
875
  """Shortcut for 'cgc bundle load'"""
789
- bundle_load(bundle_name, clear)
876
+ bundle_load(bundle_name, clear, yes=yes)
790
877
 
791
878
  # ============================================================================
792
879
  # REGISTRY COMMAND GROUP - Browse and Download Bundles
@@ -1173,6 +1260,23 @@ def index(
1173
1260
  else:
1174
1261
  index_helper(path, context)
1175
1262
 
1263
+ @app.command()
1264
+ def update(
1265
+ path: Optional[str] = typer.Argument(None, help="Path to refresh. Defaults to current directory."),
1266
+ quiet: bool = typer.Option(False, "--quiet", "-q", help="Reduce output when running from automation"),
1267
+ context: Optional[str] = typer.Option(None, "--context", "-c", help="Specific context to use (overrides mode/default)"),
1268
+ ):
1269
+ """
1270
+ Refresh an existing repository index.
1271
+
1272
+ This command is intentionally small and hook-friendly; Git hooks installed
1273
+ with `cgc hook install` call it after commits and checkouts.
1274
+ """
1275
+ _load_credentials()
1276
+ if path is None:
1277
+ path = str(Path.cwd())
1278
+ update_helper(path, context)
1279
+
1176
1280
  @app.command()
1177
1281
  def clean(
1178
1282
  context: Optional[str] = typer.Option(None, "--context", "-c", help="Specific context to use")
@@ -2708,7 +2812,9 @@ def main(
2708
2812
  "--database",
2709
2813
  "--db",
2710
2814
  "-db",
2711
- help="[Global] Temporarily override database backend (falkordb, falkordb-remote, neo4j, or kuzudb) for any command"
2815
+ help="[Global] Temporarily override database backend ("
2816
+ + "|".join(config_manager.SUPPORTED_DATABASES)
2817
+ + ") for any command"
2712
2818
  ),
2713
2819
  visual: bool = typer.Option(
2714
2820
  False,