1482 lines
55 KiB
Python
1482 lines
55 KiB
Python
"""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 re
|
||
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"
|
||
|
||
|
||
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": lambda root: root / ".opencode.json",
|
||
"key": "mcpServers",
|
||
"detect": lambda: True,
|
||
"format": "object",
|
||
"needs_type": True,
|
||
},
|
||
"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": lambda: (Path.home() / ".vscode").exists(),
|
||
"format": "object",
|
||
"needs_type": True,
|
||
},
|
||
"copilot-cli": {
|
||
"name": "GitHub Copilot CLI",
|
||
"config_path": lambda root: Path.home() / ".copilot" / "mcp-config.json",
|
||
"key": "servers",
|
||
"detect": lambda: (Path.home() / ".copilot").exists(),
|
||
"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/<name>`` on Linux/macOS or
|
||
``%LOCALAPPDATA%\\pypoetry\\Cache\\virtualenvs\\<name>`` 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()
|
||
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"] = "stdio"
|
||
if key == "opencode":
|
||
entry["env"] = []
|
||
return entry
|
||
|
||
|
||
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 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.
|
||
"""
|
||
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"]
|
||
else:
|
||
if target not in PLATFORMS:
|
||
logger.error("Unknown platform: %s", target)
|
||
return []
|
||
platforms_to_install = {target: PLATFORMS[target]}
|
||
|
||
configured: list[str] = []
|
||
|
||
for key, plat in platforms_to_install.items():
|
||
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}")
|
||
configured.append(plat["name"])
|
||
continue
|
||
if dry_run:
|
||
print(f" [dry-run] {plat['name']}: would write {config_path}")
|
||
else:
|
||
print(f" {plat['name']}: configured {config_path}")
|
||
configured.append(plat["name"])
|
||
continue
|
||
|
||
# Read existing config
|
||
existing: dict[str, Any] = {}
|
||
if config_path.exists():
|
||
raw = config_path.read_text(encoding="utf-8", errors="replace")
|
||
# Strip single-line comments and trailing commas (JSONC compat
|
||
# for editors like Zed that allow non-standard JSON).
|
||
stripped = re.sub(r'//.*?$', '', raw, flags=re.MULTILINE)
|
||
stripped = re.sub(r',(\s*[}\]])', r'\1', stripped)
|
||
try:
|
||
existing = 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 plat["format"] == "array":
|
||
arr = existing.get(server_key, [])
|
||
if not isinstance(arr, list):
|
||
arr = []
|
||
# 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}")
|
||
configured.append(plat["name"])
|
||
continue
|
||
arr_entry = {"name": "code-review-graph", **server_entry}
|
||
arr.append(arr_entry)
|
||
existing[server_key] = arr
|
||
else:
|
||
servers = existing.get(server_key, {})
|
||
if not isinstance(servers, dict):
|
||
servers = {}
|
||
if "code-review-graph" in servers:
|
||
print(f" {plat['name']}: already configured in {config_path}")
|
||
configured.append(plat["name"])
|
||
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) + "\n", encoding="utf-8")
|
||
print(f" {plat['name']}: configured {config_path}")
|
||
|
||
configured.append(plat["name"])
|
||
|
||
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="<your 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="<your 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."
|
||
),
|
||
},
|
||
"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="<your 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="<your 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/<name>/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``.
|
||
"""
|
||
repo_arg = json.dumps(repo_root.resolve().as_posix())
|
||
return {
|
||
"hooks": {
|
||
"PostToolUse": [
|
||
{
|
||
"matcher": "Edit|Write|Bash",
|
||
"hooks": [
|
||
{
|
||
"type": "command",
|
||
"command": (
|
||
"cat >/dev/null || true; "
|
||
"git rev-parse --git-dir >/dev/null 2>&1"
|
||
f" && code-review-graph update --skip-flows"
|
||
f" --repo {repo_arg}"
|
||
" || true"
|
||
),
|
||
"timeout": 30,
|
||
},
|
||
],
|
||
},
|
||
],
|
||
"SessionStart": [
|
||
{
|
||
"matcher": "",
|
||
"hooks": [
|
||
{
|
||
"type": "command",
|
||
"command": (
|
||
"cat >/dev/null || true; "
|
||
"git rev-parse --git-dir >/dev/null 2>&1"
|
||
f" && code-review-graph status --repo {repo_arg}"
|
||
" || 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 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"
|
||
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)
|
||
|
||
hooks_config = generate_hooks_config(repo_root)
|
||
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) + "\n", encoding="utf-8")
|
||
logger.info("Wrote hooks config: %s", settings_path)
|
||
|
||
|
||
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) + "\n", encoding="utf-8")
|
||
logger.info("Wrote Codex hooks config: %s", hooks_path)
|
||
return hooks_path
|
||
|
||
|
||
_CLAUDE_MD_SECTION_MARKER = "<!-- code-review-graph MCP tools -->"
|
||
|
||
_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.
|
||
_PLATFORM_INSTRUCTION_CUSTOM_SECTIONS: dict[str, tuple[str, str]] = {
|
||
".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 <X>: only files
|
||
# whose owner set includes the target (or "all") are written.
|
||
_PLATFORM_INSTRUCTION_FILES: dict[str, tuple[str, ...]] = {
|
||
"AGENTS.md": ("cursor", "opencode", "antigravity"),
|
||
"GEMINI.md": ("antigravity", "gemini-cli"),
|
||
".cursorrules": ("cursor",),
|
||
".windsurfrules": ("windsurf",),
|
||
"QODER.md": ("qoder",),
|
||
".kiro/steering/code-review-graph.md": ("kiro",),
|
||
".github/code-review-graph.instruction.md": ("copilot", "copilot-cli"),
|
||
}
|
||
|
||
|
||
# --- Gemini CLI hooks + skills (workspace-level: .gemini/) ---
|
||
|
||
|
||
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 / "crg-session-start.sh"
|
||
session_start_path.write_text(session_start_script, encoding="utf-8")
|
||
session_start_path.chmod(0o755)
|
||
|
||
update_path = hooks_dir / "crg-update.sh"
|
||
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="bash .gemini/hooks/crg-session-start.sh",
|
||
name="code-review-graph status",
|
||
timeout=10_000,
|
||
)
|
||
_ensure_group(
|
||
event_name="AfterTool",
|
||
matcher="write_file|replace",
|
||
hook_command="bash .gemini/hooks/crg-update.sh",
|
||
name="code-review-graph update",
|
||
timeout=30_000,
|
||
)
|
||
|
||
existing["hooks"] = hooks_obj
|
||
settings_path.write_text(json.dumps(existing, indent=2) + "\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>/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 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``): 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)
|
||
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) + "\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<string> {
|
||
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
|