Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ code-review-graph install # auto-detects and configures all supported p
code-review-graph build # parse your codebase
```

One command sets up everything. `install` detects which AI coding tools you have, writes the correct MCP configuration for each one, and injects graph-aware instructions into your platform rules. It auto-detects whether you installed via `uvx` or `pip`/`pipx` and generates the right config. Restart your editor/tool after installing.
One command sets up everything. `install` detects which AI coding tools you have, writes the correct MCP configuration for each one, installs platform-native hooks/skills where supported, and injects graph-aware instructions into your platform rules. It auto-detects whether you installed via `uvx` or `pip`/`pipx` and generates the right config. Restart your editor/tool after installing.

<p align="center">
<img src="diagrams/diagram8_supported_platforms.png" alt="One Install, Every Platform: auto-detects Codex, Claude Code, Cursor, Windsurf, Zed, Continue, OpenCode, Antigravity, Qwen, Qoder, and Kiro" width="85%" />
Expand Down
37 changes: 22 additions & 15 deletions code_review_graph/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,9 @@ def _handle_init(args: argparse.Namespace) -> None:
else:
print(".gitignore already contains .code-review-graph/.")

# Skills and hooks are installed by default so Claude actually uses the
# graph tools proactively. Use --no-skills / --no-hooks / --no-instructions
# to opt out.
# Platform-native skills and hooks are installed by default where supported
# so the graph tools are used proactively. Use --no-skills / --no-hooks /
# --no-instructions to opt out.
skip_skills = getattr(args, "no_skills", False)
skip_hooks = getattr(args, "no_hooks", False)
# Legacy: --skills/--hooks/--all still accepted (no-op, everything is default)
Expand All @@ -229,14 +229,15 @@ def _handle_init(args: argparse.Namespace) -> None:
generate_skills,
inject_claude_md,
inject_platform_instructions,
install_codex_hooks,
install_cursor_hooks,
install_git_hook,
install_hooks,
install_opencode_plugin,
install_qoder_skills,
)

if not skip_skills:
if not skip_skills and target in ("claude", "all"):
skills_dir = generate_skills(repo_root)
print(f"Generated skills in {skills_dir}")

Expand Down Expand Up @@ -266,6 +267,12 @@ def _handle_init(args: argparse.Namespace) -> None:
qoder_skills_dir = install_qoder_skills(repo_root)
if qoder_skills_dir:
print(f"Installed Qoder skills to {qoder_skills_dir}")
if not skip_hooks and target in ("codex", "all"):
hooks_path = install_codex_hooks(repo_root)
print(f"Installed Codex hooks in {hooks_path}")
git_hook = install_git_hook(repo_root)
if git_hook:
print(f"Installed git pre-commit hook in {git_hook}")
if not skip_hooks and target in ("claude", "qoder", "all"):
platforms_to_install = [target] if target != "all" else ["claude", "qoder"]
for plat in platforms_to_install:
Expand All @@ -275,13 +282,13 @@ def _handle_init(args: argparse.Namespace) -> None:
if git_hook:
print(f"Installed git pre-commit hook in {git_hook}")

# Cursor hooks (user-level, only if ~/.cursor exists — matching MCP detect)
if target in ("all", "cursor") and PLATFORMS["cursor"]["detect"]():
try:
hooks_path = install_cursor_hooks()
print(f"Installed Cursor hooks in {hooks_path}")
except Exception as exc:
logger.warning("Could not install Cursor hooks: %s", exc)
# Cursor hooks (user-level, only if ~/.cursor exists — matching MCP detect)
if not skip_hooks and target in ("all", "cursor") and PLATFORMS["cursor"]["detect"]():
try:
hooks_path = install_cursor_hooks()
print(f"Installed Cursor hooks in {hooks_path}")
except Exception as exc:
logger.warning("Could not install Cursor hooks: %s", exc)

# OpenCode plugin (user-level, gated by same detect() as MCP config)
if not skip_hooks and target in ("all", "opencode") and PLATFORMS["opencode"]["detect"]():
Expand Down Expand Up @@ -332,12 +339,12 @@ def main() -> None:
install_cmd.add_argument(
"--no-skills",
action="store_true",
help="Skip generating Claude Code skill files",
help="Skip generating platform-native skill files",
)
install_cmd.add_argument(
"--no-hooks",
action="store_true",
help="Skip installing Claude Code hooks",
help="Skip installing platform-native hooks",
)
install_cmd.add_argument(
"--no-instructions",
Expand Down Expand Up @@ -373,12 +380,12 @@ def main() -> None:
init_cmd.add_argument(
"--no-skills",
action="store_true",
help="Skip generating Claude Code skill files",
help="Skip generating platform-native skill files",
)
init_cmd.add_argument(
"--no-hooks",
action="store_true",
help="Skip installing Claude Code hooks",
help="Skip installing platform-native hooks",
)
init_cmd.add_argument(
"--no-instructions",
Expand Down
98 changes: 98 additions & 0 deletions code_review_graph/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,48 @@ def generate_hooks_config(repo_root: Path) -> dict[str, Any]:
}


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": (
"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": (
"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.

Expand Down Expand Up @@ -639,6 +681,62 @@ def install_hooks(repo_root: Path, platform: str = "claude") -> None:
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}
Expand Down
6 changes: 3 additions & 3 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ code-review-graph install # auto-detects and configures all supported platfor
code-review-graph build # parse your codebase
```

