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.

One Install, Every Platform: auto-detects Codex, Claude Code, Cursor, Windsurf, Zed, Continue, OpenCode, Antigravity, Qwen, Qoder, and Kiro 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")