diff --git a/code_review_graph/skills.py b/code_review_graph/skills.py index 8f72daf0..c75db4d1 100644 --- a/code_review_graph/skills.py +++ b/code_review_graph/skills.py @@ -1147,36 +1147,66 @@ def inject_platform_instructions(repo_root: Path, target: str = "all") -> list[s # --- Cursor hooks --- +def _is_windows() -> bool: + """Return True when running on Windows.""" + return platform.system() == "Windows" + + +def _cursor_hooks_dir() -> Path: + """Return the user-level Cursor hooks script directory.""" + return Path.home() / ".cursor" / "hooks" + + +def _cursor_hook_command(script_path: Path) -> str: + """Return the hooks.json command string for a hook script.""" + if _is_windows(): + return ( + "powershell -NoProfile -ExecutionPolicy Bypass -File " + f"{script_path.resolve()}" + ) + return str(script_path) + + 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/. + Each hook points to a native script in ~/.cursor/hooks/ (``.sh`` on + Linux/macOS, ``.ps1`` on Windows). Returns: Dict suitable for writing as ~/.cursor/hooks.json. """ - hooks_dir = str(Path.home() / ".cursor" / "hooks") + hooks_dir = _cursor_hooks_dir() + if _is_windows(): + update_script = hooks_dir / "crg-update.ps1" + session_start_script = hooks_dir / "crg-session-start.ps1" + pre_commit_script = hooks_dir / "crg-pre-commit.ps1" + else: + update_script = hooks_dir / "crg-update.sh" + session_start_script = hooks_dir / "crg-session-start.sh" + pre_commit_script = hooks_dir / "crg-pre-commit.sh" + return { "version": 1, "hooks": { "afterFileEdit": [ { - "command": f"{hooks_dir}/crg-update.sh", + "command": _cursor_hook_command(update_script), "timeout": 5, }, ], "sessionStart": [ { - "command": f"{hooks_dir}/crg-session-start.sh", + "command": _cursor_hook_command(session_start_script), "timeout": 5, }, ], "beforeShellExecution": [ { "matcher": "^git\\s+commit", - "command": f"{hooks_dir}/crg-pre-commit.sh", + "command": _cursor_hook_command(pre_commit_script), "timeout": 10, }, ], @@ -1184,20 +1214,8 @@ def generate_cursor_hooks_config() -> dict[str, Any]: } -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 - """ +def _cursor_hook_scripts_unix() -> dict[str, str]: + """Return Bash hook scripts for Linux/macOS Cursor hooks.""" update_script = """\ #!/usr/bin/env bash # code-review-graph: auto-update graph after file edits (Cursor hook) @@ -1270,12 +1288,110 @@ def _cursor_hook_scripts() -> dict[str, str]: } +def _cursor_hook_scripts_windows() -> dict[str, str]: + """Return PowerShell hook scripts for Windows Cursor hooks.""" + update_script = """\ +# code-review-graph: auto-update graph after file edits (Cursor hook) +# Fails gracefully — never blocks the editor. + +# Consume stdin (Cursor sends JSON context) +$null = [Console]::In.ReadToEnd() + +# Run update; swallow errors so the hook always succeeds. +try { + $null = & code-review-graph update --skip-flows 2>&1 +} catch { + # Ignore failures — hook must never block the editor. +} + +# Emit valid JSON on stdout per Cursor hooks protocol. +@{ message = 'graph updated'; passed = $true } | ConvertTo-Json -Compress +exit 0 +""" + + session_start_script = """\ +# code-review-graph: show graph status on session start (Cursor hook) +# Fails gracefully — never blocks the editor. + +# Consume stdin (Cursor sends JSON context) +$null = [Console]::In.ReadToEnd() + +# Capture status output; fall back when the graph is not built yet. +try { + $output = & code-review-graph status 2>&1 + if ($LASTEXITCODE -ne 0) { + $message = 'graph not built yet' + } else { + $message = ($output | Out-String).Trim() + if (-not $message) { + $message = 'graph not built yet' + } + } +} catch { + $message = 'graph not built yet' +} + +# Emit valid JSON on stdout per Cursor hooks protocol. +@{ message = $message; passed = $true } | ConvertTo-Json -Compress +exit 0 +""" + + pre_commit_script = """\ +# code-review-graph: detect changes before git commit (Cursor hook) +# Fails gracefully — never blocks the editor. + +# Consume stdin (Cursor sends JSON context) +$null = [Console]::In.ReadToEnd() + +# Run detect-changes; swallow errors. +try { + $output = & code-review-graph detect-changes --brief 2>&1 + if ($LASTEXITCODE -ne 0) { + $message = '' + } else { + $message = ($output | Out-String).Trim() + } +} catch { + $message = '' +} + +# Emit valid JSON on stdout per Cursor hooks protocol. +@{ message = $message; passed = $true } | ConvertTo-Json -Compress +exit 0 +""" + + return { + "crg-update.ps1": update_script, + "crg-session-start.ps1": session_start_script, + "crg-pre-commit.ps1": pre_commit_script, + } + + +def _cursor_hook_scripts() -> dict[str, str]: + """Return platform-native Cursor hook scripts. + + Three scripts are generated: + - crg-update: runs ``code-review-graph update --skip-flows`` after file edits + - crg-session-start: runs ``code-review-graph status`` on session start + - crg-pre-commit: 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 + """ + if _is_windows(): + return _cursor_hook_scripts_windows() + return _cursor_hook_scripts_unix() + + 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/``. + into any existing configuration) and creates native hook scripts + in ``~/.cursor/hooks/`` (``.sh`` on Linux/macOS, ``.ps1`` on Windows). Returns: Path to the hooks.json file that was written. @@ -1329,8 +1445,11 @@ def install_cursor_hooks() -> Path: 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) + if not _is_windows(): + # 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 diff --git a/tests/test_skills.py b/tests/test_skills.py index 2914ca79..56304755 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -963,6 +963,11 @@ def test_install_gemini_cli_skills_writes_skill_dirs(self, tmp_path): class TestCursorHooksConfig: """Tests for generate_cursor_hooks_config().""" + @pytest.fixture(autouse=True) + def _unix_platform(self): + with patch("code_review_graph.skills.platform.system", return_value="Linux"): + yield + def test_has_version_1(self): config = generate_cursor_hooks_config() assert config["version"] == 1 @@ -1006,9 +1011,43 @@ def test_commands_point_to_home_cursor_hooks(self): ) +class TestCursorHooksConfigWindows: + """Windows-specific tests for generate_cursor_hooks_config().""" + + @pytest.fixture(autouse=True) + def _windows_platform(self): + with patch("code_review_graph.skills.platform.system", return_value="Windows"): + yield + + def test_uses_powershell_commands(self): + config = generate_cursor_hooks_config() + for event, entries in config["hooks"].items(): + for entry in entries: + command = entry["command"] + assert command.startswith("powershell -NoProfile -ExecutionPolicy Bypass -File ") + assert command.endswith(".ps1") + + def test_after_file_edit_points_to_update_ps1(self): + config = generate_cursor_hooks_config() + assert "crg-update.ps1" in config["hooks"]["afterFileEdit"][0]["command"] + + def test_session_start_points_to_session_start_ps1(self): + config = generate_cursor_hooks_config() + assert "crg-session-start.ps1" in config["hooks"]["sessionStart"][0]["command"] + + def test_before_shell_execution_points_to_pre_commit_ps1(self): + config = generate_cursor_hooks_config() + assert "crg-pre-commit.ps1" in config["hooks"]["beforeShellExecution"][0]["command"] + + class TestCursorHookScripts: """Tests for _cursor_hook_scripts().""" + @pytest.fixture(autouse=True) + def _unix_platform(self): + with patch("code_review_graph.skills.platform.system", return_value="Linux"): + yield + def test_returns_three_scripts(self): scripts = _cursor_hook_scripts() assert set(scripts.keys()) == { @@ -1047,9 +1086,64 @@ def test_pre_commit_script_runs_detect_changes(self): assert "code-review-graph detect-changes --brief" in scripts["crg-pre-commit.sh"] +class TestCursorHookScriptsWindows: + """Windows-specific tests for _cursor_hook_scripts().""" + + @pytest.fixture(autouse=True) + def _windows_platform(self): + with patch("code_review_graph.skills.platform.system", return_value="Windows"): + yield + + def test_returns_three_powershell_scripts(self): + scripts = _cursor_hook_scripts() + assert set(scripts.keys()) == { + "crg-update.ps1", + "crg-session-start.ps1", + "crg-pre-commit.ps1", + } + + def test_scripts_exit_zero(self): + scripts = _cursor_hook_scripts() + for name, content in scripts.items(): + assert "exit 0" in content, f"{name} missing 'exit 0'" + + def test_scripts_consume_stdin(self): + scripts = _cursor_hook_scripts() + for name, content in scripts.items(): + assert "[Console]::In.ReadToEnd()" in content, f"{name} missing stdin consumption" + + def test_scripts_emit_json_with_convert_to_json(self): + scripts = _cursor_hook_scripts() + for name, content in scripts.items(): + assert "ConvertTo-Json -Compress" in content, f"{name} missing JSON output" + + def test_scripts_do_not_require_python(self): + scripts = _cursor_hook_scripts() + for name, content in scripts.items(): + assert "python" not in content.lower(), f"{name} should not depend on Python" + + def test_update_script_runs_update(self): + scripts = _cursor_hook_scripts() + assert "code-review-graph update --skip-flows" in scripts["crg-update.ps1"] + + def test_session_start_script_runs_status(self): + scripts = _cursor_hook_scripts() + assert "code-review-graph status" in scripts["crg-session-start.ps1"] + assert "graph not built yet" in scripts["crg-session-start.ps1"] + + def test_pre_commit_script_runs_detect_changes(self): + scripts = _cursor_hook_scripts() + assert "code-review-graph detect-changes --brief" in scripts["crg-pre-commit.ps1"] + + class TestInstallCursorHooks: """Tests for install_cursor_hooks().""" + @pytest.fixture(autouse=True) + def _unix_platform(self): + with patch("code_review_graph.skills.platform.system", return_value="Linux"): + yield + def test_creates_hooks_json(self, tmp_path): with patch("code_review_graph.skills.Path.home", return_value=tmp_path): result = install_cursor_hooks() @@ -1068,6 +1162,7 @@ def test_creates_hook_scripts(self, tmp_path): assert (hooks_dir / "crg-session-start.sh").exists() assert (hooks_dir / "crg-pre-commit.sh").exists() + @pytest.mark.skipif(sys.platform == "win32", reason="Windows does not honor Unix execute bits") def test_scripts_are_executable(self, tmp_path): with patch("code_review_graph.skills.Path.home", return_value=tmp_path): install_cursor_hooks() @@ -1125,6 +1220,46 @@ def test_handles_corrupt_existing_json(self, tmp_path): assert data["version"] == 1 +class TestInstallCursorHooksWindows: + """Windows-specific tests for install_cursor_hooks().""" + + @pytest.fixture(autouse=True) + def _windows_platform(self): + with patch("code_review_graph.skills.platform.system", return_value="Windows"): + yield + + def test_creates_powershell_hook_scripts(self, tmp_path): + with patch("code_review_graph.skills.Path.home", return_value=tmp_path): + install_cursor_hooks() + hooks_dir = tmp_path / ".cursor" / "hooks" + assert (hooks_dir / "crg-update.ps1").exists() + assert (hooks_dir / "crg-session-start.ps1").exists() + assert (hooks_dir / "crg-pre-commit.ps1").exists() + assert not (hooks_dir / "crg-update.sh").exists() + + def test_hooks_json_uses_powershell_commands(self, tmp_path): + with patch("code_review_graph.skills.Path.home", return_value=tmp_path): + install_cursor_hooks() + data = json.loads((tmp_path / ".cursor" / "hooks.json").read_text()) + for event, entries in data["hooks"].items(): + for entry in entries: + if "crg-" in entry.get("command", ""): + assert entry["command"].startswith( + "powershell -NoProfile -ExecutionPolicy Bypass -File " + ) + assert entry["command"].endswith(".ps1") + + def test_no_duplicate_on_reinstall(self, tmp_path): + with patch("code_review_graph.skills.Path.home", return_value=tmp_path): + install_cursor_hooks() + install_cursor_hooks() + + data = json.loads((tmp_path / ".cursor" / "hooks.json").read_text()) + for event, entries in data["hooks"].items(): + crg_hooks = [h for h in entries if "crg-" in h.get("command", "")] + assert len(crg_hooks) == 1, f"{event} has {len(crg_hooks)} crg hooks after reinstall" + + class TestKiroPlatform: """Tests for Kiro platform support."""