"""Claude Code skills and hooks auto-install. Generates Claude Code agent skill files, hooks configuration, and CLAUDE.md integration for seamless code-review-graph usage. Also supports multi-platform MCP server installation and Cursor hooks / OpenCode plugin generation. """ from __future__ import annotations import json import logging import os import platform import shutil import stat import subprocess import sys from pathlib import Path from typing import Any logger = logging.getLogger(__name__) # --- Multi-platform MCP install --- def _zed_settings_path() -> Path: """Return the Zed settings.json path for the current OS.""" if platform.system() == "Darwin": return Path.home() / "Library" / "Application Support" / "Zed" / "settings.json" return Path.home() / ".config" / "zed" / "settings.json" def _copilot_vscode_detected() -> bool: """Return whether a GitHub Copilot extension is installed for VS Code.""" home = Path.home() extension_dirs = [ home / ".vscode" / "extensions", home / ".vscode-insiders" / "extensions", ] system = platform.system() if system == "Darwin": for applications_dir in (Path("/Applications"), home / "Applications"): for app_name in ( "Visual Studio Code.app", "Visual Studio Code - Insiders.app", ): extension_dirs.append( applications_dir / app_name / "Contents" / "Resources" / "app" / "extensions" ) elif system == "Windows": for env_name in ("LOCALAPPDATA", "PROGRAMFILES", "PROGRAMFILES(X86)"): if install_root := os.environ.get(env_name): for app_name in ("Microsoft VS Code", "Microsoft VS Code Insiders"): extension_dirs.append( Path(install_root) / app_name / "resources" / "app" / "extensions" ) elif system == "Linux": extension_dirs.extend( [ Path("/usr/share/code/resources/app/extensions"), Path("/usr/share/code-insiders/resources/app/extensions"), Path("/usr/lib/code/resources/app/extensions"), Path("/opt/visual-studio-code/resources/app/extensions"), Path("/snap/code/current/usr/share/code/resources/app/extensions"), ] ) for command in ("code", "code-insiders"): if executable := shutil.which(command): try: parents = Path(executable).resolve().parents[:6] except OSError: continue for parent in parents: extension_dirs.extend( [ parent / "extensions", parent / "resources" / "app" / "extensions", parent / "Resources" / "app" / "extensions", ] ) for extensions_dir in dict.fromkeys(extension_dirs): try: extension_paths = list(extensions_dir.iterdir()) except OSError: continue for extension_path in extension_paths: name = extension_path.name.lower() if name.startswith("github.copilot-"): return True if "copilot" not in name: continue try: manifest = json.loads( (extension_path / "package.json").read_text(encoding="utf-8") ) except (OSError, json.JSONDecodeError): continue if ( str(manifest.get("publisher", "")).lower() == "github" and str(manifest.get("name", "")).lower() in {"copilot", "copilot-chat"} ): return True return False def _opencode_config_path(repo_root: Path) -> Path: """Return OpenCode's existing project config, preferring JSONC.""" for name in ("opencode.jsonc", "opencode.json"): path = repo_root / name if path.exists(): return path return repo_root / "opencode.jsonc" PLATFORMS: dict[str, dict[str, Any]] = { "codex": { "name": "Codex", "config_path": lambda root: Path.home() / ".codex" / "config.toml", "key": "mcp_servers", "detect": lambda: (Path.home() / ".codex").exists(), "format": "toml", "needs_type": True, }, "claude": { "name": "Claude Code", "config_path": lambda root: root / ".mcp.json", "key": "mcpServers", "detect": lambda: True, "format": "object", "needs_type": True, }, "cursor": { "name": "Cursor", "config_path": lambda root: root / ".cursor" / "mcp.json", "key": "mcpServers", "detect": lambda: (Path.home() / ".cursor").exists(), "format": "object", "needs_type": True, }, "windsurf": { "name": "Windsurf", "config_path": lambda root: Path.home() / ".codeium" / "windsurf" / "mcp_config.json", "key": "mcpServers", "detect": lambda: (Path.home() / ".codeium" / "windsurf").exists(), "format": "object", "needs_type": False, }, "zed": { "name": "Zed", "config_path": lambda root: _zed_settings_path(), "key": "context_servers", "detect": lambda: _zed_settings_path().parent.exists(), "format": "object", "needs_type": False, }, "continue": { "name": "Continue", "config_path": lambda root: Path.home() / ".continue" / "config.json", "key": "mcpServers", "detect": lambda: (Path.home() / ".continue").exists(), "format": "array", "needs_type": True, }, "opencode": { "name": "OpenCode", "config_path": _opencode_config_path, "key": "mcp", "detect": lambda: True, "format": "object", "needs_type": False, }, "antigravity": { "name": "Antigravity", "config_path": lambda root: Path.home() / ".gemini" / "antigravity" / "mcp_config.json", "key": "mcpServers", "detect": lambda: (Path.home() / ".gemini" / "antigravity").exists(), "format": "object", "needs_type": False, }, "gemini-cli": { "name": "Gemini CLI", "config_path": lambda root: root / ".gemini" / "settings.json", "key": "mcpServers", "detect": lambda: bool(shutil.which("gemini")) or (Path.home() / ".gemini").exists(), "format": "object", "needs_type": False, }, "qwen": { "name": "Qwen Code", "config_path": lambda root: Path.home() / ".qwen" / "settings.json", "key": "mcpServers", "detect": lambda: (Path.home() / ".qwen").exists(), "format": "object", "needs_type": True, }, "kiro": { "name": "Kiro", "config_path": lambda root: root / ".kiro" / "settings" / "mcp.json", "key": "mcpServers", "detect": lambda: (Path.home() / ".kiro").exists(), "format": "object", "needs_type": True, }, "qoder": { "name": "Qoder", "config_path": lambda root: root / ".qoder" / "mcp.json", "key": "mcpServers", "detect": lambda: True, "format": "object", "needs_type": True, }, "copilot": { "name": "GitHub Copilot", "config_path": lambda root: root / ".vscode" / "mcp.json", "key": "servers", "detect": _copilot_vscode_detected, "format": "object", "needs_type": True, }, "copilot-cli": { "name": "GitHub Copilot CLI", "config_path": lambda root: Path.home() / ".copilot" / "mcp-config.json", # Copilot CLI reads "mcpServers"; releases before #616 wrote # "servers", which the client silently ignores. "key": "mcpServers", "legacy_keys": ("servers",), "detect": lambda: (Path.home() / ".copilot").exists(), "format": "object", "needs_type": True, # Validated with the released Copilot CLI in #658. "server_type": "local", "entry_fields": {"tools": ["*"]}, }, "codebuddy": { "name": "CodeBuddy Code", "config_path": lambda root: root / ".mcp.json", "key": "mcpServers", "detect": lambda: True, "format": "object", "needs_type": True, }, } def _in_poetry_project() -> bool: """Return True when the running interpreter is a Poetry-managed virtualenv. Two signals are checked so that **both** ``poetry shell`` and ``poetry run`` are detected: * ``POETRY_ACTIVE=1`` — set by ``poetry shell`` when the user activates the virtual environment interactively. * ``VIRTUAL_ENV`` containing ``"pypoetry"`` — set by **both** ``poetry shell`` and ``poetry run`` because Poetry stores its virtualenvs under a path that includes the string ``pypoetry`` (e.g. ``~/.cache/pypoetry/virtualenvs/`` on Linux/macOS or ``%LOCALAPPDATA%\\pypoetry\\Cache\\virtualenvs\\`` on Windows). Checking only ``POETRY_ACTIVE`` would miss the ``poetry run`` case, which is the primary scenario described in issue #256. """ if os.environ.get("POETRY_ACTIVE") == "1": return True virtual_env = os.environ.get("VIRTUAL_ENV", "") return bool(virtual_env) and "pypoetry" in virtual_env.lower() def _in_uv_project() -> bool: """Return True if ``sys.executable`` lives inside a uv-managed project. A project is considered uv-managed when a ``uv.lock`` file exists in any ancestor directory of the running Python interpreter (stopping at the home directory to avoid false positives on system-wide installations). """ exe = Path(sys.executable).resolve() home = Path.home() for parent in exe.parents: if (parent / "uv.lock").exists(): return True # Stop searching once we reach the home directory or filesystem root if parent == home or parent == parent.parent: break return False def _detect_serve_command() -> tuple[str, list[str]]: """Return ``(command, args)`` that correctly launches ``code-review-graph serve``. Detection priority ------------------ 1. **Poetry** – ``POETRY_ACTIVE=1`` OR ``VIRTUAL_ENV`` contains ``"pypoetry"`` (covers both ``poetry shell`` and ``poetry run``) and ``poetry`` is on PATH → ``poetry run code-review-graph serve`` 2. **uv project** – ``UV_PROJECT_ENVIRONMENT`` is set, or a ``uv.lock`` ancestor is found alongside ``sys.executable``, and ``uv`` is on PATH → ``uv run code-review-graph serve`` 3. **uvx** – ``uvx`` is available on PATH (existing behaviour, unchanged) → ``uvx code-review-graph serve`` 4. **Fallback** – use the absolute path of the running Python interpreter → ``sys.executable -m code_review_graph serve`` The fallback is always safe: ``sys.executable`` is the exact interpreter that is currently running, so it resolves correctly inside any virtual environment, conda env, or system installation. """ # 1. Poetry (poetry shell or poetry run) if _in_poetry_project(): poetry = shutil.which("poetry") if poetry: return ("poetry", ["run", "code-review-graph", "serve"]) # 2. uv managed project environment if os.environ.get("UV_PROJECT_ENVIRONMENT") or _in_uv_project(): uv = shutil.which("uv") if uv: return ("uv", ["run", "code-review-graph", "serve"]) # 3. uvx global tool runner (existing behaviour, unchanged) if shutil.which("uvx"): return ("uvx", ["code-review-graph", "serve"]) # 4. Absolute-path fallback using the running interpreter return (sys.executable, ["-m", "code_review_graph", "serve"]) def _build_server_entry( plat: dict[str, Any], key: str = "", repo_root: "Path | None" = None, ) -> dict[str, Any]: """Build the MCP server entry for a platform.""" command, args = _detect_serve_command() if key == "opencode": opencode_command = [command, *args] if repo_root is not None: opencode_command.extend(("--repo", str(repo_root))) return {"type": "local", "command": opencode_command} entry: dict[str, Any] = {"command": command, "args": args} # Include cwd so the MCP server can find the graph database if repo_root is not None: entry["cwd"] = str(repo_root) if plat["needs_type"]: entry["type"] = plat.get("server_type", "stdio") entry.update(plat.get("entry_fields", {})) return entry def _warn_legacy_opencode_config(repo_root: Path) -> None: """Warn without modifying the obsolete Cursor-shaped OpenCode config.""" legacy = repo_root / ".opencode.json" if not legacy.exists(): return try: parsed = json.loads( _strip_jsonc(legacy.read_text(encoding="utf-8", errors="replace")) ) except (json.JSONDecodeError, OSError): return if not isinstance(parsed, dict): return servers = parsed.get("mcpServers") if isinstance(servers, dict) and "code-review-graph" in servers: print( f" OpenCode: legacy config found at {legacy}; leaving it unchanged. " "OpenCode now reads opencode.json or opencode.jsonc with a top-level " "'mcp' setting." ) def _format_toml_value(value: Any) -> str: """Format a primitive Python value as TOML.""" if isinstance(value, str): escaped = value.replace("\\", "\\\\").replace('"', '\\"') return f'"{escaped}"' if isinstance(value, bool): return "true" if value else "false" if isinstance(value, list): return "[" + ", ".join(_format_toml_value(item) for item in value) + "]" raise TypeError(f"Unsupported TOML value: {type(value)!r}") def _merge_toml_mcp_server( config_path: Path, server_name: str, server_entry: dict[str, Any], dry_run: bool = False, ) -> bool: """Append a Codex MCP server section without clobbering the rest of the file.""" section_header = f"[mcp_servers.{server_name}]" existing = "" if config_path.exists(): existing = config_path.read_text(encoding="utf-8") if section_header in existing: return False section_lines = [section_header] for key, value in server_entry.items(): section_lines.append(f"{key} = {_format_toml_value(value)}") section = "\n".join(section_lines) + "\n" if dry_run: return True config_path.parent.mkdir(parents=True, exist_ok=True) prefix = "" if existing: prefix = existing if existing.endswith("\n") else existing + "\n" if not prefix.endswith("\n\n"): prefix += "\n" config_path.write_text(prefix + section, encoding="utf-8") return True def _strip_jsonc(text: str) -> str: """Strip JSONC comments and trailing commas without corrupting string values. Editors like Zed accept non-standard JSON (``//`` and ``/* */`` comments, trailing commas). To merge such a config we must reduce it to strict JSON first. A naive regex pass cannot tell structure from data: it would delete a comma inside ``"foo, bar"`` or truncate a ``"https://..."`` URL at the ``//``. This walks the text character by character, tracking whether we are inside a double-quoted string (respecting ``\\`` escapes), and only removes comments and trailing commas that appear in structural position. Content inside string values is preserved verbatim. (GH #553) """ def _skip_comment(s: str, idx: int) -> int | None: """If a comment starts at ``idx``, return the index just past it.""" if s[idx] != "/" or idx + 1 >= len(s): return None nxt = s[idx + 1] if nxt == "/": idx += 2 while idx < len(s) and s[idx] != "\n": idx += 1 return idx if nxt == "*": idx += 2 while idx + 1 < len(s) and not (s[idx] == "*" and s[idx + 1] == "/"): idx += 1 return idx + 2 # consume the closing */ (or run off the end if unterminated) return None out: list[str] = [] i = 0 n = len(text) in_string = False while i < n: ch = text[i] if in_string: out.append(ch) if ch == "\\" and i + 1 < n: out.append(text[i + 1]) # escaped char is data, never a delimiter i += 2 continue if ch == '"': in_string = False i += 1 continue # Outside a string. if ch == '"': in_string = True out.append(ch) i += 1 continue past = _skip_comment(text, i) if past is not None: i = past continue if ch == ",": # Trailing comma if the next significant char (skipping whitespace # and comments) closes an object or array. j = i + 1 while j < n: if text[j] in " \t\r\n": j += 1 continue past = _skip_comment(text, j) if past is not None: j = past continue break if j < n and text[j] in "}]": i += 1 # drop the trailing comma continue out.append(ch) i += 1 return "".join(out) def install_platform_configs( repo_root: Path, target: str = "all", dry_run: bool = False, ) -> list[str]: """Install MCP config for one or all detected platforms. Args: repo_root: Project root directory. target: Platform key or "all". dry_run: If True, print what would be done without writing. Returns: List of platform names that were configured. """ shared_aliases: dict[str, tuple[str, ...]] = {} if target == "all": platforms_to_install = {k: v for k, v in PLATFORMS.items() if v["detect"]()} # Workspace-level Kiro detection if "kiro" not in platforms_to_install and (repo_root / ".kiro").is_dir(): platforms_to_install["kiro"] = PLATFORMS["kiro"] # Claude Code and CodeBuddy intentionally share the official project # .mcp.json/mcpServers contract. Process that exact pair once, but do # not deduplicate arbitrary clients merely because a config path # happens to match: their keys or entry schemas may differ. claude = platforms_to_install.get("claude") codebuddy = platforms_to_install.get("codebuddy") if claude is not None and codebuddy is not None: same_contract = ( claude["config_path"](repo_root) == codebuddy["config_path"](repo_root) and all( claude[field] == codebuddy[field] for field in ("key", "format", "needs_type") ) ) if same_contract: shared_aliases["claude"] = ("codebuddy",) del platforms_to_install["codebuddy"] else: if target not in PLATFORMS: logger.error("Unknown platform: %s", target) return [] platforms_to_install = {target: PLATFORMS[target]} configured: list[str] = [] def _record_configured(key: str, plat: dict[str, Any]) -> None: configured.append(plat["name"]) configured.extend(PLATFORMS[alias]["name"] for alias in shared_aliases.get(key, ())) for key, plat in platforms_to_install.items(): if key == "opencode": _warn_legacy_opencode_config(repo_root) config_path: Path = plat["config_path"](repo_root) server_key = plat["key"] server_entry = _build_server_entry(plat, key=key, repo_root=repo_root) if plat["format"] == "toml": changed = _merge_toml_mcp_server( config_path, "code-review-graph", server_entry, dry_run=dry_run, ) if not changed: print(f" {plat['name']}: already configured in {config_path}") _record_configured(key, plat) continue if dry_run: print(f" [dry-run] {plat['name']}: would write {config_path}") else: print(f" {plat['name']}: configured {config_path}") _record_configured(key, plat) continue # Read existing config existing: dict[str, Any] = {} if config_path.exists(): raw = config_path.read_text(encoding="utf-8", errors="replace") # Strip comments and trailing commas (JSONC compat for editors like # Zed that allow non-standard JSON) without corrupting string values. stripped = _strip_jsonc(raw) if not stripped.strip(): # An empty (or comment-only) file is a valid empty config, # not a parse failure — proceed and write a fresh one rather # than mis-flagging it "unparseable" and skipping. See #344. existing = {} else: try: parsed = json.loads(stripped) except (json.JSONDecodeError, OSError): print(f" {plat['name']}: {config_path} contains " f"unparseable JSON — skipping to avoid data loss. " f"Please add the MCP config manually.") continue if not isinstance(parsed, dict): # Valid JSON, but the top level is a list/scalar rather # than an object. Writing our server object would clobber # the user's data, and the ``.get()`` calls below would # raise AttributeError. Refuse and skip. See #344. print(f" {plat['name']}: {config_path} is valid JSON but " f"not a top-level object " f"({type(parsed).__name__}) — skipping to avoid " f"data loss. Please add the MCP config manually.") continue existing = parsed expected_container = list if plat["format"] == "array" else dict if server_key in existing and not isinstance( existing[server_key], expected_container ): expected_name = "array" if expected_container is list else "object" actual_name = type(existing[server_key]).__name__ print( f" {plat['name']}: {config_path} setting {server_key!r} " f"is {actual_name}; expected a JSON {expected_name} — " f"skipping to avoid data loss. Please repair that setting " f"or add the MCP config manually." ) continue if plat["format"] == "array": arr = existing.get(server_key, []) # Check if already present if any(isinstance(s, dict) and s.get("name") == "code-review-graph" for s in arr): print(f" {plat['name']}: already configured in {config_path}") _record_configured(key, plat) continue arr_entry = {"name": "code-review-graph", **server_entry} arr.append(arr_entry) existing[server_key] = arr else: # Remove entries written under keys the client never read, then # install the validated entry under the current key. migrated = False for legacy_key in plat.get("legacy_keys", ()): legacy = existing.get(legacy_key) if ( isinstance(legacy, dict) and "code-review-graph" in legacy ): del legacy["code-review-graph"] if not legacy: del existing[legacy_key] migrated = True servers = existing.get(server_key, {}) if "code-review-graph" in servers and not migrated: print(f" {plat['name']}: already configured in {config_path}") _record_configured(key, plat) continue servers["code-review-graph"] = server_entry existing[server_key] = servers if dry_run: print(f" [dry-run] {plat['name']}: would write {config_path}") else: config_path.parent.mkdir(parents=True, exist_ok=True) config_path.write_text( json.dumps(existing, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" ) print(f" {plat['name']}: configured {config_path}") _record_configured(key, plat) return configured # --- Skill file contents --- _SKILLS: dict[str, dict[str, str]] = { "explore-codebase.md": { "name": "explore-codebase", "description": "Navigate and understand codebase structure using the knowledge graph", "body": ( "## Explore Codebase\n\n" "Use the code-review-graph MCP tools to explore and understand the codebase.\n\n" "### Steps\n\n" "1. Run `list_graph_stats` to see overall codebase metrics.\n" "2. Run `get_architecture_overview_tool` for high-level community structure.\n" "3. Use `list_communities_tool` to find major modules, then `get_community` " "for details.\n" "4. Use `semantic_search_nodes_tool` to find specific functions or classes.\n" "5. Use `query_graph_tool` with patterns like `callers_of`, `callees_of`, " "`imports_of` to trace relationships.\n" "6. Use `list_flows` and `get_flow` to understand execution paths.\n\n" "### Tips\n\n" "- Start broad (stats, architecture) then narrow down to specific areas.\n" "- Use `children_of` on a file to see all its functions and classes.\n" "- Use `find_large_functions` to identify complex code.\n\n" "## Token Efficiency Rules\n" '- ALWAYS start with `get_minimal_context(task="")` ' "before any other graph tool.\n" '- Use `detail_level="minimal"` on all calls. Only escalate to ' '"standard" when minimal is insufficient.\n' "- Target: complete any review/debug/refactor task in ≤5 tool calls " "and ≤800 total output tokens." ), }, "review-changes.md": { "name": "review-changes", "description": "Perform a structured code review using change detection and impact", "body": ( "## Review Changes\n\n" "Perform a thorough, risk-aware code review using the knowledge graph.\n\n" "### Steps\n\n" "1. Run `detect_changes_tool` to get risk-scored change analysis.\n" "2. Run `get_affected_flows_tool` to find impacted execution paths.\n" "3. For each high-risk function, run `query_graph_tool` with " 'pattern="tests_for" to check test coverage.\n' "4. Run `get_impact_radius_tool` to understand the blast radius.\n" "5. For any untested changes, suggest specific test cases.\n\n" "### Output Format\n\n" "Provide findings grouped by risk level (high/medium/low) with:\n" "- What changed and why it matters\n" "- Test coverage status\n" "- Suggested improvements\n" "- Overall merge recommendation\n\n" "## Token Efficiency Rules\n" '- ALWAYS start with `get_minimal_context(task="")` ' "before any other graph tool.\n" '- Use `detail_level="minimal"` on all calls. Only escalate to ' '"standard" when minimal is insufficient.\n' "- Target: complete any review/debug/refactor task in ≤5 tool calls " "and ≤800 total output tokens." ), }, "unified-review.md": { "name": "unified-review", "description": ( "Three-layer unified code review fusing CRG graph context with " "ai-code-review scoring methodology and gstack-review fix-first workflow" ), "body": ( "## Unified Review\n\n" "Perform a three-layer, read-only code review that fuses:\n" "- **CRG graph context** (blast radius, test gaps, affected flows)\n" "- **ai-code-review methodology** (Layer-1 chain decomposition, " "Layer-2 quantitative scoring, Layer-3 acceptance)\n" "- **gstack-review workflow** (confidence calibration, fix-first, " "specialist subagents, review-log persistence)\n\n" "**This skill is READ-ONLY.** Every finding is presented to the " "user for a manual fix decision. Never apply code changes, commit, " "or push.\n\n" "### Token Efficiency Rules\n" '- ALWAYS start with `get_minimal_context(task="unified review")`. ' "Use `detail_level=\"minimal\"` on all calls; escalate to " '"standard" only when a metric or finding needs evidence.\n\n' "### Step 0 - Scope\n" "Review always runs at the fixed `standard` tier (all layers).\n" "Detect the project language/framework and the review scope " "(change/file/service/chain level). Declare both in the report " "header.\n\n" "### Step 1 - Graph context (CRG)\n" "1. Call `build_or_update_graph_tool()` to ensure the graph is " "current.\n" "2. Call `get_review_context_tool()` for changed files, blast " "radius, source snippets and review guidance.\n" "3. Call `detect_changes_tool()` for risk-scored change analysis, " "test gaps and affected flows.\n\n" "### Step 2 - Layer 1: Chain decomposition (ai-code-review)\n" "Inspect the changed code across eight categories: interface, " "business, data, utility, error handling, security, performance, " "observability. Mark each `✅ Clean / ⚠️ Issues Found / — N/A`. " "Apply the gstack CRITICAL categories as a sub-pass: SQL & Data " "Safety, Race Conditions & Concurrency, LLM Output Trust " "Boundary, Shell Injection, and Enum & Value Completeness. Enum " "completeness requires reading code OUTSIDE the diff (Grep for " "sibling values, then Read each consumer).\n\n" "### Step 3 - Layer 2: Quantitative scoring\n" "Call `score_review_tool()` for the objective metrics (SQL risk, " "exception coverage, redundancy, high-risk density, " "vulnerability heuristic); these five are the full metric set.\n\n" "### Step 4 - Specialist dispatch (gstack, diff >= 50 lines)\n" "When the diff has 50+ changed lines, dispatch specialist " "subagents in parallel via the Agent/task tool, each with a " "fresh context and its own checklist: testing, maintainability, " "security, performance, data-migration, api-contract. Security " "and data-migration always run (insurance). Collect each " "specialist's JSON findings.\n\n" "### Step 5 - Merge and dedupe\n" "Call `dedupe_findings_tool(findings=)` to " "merge by fingerprint (`path:line:category`), boost " "multi-source confidence (+1, cap 10), route low-confidence " "findings to the appendix, and compute the PR quality score.\n\n" "### Step 6 - Manual adjudication (READ-ONLY)\n" "Present every merged finding with its severity " "(🔴 blocker / 🟡 major / 🔵 minor), confidence (1-10), " "file:line and a proposed fix. Group by severity and ask the " "user per batch: fix / skip / self-fix. 🔴 blockers cannot be " "batch-skipped. Record skipped findings for prior-review " "suppression on the next run. **Do not modify code.**\n\n" "### Step 7 - Acceptance gate (ai-code-review)\n" "Any 🔴 blocker → verdict `❌ FAIL` regardless of other scores. " "Classify each finding as Ready / Needs Fix / Unusable. Verify " "the change does not deviate from requirements or architecture " "conventions.\n\n" "### Step 8 - Report\n" "Call `generate_report_tool(review_data=)` to write " "`code-review-report.html` and `code-review-report.md` (default " "`format=\"both\"`). Also present the text report inline.\n\n" "### Step 9 - Persistence (optional)\n" "If the `gstack-review-log` binary is available, record the " "review outcome (status, counts, quality score, per-finding " "actions). If it is unavailable, skip silently.\n\n" "### Output Format\n" "`Unified Review: N issues (X blocker, Y major, Z minor) — " "verdict: ✅ PASS / ❌ FAIL`. List each issue with severity, " "confidence, file:line, problem, and proposed fix. List " "manual-review items (payment, order, inventory, permission, " "distributed-lock, data-migration) explicitly.\n\n" "## Token Efficiency Rules\n" '- ALWAYS start with `get_minimal_context(task="unified review")` ' "before any other graph tool.\n" '- Use `detail_level="minimal"` on all calls. Only escalate to ' '"standard" when minimal is insufficient.\n' "- Target: complete a unified review in ≤8 tool calls and " "≤1200 total output tokens." ), }, "project-review.md": { "name": "project-review", "description": ( "Whole-project or single-feature code review (not diff-based) " "using graph-wide analysis and objective scoring" ), "body": ( "## Project Review\n\n" "Review the entire codebase or a single feature/module, " "independent of the git diff. Two scopes, driven by the " "user's instruction:\n" "- **whole-project**: \"对项目代码进行全面审查\", \"全面审查\", " "\"整个项目\" → review every source file in the graph.\n" "- **feature**: \"审查 <功能/模块> 的代码\" (e.g. payment, " "auth) → review only the code related to the target.\n\n" "**This skill is READ-ONLY.** Every finding is presented to the " "user for a manual fix decision. Never apply code changes, " "commit, or push.\n\n" "### Token Efficiency Rules\n" '- ALWAYS start with `get_minimal_context(task="project review")`. ' 'Use `detail_level="minimal"` on all calls; escalate to ' '"standard" only when a metric or finding needs evidence.\n\n' "### Step 0 - Parse the scope\n" "Read the user's instruction and set scope: whole-project " "(contains 全面/整个项目/所有/all) or feature + target " "(extract the feature/module keyword). Declare both in the " "report header.\n\n" "### Step 1 - Graph ready\n" "1. Call `build_or_update_graph_tool()` to ensure the graph is " "current.\n" "2. Call `get_minimal_context_tool(task=\"project review\")` " "for stats and community overview.\n\n" "### Step 2 - Architecture map\n" "Call `get_architecture_overview_tool(detail_level=\"minimal\")` " "and `list_communities_tool(detail_level=\"minimal\")` to map " "the module structure.\n\n" "### Step 3 - High-risk scan (whole-project)\n" "Call `get_knowledge_gaps_tool()`, `get_hub_nodes_tool()`, " "`get_bridge_nodes_tool()`, `find_large_functions_tool()` and " "`get_surprising_connections_tool()` to locate hotspots, " "chokepoints, untested areas and odd coupling.\n\n" "### Step 4 - Objective scoring\n" "- whole-project: `score_review_tool(all_files=True)` scores " "every source file in the graph.\n" "- feature: locate the target files with " "`semantic_search_nodes_tool(query=)` and " "`query_graph_tool(pattern=\"children_of\", target=)`, " "then `score_review_tool(changed_files=)` and " "`get_impact_radius_tool(changed_files=)` for the " "blast radius.\n\n" "### Step 5 - Chain decomposition\n" "Inspect the scored code across eight categories (interface, " "business, data, utility, error handling, security, " "performance, observability) and apply the gstack CRITICAL " "sub-pass (SQL & Data Safety, Race Conditions, LLM Output " "Trust Boundary, Shell Injection, Enum Completeness). Mark " "each ✅ / ⚠️ / —.\n\n" "### Step 6 - Manual adjudication (READ-ONLY)\n" "Present every finding with severity (🔴 blocker / 🟡 major / " "🔵 minor), confidence (1-10), file:line and a proposed fix. " "Group by severity and ask the user per batch: fix / skip / " "self-fix. 🔴 blockers cannot be batch-skipped. **Do not " "modify code.**\n\n" "### Step 7 - Acceptance gate\n" "Any 🔴 blocker → verdict `❌ FAIL`. Classify each finding as " "Ready / Needs Fix / Unusable.\n\n" "### Step 8 - Report\n" "Call `generate_report_tool(review_data=)` to write `code-review-report.html` and " "`code-review-report.md` (default `format=\"both\"`).\n\n" "### Output Format\n" "`Project Review: N issues (X blocker, Y major, Z minor) — " "verdict: ✅ PASS / ❌ FAIL`. List each issue with severity, " "confidence, file:line, problem, and proposed fix.\n\n" "## Token Efficiency Rules\n" '- ALWAYS start with `get_minimal_context(task="project review")` ' "before any other graph tool.\n" '- Use `detail_level="minimal"` on all calls. Only escalate to ' '"standard" when minimal is insufficient.\n' "- Target: complete a project review in ≤12 tool calls and " "≤1800 total output tokens." ), }, "debug-issue.md": { "name": "debug-issue", "description": "Systematically debug issues using graph-powered code navigation", "body": ( "## Debug Issue\n\n" "Use the knowledge graph to systematically trace and debug issues.\n\n" "### Steps\n\n" "1. Use `semantic_search_nodes_tool` to find code related to the issue.\n" "2. Use `query_graph_tool` with `callers_of` and `callees_of` to trace " "call chains.\n" "3. Use `get_flow` to see full execution paths through suspected areas.\n" "4. Run `detect_changes_tool` to check if recent changes caused the issue.\n" "5. Use `get_impact_radius_tool` on suspected files to see what else is affected.\n\n" "### Tips\n\n" "- Check both callers and callees to understand the full context.\n" "- Look at affected flows to find the entry point that triggers the bug.\n" "- Recent changes are the most common source of new issues.\n\n" "## Token Efficiency Rules\n" '- ALWAYS start with `get_minimal_context(task="")` ' "before any other graph tool.\n" '- Use `detail_level="minimal"` on all calls. Only escalate to ' '"standard" when minimal is insufficient.\n' "- Target: complete any review/debug/refactor task in ≤5 tool calls " "and ≤800 total output tokens." ), }, "refactor-safely.md": { "name": "refactor-safely", "description": "Plan and execute safe refactoring using dependency analysis", "body": ( "## Refactor Safely\n\n" "Use the knowledge graph to plan and execute refactoring with confidence.\n\n" "### Steps\n\n" '1. Use `refactor_tool` with mode="suggest" for community-driven ' "refactoring suggestions.\n" '2. Use `refactor_tool` with mode="dead_code" to find unreferenced code.\n' '3. For renames, use `refactor_tool` with mode="rename" to preview all ' "affected locations.\n" "4. Use `apply_refactor_tool` with the refactor_id to apply renames.\n" "5. After changes, run `detect_changes_tool` to verify the refactoring impact.\n\n" "### Safety Checks\n\n" "- Always preview before applying (rename mode gives you an edit list).\n" "- Check `get_impact_radius_tool` before major refactors.\n" "- Use `get_affected_flows_tool` to ensure no critical paths are broken.\n" "- Run `find_large_functions` to identify decomposition targets.\n\n" "## Token Efficiency Rules\n" '- ALWAYS start with `get_minimal_context(task="")` ' "before any other graph tool.\n" '- Use `detail_level="minimal"` on all calls. Only escalate to ' '"standard" when minimal is insufficient.\n' "- Target: complete any review/debug/refactor task in ≤5 tool calls " "and ≤800 total output tokens." ), }, } def generate_skills(repo_root: Path, skills_dir: Path | None = None) -> Path: """Generate Claude Code skill files. Creates `.claude/skills/` directory with 4 skill markdown files, each containing frontmatter and instructions. Args: repo_root: Repository root directory. skills_dir: Custom skills directory. Defaults to repo_root/.claude/skills. Returns: Path to the skills directory. """ if skills_dir is None: skills_dir = repo_root / ".claude" / "skills" skills_dir.mkdir(parents=True, exist_ok=True) for filename, skill in _SKILLS.items(): # Claude Code expects skills at .claude/skills//SKILL.md skill_name = filename.removesuffix(".md") skill_subdir = skills_dir / skill_name skill_subdir.mkdir(parents=True, exist_ok=True) path = skill_subdir / "SKILL.md" content = ( "---\n" f"name: {skill['name']}\n" f"description: {skill['description']}\n" "---\n\n" f"{skill['body']}\n" ) path.write_text(content, encoding="utf-8") logger.info("Wrote skill: %s", path) return skills_dir def generate_hooks_config(repo_root: Path) -> dict[str, Any]: """Generate Claude Code hooks configuration. Hooks use the v1.x+ schema: each entry needs a ``matcher`` and a nested ``hooks`` array. Timeouts are in seconds. ``PreCommit`` is not a valid Claude Code event — pre-commit checks are handled by ``install_git_hook``. The ``repo_root`` parameter is retained for backward compatibility but is not embedded in hook commands. Instead, the repo root is resolved at runtime via ``git rev-parse --show-toplevel`` so that ``settings.json`` is shareable across collaborators with different checkout paths. A PATH guard ensures the hook exits silently when the binary is not on ``$PATH`` (e.g. installed in a project venv). """ return { "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": ( "cat >/dev/null || true; " "command -v code-review-graph >/dev/null 2>&1 || exit 0; " "git rev-parse --git-dir >/dev/null 2>&1" " && code-review-graph update --skip-flows" " --repo \"$(git rev-parse --show-toplevel 2>/dev/null)\"" " || true" ), "timeout": 30, }, ], }, ], "SessionStart": [ { "matcher": "", "hooks": [ { "type": "command", "command": ( "cat >/dev/null || true; " "command -v code-review-graph >/dev/null 2>&1 || exit 0; " "git rev-parse --git-dir >/dev/null 2>&1" " && code-review-graph status" " --repo \"$(git rev-parse --show-toplevel 2>/dev/null)\"" " || echo 'Not a git repo, skipping'" ), "timeout": 10, }, ], }, ], } } def generate_codex_hooks_config(repo_root: Path) -> dict[str, Any]: """Generate native Codex hooks configuration for ~/.codex/hooks.json.""" return { "hooks": { "PostToolUse": [ { "matcher": "Write|Edit|Bash", "hooks": [ { "type": "command", "command": ( "cat >/dev/null || true; " "git rev-parse --git-dir >/dev/null 2>&1" " && code-review-graph update --skip-flows" " || true" ), "timeout": 30, "statusMessage": "Updating code-review-graph", }, ], }, ], "SessionStart": [ { "matcher": "startup|resume", "hooks": [ { "type": "command", "command": ( "cat >/dev/null || true; " "git rev-parse --git-dir >/dev/null 2>&1" " && code-review-graph status" " || echo 'Not a git repo, skipping'" ), "timeout": 10, "statusMessage": "Checking code-review-graph status", }, ], }, ], } } def install_git_hook(repo_root: Path) -> Path | None: """Install a git pre-commit hook that prints a risk summary before each commit. Called automatically by ``code-review-graph install``. The hooks directory is resolved via ``git rev-parse --git-path hooks`` so the hook lands where git actually runs it — including linked worktrees and submodules (where ``.git`` is a file, not a directory) and repos with ``core.hooksPath`` set (issue #313). ``core.hooksPath`` users with their own hook manager (husky, pre-commit) may prefer integrating the ``code-review-graph`` commands into that manager manually instead. Creates ``pre-commit`` if it doesn't exist, or appends to an existing one — the hook is appended, not overwritten, preserving any hooks already there. Falls back to the legacy ``.git/hooks`` resolution when git itself is unavailable. Returns None when no hooks directory can be determined. """ script = """\ #!/bin/sh # Installed by code-review-graph. Remove this file to disable pre-commit graph checks. if command -v code-review-graph >/dev/null 2>&1; then code-review-graph update || true code-review-graph detect-changes --brief || true fi """ marker = "code-review-graph detect-changes" hooks_dir: Path | None = None try: result = subprocess.run( ["git", "rev-parse", "--git-path", "hooks"], capture_output=True, text=True, encoding="utf-8", cwd=str(repo_root), timeout=10, stdin=subprocess.DEVNULL, ) if result.returncode == 0 and result.stdout.strip(): # Output is relative to repo_root (".git/hooks", a core.hooksPath # value such as ".husky") or absolute (linked worktrees). hooks_dir = repo_root / result.stdout.strip() except (subprocess.TimeoutExpired, OSError) as exc: logger.warning("git unavailable (%s); falling back to .git/hooks resolution.", exc) if hooks_dir is None: git_dir = repo_root / ".git" if not git_dir.is_dir(): logger.warning( "No git hooks directory found at %s — skipping git hook install.", repo_root ) return None hooks_dir = git_dir / "hooks" hook_path = hooks_dir / "pre-commit" hook_path.parent.mkdir(parents=True, exist_ok=True) if hook_path.exists(): existing = hook_path.read_text(encoding="utf-8") if marker in existing: return hook_path hook_path.write_text(existing.rstrip("\n") + "\n" + script, encoding="utf-8") else: hook_path.write_text(script, encoding="utf-8") hook_path.chmod(0o755) logger.info("Wrote git pre-commit hook: %s", hook_path) return hook_path def _merge_hooks_into_settings( settings_dir: Path, hooks_config: dict[str, Any], ) -> Path: """Merge hook entries into a project settings file without clobbering users.""" settings_dir.mkdir(parents=True, exist_ok=True) settings_path = settings_dir / "settings.json" existing: dict[str, Any] = {} if settings_path.exists(): try: existing = json.loads(settings_path.read_text(encoding="utf-8", errors="replace")) backup_path = settings_dir / "settings.json.bak" shutil.copy2(settings_path, backup_path) logger.info("Backed up existing settings to %s", backup_path) except (json.JSONDecodeError, OSError) as exc: logger.warning("Could not read existing %s: %s", settings_path, exc) existing_hooks = existing.get("hooks", {}) if not isinstance(existing_hooks, dict): logger.warning("Existing hooks config is not a dict; replacing with defaults") existing_hooks = {} merged_hooks = dict(existing_hooks) for hook_name, hook_entries in hooks_config.get("hooks", {}).items(): if isinstance(merged_hooks.get(hook_name), list): merged_list = list(merged_hooks[hook_name]) for entry in hook_entries: if entry not in merged_list: merged_list.append(entry) merged_hooks[hook_name] = merged_list else: merged_hooks[hook_name] = hook_entries existing["hooks"] = merged_hooks settings_path.write_text( json.dumps(existing, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" ) logger.info("Wrote hooks config: %s", settings_path) return settings_path def install_hooks(repo_root: Path, platform: str = "claude") -> None: """Write hooks config to platform-specific settings.json. Merges new hook entries into existing settings, preserving both non-hook configuration and user-defined hooks. A backup of the original file is created before any modifications. Args: repo_root: Repository root directory. platform: Target platform ("claude" or "qoder"). """ if platform == "qoder": settings_dir = repo_root / ".qoder" else: settings_dir = repo_root / ".claude" _merge_hooks_into_settings(settings_dir, generate_hooks_config(repo_root)) def install_codebuddy_hooks(repo_root: Path) -> Path: """Install runtime-resolved POSIX hooks in .codebuddy/settings.json. CodeBuddy uses the same nested hook schema and POSIX-shell execution model as the existing project hooks. The shared generator deliberately resolves the checkout at hook runtime instead of embedding the installer's absolute path, so committed settings work for every collaborator. """ hooks_config = generate_hooks_config(repo_root) # CodeBuddy's Bash tool can create or rewrite files without going through # Edit/Write, so its PostToolUse contract also observes Bash. The command # itself still resolves the repository dynamically at hook runtime. hooks_config["hooks"]["PostToolUse"][0]["matcher"] = "Edit|Write|Bash" return _merge_hooks_into_settings( repo_root / ".codebuddy", hooks_config, ) def install_codex_hooks(repo_root: Path) -> Path: """Write native Codex hooks config to ~/.codex/hooks.json. Merges code-review-graph hook entries into any existing hooks.json, preserving user-defined hook entries and other top-level settings. A backup of the original file is created before modifications. """ codex_dir = Path.home() / ".codex" codex_dir.mkdir(parents=True, exist_ok=True) hooks_path = codex_dir / "hooks.json" existing: dict[str, Any] = {} if hooks_path.exists(): try: existing = json.loads(hooks_path.read_text(encoding="utf-8", errors="replace")) backup_path = codex_dir / "hooks.json.bak" shutil.copy2(hooks_path, backup_path) logger.info("Backed up existing Codex hooks to %s", backup_path) except (json.JSONDecodeError, OSError) as exc: logger.warning("Could not read existing %s: %s", hooks_path, exc) hooks_config = generate_codex_hooks_config(repo_root) existing_hooks = existing.get("hooks", {}) if not isinstance(existing_hooks, dict): logger.warning("Existing Codex hooks config is not a dict; replacing with defaults") existing_hooks = {} merged_hooks = dict(existing_hooks) for hook_name, hook_entries in hooks_config.get("hooks", {}).items(): if isinstance(merged_hooks.get(hook_name), list): merged_list = list(merged_hooks[hook_name]) existing_commands = { hook.get("command", "") for entry in merged_list if isinstance(entry, dict) for hook in entry.get("hooks", []) if isinstance(hook, dict) } for entry in hook_entries: entry_commands = [ hook.get("command", "") for hook in entry.get("hooks", []) if isinstance(hook, dict) ] if not any(command in existing_commands for command in entry_commands): merged_list.append(entry) merged_hooks[hook_name] = merged_list else: merged_hooks[hook_name] = hook_entries existing["hooks"] = merged_hooks hooks_path.write_text( json.dumps(existing, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" ) logger.info("Wrote Codex hooks config: %s", hooks_path) return hooks_path _CLAUDE_MD_SECTION_MARKER = "" _CLAUDE_MD_SECTION = f"""{_CLAUDE_MD_SECTION_MARKER} ## MCP Tools: code-review-graph **IMPORTANT: This project has a knowledge graph. ALWAYS use the code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore the codebase.** The graph is faster, cheaper (fewer tokens), and gives you structural context (callers, dependents, test coverage) that file scanning cannot. ### When to use graph tools FIRST - **Exploring code**: `semantic_search_nodes_tool` or `query_graph_tool` instead of Grep - **Understanding impact**: `get_impact_radius_tool` instead of manually tracing imports - **Code review**: `detect_changes_tool` + `get_review_context_tool` instead of reading entire files - **Finding relationships**: `query_graph_tool` with callers_of/callees_of/imports_of/tests_for - **Architecture questions**: `get_architecture_overview_tool` + `list_communities_tool` Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need. ### Key Tools | Tool | Use when | | ------ | ---------- | | `detect_changes_tool` | Reviewing code changes — gives risk-scored analysis | | `get_review_context_tool` | Need source snippets for review — token-efficient | | `get_impact_radius_tool` | Understanding blast radius of a change | | `get_affected_flows_tool` | Finding which execution paths are impacted | | `query_graph_tool` | Tracing callers, callees, imports, tests, dependencies | | `semantic_search_nodes_tool` | Finding functions/classes by name or keyword | | `get_architecture_overview_tool` | Understanding high-level codebase structure | | `refactor_tool` | Planning renames, finding dead code | ### Workflow 1. The graph auto-updates on file changes (via hooks). 2. Use `detect_changes_tool` for code review. 3. Use `get_affected_flows_tool` to understand impact. 4. Use `query_graph_tool` pattern=\"tests_for\" to check coverage. """ # Copilot-specific instruction file content: uses VS Code tool references and # includes YAML front matter so Copilot Chat applies it across the workspace. _COPILOT_SECTION = f"""--- applyTo: '**' description: >- Use code-review-graph MCP tools for token-efficient codebase exploration and code review. --- {_CLAUDE_MD_SECTION_MARKER} ## MCP Tools: code-review-graph **IMPORTANT: This project has a knowledge graph. ALWAYS use the code-review-graph MCP tools BEFORE using file/search tools to explore the codebase.** The graph is faster, cheaper (fewer tokens), and gives you structural context (callers, dependents, test coverage) that file scanning cannot. ### When to use graph tools FIRST - **Exploring code**: `semantic_search_nodes_tool` or `query_graph_tool` - **Understanding impact**: `get_impact_radius_tool` - **Code review**: `detect_changes_tool` + `get_review_context_tool` - **Finding relationships**: `query_graph_tool` callers_of/callees_of - **Architecture questions**: `get_architecture_overview_tool` Fall back to file/search tools **only** when the graph doesn't cover what you need. ### Key Tools | Tool | Use when | | ------ | ---------- | | `detect_changes_tool` | Risk-scored change analysis | | `get_review_context_tool` | Token-efficient source snippets | | `get_impact_radius_tool` | Blast radius of a change | | `get_affected_flows_tool` | Impacted execution paths | | `query_graph_tool` | Trace callers, callees, imports, tests | | `semantic_search_nodes_tool` | Find functions/classes by keyword | | `get_architecture_overview_tool` | High-level structure | | `refactor_tool` | Rename planning, dead code | ### Workflow 1. The graph auto-updates on file changes (via hooks). 2. Use `detect_changes_tool` for code review. 3. Use `get_affected_flows_tool` to understand impact. 4. Use `query_graph_tool` pattern=\"tests_for\" to check coverage. """ # Maps instruction file path → (marker, section) for files that need content # different from the default _CLAUDE_MD_SECTION. Legacy paths remain here so # uninstall can identify sections written by older releases. _PLATFORM_INSTRUCTION_CUSTOM_SECTIONS: dict[str, tuple[str, str]] = { ".github/instructions/code-review-graph.instructions.md": ( _CLAUDE_MD_SECTION_MARKER, _COPILOT_SECTION, ), ".github/code-review-graph.instruction.md": (_CLAUDE_MD_SECTION_MARKER, _COPILOT_SECTION), } def _inject_instructions(file_path: Path, marker: str, section: str) -> bool: """Append an instruction section to a file if not already present. Idempotent: checks if the marker is already present before appending. Creates the file if it doesn't exist. Returns True if the file was modified. """ existing = "" if file_path.exists(): existing = file_path.read_text(encoding="utf-8", errors="replace") if marker in existing: logger.info("%s already contains instructions, skipping.", file_path.name) return False separator = "\n" if existing and not existing.endswith("\n") else "" extra_newline = "\n" if existing else "" file_path.parent.mkdir(parents=True, exist_ok=True) file_path.write_text(existing + separator + extra_newline + section, encoding="utf-8") logger.info("Appended MCP tools section to %s", file_path) return True def inject_claude_md(repo_root: Path) -> None: """Append MCP tools section to CLAUDE.md.""" _inject_instructions( repo_root / "CLAUDE.md", _CLAUDE_MD_SECTION_MARKER, _CLAUDE_MD_SECTION, ) # Cross-platform instruction files and which platforms own each one. # Used to filter writes when the user passes --platform : only files # whose owner set includes the target (or "all") are written. _PLATFORM_INSTRUCTION_FILES: dict[str, tuple[str, ...]] = { "AGENTS.md": ("cursor", "opencode", "antigravity", "codex"), "GEMINI.md": ("antigravity", "gemini-cli"), ".cursorrules": ("cursor",), ".windsurfrules": ("windsurf",), "QODER.md": ("qoder",), ".kiro/steering/code-review-graph.md": ("kiro",), ".github/instructions/code-review-graph.instructions.md": ("copilot", "copilot-cli"), "CODEBUDDY.md": ("codebuddy",), } # Superseded paths written by older releases. Reinstall removes only the exact # generated section and leaves any user-authored content intact. _LEGACY_PLATFORM_INSTRUCTION_FILES: dict[str, tuple[str, ...]] = { ".github/code-review-graph.instruction.md": ("copilot", "copilot-cli"), } def _remove_legacy_instruction_file(path: Path) -> None: """Strip an exact generated section from a superseded instruction file.""" if not path.exists(): return content = path.read_text(encoding="utf-8", errors="replace") if _CLAUDE_MD_SECTION_MARKER not in content: return for section in (_COPILOT_SECTION, _CLAUDE_MD_SECTION): content = content.replace(section, "") if _CLAUDE_MD_SECTION_MARKER in content: return if content.strip(): path.write_text(content.rstrip() + "\n", encoding="utf-8") logger.info("Removed legacy instruction section from %s", path) else: path.unlink() logger.info("Removed legacy instruction file %s", path) # --- Gemini CLI hooks + skills (workspace-level: .gemini/) --- _GEMINI_CLI_HOOK_FILENAMES = ("crg-session-start.sh", "crg-update.sh") def install_gemini_cli_hooks(repo_root: Path) -> Path: """Install Gemini CLI hooks in .gemini/settings.json and write hook scripts. Hooks schema reference: - https://geminicli.com/docs/hooks/reference/ This is workspace-scoped (project) configuration: .gemini/settings.json """ settings_dir = repo_root / ".gemini" settings_dir.mkdir(parents=True, exist_ok=True) settings_path = settings_dir / "settings.json" existing: dict[str, Any] = {} if settings_path.exists(): try: existing = json.loads(settings_path.read_text(encoding="utf-8", errors="replace")) backup_path = settings_dir / "settings.json.bak" shutil.copy2(settings_path, backup_path) logger.info("Backed up existing Gemini CLI settings to %s", backup_path) except (json.JSONDecodeError, OSError) as exc: logger.warning("Could not read existing %s: %s", settings_path, exc) hooks_dir = settings_dir / "hooks" hooks_dir.mkdir(parents=True, exist_ok=True) repo_arg = repo_root.resolve().as_posix() session_start_script = """\ #!/usr/bin/env bash # code-review-graph: session start status (Gemini CLI hook) # Must output ONLY JSON on stdout. Logs go to stderr. Never blocks the session. set -euo pipefail cat > /dev/null || true msg="$(code-review-graph status --repo "__CRG_REPO__" 2>&1 | head -n 1 || true)" CRG_MSG="$msg" python3 -c ' import json,os m=os.environ.get("CRG_MSG","") print(json.dumps({"systemMessage":m,"suppressOutput":True})) ' 2>/dev/null || echo '{"suppressOutput": true}' exit 0 """ session_start_script = session_start_script.replace("__CRG_REPO__", repo_arg) update_script = """\ #!/usr/bin/env bash # code-review-graph: incremental update after write/replace (Gemini CLI hook) # Must output ONLY JSON on stdout. Low-noise: no systemMessage. set -euo pipefail cat > /dev/null || true code-review-graph update --skip-flows --repo "__CRG_REPO__" >/dev/null 2>&1 || true echo '{"suppressOutput": true}' exit 0 """ update_script = update_script.replace("__CRG_REPO__", repo_arg) session_start_path = hooks_dir / _GEMINI_CLI_HOOK_FILENAMES[0] session_start_path.write_text(session_start_script, encoding="utf-8") session_start_path.chmod(0o755) update_path = hooks_dir / _GEMINI_CLI_HOOK_FILENAMES[1] update_path.write_text(update_script, encoding="utf-8") update_path.chmod(0o755) hooks_obj = existing.get("hooks", {}) if not isinstance(hooks_obj, dict): hooks_obj = {} def _ensure_group( event_name: str, matcher: str, hook_command: str, name: str, timeout: int, ) -> None: arr = hooks_obj.get(event_name, []) if not isinstance(arr, list): arr = [] # De-duplicate by command (and type) inside nested hooks list. def _group_has_command(group: Any) -> bool: if not isinstance(group, dict): return False nested = group.get("hooks", []) if not isinstance(nested, list): return False for h in nested: if isinstance(h, dict) and h.get("type") == "command" \ and h.get("command") == hook_command: return True return False if any(_group_has_command(g) for g in arr): hooks_obj[event_name] = arr return arr.append( { "matcher": matcher, "hooks": [ { "type": "command", "command": hook_command, "name": name, "timeout": timeout, } ], } ) hooks_obj[event_name] = arr _ensure_group( event_name="SessionStart", matcher="", hook_command=f"bash .gemini/hooks/{_GEMINI_CLI_HOOK_FILENAMES[0]}", name="code-review-graph status", timeout=10_000, ) _ensure_group( event_name="AfterTool", matcher="write_file|replace", hook_command=f"bash .gemini/hooks/{_GEMINI_CLI_HOOK_FILENAMES[1]}", name="code-review-graph update", timeout=30_000, ) existing["hooks"] = hooks_obj settings_path.write_text( json.dumps(existing, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" ) logger.info("Wrote Gemini CLI hooks config: %s", settings_path) return settings_path def install_gemini_cli_skills(repo_root: Path) -> Path: """Install Gemini CLI Agent Skills in .gemini/skills//SKILL.md.""" skills_root = repo_root / ".gemini" / "skills" skills_root.mkdir(parents=True, exist_ok=True) for filename, skill in _SKILLS.items(): slug = filename.rsplit(".", 1)[0] skill_dir = skills_root / slug skill_dir.mkdir(parents=True, exist_ok=True) skill_path = skill_dir / "SKILL.md" content = ( "---\n" f"name: {slug}\n" f"description: {skill['description']}\n" "---\n\n" f"{skill['body']}\n" ) skill_path.write_text(content, encoding="utf-8") logger.info("Wrote Gemini CLI skill: %s", skill_path) return skills_root def install_codebuddy_skills(repo_root: Path) -> Path: """Install project skills in .codebuddy/skills//SKILL.md.""" skills_root = repo_root / ".codebuddy" / "skills" skills_root.mkdir(parents=True, exist_ok=True) for filename, skill in _SKILLS.items(): slug = filename.rsplit(".", 1)[0] skill_dir = skills_root / slug skill_dir.mkdir(parents=True, exist_ok=True) skill_path = skill_dir / "SKILL.md" content = ( "---\n" f"name: {slug}\n" f"description: {skill['description']}\n" "---\n\n" f"{skill['body']}\n" ) skill_path.write_text(content, encoding="utf-8") logger.info("Wrote CodeBuddy skill: %s", skill_path) return skills_root def inject_platform_instructions(repo_root: Path, target: str = "all") -> list[str]: """Inject 'use graph first' instructions into platform rule files. Writes AGENTS.md, GEMINI.md, .cursorrules, and/or .windsurfrules depending on ``target``: - ``"all"`` (default): writes every file — matches pre-filter behavior. - ``"claude"``: writes nothing (CLAUDE.md is handled by ``inject_claude_md``). - any other platform key (``cursor``, ``windsurf``, ``antigravity``, ``opencode``, ``codex``): writes only the files associated with that platform. Returns list of filenames that were created or updated. """ updated: list[str] = [] for filename, owners in _PLATFORM_INSTRUCTION_FILES.items(): if target != "all" and target not in owners: continue path = repo_root / filename if filename in _PLATFORM_INSTRUCTION_CUSTOM_SECTIONS: marker, section = _PLATFORM_INSTRUCTION_CUSTOM_SECTIONS[filename] else: marker, section = _CLAUDE_MD_SECTION_MARKER, _CLAUDE_MD_SECTION if _inject_instructions(path, marker, section): updated.append(filename) for filename, owners in _LEGACY_PLATFORM_INSTRUCTION_FILES.items(): if target != "all" and target not in owners: continue _remove_legacy_instruction_file(repo_root / filename) return updated # --- Cursor hooks --- def generate_cursor_hooks_config() -> dict[str, Any]: """Generate Cursor hooks.json configuration. Returns a dict conforming to the Cursor hooks schema (version 1) with hooks for afterFileEdit, sessionStart, and beforeShellExecution. Each hook points to a shell script in ~/.cursor/hooks/. Returns: Dict suitable for writing as ~/.cursor/hooks.json. """ hooks_dir = str(Path.home() / ".cursor" / "hooks") return { "version": 1, "hooks": { "afterFileEdit": [ { "command": f"{hooks_dir}/crg-update.sh", "timeout": 5, }, ], "sessionStart": [ { "command": f"{hooks_dir}/crg-session-start.sh", "timeout": 5, }, ], "beforeShellExecution": [ { "matcher": "^git\\s+commit", "command": f"{hooks_dir}/crg-pre-commit.sh", "timeout": 10, }, ], }, } def _cursor_hook_scripts() -> dict[str, str]: """Return a mapping of filename -> shell script content for Cursor hooks. Three scripts are generated: - crg-update.sh: runs ``code-review-graph update --skip-flows`` after file edits - crg-session-start.sh: runs ``code-review-graph status`` on session start - crg-pre-commit.sh: runs ``code-review-graph detect-changes --brief`` before git commit commands All scripts: - Read stdin (Cursor passes JSON context) and discard it - Fail gracefully (exit 0) so they never block the editor - Emit valid JSON on stdout per the Cursor hooks protocol """ update_script = """\ #!/usr/bin/env bash # code-review-graph: auto-update graph after file edits (Cursor hook) # Fails gracefully — never blocks the editor. set -euo pipefail # Consume stdin (Cursor sends JSON context) cat > /dev/null # Run update; swallow errors so the hook always succeeds. output=$(code-review-graph update --skip-flows 2>&1) || true # Emit valid JSON on stdout per Cursor hooks protocol. python3 -c " import json, sys print(json.dumps({'message': 'graph updated', 'passed': True})) " 2>/dev/null || echo '{"passed":true}' exit 0 """ session_start_script = """\ #!/usr/bin/env bash # code-review-graph: show graph status on session start (Cursor hook) # Fails gracefully — never blocks the editor. set -euo pipefail # Consume stdin cat > /dev/null # Capture status output output=$(code-review-graph status 2>&1) || output="graph not built yet" # Emit valid JSON on stdout python3 -c " import json, sys msg = sys.stdin.read() print(json.dumps({'message': msg, 'passed': True})) " <<< "$output" 2>/dev/null || echo '{"passed":true}' exit 0 """ pre_commit_script = """\ #!/usr/bin/env bash # code-review-graph: detect changes before git commit (Cursor hook) # Fails gracefully — never blocks the editor. set -euo pipefail # Consume stdin cat > /dev/null # Run detect-changes; swallow errors output=$(code-review-graph detect-changes --brief 2>&1) || output="" # Emit valid JSON on stdout python3 -c " import json, sys msg = sys.stdin.read() print(json.dumps({'message': msg, 'passed': True})) " <<< "$output" 2>/dev/null || echo '{"passed":true}' exit 0 """ return { "crg-update.sh": update_script, "crg-session-start.sh": session_start_script, "crg-pre-commit.sh": pre_commit_script, } def install_cursor_hooks() -> Path: """Install Cursor hooks configuration and scripts at user level. Writes ``~/.cursor/hooks.json`` (merging code-review-graph hooks into any existing configuration) and creates executable shell scripts in ``~/.cursor/hooks/``. Returns: Path to the hooks.json file that was written. """ cursor_dir = Path.home() / ".cursor" hooks_json_path = cursor_dir / "hooks.json" hooks_script_dir = cursor_dir / "hooks" # --- Merge hooks.json --- existing: dict[str, Any] = {} if hooks_json_path.exists(): try: existing = json.loads(hooks_json_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError) as exc: logger.warning("Could not read existing %s: %s", hooks_json_path, exc) new_config = generate_cursor_hooks_config() # Preserve version (use ours if absent) existing.setdefault("version", new_config["version"]) # Merge hook arrays per event type existing_hooks = existing.get("hooks", {}) if not isinstance(existing_hooks, dict): existing_hooks = {} for event, entries in new_config["hooks"].items(): event_hooks = existing_hooks.get(event, []) if not isinstance(event_hooks, list): event_hooks = [] # De-duplicate: skip if a hook with the same command already exists existing_commands = {h.get("command", "") for h in event_hooks if isinstance(h, dict)} for entry in entries: if entry["command"] not in existing_commands: event_hooks.append(entry) existing_hooks[event] = event_hooks existing["hooks"] = existing_hooks cursor_dir.mkdir(parents=True, exist_ok=True) hooks_json_path.write_text( json.dumps(existing, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) logger.info("Wrote Cursor hooks config: %s", hooks_json_path) # --- Write hook scripts --- hooks_script_dir.mkdir(parents=True, exist_ok=True) scripts = _cursor_hook_scripts() for filename, content in scripts.items(): script_path = hooks_script_dir / filename script_path.write_text(content, encoding="utf-8") # Make executable (owner rwx, group rx, other rx) script_path.chmod(stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH) logger.info("Wrote Cursor hook script: %s", script_path) return hooks_json_path def install_qoder_skills(repo_root: Path) -> Path | None: """Install skills to Qoder's project-level skills directory. Qoder expects skills in .qoder/skills/{skillName}/SKILL.md format within the project. This function copies the project's skills/ directory contents to that location. Args: repo_root: Repository root directory (where the skills/ folder is located). Returns: Path to the Qoder skills directory, or None if installation failed. """ # Qoder skills directory (project-level) qoder_skills_dir = repo_root / ".qoder" / "skills" qoder_skills_dir.mkdir(parents=True, exist_ok=True) # Source skills directory in the project source_skills_dir = repo_root / "skills" if not source_skills_dir.exists(): logger.warning("No skills/ directory found in %s", repo_root) return None installed_count = 0 for skill_dir in source_skills_dir.iterdir(): if skill_dir.is_dir(): skill_file = skill_dir / "SKILL.md" if skill_file.exists(): target_dir = qoder_skills_dir / skill_dir.name target_dir.mkdir(parents=True, exist_ok=True) target_file = target_dir / "SKILL.md" target_file.write_text(skill_file.read_text(encoding="utf-8"), encoding="utf-8") logger.info("Installed Qoder skill: %s", skill_dir.name) installed_count += 1 if installed_count > 0: logger.info("Installed %d skill(s) to %s", installed_count, qoder_skills_dir) return qoder_skills_dir return None # --- OpenCode plugin --- def _opencode_plugin_content() -> str: """Return TypeScript source for the OpenCode user-level plugin. The plugin hooks into three OpenCode events to mirror the Claude Code hook behaviors: 1. ``file.edited`` — runs ``code-review-graph update --skip-flows`` 2. ``session.created`` — runs ``code-review-graph status`` 3. ``tool.execute.before`` — when the tool is a shell command starting with ``git commit``, runs ``code-review-graph detect-changes --brief`` All handlers use try/catch so errors never break the editor session. The plugin uses Bun's ``$`` shell API (provided by OpenCode's plugin context) for subprocess execution. """ return """\ import type { Plugin } from "@opencode-ai/plugin" /** * code-review-graph plugin for OpenCode. * * Keeps the knowledge graph up-to-date and surfaces status * information automatically during coding sessions. * * Installed by: code-review-graph install --platform opencode */ // Helper: run a shell command quietly, swallowing errors. async function run($: any, cmd: string): Promise { try { const result = await $`${cmd}`.quiet() return result.stdout?.toString().trim() ?? "" } catch { return "" } } export default (app: any) => { // 1. Auto-update graph after file edits app.on("file.edited", async ({ $ }: { $: any }) => { try { await $`code-review-graph update --skip-flows`.quiet() } catch { // Swallow — graph may not be built yet for this project. } }) // 2. Show graph status when a new session starts app.on("session.created", async ({ $ }: { $: any }) => { try { const result = await $`code-review-graph status`.quiet() const output = result.stdout?.toString().trim() if (output) { console.log("[code-review-graph]", output) } } catch { // Swallow — not every project has a graph. } }) // 3. Detect changes before git commit commands app.on("tool.execute.before", async (ctx: any) => { try { const input = ctx?.input ?? ctx?.params ?? {} const cmd = input.command ?? input.cmd ?? input.content ?? "" if (typeof cmd === "string" && /^git\\s+commit/i.test(cmd)) { const result = await ctx.$`code-review-graph detect-changes --brief`.quiet() const output = result.stdout?.toString().trim() if (output) { console.log("[code-review-graph] Pre-commit analysis:\\n" + output) } } } catch { // Swallow — never block a commit. } }) } """ def install_opencode_plugin() -> Path: """Install the OpenCode user-level plugin for code-review-graph. Writes ``~/.config/opencode/plugins/crg-plugin.ts``. Creates the directories if they don't exist. If the file already exists it is overwritten (the plugin is self-contained and idempotent). Returns: Path to the plugin file that was written. """ plugins_dir = Path.home() / ".config" / "opencode" / "plugins" plugin_path = plugins_dir / "crg-plugin.ts" plugins_dir.mkdir(parents=True, exist_ok=True) plugin_path.write_text(_opencode_plugin_content(), encoding="utf-8") logger.info("Wrote OpenCode plugin: %s", plugin_path) return plugin_path