code-review-graph-codeblackwell 2.3.6.post1__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 (74) hide show
  1. code_review_graph/__init__.py +20 -0
  2. code_review_graph/__main__.py +4 -0
  3. code_review_graph/analysis.py +410 -0
  4. code_review_graph/changes.py +409 -0
  5. code_review_graph/cli.py +1255 -0
  6. code_review_graph/communities.py +874 -0
  7. code_review_graph/constants.py +23 -0
  8. code_review_graph/context_savings.py +317 -0
  9. code_review_graph/custom_languages.py +322 -0
  10. code_review_graph/daemon.py +1009 -0
  11. code_review_graph/daemon_cli.py +320 -0
  12. code_review_graph/docs/LLM-OPTIMIZED-REFERENCE.md +71 -0
  13. code_review_graph/embeddings.py +1006 -0
  14. code_review_graph/enrich.py +303 -0
  15. code_review_graph/eval/__init__.py +33 -0
  16. code_review_graph/eval/benchmarks/__init__.py +1 -0
  17. code_review_graph/eval/benchmarks/agent_baseline.py +193 -0
  18. code_review_graph/eval/benchmarks/build_performance.py +60 -0
  19. code_review_graph/eval/benchmarks/flow_completeness.py +36 -0
  20. code_review_graph/eval/benchmarks/impact_accuracy.py +220 -0
  21. code_review_graph/eval/benchmarks/multi_hop_retrieval.py +125 -0
  22. code_review_graph/eval/benchmarks/search_quality.py +59 -0
  23. code_review_graph/eval/benchmarks/token_efficiency.py +143 -0
  24. code_review_graph/eval/configs/code-review-graph.yaml +50 -0
  25. code_review_graph/eval/configs/express.yaml +45 -0
  26. code_review_graph/eval/configs/fastapi.yaml +48 -0
  27. code_review_graph/eval/configs/flask.yaml +50 -0
  28. code_review_graph/eval/configs/gin.yaml +51 -0
  29. code_review_graph/eval/configs/httpx.yaml +48 -0
  30. code_review_graph/eval/reporter.py +301 -0
  31. code_review_graph/eval/runner.py +211 -0
  32. code_review_graph/eval/scorer.py +85 -0
  33. code_review_graph/eval/token_benchmark.py +182 -0
  34. code_review_graph/exports.py +409 -0
  35. code_review_graph/flows.py +698 -0
  36. code_review_graph/graph.py +1427 -0
  37. code_review_graph/graph_diff.py +122 -0
  38. code_review_graph/hints.py +384 -0
  39. code_review_graph/incremental.py +1245 -0
  40. code_review_graph/jedi_resolver.py +303 -0
  41. code_review_graph/main.py +1079 -0
  42. code_review_graph/memory.py +142 -0
  43. code_review_graph/migrations.py +284 -0
  44. code_review_graph/parser.py +6957 -0
  45. code_review_graph/postprocessing.py +134 -0
  46. code_review_graph/prompts.py +159 -0
  47. code_review_graph/refactor.py +852 -0
  48. code_review_graph/registry.py +319 -0
  49. code_review_graph/rescript_resolver.py +206 -0
  50. code_review_graph/search.py +447 -0
  51. code_review_graph/skills.py +1481 -0
  52. code_review_graph/spring_resolver.py +200 -0
  53. code_review_graph/temporal_resolver.py +199 -0
  54. code_review_graph/token_benchmark.py +125 -0
  55. code_review_graph/tools/__init__.py +156 -0
  56. code_review_graph/tools/_common.py +176 -0
  57. code_review_graph/tools/analysis_tools.py +184 -0
  58. code_review_graph/tools/build.py +541 -0
  59. code_review_graph/tools/community_tools.py +246 -0
  60. code_review_graph/tools/context.py +152 -0
  61. code_review_graph/tools/docs.py +274 -0
  62. code_review_graph/tools/flows_tools.py +176 -0
  63. code_review_graph/tools/query.py +692 -0
  64. code_review_graph/tools/refactor_tools.py +168 -0
  65. code_review_graph/tools/registry_tools.py +125 -0
  66. code_review_graph/tools/review.py +477 -0
  67. code_review_graph/tsconfig_resolver.py +257 -0
  68. code_review_graph/visualization.py +2184 -0
  69. code_review_graph/wiki.py +305 -0
  70. code_review_graph_codeblackwell-2.3.6.post1.dist-info/METADATA +718 -0
  71. code_review_graph_codeblackwell-2.3.6.post1.dist-info/RECORD +74 -0
  72. code_review_graph_codeblackwell-2.3.6.post1.dist-info/WHEEL +4 -0
  73. code_review_graph_codeblackwell-2.3.6.post1.dist-info/entry_points.txt +3 -0
  74. code_review_graph_codeblackwell-2.3.6.post1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1481 @@