`install` detects which AI coding tools you have and writes the correct MCP configuration for each one. Restart your editor/tool after installing.
`install` detects which AI coding tools you have, writes the correct MCP configuration for each one, and installs platform-native hooks where supported. Restart your editor/tool after installing.

To target a specific platform instead of auto-detecting all:

Expand All @@ -24,8 +24,8 @@ code-review-graph install --platform claude-code

| Platform | Config file |
|----------|-------------|
| **Codex** | `~/.codex/config.toml` |
| **Claude Code** | `.mcp.json` |
| **Codex** | `~/.codex/config.toml` + `~/.codex/hooks.json` |
| **Claude Code** | `.mcp.json` + `.claude/settings.json` |
| **Cursor** | `.cursor/mcp.json` |
| **Windsurf** | `.windsurf/mcp.json` |
| **Zed** | `.zed/settings.json` |
Expand Down
98 changes: 98 additions & 0 deletions tests/test_cli_install.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Tests for install CLI platform-specific behavior."""

from __future__ import annotations

import argparse
from pathlib import Path

from code_review_graph.cli import _handle_init


def _args(tmp_path: Path, platform: str) -> argparse.Namespace:
return argparse.Namespace(
repo=str(tmp_path),
dry_run=False,
platform=platform,
yes=True,
no_instructions=True,
no_skills=False,
no_hooks=False,
)


def test_handle_init_codex_skips_claude_skills(monkeypatch, tmp_path, capsys):
monkeypatch.setattr(
"code_review_graph.incremental.find_repo_root",
lambda: tmp_path,
)
monkeypatch.setattr(
"code_review_graph.incremental.ensure_repo_gitignore_excludes_crg",
lambda repo_root: "created",
)
monkeypatch.setattr(
"code_review_graph.skills.install_platform_configs",
lambda repo_root, target, dry_run=False: ["Codex"],
)

called = {"generate_skills": False, "codex_hooks": False, "git_hook": False}

def _generate_skills(repo_root):
called["generate_skills"] = True
return repo_root / ".claude" / "skills"

def _install_codex_hooks(repo_root):
called["codex_hooks"] = True
return Path("/tmp/fake-codex-hooks.json")

def _install_git_hook(repo_root):
called["git_hook"] = True
return repo_root / ".git" / "hooks" / "pre-commit"

monkeypatch.setattr("code_review_graph.skills.generate_skills", _generate_skills)
monkeypatch.setattr("code_review_graph.skills.install_codex_hooks", _install_codex_hooks)
monkeypatch.setattr("code_review_graph.skills.install_git_hook", _install_git_hook)

_handle_init(_args(tmp_path, "codex"))
out = capsys.readouterr().out

assert called["generate_skills"] is False
assert called["codex_hooks"] is True
assert called["git_hook"] is True
assert "Installed Codex hooks" in out


def test_handle_init_cursor_installs_cursor_hooks(monkeypatch, tmp_path, capsys):
monkeypatch.setattr(
"code_review_graph.incremental.find_repo_root",
lambda: tmp_path,
)
monkeypatch.setattr(
"code_review_graph.incremental.ensure_repo_gitignore_excludes_crg",
lambda repo_root: "created",
)
monkeypatch.setattr(
"code_review_graph.skills.install_platform_configs",
lambda repo_root, target, dry_run=False: ["Cursor"],
)
monkeypatch.setitem(
__import__("code_review_graph.skills", fromlist=["PLATFORMS"]).PLATFORMS,
"cursor",
{
**__import__("code_review_graph.skills", fromlist=["PLATFORMS"]).PLATFORMS["cursor"],
"detect": lambda: True,
},
)

called = {"cursor_hooks": False}

def _install_cursor_hooks():
called["cursor_hooks"] = True
return Path("/tmp/fake-cursor-hooks.json")

monkeypatch.setattr("code_review_graph.skills.install_cursor_hooks", _install_cursor_hooks)

_handle_init(_args(tmp_path, "cursor"))
out = capsys.readouterr().out

assert called["cursor_hooks"] is True
assert "Installed Cursor hooks" in out
Loading