diff --git a/README.md b/README.md index 0724903..3bf38c0 100644 --- a/README.md +++ b/README.md @@ -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.
diff --git a/code_review_graph/cli.py b/code_review_graph/cli.py
index 18e9d52..f424789 100644
--- a/code_review_graph/cli.py
+++ b/code_review_graph/cli.py
@@ -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)
@@ -229,6 +229,7 @@ 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,
@@ -236,7 +237,7 @@ def _handle_init(args: argparse.Namespace) -> None:
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}")
@@ -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:
@@ -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"]():
@@ -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",
@@ -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",
diff --git a/code_review_graph/skills.py b/code_review_graph/skills.py
index 6626553..81f540c 100644
--- a/code_review_graph/skills.py
+++ b/code_review_graph/skills.py
@@ -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.
@@ -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 = ""
_CLAUDE_MD_SECTION = f"""{_CLAUDE_MD_SECTION_MARKER}
diff --git a/docs/USAGE.md b/docs/USAGE.md
index e2fa865..6e190da 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -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:
@@ -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` |
diff --git a/tests/test_cli_install.py b/tests/test_cli_install.py
new file mode 100644
index 0000000..883e305
--- /dev/null
+++ b/tests/test_cli_install.py
@@ -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
diff --git a/tests/test_skills.py b/tests/test_skills.py
index 873ed2f..2592e45 100644
--- a/tests/test_skills.py
+++ b/tests/test_skills.py
@@ -17,17 +17,18 @@
from code_review_graph.skills import (
_CLAUDE_MD_SECTION_MARKER,
PLATFORMS,
- _build_server_entry,
_cursor_hook_scripts,
_detect_serve_command,
_in_poetry_project,
_in_uv_project,
_opencode_plugin_content,
+ generate_codex_hooks_config,
generate_cursor_hooks_config,
generate_hooks_config,
generate_skills,
inject_claude_md,
inject_platform_instructions,
+ install_codex_hooks,
install_cursor_hooks,
install_git_hook,
install_hooks,
@@ -35,11 +36,6 @@
install_platform_configs,
)
-try:
- import tomllib
-except ModuleNotFoundError:
- tomllib = None # type: ignore[assignment]
-
_needs_tomllib = pytest.mark.skipif(
tomllib is None, reason="tomllib requires Python 3.11+",
)
@@ -261,6 +257,97 @@ def test_creates_claude_directory(self, tmp_path):
install_hooks(tmp_path)
assert (tmp_path / ".claude").is_dir()
+
+class TestGenerateCodexHooksConfig:
+ def test_returns_dict_with_hooks(self, tmp_path):
+ config = generate_codex_hooks_config(tmp_path)
+ assert "hooks" in config
+
+ def test_has_post_tool_use(self, tmp_path):
+ config = generate_codex_hooks_config(tmp_path)
+ assert "PostToolUse" in config["hooks"]
+ entry = config["hooks"]["PostToolUse"][0]
+ assert entry["matcher"] == "Write|Edit|Bash"
+ inner = entry["hooks"][0]
+ assert inner["type"] == "command"
+ assert "update" in inner["command"]
+ assert inner["statusMessage"] == "Updating code-review-graph"
+
+ def test_has_session_start(self, tmp_path):
+ config = generate_codex_hooks_config(tmp_path)
+ assert "SessionStart" in config["hooks"]
+ entry = config["hooks"]["SessionStart"][0]
+ assert entry["matcher"] == "startup|resume"
+ inner = entry["hooks"][0]
+ assert inner["type"] == "command"
+ assert "status" in inner["command"]
+ assert inner["statusMessage"] == "Checking code-review-graph status"
+
+ def test_commands_do_not_pin_a_specific_repo_path(self, tmp_path):
+ config = generate_codex_hooks_config(tmp_path / "repo with spaces")
+ post_cmd = config["hooks"]["PostToolUse"][0]["hooks"][0]["command"]
+ session_cmd = config["hooks"]["SessionStart"][0]["hooks"][0]["command"]
+ assert "--repo" not in post_cmd
+ assert "--repo" not in session_cmd
+ assert "code-review-graph update --skip-flows" in post_cmd
+ assert "code-review-graph status" in session_cmd
+
+
+class TestInstallCodexHooks:
+ def test_creates_hooks_file(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("HOME", str(tmp_path))
+ hooks_path = install_codex_hooks(tmp_path / "repo")
+ assert hooks_path == tmp_path / ".codex" / "hooks.json"
+ assert hooks_path.exists()
+ data = json.loads(hooks_path.read_text())
+ assert "hooks" in data
+ assert "PostToolUse" in data["hooks"]
+ assert "SessionStart" in data["hooks"]
+
+ def test_merges_with_existing(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("HOME", str(tmp_path))
+ codex_dir = tmp_path / ".codex"
+ codex_dir.mkdir(parents=True)
+ existing = {
+ "customSetting": True,
+ "hooks": {
+ "Stop": [{"hooks": [{"type": "command", "command": "echo stop"}]}],
+ },
+ }
+ (codex_dir / "hooks.json").write_text(json.dumps(existing), encoding="utf-8")
+
+ install_codex_hooks(tmp_path / "repo")
+
+ data = json.loads((codex_dir / "hooks.json").read_text())
+ assert data["customSetting"] is True
+ assert "Stop" in data["hooks"]
+ assert "PostToolUse" in data["hooks"]
+ assert "SessionStart" in data["hooks"]
+
+ def test_creates_hooks_backup(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("HOME", str(tmp_path))
+ codex_dir = tmp_path / ".codex"
+ codex_dir.mkdir(parents=True)
+ existing = {"hooks": {"Stop": []}}
+ hooks_path = codex_dir / "hooks.json"
+ hooks_path.write_text(json.dumps(existing), encoding="utf-8")
+
+ install_codex_hooks(tmp_path / "repo")
+
+ backup_path = codex_dir / "hooks.json.bak"
+ assert backup_path.exists()
+ backup = json.loads(backup_path.read_text())
+ assert backup == existing
+
+ def test_idempotent_by_command(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("HOME", str(tmp_path))
+ repo_root = tmp_path / "repo"
+ install_codex_hooks(repo_root)
+ install_codex_hooks(repo_root)
+ data = json.loads((tmp_path / ".codex" / "hooks.json").read_text())
+ assert len(data["hooks"]["PostToolUse"]) == 1
+ assert len(data["hooks"]["SessionStart"]) == 1
+
def test_install_qoder_hooks(self, tmp_path):
install_hooks(tmp_path, platform="qoder")
settings_path = tmp_path / ".qoder" / "settings.json"
@@ -329,11 +416,25 @@ def test_idempotent_with_existing_content(self, tmp_path):
class TestInjectPlatformInstructionsFiltering:
def test_all_writes_every_file(self, tmp_path):
updated = inject_platform_instructions(tmp_path, target="all")
- assert set(updated) == {"AGENTS.md", "GEMINI.md", ".cursorrules", ".windsurfrules", "QODER.md", ".kiro/steering/code-review-graph.md"}
+ assert set(updated) == {
+ "AGENTS.md",
+ "GEMINI.md",
+ ".cursorrules",
+ ".windsurfrules",
+ "QODER.md",
+ ".kiro/steering/code-review-graph.md",
+ }
def test_default_is_all(self, tmp_path):
updated = inject_platform_instructions(tmp_path)
- assert set(updated) == {"AGENTS.md", "GEMINI.md", ".cursorrules", ".windsurfrules", "QODER.md", ".kiro/steering/code-review-graph.md"}
+ assert set(updated) == {
+ "AGENTS.md",
+ "GEMINI.md",
+ ".cursorrules",
+ ".windsurfrules",
+ "QODER.md",
+ ".kiro/steering/code-review-graph.md",
+ }
def test_claude_writes_nothing(self, tmp_path):
updated = inject_platform_instructions(tmp_path, target="claude")