1
+ """Claude Code skills and hooks auto-install.
2
+
3
+ Generates Claude Code agent skill files, hooks configuration, and
4
+ CLAUDE.md integration for seamless code-review-graph usage.
5
+ Also supports multi-platform MCP server installation and
6
+ Cursor hooks / OpenCode plugin generation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import platform
15
+ import re
16
+ import shutil
17
+ import stat
18
+ import subprocess
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ # --- Multi-platform MCP install ---
27
+
28
+
29
+ def _zed_settings_path() -> Path:
30
+ """Return the Zed settings.json path for the current OS."""
31
+ if platform.system() == "Darwin":
32
+ return Path.home() / "Library" / "Application Support" / "Zed" / "settings.json"
33
+ return Path.home() / ".config" / "zed" / "settings.json"
34
+
35
+
36
+ PLATFORMS: dict[str, dict[str, Any]] = {
37
+ "codex": {
38
+ "name": "Codex",
39
+ "config_path": lambda root: Path.home() / ".codex" / "config.toml",
40
+ "key": "mcp_servers",
41
+ "detect": lambda: (Path.home() / ".codex").exists(),
42
+ "format": "toml",
43
+ "needs_type": True,
44
+ },
45
+ "claude": {
46
+ "name": "Claude Code",
47
+ "config_path": lambda root: root / ".mcp.json",
48
+ "key": "mcpServers",
49
+ "detect": lambda: True,
50
+ "format": "object",
51
+ "needs_type": True,
52
+ },
53
+ "cursor": {
54
+ "name": "Cursor",
55
+ "config_path": lambda root: root / ".cursor" / "mcp.json",
56
+ "key": "mcpServers",
57
+ "detect": lambda: (Path.home() / ".cursor").exists(),
58
+ "format": "object",
59
+ "needs_type": True,
60
+ },
61
+ "windsurf": {
62
+ "name": "Windsurf",
63
+ "config_path": lambda root: Path.home() / ".codeium" / "windsurf" / "mcp_config.json",
64
+ "key": "mcpServers",
65
+ "detect": lambda: (Path.home() / ".codeium" / "windsurf").exists(),
66
+ "format": "object",
67
+ "needs_type": False,
68
+ },
69
+ "zed": {
70
+ "name": "Zed",
71
+ "config_path": lambda root: _zed_settings_path(),
72
+ "key": "context_servers",
73
+ "detect": lambda: _zed_settings_path().parent.exists(),
74
+ "format": "object",
75
+ "needs_type": False,
76
+ },
77
+ "continue": {
78
+ "name": "Continue",
79
+ "config_path": lambda root: Path.home() / ".continue" / "config.json",
80
+ "key": "mcpServers",
81
+ "detect": lambda: (Path.home() / ".continue").exists(),
82
+ "format": "array",
83
+ "needs_type": True,
84
+ },
85
+ "opencode": {
86
+ "name": "OpenCode",
87
+ "config_path": lambda root: root / ".opencode.json",
88
+ "key": "mcpServers",
89
+ "detect": lambda: True,
90
+ "format": "object",
91
+ "needs_type": True,
92
+ },
93
+ "antigravity": {
94
+ "name": "Antigravity",
95
+ "config_path": lambda root: Path.home() / ".gemini" / "antigravity" / "mcp_config.json",
96
+ "key": "mcpServers",
97
+ "detect": lambda: (Path.home() / ".gemini" / "antigravity").exists(),
98
+ "format": "object",
99
+ "needs_type": False,
100
+ },
101
+ "gemini-cli": {
102
+ "name": "Gemini CLI",
103
+ "config_path": lambda root: root / ".gemini" / "settings.json",
104
+ "key": "mcpServers",
105
+ "detect": lambda: bool(shutil.which("gemini")) or (Path.home() / ".gemini").exists(),
106
+ "format": "object",
107
+ "needs_type": False,
108
+ },
109
+ "qwen": {
110
+ "name": "Qwen Code",
111
+ "config_path": lambda root: Path.home() / ".qwen" / "settings.json",
112
+ "key": "mcpServers",
113
+ "detect": lambda: (Path.home() / ".qwen").exists(),
114
+ "format": "object",
115
+ "needs_type": True,
116
+ },
117
+ "kiro": {
118
+ "name": "Kiro",
119
+ "config_path": lambda root: root / ".kiro" / "settings" / "mcp.json",
120
+ "key": "mcpServers",
121
+ "detect": lambda: (Path.home() / ".kiro").exists(),
122
+ "format": "object",
123
+ "needs_type": True,
124
+ },
125
+ "qoder": {
126
+ "name": "Qoder",
127
+ "config_path": lambda root: root / ".qoder" / "mcp.json",
128
+ "key": "mcpServers",
129
+ "detect": lambda: True,
130
+ "format": "object",
131
+ "needs_type": True,
132
+ },
133
+ "copilot": {
134
+ "name": "GitHub Copilot",
135
+ "config_path": lambda root: root / ".vscode" / "mcp.json",
136
+ "key": "servers",
137
+ "detect": lambda: (Path.home() / ".vscode").exists(),
138
+ "format": "object",
139
+ "needs_type": True,
140
+ },
141
+ "copilot-cli": {
142
+ "name": "GitHub Copilot CLI",
143
+ "config_path": lambda root: Path.home() / ".copilot" / "mcp-config.json",
144
+ "key": "servers",
145
+ "detect": lambda: (Path.home() / ".copilot").exists(),
146
+ "format": "object",
147
+ "needs_type": True,
148
+ },
149
+ }
150
+
151
+
152
+ def _in_poetry_project() -> bool:
153
+ """Return True when the running interpreter is a Poetry-managed virtualenv.
154
+
155
+ Two signals are checked so that **both** ``poetry shell`` and ``poetry run``
156
+ are detected:
157
+
158
+ * ``POETRY_ACTIVE=1`` — set by ``poetry shell`` when the user activates the
159
+ virtual environment interactively.
160
+ * ``VIRTUAL_ENV`` containing ``"pypoetry"`` — set by **both** ``poetry shell``
161
+ and ``poetry run`` because Poetry stores its virtualenvs under a path that
162
+ includes the string ``pypoetry`` (e.g.
163
+ ``~/.cache/pypoetry/virtualenvs/<name>`` on Linux/macOS or
164
+ ``%LOCALAPPDATA%\\pypoetry\\Cache\\virtualenvs\\<name>`` on Windows).
165
+
166
+ Checking only ``POETRY_ACTIVE`` would miss the ``poetry run`` case, which is
167
+ the primary scenario described in issue #256.
168
+ """
169
+ if os.environ.get("POETRY_ACTIVE") == "1":
170
+ return True
171
+ virtual_env = os.environ.get("VIRTUAL_ENV", "")
172
+ return bool(virtual_env) and "pypoetry" in virtual_env.lower()
173
+
174
+
175
+ def _in_uv_project() -> bool:
176
+ """Return True if ``sys.executable`` lives inside a uv-managed project.
177
+
178
+ A project is considered uv-managed when a ``uv.lock`` file exists in any
179
+ ancestor directory of the running Python interpreter (stopping at the home
180
+ directory to avoid false positives on system-wide installations).
181
+ """
182
+ exe = Path(sys.executable).resolve()
183
+ home = Path.home()
184
+ for parent in exe.parents:
185
+ if (parent / "uv.lock").exists():
186
+ return True
187
+ # Stop searching once we reach the home directory or filesystem root
188
+ if parent == home or parent == parent.parent:
189
+ break
190
+ return False
191
+
192
+
193
+ def _detect_serve_command() -> tuple[str, list[str]]:
194
+ """Return ``(command, args)`` that correctly launches ``code-review-graph serve``.
195
+
196
+ Detection priority
197
+ ------------------
198
+ 1. **Poetry** – ``POETRY_ACTIVE=1`` OR ``VIRTUAL_ENV`` contains ``"pypoetry"``
199
+ (covers both ``poetry shell`` and ``poetry run``) and ``poetry`` is on PATH
200
+ → ``poetry run code-review-graph serve``
201
+ 2. **uv project** – ``UV_PROJECT_ENVIRONMENT`` is set, or a ``uv.lock``
202
+ ancestor is found alongside ``sys.executable``, and ``uv`` is on PATH
203
+ → ``uv run code-review-graph serve``
204
+ 3. **uvx** – ``uvx`` is available on PATH (existing behaviour, unchanged)
205
+ → ``uvx code-review-graph serve``
206
+ 4. **Fallback** – use the absolute path of the running Python interpreter
207
+ → ``sys.executable -m code_review_graph serve``
208
+
209
+ The fallback is always safe: ``sys.executable`` is the exact interpreter
210
+ that is currently running, so it resolves correctly inside any virtual
211
+ environment, conda env, or system installation.
212
+ """
213
+ # 1. Poetry (poetry shell or poetry run)
214
+ if _in_poetry_project():
215
+ poetry = shutil.which("poetry")
216
+ if poetry:
217
+ return ("poetry", ["run", "code-review-graph", "serve"])
218
+
219
+ # 2. uv managed project environment
220
+ if os.environ.get("UV_PROJECT_ENVIRONMENT") or _in_uv_project():
221
+ uv = shutil.which("uv")
222
+ if uv:
223
+ return ("uv", ["run", "code-review-graph", "serve"])
224
+
225
+ # 3. uvx global tool runner (existing behaviour, unchanged)
226
+ if shutil.which("uvx"):
227
+ return ("uvx", ["code-review-graph", "serve"])
228
+
229
+ # 4. Absolute-path fallback using the running interpreter
230
+ return (sys.executable, ["-m", "code_review_graph", "serve"])
231
+
232
+
233
+ def _build_server_entry(
234
+ plat: dict[str, Any], key: str = "", repo_root: "Path | None" = None,
235
+ ) -> dict[str, Any]:
236
+ """Build the MCP server entry for a platform."""
237
+ command, args = _detect_serve_command()
238
+ entry: dict[str, Any] = {"command": command, "args": args}
239
+ # Include cwd so the MCP server can find the graph database
240
+ if repo_root is not None:
241
+ entry["cwd"] = str(repo_root)
242
+ if plat["needs_type"]:
243
+ entry["type"] = "stdio"
244
+ if key == "opencode":
245
+ entry["env"] = []
246
+ return entry
247
+
248
+
249
+ def _format_toml_value(value: Any) -> str:
250
+ """Format a primitive Python value as TOML."""
251
+ if isinstance(value, str):
252
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
253
+ return f'"{escaped}"'
254
+ if isinstance(value, bool):
255
+ return "true" if value else "false"
256
+ if isinstance(value, list):
257
+ return "[" + ", ".join(_format_toml_value(item) for item in value) + "]"
258
+ raise TypeError(f"Unsupported TOML value: {type(value)!r}")
259
+
260
+
261
+ def _merge_toml_mcp_server(
262
+ config_path: Path,
263
+ server_name: str,
264
+ server_entry: dict[str, Any],
265
+ dry_run: bool = False,
266
+ ) -> bool:
267
+ """Append a Codex MCP server section without clobbering the rest of the file."""
268
+ section_header = f"[mcp_servers.{server_name}]"
269
+ existing = ""
270
+ if config_path.exists():
271
+ existing = config_path.read_text(encoding="utf-8")
272
+ if section_header in existing:
273
+ return False
274
+
275
+ section_lines = [section_header]
276
+ for key, value in server_entry.items():
277
+ section_lines.append(f"{key} = {_format_toml_value(value)}")
278
+ section = "\n".join(section_lines) + "\n"
279
+
280
+ if dry_run:
281
+ return True
282
+
283
+ config_path.parent.mkdir(parents=True, exist_ok=True)
284
+ prefix = ""
285
+ if existing:
286
+ prefix = existing if existing.endswith("\n") else existing + "\n"
287
+ if not prefix.endswith("\n\n"):
288
+ prefix += "\n"
289
+ config_path.write_text(prefix + section, encoding="utf-8")
290
+ return True
291
+
292
+
293
+ def install_platform_configs(
294
+ repo_root: Path,
295
+ target: str = "all",
296
+ dry_run: bool = False,
297
+ ) -> list[str]:
298
+ """Install MCP config for one or all detected platforms.
299
+
300
+ Args:
301
+ repo_root: Project root directory.
302
+ target: Platform key or "all".
303
+ dry_run: If True, print what would be done without writing.
304
+
305
+ Returns:
306
+ List of platform names that were configured.
307
+ """
308
+ if target == "all":
309
+ platforms_to_install = {k: v for k, v in PLATFORMS.items() if v["detect"]()}
310
+ # Workspace-level Kiro detection
311
+ if "kiro" not in platforms_to_install and (repo_root / ".kiro").is_dir():
312
+ platforms_to_install["kiro"] = PLATFORMS["kiro"]
313
+ else:
314
+ if target not in PLATFORMS:
315
+ logger.error("Unknown platform: %s", target)
316
+ return []
317
+ platforms_to_install = {target: PLATFORMS[target]}
318
+
319
+ configured: list[str] = []
320
+
321
+ for key, plat in platforms_to_install.items():
322
+ config_path: Path = plat["config_path"](repo_root)
323
+ server_key = plat["key"]
324
+ server_entry = _build_server_entry(plat, key=key, repo_root=repo_root)
325
+
326
+ if plat["format"] == "toml":
327
+ changed = _merge_toml_mcp_server(
328
+ config_path,
329
+ "code-review-graph",
330
+ server_entry,
331
+ dry_run=dry_run,
332
+ )
333
+ if not changed:
334
+ print(f" {plat['name']}: already configured in {config_path}")
335
+ configured.append(plat["name"])
336
+ continue
337
+ if dry_run:
338
+ print(f" [dry-run] {plat['name']}: would write {config_path}")
339
+ else:
340
+ print(f" {plat['name']}: configured {config_path}")
341
+ configured.append(plat["name"])
342
+ continue
343
+
344
+ # Read existing config
345
+ existing: dict[str, Any] = {}
346
+ if config_path.exists():
347
+ raw = config_path.read_text(encoding="utf-8", errors="replace")
348
+ # Strip single-line comments and trailing commas (JSONC compat
349
+ # for editors like Zed that allow non-standard JSON).
350
+ stripped = re.sub(r'//.*?$', '', raw, flags=re.MULTILINE)
351
+ stripped = re.sub(r',(\s*[}\]])', r'\1', stripped)
352
+ try:
353
+ existing = json.loads(stripped)
354
+ except (json.JSONDecodeError, OSError):
355
+ print(f" {plat['name']}: {config_path} contains "
356
+ f"unparseable JSON — skipping to avoid data loss. "
357
+ f"Please add the MCP config manually.")
358
+ continue
359
+
360
+ if plat["format"] == "array":
361
+ arr = existing.get(server_key, [])
362
+ if not isinstance(arr, list):
363
+ arr = []
364
+ # Check if already present
365
+ if any(isinstance(s, dict) and s.get("name") == "code-review-graph" for s in arr):
366
+ print(f" {plat['name']}: already configured in {config_path}")
367
+ configured.append(plat["name"])
368
+ continue
369
+ arr_entry = {"name": "code-review-graph", **server_entry}
370
+ arr.append(arr_entry)
371
+ existing[server_key] = arr
372
+ else:
373
+ servers = existing.get(server_key, {})
374
+ if not isinstance(servers, dict):
375
+ servers = {}
376
+ if "code-review-graph" in servers:
377
+ print(f" {plat['name']}: already configured in {config_path}")
378
+ configured.append(plat["name"])
379
+ continue
380
+ servers["code-review-graph"] = server_entry
381
+ existing[server_key] = servers
382
+
383
+ if dry_run:
384
+ print(f" [dry-run] {plat['name']}: would write {config_path}")
385
+ else:
386
+ config_path.parent.mkdir(parents=True, exist_ok=True)
387
+ config_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
388
+ print(f" {plat['name']}: configured {config_path}")
389
+
390
+ configured.append(plat["name"])
391
+
392
+ return configured
393
+
394
+
395
+ # --- Skill file contents ---
396
+
397
+ _SKILLS: dict[str, dict[str, str]] = {
398
+ "explore-codebase.md": {
399
+ "name": "Explore Codebase",
400
+ "description": "Navigate and understand codebase structure using the knowledge graph",
401
+ "body": (
402
+ "## Explore Codebase\n\n"
403
+ "Use the code-review-graph MCP tools to explore and understand the codebase.\n\n"
404
+ "### Steps\n\n"
405
+ "1. Run `list_graph_stats` to see overall codebase metrics.\n"
406
+ "2. Run `get_architecture_overview_tool` for high-level community structure.\n"
407
+ "3. Use `list_communities_tool` to find major modules, then `get_community` "
408
+ "for details.\n"
409
+ "4. Use `semantic_search_nodes_tool` to find specific functions or classes.\n"
410
+ "5. Use `query_graph_tool` with patterns like `callers_of`, `callees_of`, "
411
+ "`imports_of` to trace relationships.\n"
412
+ "6. Use `list_flows` and `get_flow` to understand execution paths.\n\n"
413
+ "### Tips\n\n"
414
+ "- Start broad (stats, architecture) then narrow down to specific areas.\n"
415
+ "- Use `children_of` on a file to see all its functions and classes.\n"
416
+ "- Use `find_large_functions` to identify complex code.\n\n"
417
+ "## Token Efficiency Rules\n"
418
+ '- ALWAYS start with `get_minimal_context(task="<your task>")` '
419
+ "before any other graph tool.\n"
420
+ '- Use `detail_level="minimal"` on all calls. Only escalate to '
421
+ '"standard" when minimal is insufficient.\n'
422
+ "- Target: complete any review/debug/refactor task in ≤5 tool calls "
423
+ "and ≤800 total output tokens."
424
+ ),
425
+ },
426
+ "review-changes.md": {
427
+ "name": "Review Changes",
428
+ "description": "Perform a structured code review using change detection and impact",
429
+ "body": (
430
+ "## Review Changes\n\n"
431
+ "Perform a thorough, risk-aware code review using the knowledge graph.\n\n"
432
+ "### Steps\n\n"
433
+ "1. Run `detect_changes_tool` to get risk-scored change analysis.\n"
434
+ "2. Run `get_affected_flows_tool` to find impacted execution paths.\n"
435
+ "3. For each high-risk function, run `query_graph_tool` with "
436
+ 'pattern="tests_for" to check test coverage.\n'
437
+ "4. Run `get_impact_radius_tool` to understand the blast radius.\n"
438
+ "5. For any untested changes, suggest specific test cases.\n\n"
439
+ "### Output Format\n\n"
440
+ "Provide findings grouped by risk level (high/medium/low) with:\n"
441
+ "- What changed and why it matters\n"
442
+ "- Test coverage status\n"
443
+ "- Suggested improvements\n"
444
+ "- Overall merge recommendation\n\n"
445
+ "## Token Efficiency Rules\n"
446
+ '- ALWAYS start with `get_minimal_context(task="<your task>")` '
447
+ "before any other graph tool.\n"
448
+ '- Use `detail_level="minimal"` on all calls. Only escalate to '
449
+ '"standard" when minimal is insufficient.\n'
450
+ "- Target: complete any review/debug/refactor task in ≤5 tool calls "
451
+ "and ≤800 total output tokens."
452
+ ),
453
+ },
454
+ "debug-issue.md": {
455
+ "name": "Debug Issue",
456
+ "description": "Systematically debug issues using graph-powered code navigation",
457
+ "body": (
458
+ "## Debug Issue\n\n"
459
+ "Use the knowledge graph to systematically trace and debug issues.\n\n"
460
+ "### Steps\n\n"
461
+ "1. Use `semantic_search_nodes_tool` to find code related to the issue.\n"
462
+ "2. Use `query_graph_tool` with `callers_of` and `callees_of` to trace "
463
+ "call chains.\n"
464
+ "3. Use `get_flow` to see full execution paths through suspected areas.\n"
465
+ "4. Run `detect_changes_tool` to check if recent changes caused the issue.\n"
466
+ "5. Use `get_impact_radius_tool` on suspected files to see what else is affected.\n\n"
467
+ "### Tips\n\n"
468
+ "- Check both callers and callees to understand the full context.\n"
469
+ "- Look at affected flows to find the entry point that triggers the bug.\n"
470
+ "- Recent changes are the most common source of new issues.\n\n"
471
+ "## Token Efficiency Rules\n"
472
+ '- ALWAYS start with `get_minimal_context(task="<your task>")` '
473
+ "before any other graph tool.\n"
474
+ '- Use `detail_level="minimal"` on all calls. Only escalate to '
475
+ '"standard" when minimal is insufficient.\n'
476
+ "- Target: complete any review/debug/refactor task in ≤5 tool calls "
477
+ "and ≤800 total output tokens."
478
+ ),
479
+ },
480
+ "refactor-safely.md": {
481
+ "name": "Refactor Safely",
482
+ "description": "Plan and execute safe refactoring using dependency analysis",
483
+ "body": (
484
+ "## Refactor Safely\n\n"
485
+ "Use the knowledge graph to plan and execute refactoring with confidence.\n\n"
486
+ "### Steps\n\n"
487
+ '1. Use `refactor_tool` with mode="suggest" for community-driven '
488
+ "refactoring suggestions.\n"
489
+ '2. Use `refactor_tool` with mode="dead_code" to find unreferenced code.\n'
490
+ '3. For renames, use `refactor_tool` with mode="rename" to preview all '
491
+ "affected locations.\n"
492
+ "4. Use `apply_refactor_tool` with the refactor_id to apply renames.\n"
493
+ "5. After changes, run `detect_changes_tool` to verify the refactoring impact.\n\n"
494
+ "### Safety Checks\n\n"
495
+ "- Always preview before applying (rename mode gives you an edit list).\n"
496
+ "- Check `get_impact_radius_tool` before major refactors.\n"
497
+ "- Use `get_affected_flows_tool` to ensure no critical paths are broken.\n"
498
+ "- Run `find_large_functions` to identify decomposition targets.\n\n"
499
+ "## Token Efficiency Rules\n"
500
+ '- ALWAYS start with `get_minimal_context(task="<your task>")` '
501
+ "before any other graph tool.\n"
502
+ '- Use `detail_level="minimal"` on all calls. Only escalate to '
503
+ '"standard" when minimal is insufficient.\n'
504
+ "- Target: complete any review/debug/refactor task in ≤5 tool calls "
505
+ "and ≤800 total output tokens."
506
+ ),
507
+ },
508
+ }
509
+
510
+
511
+ def generate_skills(repo_root: Path, skills_dir: Path | None = None) -> Path:
512
+ """Generate Claude Code skill files.
513
+
514
+ Creates `.claude/skills/` directory with 4 skill markdown files,
515
+ each containing frontmatter and instructions.
516
+
517
+ Args:
518
+ repo_root: Repository root directory.
519
+ skills_dir: Custom skills directory. Defaults to repo_root/.claude/skills.
520
+
521
+ Returns:
522
+ Path to the skills directory.
523
+ """
524
+ if skills_dir is None:
525
+ skills_dir = repo_root / ".claude" / "skills"
526
+ skills_dir.mkdir(parents=True, exist_ok=True)
527
+
528
+ for filename, skill in _SKILLS.items():
529
+ # Claude Code expects skills at .claude/skills/<name>/skill.md
530
+ skill_name = filename.removesuffix(".md")
531
+ skill_subdir = skills_dir / skill_name
532
+ skill_subdir.mkdir(parents=True, exist_ok=True)
533
+ path = skill_subdir / "skill.md"
534
+ content = (
535
+ "---\n"
536
+ f"name: {skill['name']}\n"
537
+ f"description: {skill['description']}\n"
538
+ "---\n\n"
539
+ f"{skill['body']}\n"
540
+ )
541
+ path.write_text(content, encoding="utf-8")
542
+ logger.info("Wrote skill: %s", path)
543
+
544
+ return skills_dir
545
+
546
+
547
+ def generate_hooks_config(repo_root: Path) -> dict[str, Any]:
548
+ """Generate Claude Code hooks configuration.
549
+
550
+ Hooks use the v1.x+ schema: each entry needs a ``matcher`` and a nested
551
+ ``hooks`` array. Timeouts are in seconds. ``PreCommit`` is not a valid
552
+ Claude Code event — pre-commit checks are handled by ``install_git_hook``.
553
+ """
554
+ repo_arg = json.dumps(repo_root.resolve().as_posix())
555
+ return {
556
+ "hooks": {
557
+ "PostToolUse": [
558
+ {
559
+ "matcher": "Edit|Write|Bash",
560
+ "hooks": [
561
+ {
562
+ "type": "command",
563
+ "command": (
564
+ "cat >/dev/null || true; "
565
+ "git rev-parse --git-dir >/dev/null 2>&1"
566
+ f" && code-review-graph update --skip-flows"
567
+ f" --repo {repo_arg}"
568
+ " || true"
569
+ ),
570
+ "timeout": 30,
571
+ },
572
+ ],
573
+ },
574
+ ],
575
+ "SessionStart": [
576
+ {
577
+ "matcher": "",
578
+ "hooks": [
579
+ {
580
+ "type": "command",
581
+ "command": (
582
+ "cat >/dev/null || true; "
583
+ "git rev-parse --git-dir >/dev/null 2>&1"
584
+ f" && code-review-graph status --repo {repo_arg}"
585
+ " || echo 'Not a git repo, skipping'"
586
+ ),
587
+ "timeout": 10,
588
+ },
589
+ ],
590
+ },
591
+ ],
592
+ }
593
+ }
594
+
595
+
596
+ def generate_codex_hooks_config(repo_root: Path) -> dict[str, Any]:
597
+ """Generate native Codex hooks configuration for ~/.codex/hooks.json."""
598
+ return {
599
+ "hooks": {
600
+ "PostToolUse": [
601
+ {
602
+ "matcher": "Write|Edit|Bash",
603
+ "hooks": [
604
+ {
605
+ "type": "command",
606
+ "command": (
607
+ "cat >/dev/null || true; "
608
+ "git rev-parse --git-dir >/dev/null 2>&1"
609
+ " && code-review-graph update --skip-flows"
610
+ " || true"
611
+ ),
612
+ "timeout": 30,
613
+ "statusMessage": "Updating code-review-graph",
614
+ },
615
+ ],
616
+ },
617
+ ],
618
+ "SessionStart": [
619
+ {
620
+ "matcher": "startup|resume",
621
+ "hooks": [
622
+ {
623
+ "type": "command",
624
+ "command": (
625
+ "cat >/dev/null || true; "
626
+ "git rev-parse --git-dir >/dev/null 2>&1"
627
+ " && code-review-graph status"
628
+ " || echo 'Not a git repo, skipping'"
629
+ ),
630
+ "timeout": 10,
631
+ "statusMessage": "Checking code-review-graph status",
632
+ },
633
+ ],
634
+ },
635
+ ],
636
+ }
637
+ }
638
+
639
+
640
+ def install_git_hook(repo_root: Path) -> Path | None:
641
+ """Install a git pre-commit hook that prints a risk summary before each commit.
642
+
643
+ Called automatically by ``code-review-graph install``.
644
+ The hooks directory is resolved via ``git rev-parse --git-path hooks`` so
645
+ the hook lands where git actually runs it — including linked worktrees
646
+ and submodules (where ``.git`` is a file, not a directory) and repos with
647
+ ``core.hooksPath`` set (issue #313). ``core.hooksPath`` users with their
648
+ own hook manager (husky, pre-commit) may prefer integrating the
649
+ ``code-review-graph`` commands into that manager manually instead.
650
+
651
+ Creates ``pre-commit`` if it doesn't exist, or appends to an existing
652
+ one — the hook is appended, not overwritten, preserving any hooks
653
+ already there. Falls back to the legacy ``.git/hooks`` resolution when
654
+ git itself is unavailable. Returns None when no hooks directory can be
655
+ determined.
656
+ """
657
+ script = """\
658
+ #!/bin/sh
659
+ # Installed by code-review-graph. Remove this file to disable pre-commit graph checks.
660
+ if command -v code-review-graph >/dev/null 2>&1; then
661
+ code-review-graph update || true
662
+ code-review-graph detect-changes --brief || true
663
+ fi
664
+ """
665
+ marker = "code-review-graph detect-changes"
666
+
667
+ hooks_dir: Path | None = None
668
+ try:
669
+ result = subprocess.run(
670
+ ["git", "rev-parse", "--git-path", "hooks"],
671
+ capture_output=True,
672
+ text=True,
673
+ encoding="utf-8",
674
+ cwd=str(repo_root),
675
+ timeout=10,
676
+ stdin=subprocess.DEVNULL,
677
+ )
678
+ if result.returncode == 0 and result.stdout.strip():
679
+ # Output is relative to repo_root (".git/hooks", a core.hooksPath
680
+ # value such as ".husky") or absolute (linked worktrees).
681
+ hooks_dir = repo_root / result.stdout.strip()
682
+ except (subprocess.TimeoutExpired, OSError) as exc:
683
+ logger.warning("git unavailable (%s); falling back to .git/hooks resolution.", exc)
684
+
685
+ if hooks_dir is None:
686
+ git_dir = repo_root / ".git"
687
+ if not git_dir.is_dir():
688
+ logger.warning(
689
+ "No git hooks directory found at %s — skipping git hook install.", repo_root
690
+ )
691
+ return None
692
+ hooks_dir = git_dir / "hooks"
693
+
694
+ hook_path = hooks_dir / "pre-commit"
695
+ hook_path.parent.mkdir(parents=True, exist_ok=True)
696
+
697
+ if hook_path.exists():
698
+ existing = hook_path.read_text(encoding="utf-8")
699
+ if marker in existing:
700
+ return hook_path
701
+ hook_path.write_text(existing.rstrip("\n") + "\n" + script, encoding="utf-8")
702
+ else:
703
+ hook_path.write_text(script, encoding="utf-8")
704
+
705
+ hook_path.chmod(0o755)
706
+ logger.info("Wrote git pre-commit hook: %s", hook_path)
707
+ return hook_path
708
+
709
+
710
+ def install_hooks(repo_root: Path, platform: str = "claude") -> None:
711
+ """Write hooks config to platform-specific settings.json.
712
+
713
+ Merges new hook entries into existing settings, preserving both
714
+ non-hook configuration and user-defined hooks. A backup of the
715
+ original file is created before any modifications.
716
+
717
+ Args:
718
+ repo_root: Repository root directory.
719
+ platform: Target platform ("claude" or "qoder").
720
+ """
721
+ if platform == "qoder":
722
+ settings_dir = repo_root / ".qoder"
723
+ else:
724
+ settings_dir = repo_root / ".claude"
725
+ settings_dir.mkdir(parents=True, exist_ok=True)
726
+ settings_path = settings_dir / "settings.json"
727
+
728
+ existing: dict[str, Any] = {}
729
+ if settings_path.exists():
730
+ try:
731
+ existing = json.loads(settings_path.read_text(encoding="utf-8", errors="replace"))
732
+ backup_path = settings_dir / "settings.json.bak"
733
+ shutil.copy2(settings_path, backup_path)
734
+ logger.info("Backed up existing settings to %s", backup_path)
735
+ except (json.JSONDecodeError, OSError) as exc:
736
+ logger.warning("Could not read existing %s: %s", settings_path, exc)
737
+
738
+ hooks_config = generate_hooks_config(repo_root)
739
+ existing_hooks = existing.get("hooks", {})
740
+ if not isinstance(existing_hooks, dict):
741
+ logger.warning("Existing hooks config is not a dict; replacing with defaults")
742
+ existing_hooks = {}
743
+
744
+ merged_hooks = dict(existing_hooks)
745
+ for hook_name, hook_entries in hooks_config.get("hooks", {}).items():
746
+ if isinstance(merged_hooks.get(hook_name), list):
747
+ merged_list = list(merged_hooks[hook_name])
748
+ for entry in hook_entries:
749
+ if entry not in merged_list:
750
+ merged_list.append(entry)
751
+ merged_hooks[hook_name] = merged_list
752
+ else:
753
+ merged_hooks[hook_name] = hook_entries
754
+
755
+ existing["hooks"] = merged_hooks
756
+
757
+ settings_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
758
+ logger.info("Wrote hooks config: %s", settings_path)
759
+
760
+
761
+ def install_codex_hooks(repo_root: Path) -> Path:
762
+ """Write native Codex hooks config to ~/.codex/hooks.json.
763
+
764
+ Merges code-review-graph hook entries into any existing hooks.json,
765
+ preserving user-defined hook entries and other top-level settings.
766
+ A backup of the original file is created before modifications.
767
+ """
768
+ codex_dir = Path.home() / ".codex"
769
+ codex_dir.mkdir(parents=True, exist_ok=True)
770
+ hooks_path = codex_dir / "hooks.json"
771
+
772
+ existing: dict[str, Any] = {}
773
+ if hooks_path.exists():
774
+ try:
775
+ existing = json.loads(hooks_path.read_text(encoding="utf-8", errors="replace"))
776
+ backup_path = codex_dir / "hooks.json.bak"
777
+ shutil.copy2(hooks_path, backup_path)
778
+ logger.info("Backed up existing Codex hooks to %s", backup_path)
779
+ except (json.JSONDecodeError, OSError) as exc:
780
+ logger.warning("Could not read existing %s: %s", hooks_path, exc)
781
+
782
+ hooks_config = generate_codex_hooks_config(repo_root)
783
+ existing_hooks = existing.get("hooks", {})
784
+ if not isinstance(existing_hooks, dict):
785
+ logger.warning("Existing Codex hooks config is not a dict; replacing with defaults")
786
+ existing_hooks = {}
787
+
788
+ merged_hooks = dict(existing_hooks)
789
+ for hook_name, hook_entries in hooks_config.get("hooks", {}).items():
790
+ if isinstance(merged_hooks.get(hook_name), list):
791
+ merged_list = list(merged_hooks[hook_name])
792
+ existing_commands = {
793
+ hook.get("command", "")
794
+ for entry in merged_list
795
+ if isinstance(entry, dict)
796
+ for hook in entry.get("hooks", [])
797
+ if isinstance(hook, dict)
798
+ }
799
+ for entry in hook_entries:
800
+ entry_commands = [
801
+ hook.get("command", "")
802
+ for hook in entry.get("hooks", [])
803
+ if isinstance(hook, dict)
804
+ ]
805
+ if not any(command in existing_commands for command in entry_commands):
806
+ merged_list.append(entry)
807
+ merged_hooks[hook_name] = merged_list
808
+ else:
809
+ merged_hooks[hook_name] = hook_entries
810
+
811
+ existing["hooks"] = merged_hooks
812
+ hooks_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
813
+ logger.info("Wrote Codex hooks config: %s", hooks_path)
814
+ return hooks_path
815
+
816
+
817
+ _CLAUDE_MD_SECTION_MARKER = "<!-- code-review-graph MCP tools -->"
818
+
819
+ _CLAUDE_MD_SECTION = f"""{_CLAUDE_MD_SECTION_MARKER}
820
+ ## MCP Tools: code-review-graph
821
+
822
+ **IMPORTANT: This project has a knowledge graph. ALWAYS use the
823
+ code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
824
+ the codebase.** The graph is faster, cheaper (fewer tokens), and gives
825
+ you structural context (callers, dependents, test coverage) that file
826
+ scanning cannot.
827
+
828
+ ### When to use graph tools FIRST
829
+
830
+ - **Exploring code**: `semantic_search_nodes_tool` or `query_graph_tool` instead of Grep
831
+ - **Understanding impact**: `get_impact_radius_tool` instead of manually tracing imports
832
+ - **Code review**: `detect_changes_tool` + `get_review_context_tool` instead of reading entire files
833
+ - **Finding relationships**: `query_graph_tool` with callers_of/callees_of/imports_of/tests_for
834
+ - **Architecture questions**: `get_architecture_overview_tool` + `list_communities_tool`
835
+
836
+ Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
837
+
838
+ ### Key Tools
839
+
840
+ | Tool | Use when |
841
+ | ------ | ---------- |
842
+ | `detect_changes_tool` | Reviewing code changes — gives risk-scored analysis |
843
+ | `get_review_context_tool` | Need source snippets for review — token-efficient |
844
+ | `get_impact_radius_tool` | Understanding blast radius of a change |
845
+ | `get_affected_flows_tool` | Finding which execution paths are impacted |
846
+ | `query_graph_tool` | Tracing callers, callees, imports, tests, dependencies |
847
+ | `semantic_search_nodes_tool` | Finding functions/classes by name or keyword |
848
+ | `get_architecture_overview_tool` | Understanding high-level codebase structure |
849
+ | `refactor_tool` | Planning renames, finding dead code |
850
+
851
+ ### Workflow
852
+
853
+ 1. The graph auto-updates on file changes (via hooks).
854
+ 2. Use `detect_changes_tool` for code review.
855
+ 3. Use `get_affected_flows_tool` to understand impact.
856
+ 4. Use `query_graph_tool` pattern=\"tests_for\" to check coverage.
857
+ """
858
+
859
+ # Copilot-specific instruction file content: uses VS Code tool references and
860
+ # includes YAML front matter so Copilot Chat applies it across the workspace.
861
+ _COPILOT_SECTION = f"""---
862
+ applyTo: '**'
863
+ description: >-
864
+ Use code-review-graph MCP tools for token-efficient
865
+ codebase exploration and code review.
866
+ ---
867
+
868
+ {_CLAUDE_MD_SECTION_MARKER}
869
+ ## MCP Tools: code-review-graph
870
+
871
+ **IMPORTANT: This project has a knowledge graph. ALWAYS use the
872
+ code-review-graph MCP tools BEFORE using file/search tools to
873
+ explore the codebase.** The graph is faster, cheaper (fewer
874
+ tokens), and gives you structural context (callers, dependents,
875
+ test coverage) that file scanning cannot.
876
+
877
+ ### When to use graph tools FIRST
878
+
879
+ - **Exploring code**: `semantic_search_nodes_tool` or `query_graph_tool`
880
+ - **Understanding impact**: `get_impact_radius_tool`
881
+ - **Code review**: `detect_changes_tool` + `get_review_context_tool`
882
+ - **Finding relationships**: `query_graph_tool` callers_of/callees_of
883
+ - **Architecture questions**: `get_architecture_overview_tool`
884
+
885
+ Fall back to file/search tools **only** when the graph doesn't
886
+ cover what you need.
887
+
888
+ ### Key Tools
889
+
890
+ | Tool | Use when |
891
+ | ------ | ---------- |
892
+ | `detect_changes_tool` | Risk-scored change analysis |
893
+ | `get_review_context_tool` | Token-efficient source snippets |
894
+ | `get_impact_radius_tool` | Blast radius of a change |
895
+ | `get_affected_flows_tool` | Impacted execution paths |
896
+ | `query_graph_tool` | Trace callers, callees, imports, tests |
897
+ | `semantic_search_nodes_tool` | Find functions/classes by keyword |
898
+ | `get_architecture_overview_tool` | High-level structure |
899
+ | `refactor_tool` | Rename planning, dead code |
900
+
901
+ ### Workflow
902
+
903
+ 1. The graph auto-updates on file changes (via hooks).
904
+ 2. Use `detect_changes_tool` for code review.
905
+ 3. Use `get_affected_flows_tool` to understand impact.
906
+ 4. Use `query_graph_tool` pattern=\"tests_for\" to check coverage.
907
+ """
908
+
909
+ # Maps instruction file path → (marker, section) for files that need content
910
+ # different from the default _CLAUDE_MD_SECTION.
911
+ _PLATFORM_INSTRUCTION_CUSTOM_SECTIONS: dict[str, tuple[str, str]] = {
912
+ ".github/code-review-graph.instruction.md": (_CLAUDE_MD_SECTION_MARKER, _COPILOT_SECTION),
913
+ }
914
+
915
+
916
+ def _inject_instructions(file_path: Path, marker: str, section: str) -> bool:
917
+ """Append an instruction section to a file if not already present.
918
+
919
+ Idempotent: checks if the marker is already present before appending.
920
+ Creates the file if it doesn't exist.
921
+
922
+ Returns True if the file was modified.
923
+ """
924
+ existing = ""
925
+ if file_path.exists():
926
+ existing = file_path.read_text(encoding="utf-8", errors="replace")
927
+
928
+ if marker in existing:
929
+ logger.info("%s already contains instructions, skipping.", file_path.name)
930
+ return False
931
+
932
+ separator = "\n" if existing and not existing.endswith("\n") else ""
933
+ extra_newline = "\n" if existing else ""
934
+ file_path.parent.mkdir(parents=True, exist_ok=True)
935
+ file_path.write_text(existing + separator + extra_newline + section, encoding="utf-8")
936
+ logger.info("Appended MCP tools section to %s", file_path)
937
+ return True
938
+
939
+
940
+ def inject_claude_md(repo_root: Path) -> None:
941
+ """Append MCP tools section to CLAUDE.md."""
942
+ _inject_instructions(
943
+ repo_root / "CLAUDE.md",
944
+ _CLAUDE_MD_SECTION_MARKER,
945
+ _CLAUDE_MD_SECTION,
946
+ )
947
+
948
+
949
+ # Cross-platform instruction files and which platforms own each one.
950
+ # Used to filter writes when the user passes --platform <X>: only files
951
+ # whose owner set includes the target (or "all") are written.
952
+ _PLATFORM_INSTRUCTION_FILES: dict[str, tuple[str, ...]] = {
953
+ "AGENTS.md": ("cursor", "opencode", "antigravity"),
954
+ "GEMINI.md": ("antigravity", "gemini-cli"),
955
+ ".cursorrules": ("cursor",),
956
+ ".windsurfrules": ("windsurf",),
957
+ "QODER.md": ("qoder",),
958
+ ".kiro/steering/code-review-graph.md": ("kiro",),
959
+ ".github/code-review-graph.instruction.md": ("copilot", "copilot-cli"),
960
+ }
961
+
962
+
963
+ # --- Gemini CLI hooks + skills (workspace-level: .gemini/) ---
964
+
965
+
966
+ def install_gemini_cli_hooks(repo_root: Path) -> Path:
967
+ """Install Gemini CLI hooks in .gemini/settings.json and write hook scripts.
968
+
969
+ Hooks schema reference:
970
+ - https://geminicli.com/docs/hooks/reference/
971
+
972
+ This is workspace-scoped (project) configuration: .gemini/settings.json
973
+ """
974
+ settings_dir = repo_root / ".gemini"
975
+ settings_dir.mkdir(parents=True, exist_ok=True)
976
+ settings_path = settings_dir / "settings.json"
977
+
978
+ existing: dict[str, Any] = {}
979
+ if settings_path.exists():
980
+ try:
981
+ existing = json.loads(settings_path.read_text(encoding="utf-8", errors="replace"))
982
+ backup_path = settings_dir / "settings.json.bak"
983
+ shutil.copy2(settings_path, backup_path)
984
+ logger.info("Backed up existing Gemini CLI settings to %s", backup_path)
985
+ except (json.JSONDecodeError, OSError) as exc:
986
+ logger.warning("Could not read existing %s: %s", settings_path, exc)
987
+
988
+ hooks_dir = settings_dir / "hooks"
989
+ hooks_dir.mkdir(parents=True, exist_ok=True)
990
+
991
+ repo_arg = repo_root.resolve().as_posix()
992
+ session_start_script = """\
993
+ #!/usr/bin/env bash
994
+ # code-review-graph: session start status (Gemini CLI hook)
995
+ # Must output ONLY JSON on stdout. Logs go to stderr. Never blocks the session.
996
+ set -euo pipefail
997
+
998
+ cat > /dev/null || true
999
+
1000
+ msg="$(code-review-graph status --repo "__CRG_REPO__" 2>&1 | head -n 1 || true)"
1001
+
1002
+ CRG_MSG="$msg" python3 -c '
1003
+ import json,os
1004
+ m=os.environ.get("CRG_MSG","")
1005
+ print(json.dumps({"systemMessage":m,"suppressOutput":True}))
1006
+ ' 2>/dev/null || echo '{"suppressOutput": true}'
1007
+ exit 0
1008
+ """
1009
+ session_start_script = session_start_script.replace("__CRG_REPO__", repo_arg)
1010
+
1011
+ update_script = """\
1012
+ #!/usr/bin/env bash
1013
+ # code-review-graph: incremental update after write/replace (Gemini CLI hook)
1014
+ # Must output ONLY JSON on stdout. Low-noise: no systemMessage.
1015
+ set -euo pipefail
1016
+
1017
+ cat > /dev/null || true
1018
+
1019
+ code-review-graph update --skip-flows --repo "__CRG_REPO__" >/dev/null 2>&1 || true
1020
+ echo '{"suppressOutput": true}'
1021
+ exit 0
1022
+ """
1023
+ update_script = update_script.replace("__CRG_REPO__", repo_arg)
1024
+
1025
+ session_start_path = hooks_dir / "crg-session-start.sh"
1026
+ session_start_path.write_text(session_start_script, encoding="utf-8")
1027
+ session_start_path.chmod(0o755)
1028
+
1029
+ update_path = hooks_dir / "crg-update.sh"
1030
+ update_path.write_text(update_script, encoding="utf-8")
1031
+ update_path.chmod(0o755)
1032
+
1033
+ hooks_obj = existing.get("hooks", {})
1034
+ if not isinstance(hooks_obj, dict):
1035
+ hooks_obj = {}
1036
+
1037
+ def _ensure_group(
1038
+ event_name: str, matcher: str, hook_command: str, name: str, timeout: int,
1039
+ ) -> None:
1040
+ arr = hooks_obj.get(event_name, [])
1041
+ if not isinstance(arr, list):
1042
+ arr = []
1043
+
1044
+ # De-duplicate by command (and type) inside nested hooks list.
1045
+ def _group_has_command(group: Any) -> bool:
1046
+ if not isinstance(group, dict):
1047
+ return False
1048
+ nested = group.get("hooks", [])
1049
+ if not isinstance(nested, list):
1050
+ return False
1051
+ for h in nested:
1052
+ if isinstance(h, dict) and h.get("type") == "command" \
1053
+ and h.get("command") == hook_command:
1054
+ return True
1055
+ return False
1056
+
1057
+ if any(_group_has_command(g) for g in arr):
1058
+ hooks_obj[event_name] = arr
1059
+ return
1060
+
1061
+ arr.append(
1062
+ {
1063
+ "matcher": matcher,
1064
+ "hooks": [
1065
+ {
1066
+ "type": "command",
1067
+ "command": hook_command,
1068
+ "name": name,
1069
+ "timeout": timeout,
1070
+ }
1071
+ ],
1072
+ }
1073
+ )
1074
+ hooks_obj[event_name] = arr
1075
+
1076
+ _ensure_group(
1077
+ event_name="SessionStart",
1078
+ matcher="",
1079
+ hook_command="bash .gemini/hooks/crg-session-start.sh",
1080
+ name="code-review-graph status",
1081
+ timeout=10_000,
1082
+ )
1083
+ _ensure_group(
1084
+ event_name="AfterTool",
1085
+ matcher="write_file|replace",
1086
+ hook_command="bash .gemini/hooks/crg-update.sh",
1087
+ name="code-review-graph update",
1088
+ timeout=30_000,
1089
+ )
1090
+
1091
+ existing["hooks"] = hooks_obj
1092
+ settings_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
1093
+ logger.info("Wrote Gemini CLI hooks config: %s", settings_path)
1094
+ return settings_path
1095
+
1096
+
1097
+ def install_gemini_cli_skills(repo_root: Path) -> Path:
1098
+ """Install Gemini CLI Agent Skills in .gemini/skills/<skill>/SKILL.md."""
1099
+ skills_root = repo_root / ".gemini" / "skills"
1100
+ skills_root.mkdir(parents=True, exist_ok=True)
1101
+
1102
+ for filename, skill in _SKILLS.items():
1103
+ slug = filename.rsplit(".", 1)[0]
1104
+ skill_dir = skills_root / slug
1105
+ skill_dir.mkdir(parents=True, exist_ok=True)
1106
+ skill_path = skill_dir / "SKILL.md"
1107
+ content = (
1108
+ "---\n"
1109
+ f"name: {slug}\n"
1110
+ f"description: {skill['description']}\n"
1111
+ "---\n\n"
1112
+ f"{skill['body']}\n"
1113
+ )
1114
+ skill_path.write_text(content, encoding="utf-8")
1115
+ logger.info("Wrote Gemini CLI skill: %s", skill_path)
1116
+
1117
+ return skills_root
1118
+
1119
+
1120
+ def inject_platform_instructions(repo_root: Path, target: str = "all") -> list[str]:
1121
+ """Inject 'use graph first' instructions into platform rule files.
1122
+
1123
+ Writes AGENTS.md, GEMINI.md, .cursorrules, and/or .windsurfrules
1124
+ depending on ``target``:
1125
+
1126
+ - ``"all"`` (default): writes every file — matches pre-filter behavior.
1127
+ - ``"claude"``: writes nothing (CLAUDE.md is handled by ``inject_claude_md``).
1128
+ - any other platform key (``cursor``, ``windsurf``, ``antigravity``,
1129
+ ``opencode``): writes only the files associated with that platform.
1130
+
1131
+ Returns list of filenames that were created or updated.
1132
+ """
1133
+ updated: list[str] = []
1134
+ for filename, owners in _PLATFORM_INSTRUCTION_FILES.items():
1135
+ if target != "all" and target not in owners:
1136
+ continue
1137
+ path = repo_root / filename
1138
+ if filename in _PLATFORM_INSTRUCTION_CUSTOM_SECTIONS:
1139
+ marker, section = _PLATFORM_INSTRUCTION_CUSTOM_SECTIONS[filename]
1140
+ else:
1141
+ marker, section = _CLAUDE_MD_SECTION_MARKER, _CLAUDE_MD_SECTION
1142
+ if _inject_instructions(path, marker, section):
1143
+ updated.append(filename)
1144
+ return updated
1145
+
1146
+
1147
+ # --- Cursor hooks ---
1148
+
1149
+
1150
+ def generate_cursor_hooks_config() -> dict[str, Any]:
1151
+ """Generate Cursor hooks.json configuration.
1152
+
1153
+ Returns a dict conforming to the Cursor hooks schema (version 1) with
1154
+ hooks for afterFileEdit, sessionStart, and beforeShellExecution.
1155
+ Each hook points to a shell script in ~/.cursor/hooks/.
1156
+
1157
+ Returns:
1158
+ Dict suitable for writing as ~/.cursor/hooks.json.
1159
+ """
1160
+ hooks_dir = str(Path.home() / ".cursor" / "hooks")
1161
+ return {
1162
+ "version": 1,
1163
+ "hooks": {
1164
+ "afterFileEdit": [
1165
+ {
1166
+ "command": f"{hooks_dir}/crg-update.sh",
1167
+ "timeout": 5,
1168
+ },
1169
+ ],
1170
+ "sessionStart": [
1171
+ {
1172
+ "command": f"{hooks_dir}/crg-session-start.sh",
1173
+ "timeout": 5,
1174
+ },
1175
+ ],
1176
+ "beforeShellExecution": [
1177
+ {
1178
+ "matcher": "^git\\s+commit",
1179
+ "command": f"{hooks_dir}/crg-pre-commit.sh",
1180
+ "timeout": 10,
1181
+ },
1182
+ ],
1183
+ },
1184
+ }
1185
+
1186
+
1187
+ def _cursor_hook_scripts() -> dict[str, str]:
1188
+ """Return a mapping of filename -> shell script content for Cursor hooks.
1189
+
1190
+ Three scripts are generated:
1191
+ - crg-update.sh: runs ``code-review-graph update --skip-flows`` after file edits
1192
+ - crg-session-start.sh: runs ``code-review-graph status`` on session start
1193
+ - crg-pre-commit.sh: runs ``code-review-graph detect-changes --brief`` before
1194
+ git commit commands
1195
+
1196
+ All scripts:
1197
+ - Read stdin (Cursor passes JSON context) and discard it
1198
+ - Fail gracefully (exit 0) so they never block the editor
1199
+ - Emit valid JSON on stdout per the Cursor hooks protocol
1200
+ """
1201
+ update_script = """\
1202
+ #!/usr/bin/env bash
1203
+ # code-review-graph: auto-update graph after file edits (Cursor hook)
1204
+ # Fails gracefully — never blocks the editor.
1205
+ set -euo pipefail
1206
+
1207
+ # Consume stdin (Cursor sends JSON context)
1208
+ cat > /dev/null
1209
+
1210
+ # Run update; swallow errors so the hook always succeeds.
1211
+ output=$(code-review-graph update --skip-flows 2>&1) || true
1212
+
1213
+ # Emit valid JSON on stdout per Cursor hooks protocol.
1214
+ python3 -c "
1215
+ import json, sys
1216
+ print(json.dumps({'message': 'graph updated', 'passed': True}))
1217
+ " 2>/dev/null || echo '{"passed":true}'
1218
+
1219
+ exit 0
1220
+ """
1221
+
1222
+ session_start_script = """\
1223
+ #!/usr/bin/env bash
1224
+ # code-review-graph: show graph status on session start (Cursor hook)
1225
+ # Fails gracefully — never blocks the editor.
1226
+ set -euo pipefail
1227
+
1228
+ # Consume stdin
1229
+ cat > /dev/null
1230
+
1231
+ # Capture status output
1232
+ output=$(code-review-graph status 2>&1) || output="graph not built yet"
1233
+
1234
+ # Emit valid JSON on stdout
1235
+ python3 -c "
1236
+ import json, sys
1237
+ msg = sys.stdin.read()
1238
+ print(json.dumps({'message': msg, 'passed': True}))
1239
+ " <<< "$output" 2>/dev/null || echo '{"passed":true}'
1240
+
1241
+ exit 0
1242
+ """
1243
+
1244
+ pre_commit_script = """\
1245
+ #!/usr/bin/env bash
1246
+ # code-review-graph: detect changes before git commit (Cursor hook)
1247
+ # Fails gracefully — never blocks the editor.
1248
+ set -euo pipefail
1249
+
1250
+ # Consume stdin
1251
+ cat > /dev/null
1252
+
1253
+ # Run detect-changes; swallow errors
1254
+ output=$(code-review-graph detect-changes --brief 2>&1) || output=""
1255
+
1256
+ # Emit valid JSON on stdout
1257
+ python3 -c "
1258
+ import json, sys
1259
+ msg = sys.stdin.read()
1260
+ print(json.dumps({'message': msg, 'passed': True}))
1261
+ " <<< "$output" 2>/dev/null || echo '{"passed":true}'
1262
+
1263
+ exit 0
1264
+ """
1265
+
1266
+ return {
1267
+ "crg-update.sh": update_script,
1268
+ "crg-session-start.sh": session_start_script,
1269
+ "crg-pre-commit.sh": pre_commit_script,
1270
+ }
1271
+
1272
+
1273
+ def install_cursor_hooks() -> Path:
1274
+ """Install Cursor hooks configuration and scripts at user level.
1275
+
1276
+ Writes ``~/.cursor/hooks.json`` (merging code-review-graph hooks
1277
+ into any existing configuration) and creates executable shell scripts
1278
+ in ``~/.cursor/hooks/``.
1279
+
1280
+ Returns:
1281
+ Path to the hooks.json file that was written.
1282
+ """
1283
+ cursor_dir = Path.home() / ".cursor"
1284
+ hooks_json_path = cursor_dir / "hooks.json"
1285
+ hooks_script_dir = cursor_dir / "hooks"
1286
+
1287
+ # --- Merge hooks.json ---
1288
+ existing: dict[str, Any] = {}
1289
+ if hooks_json_path.exists():
1290
+ try:
1291
+ existing = json.loads(hooks_json_path.read_text(encoding="utf-8"))
1292
+ except (json.JSONDecodeError, OSError) as exc:
1293
+ logger.warning("Could not read existing %s: %s", hooks_json_path, exc)
1294
+
1295
+ new_config = generate_cursor_hooks_config()
1296
+
1297
+ # Preserve version (use ours if absent)
1298
+ existing.setdefault("version", new_config["version"])
1299
+
1300
+ # Merge hook arrays per event type
1301
+ existing_hooks = existing.get("hooks", {})
1302
+ if not isinstance(existing_hooks, dict):
1303
+ existing_hooks = {}
1304
+
1305
+ for event, entries in new_config["hooks"].items():
1306
+ event_hooks = existing_hooks.get(event, [])
1307
+ if not isinstance(event_hooks, list):
1308
+ event_hooks = []
1309
+ # De-duplicate: skip if a hook with the same command already exists
1310
+ existing_commands = {h.get("command", "") for h in event_hooks if isinstance(h, dict)}
1311
+ for entry in entries:
1312
+ if entry["command"] not in existing_commands:
1313
+ event_hooks.append(entry)
1314
+ existing_hooks[event] = event_hooks
1315
+
1316
+ existing["hooks"] = existing_hooks
1317
+
1318
+ cursor_dir.mkdir(parents=True, exist_ok=True)
1319
+ hooks_json_path.write_text(
1320
+ json.dumps(existing, indent=2) + "\n",
1321
+ encoding="utf-8",
1322
+ )
1323
+ logger.info("Wrote Cursor hooks config: %s", hooks_json_path)
1324
+
1325
+ # --- Write hook scripts ---
1326
+ hooks_script_dir.mkdir(parents=True, exist_ok=True)
1327
+ scripts = _cursor_hook_scripts()
1328
+
1329
+ for filename, content in scripts.items():
1330
+ script_path = hooks_script_dir / filename
1331
+ script_path.write_text(content, encoding="utf-8")
1332
+ # Make executable (owner rwx, group rx, other rx)
1333
+ script_path.chmod(stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
1334
+ logger.info("Wrote Cursor hook script: %s", script_path)
1335
+
1336
+ return hooks_json_path
1337
+
1338
+
1339
+ def install_qoder_skills(repo_root: Path) -> Path | None:
1340
+ """Install skills to Qoder's project-level skills directory.
1341
+
1342
+ Qoder expects skills in .qoder/skills/{skillName}/SKILL.md format within the project.
1343
+ This function copies the project's skills/ directory contents to that location.
1344
+
1345
+ Args:
1346
+ repo_root: Repository root directory (where the skills/ folder is located).
1347
+
1348
+ Returns:
1349
+ Path to the Qoder skills directory, or None if installation failed.
1350
+ """
1351
+ # Qoder skills directory (project-level)
1352
+ qoder_skills_dir = repo_root / ".qoder" / "skills"
1353
+ qoder_skills_dir.mkdir(parents=True, exist_ok=True)
1354
+
1355
+ # Source skills directory in the project
1356
+ source_skills_dir = repo_root / "skills"
1357
+ if not source_skills_dir.exists():
1358
+ logger.warning("No skills/ directory found in %s", repo_root)
1359
+ return None
1360
+
1361
+ installed_count = 0
1362
+ for skill_dir in source_skills_dir.iterdir():
1363
+ if skill_dir.is_dir():
1364
+ skill_file = skill_dir / "SKILL.md"
1365
+ if skill_file.exists():
1366
+ target_dir = qoder_skills_dir / skill_dir.name
1367
+ target_dir.mkdir(parents=True, exist_ok=True)
1368
+ target_file = target_dir / "SKILL.md"
1369
+ target_file.write_text(skill_file.read_text(encoding="utf-8"), encoding="utf-8")
1370
+ logger.info("Installed Qoder skill: %s", skill_dir.name)
1371
+ installed_count += 1
1372
+
1373
+ if installed_count > 0:
1374
+ logger.info("Installed %d skill(s) to %s", installed_count, qoder_skills_dir)
1375
+ return qoder_skills_dir
1376
+ return None
1377
+
1378
+
1379
+ # --- OpenCode plugin ---
1380
+
1381
+
1382
+ def _opencode_plugin_content() -> str:
1383
+ """Return TypeScript source for the OpenCode user-level plugin.
1384
+
1385
+ The plugin hooks into three OpenCode events to mirror the Claude Code
1386
+ hook behaviors:
1387
+
1388
+ 1. ``file.edited`` — runs ``code-review-graph update --skip-flows``
1389
+ 2. ``session.created`` — runs ``code-review-graph status``
1390
+ 3. ``tool.execute.before`` — when the tool is a shell command starting
1391
+ with ``git commit``, runs ``code-review-graph detect-changes --brief``
1392
+
1393
+ All handlers use try/catch so errors never break the editor session.
1394
+ The plugin uses Bun's ``$`` shell API (provided by OpenCode's plugin
1395
+ context) for subprocess execution.
1396
+ """
1397
+ return """\
1398
+ import type { Plugin } from "@opencode-ai/plugin"
1399
+
1400
+ /**
1401
+ * code-review-graph plugin for OpenCode.
1402
+ *
1403
+ * Keeps the knowledge graph up-to-date and surfaces status
1404
+ * information automatically during coding sessions.
1405
+ *
1406
+ * Installed by: code-review-graph install --platform opencode
1407
+ */
1408
+
1409
+ // Helper: run a shell command quietly, swallowing errors.
1410
+ async function run($: any, cmd: string): Promise<string> {
1411
+ try {
1412
+ const result = await $`${cmd}`.quiet()
1413
+ return result.stdout?.toString().trim() ?? ""
1414
+ } catch {
1415
+ return ""
1416
+ }
1417
+ }
1418
+
1419
+ export default (app: any) => {
1420
+ // 1. Auto-update graph after file edits
1421
+ app.on("file.edited", async ({ $ }: { $: any }) => {
1422
+ try {
1423
+ await $`code-review-graph update --skip-flows`.quiet()
1424
+ } catch {
1425
+ // Swallow — graph may not be built yet for this project.
1426
+ }
1427
+ })
1428
+
1429
+ // 2. Show graph status when a new session starts
1430
+ app.on("session.created", async ({ $ }: { $: any }) => {
1431
+ try {
1432
+ const result = await $`code-review-graph status`.quiet()
1433
+ const output = result.stdout?.toString().trim()
1434
+ if (output) {
1435
+ console.log("[code-review-graph]", output)
1436
+ }
1437
+ } catch {
1438
+ // Swallow — not every project has a graph.
1439
+ }
1440
+ })
1441
+
1442
+ // 3. Detect changes before git commit commands
1443
+ app.on("tool.execute.before", async (ctx: any) => {
1444
+ try {
1445
+ const input = ctx?.input ?? ctx?.params ?? {}
1446
+ const cmd =
1447
+ input.command ?? input.cmd ?? input.content ?? ""
1448
+ if (typeof cmd === "string" && /^git\\s+commit/i.test(cmd)) {
1449
+ const result =
1450
+ await ctx.$`code-review-graph detect-changes --brief`.quiet()
1451
+ const output = result.stdout?.toString().trim()
1452
+ if (output) {
1453
+ console.log("[code-review-graph] Pre-commit analysis:\\n" + output)
1454
+ }
1455
+ }
1456
+ } catch {
1457
+ // Swallow — never block a commit.
1458
+ }
1459
+ })
1460
+ }
1461
+ """
1462
+
1463
+
1464
+ def install_opencode_plugin() -> Path:
1465
+ """Install the OpenCode user-level plugin for code-review-graph.
1466
+
1467
+ Writes ``~/.config/opencode/plugins/crg-plugin.ts``. Creates the
1468
+ directories if they don't exist. If the file already exists it is
1469
+ overwritten (the plugin is self-contained and idempotent).
1470
+
1471
+ Returns:
1472
+ Path to the plugin file that was written.
1473
+ """
1474
+ plugins_dir = Path.home() / ".config" / "opencode" / "plugins"
1475
+ plugin_path = plugins_dir / "crg-plugin.ts"
1476
+
1477
+ plugins_dir.mkdir(parents=True, exist_ok=True)
1478
+ plugin_path.write_text(_opencode_plugin_content(), encoding="utf-8")
1479
+ logger.info("Wrote OpenCode plugin: %s", plugin_path)
1480
+
1481
+ return plugin_path