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.
- code_review_graph/__init__.py +20 -0
- code_review_graph/__main__.py +4 -0
- code_review_graph/analysis.py +410 -0
- code_review_graph/changes.py +409 -0
- code_review_graph/cli.py +1255 -0
- code_review_graph/communities.py +874 -0
- code_review_graph/constants.py +23 -0
- code_review_graph/context_savings.py +317 -0
- code_review_graph/custom_languages.py +322 -0
- code_review_graph/daemon.py +1009 -0
- code_review_graph/daemon_cli.py +320 -0
- code_review_graph/docs/LLM-OPTIMIZED-REFERENCE.md +71 -0
- code_review_graph/embeddings.py +1006 -0
- code_review_graph/enrich.py +303 -0
- code_review_graph/eval/__init__.py +33 -0
- code_review_graph/eval/benchmarks/__init__.py +1 -0
- code_review_graph/eval/benchmarks/agent_baseline.py +193 -0
- code_review_graph/eval/benchmarks/build_performance.py +60 -0
- code_review_graph/eval/benchmarks/flow_completeness.py +36 -0
- code_review_graph/eval/benchmarks/impact_accuracy.py +220 -0
- code_review_graph/eval/benchmarks/multi_hop_retrieval.py +125 -0
- code_review_graph/eval/benchmarks/search_quality.py +59 -0
- code_review_graph/eval/benchmarks/token_efficiency.py +143 -0
- code_review_graph/eval/configs/code-review-graph.yaml +50 -0
- code_review_graph/eval/configs/express.yaml +45 -0
- code_review_graph/eval/configs/fastapi.yaml +48 -0
- code_review_graph/eval/configs/flask.yaml +50 -0
- code_review_graph/eval/configs/gin.yaml +51 -0
- code_review_graph/eval/configs/httpx.yaml +48 -0
- code_review_graph/eval/reporter.py +301 -0
- code_review_graph/eval/runner.py +211 -0
- code_review_graph/eval/scorer.py +85 -0
- code_review_graph/eval/token_benchmark.py +182 -0
- code_review_graph/exports.py +409 -0
- code_review_graph/flows.py +698 -0
- code_review_graph/graph.py +1427 -0
- code_review_graph/graph_diff.py +122 -0
- code_review_graph/hints.py +384 -0
- code_review_graph/incremental.py +1245 -0
- code_review_graph/jedi_resolver.py +303 -0
- code_review_graph/main.py +1079 -0
- code_review_graph/memory.py +142 -0
- code_review_graph/migrations.py +284 -0
- code_review_graph/parser.py +6957 -0
- code_review_graph/postprocessing.py +134 -0
- code_review_graph/prompts.py +159 -0
- code_review_graph/refactor.py +852 -0
- code_review_graph/registry.py +319 -0
- code_review_graph/rescript_resolver.py +206 -0
- code_review_graph/search.py +447 -0
- code_review_graph/skills.py +1481 -0
- code_review_graph/spring_resolver.py +200 -0
- code_review_graph/temporal_resolver.py +199 -0
- code_review_graph/token_benchmark.py +125 -0
- code_review_graph/tools/__init__.py +156 -0
- code_review_graph/tools/_common.py +176 -0
- code_review_graph/tools/analysis_tools.py +184 -0
- code_review_graph/tools/build.py +541 -0
- code_review_graph/tools/community_tools.py +246 -0
- code_review_graph/tools/context.py +152 -0
- code_review_graph/tools/docs.py +274 -0
- code_review_graph/tools/flows_tools.py +176 -0
- code_review_graph/tools/query.py +692 -0
- code_review_graph/tools/refactor_tools.py +168 -0
- code_review_graph/tools/registry_tools.py +125 -0
- code_review_graph/tools/review.py +477 -0
- code_review_graph/tsconfig_resolver.py +257 -0
- code_review_graph/visualization.py +2184 -0
- code_review_graph/wiki.py +305 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/METADATA +718 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/RECORD +74 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/WHEEL +4 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/entry_points.txt +3 -0
- 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
|