From 0a0b8f4fb195a7bd40cc66bd0ee2cc9428ccc34c Mon Sep 17 00:00:00 2001 From: Tirth Kanani Date: Fri, 17 Jul 2026 18:16:47 +0100 Subject: [PATCH 1/3] fix(cursor): install native PowerShell hooks on Windows Co-authored-by: Mohammad Farahani --- CHANGELOG.md | 3 + code_review_graph/skills.py | 169 ++++++++++++++++++++++++--- docs/USAGE.md | 2 +- tests/test_skills.py | 220 ++++++++++++++++++++++++++++++++++++ 4 files changed, 379 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43483079..89a4a7aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,9 @@ - Hardened generated skills/configuration: uppercase `SKILL.md` (PR #563), string-safe JSONC plus top-level and nested-container data-preservation guards (#553, PR #354), and portable PATH-aware hooks (PR #565). +- Cursor installation now writes native PowerShell hooks on Windows, quotes + user paths containing spaces, and migrates CRG-owned Bash entries without + duplicating hooks or removing unrelated user configuration (PR #617). - Packaged documentation remains reachable through the MCP wrapper (#613), Action comments render repository-relative paths, and both visualization templates select the graph SVG specifically (PR #564). diff --git a/code_review_graph/skills.py b/code_review_graph/skills.py index 7e436528..47e4e23a 100644 --- a/code_review_graph/skills.py +++ b/code_review_graph/skills.py @@ -1379,36 +1379,62 @@ def inject_platform_instructions(repo_root: Path, target: str = "all") -> list[s # --- Cursor hooks --- +def _is_windows() -> bool: + """Return whether hook installation is running on Windows.""" + return platform.system() == "Windows" + + +def _cursor_hooks_dir() -> Path: + """Return Cursor's user-level hook script directory.""" + return Path.home() / ".cursor" / "hooks" + + +def _cursor_hook_command(script_path: Path) -> str: + """Return the platform-native command used by Cursor to run a hook.""" + if _is_windows(): + resolved = script_path.resolve() + return ( + "powershell.exe -NoLogo -NoProfile -NonInteractive " + f'-ExecutionPolicy Bypass -File "{resolved}"' + ) + 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/. Returns: Dict suitable for writing as ~/.cursor/hooks.json. """ - hooks_dir = str(Path.home() / ".cursor" / "hooks") + hooks_dir = _cursor_hooks_dir() + suffix = ".ps1" if _is_windows() else ".sh" return { "version": 1, "hooks": { "afterFileEdit": [ { - "command": f"{hooks_dir}/crg-update.sh", + "command": _cursor_hook_command(hooks_dir / f"crg-update{suffix}"), "timeout": 5, }, ], "sessionStart": [ { - "command": f"{hooks_dir}/crg-session-start.sh", + "command": _cursor_hook_command( + hooks_dir / f"crg-session-start{suffix}" + ), "timeout": 5, }, ], "beforeShellExecution": [ { "matcher": "^git\\s+commit", - "command": f"{hooks_dir}/crg-pre-commit.sh", + "command": _cursor_hook_command( + hooks_dir / f"crg-pre-commit{suffix}" + ), "timeout": 10, }, ], @@ -1416,8 +1442,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. +def _cursor_hook_scripts_unix() -> dict[str, str]: + """Return a mapping of filename -> Bash script content for Cursor hooks. Three scripts are generated: - crg-update.sh: runs ``code-review-graph update --skip-flows`` after file edits @@ -1502,11 +1528,99 @@ def _cursor_hook_scripts() -> dict[str, str]: } +def _cursor_hook_scripts_windows() -> dict[str, str]: + """Return native PowerShell scripts for Cursor hooks on Windows.""" + update_script = """\ +# code-review-graph: auto-update graph after file edits (Cursor hook) +# Consume Cursor's JSON event before doing any work. +$null = [Console]::In.ReadToEnd() +$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) + +try { + $null = & code-review-graph update --skip-flows 2>&1 +} catch { + # Hooks are fail-open and must never block Cursor. +} + +@{} | ConvertTo-Json -Compress +exit 0 +""" + + session_start_script = """\ +# code-review-graph: show graph status on session start (Cursor hook) +$null = [Console]::In.ReadToEnd() +$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) +$message = 'graph not built yet' + +try { + $output = & code-review-graph status 2>&1 + if ($LASTEXITCODE -eq 0) { + $candidate = ($output | Out-String).Trim() + if ($candidate) { + $message = $candidate + } + } +} catch { + # Keep the fail-open fallback message. +} + +@{ additional_context = $message } | ConvertTo-Json -Compress +exit 0 +""" + + pre_commit_script = """\ +# code-review-graph: detect changes before git commit (Cursor hook) +$null = [Console]::In.ReadToEnd() +$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) +$message = '' + +try { + $output = & code-review-graph detect-changes --brief 2>&1 + if ($LASTEXITCODE -eq 0) { + $message = ($output | Out-String).Trim() + } +} catch { + # Hooks are fail-open and must never block Cursor. +} + +@{ + 'continue' = $true + 'permission' = 'allow' + 'agent_message' = $message +} | 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.""" + if _is_windows(): + return _cursor_hook_scripts_windows() + return _cursor_hook_scripts_unix() + + +def _is_crg_cursor_hook(command: Any, script_stem: str) -> bool: + """Return whether a command targets CRG's exact user-level hook path.""" + if not isinstance(command, str): + return False + normalized = command.strip().removesuffix('"').replace("\\", "/") + return any( + normalized.endswith(f"/.cursor/hooks/{script_stem}{suffix}") + for suffix in (".sh", ".ps1") + ) + + 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 + into any existing configuration) and creates platform-native scripts in ``~/.cursor/hooks/``. Returns: @@ -1538,11 +1652,31 @@ def install_cursor_hooks() -> Path: 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) + script_stem = { + "afterFileEdit": "crg-update", + "sessionStart": "crg-session-start", + "beforeShellExecution": "crg-pre-commit", + }[event] + migrated = False + merged_event_hooks: list[Any] = [] + for existing_entry in event_hooks: + command = ( + existing_entry.get("command") + if isinstance(existing_entry, dict) + else None + ) + if _is_crg_cursor_hook(command, script_stem): + if not migrated: + merged_entry = dict(existing_entry) + merged_entry.update(entry) + merged_event_hooks.append(merged_entry) + migrated = True + continue + merged_event_hooks.append(existing_entry) + if not migrated: + merged_event_hooks.append(entry) + event_hooks = merged_event_hooks existing_hooks[event] = event_hooks existing["hooks"] = existing_hooks @@ -1561,8 +1695,15 @@ 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 Bash scripts 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/docs/USAGE.md b/docs/USAGE.md index 59c46c1f..7ff24814 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -28,7 +28,7 @@ code-review-graph install --platform codebuddy | **Codex** | `~/.codex/config.toml` + `~/.codex/hooks.json` | | **Claude Code** | `.mcp.json` + `.claude/settings.json` | | **CodeBuddy Code** | `.mcp.json` + `CODEBUDDY.md` + `.codebuddy/settings.json` + `.codebuddy/skills//SKILL.md` | -| **Cursor** | `.cursor/mcp.json` | +| **Cursor** | `.cursor/mcp.json` + `~/.cursor/hooks.json` (`.sh` or `.ps1`) | | **Windsurf** | `~/.codeium/windsurf/mcp_config.json` | | **Zed** | `.zed/settings.json` | | **Continue** | `.continue/config.json` | diff --git a/tests/test_skills.py b/tests/test_skills.py index c6bfc9ee..c38cf0f6 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -2,9 +2,11 @@ import json import os +import shutil import stat import subprocess import sys +import threading from pathlib import Path from unittest.mock import patch @@ -1466,6 +1468,224 @@ def test_handles_corrupt_existing_json(self, tmp_path): data = json.loads(result.read_text()) assert data["version"] == 1 + def test_windows_commands_quote_paths_with_spaces(self, tmp_path): + home = tmp_path / "User With Spaces" + with ( + patch("code_review_graph.skills.Path.home", return_value=home), + patch("code_review_graph.skills.platform.system", return_value="Windows"), + ): + config = generate_cursor_hooks_config() + + for entries in config["hooks"].values(): + for entry in entries: + command = entry["command"] + assert command.startswith( + 'powershell.exe -NoLogo -NoProfile -NonInteractive ' + '-ExecutionPolicy Bypass -File "' + ) + assert command.endswith('.ps1"') + assert "User With Spaces" in command + + def test_windows_scripts_are_native_and_protocol_safe(self): + with patch("code_review_graph.skills.platform.system", return_value="Windows"): + scripts = _cursor_hook_scripts() + + assert set(scripts) == { + "crg-update.ps1", + "crg-session-start.ps1", + "crg-pre-commit.ps1", + } + for name, content in scripts.items(): + assert "[Console]::In.ReadToEnd()" in content, name + assert "ConvertTo-Json -Compress" in content, name + assert "exit 0" in content, name + assert "python" not in content.lower(), name + + def test_windows_install_migrates_unix_entries_without_data_loss(self, tmp_path): + home = tmp_path / "User With Spaces" + cursor_dir = home / ".cursor" + hooks_dir = cursor_dir / "hooks" + hooks_dir.mkdir(parents=True) + with ( + patch("code_review_graph.skills.Path.home", return_value=home), + patch("code_review_graph.skills.platform.system", return_value="Linux"), + ): + old_config = generate_cursor_hooks_config() + + custom_edit_hook = {"command": "custom-tool audit", "timeout": 17} + old_config["customSetting"] = {"preserve": True} + old_config["hooks"]["afterFileEdit"].append(custom_edit_hook) + old_config["hooks"]["stop"] = [{"command": "custom-tool stop"}] + (cursor_dir / "hooks.json").write_text(json.dumps(old_config), encoding="utf-8") + for name in ("crg-update.sh", "crg-session-start.sh", "crg-pre-commit.sh"): + (hooks_dir / name).write_text("user-preserved-old-script", encoding="utf-8") + + with ( + patch("code_review_graph.skills.Path.home", return_value=home), + patch("code_review_graph.skills.platform.system", return_value="Windows"), + ): + install_cursor_hooks() + install_cursor_hooks() + + data = json.loads((cursor_dir / "hooks.json").read_text(encoding="utf-8")) + assert data["customSetting"] == {"preserve": True} + assert custom_edit_hook in data["hooks"]["afterFileEdit"] + assert data["hooks"]["stop"] == [{"command": "custom-tool stop"}] + for event in ("afterFileEdit", "sessionStart", "beforeShellExecution"): + crg_entries = [ + entry + for entry in data["hooks"][event] + if "crg-" in entry.get("command", "") + ] + assert len(crg_entries) == 1, event + assert crg_entries[0]["command"].endswith('.ps1"') + assert ".sh" not in crg_entries[0]["command"] + + for name in ("crg-update", "crg-session-start", "crg-pre-commit"): + assert (hooks_dir / f"{name}.ps1").exists() + assert (hooks_dir / f"{name}.sh").read_text() == "user-preserved-old-script" + + @staticmethod + def _run_windows_hook(command, *, cwd, payload, env): + comspec = os.environ.get("COMSPEC", "cmd.exe") + proc = subprocess.Popen( + [comspec, "/d", "/s", "/c", command], + cwd=cwd, + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + assert proc.stdin is not None + write_result = {} + + def write_payload(): + try: + write_result["bytes"] = proc.stdin.write(payload) + proc.stdin.close() + except OSError as exc: # pragma: no cover - Windows regression guard + write_result["error"] = exc + + writer = threading.Thread(target=write_payload, daemon=True) + writer.start() + writer.join(timeout=30) + if writer.is_alive(): + proc.kill() + writer.join(timeout=5) + proc.wait(timeout=5) + pytest.fail("Cursor hook did not drain stdin within 30 seconds") + if "error" in write_result: + proc.kill() + proc.wait(timeout=5) + pytest.fail(f"Cursor hook closed stdin early: {write_result['error']}") + assert write_result["bytes"] == len(payload) + + proc.stdin = None + stdout, stderr = proc.communicate(timeout=30) + return proc.returncode, stdout, stderr + + @pytest.mark.skipif(sys.platform != "win32", reason="requires PowerShell") + @pytest.mark.parametrize( + ("event", "expected_args"), + [ + ("afterFileEdit", "update --skip-flows"), + ("sessionStart", "status"), + ("beforeShellExecution", "detect-changes --brief"), + ], + ) + def test_windows_commands_execute_and_drain_large_stdin( + self, tmp_path, monkeypatch, event, expected_args + ): + home = tmp_path / "User With Spaces" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + marker = tmp_path / "cursor-hook-invocations.txt" + (bin_dir / "code-review-graph.cmd").write_text( + '@echo off\r\n>>"%CRG_HOOK_MARKER%" echo %*\r\n' + 'echo output with "quotes"\r\nexit /b %CRG_HOOK_EXIT%\r\n', + encoding="utf-8", + ) + monkeypatch.setenv("CRG_HOOK_MARKER", str(marker)) + monkeypatch.setenv("CRG_HOOK_EXIT", "0") + monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ['PATH']}") + with ( + patch("code_review_graph.skills.Path.home", return_value=home), + patch("code_review_graph.skills.platform.system", return_value="Windows"), + ): + install_cursor_hooks() + config = generate_cursor_hooks_config() + + command = config["hooks"][event][0]["command"] + payload = json.dumps({"payload": "x" * (2 * 1024 * 1024)}).encode() + returncode, stdout, stderr = self._run_windows_hook( + command, + cwd=tmp_path, + payload=payload, + env=os.environ.copy(), + ) + + assert returncode == 0, stderr.decode(errors="replace") + json.loads(stdout.decode(encoding="utf-8-sig")) + assert expected_args in marker.read_text(encoding="utf-8") + + @pytest.mark.skipif(sys.platform != "win32", reason="requires PowerShell") + @pytest.mark.parametrize("exit_code", [1, 23]) + def test_windows_commands_fail_open_on_tool_errors( + self, tmp_path, monkeypatch, exit_code + ): + home = tmp_path / "User With Spaces" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + (bin_dir / "code-review-graph.cmd").write_text( + "@echo off\r\necho simulated failure 1>&2\r\n" + "exit /b %CRG_HOOK_EXIT%\r\n", + encoding="utf-8", + ) + monkeypatch.setenv("CRG_HOOK_EXIT", str(exit_code)) + monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ['PATH']}") + with ( + patch("code_review_graph.skills.Path.home", return_value=home), + patch("code_review_graph.skills.platform.system", return_value="Windows"), + ): + install_cursor_hooks() + config = generate_cursor_hooks_config() + + for entries in config["hooks"].values(): + command = entries[0]["command"] + returncode, stdout, stderr = self._run_windows_hook( + command, + cwd=tmp_path, + payload=b'{"event":"tool-error"}', + env=os.environ.copy(), + ) + assert returncode == 0, stderr.decode(errors="replace") + json.loads(stdout.decode(encoding="utf-8-sig")) + + @pytest.mark.skipif(sys.platform != "win32", reason="requires PowerShell") + def test_windows_commands_fail_open_when_tool_is_missing(self, tmp_path): + home = tmp_path / "User With Spaces" + with ( + patch("code_review_graph.skills.Path.home", return_value=home), + patch("code_review_graph.skills.platform.system", return_value="Windows"), + ): + install_cursor_hooks() + config = generate_cursor_hooks_config() + + powershell = shutil.which("powershell.exe") + assert powershell is not None + env = os.environ.copy() + env["PATH"] = str(Path(powershell).parent) + for entries in config["hooks"].values(): + command = entries[0]["command"] + returncode, stdout, stderr = self._run_windows_hook( + command, + cwd=tmp_path, + payload=b'{"event":"missing-tool"}', + env=env, + ) + assert returncode == 0, stderr.decode(errors="replace") + json.loads(stdout.decode(encoding="utf-8-sig")) + class TestKiroPlatform: """Tests for Kiro platform support.""" From 61c7fc572afffe3025d5e9c4874accb672b75bb4 Mon Sep 17 00:00:00 2001 From: Tirth Kanani Date: Fri, 17 Jul 2026 18:26:40 +0100 Subject: [PATCH 2/3] test(cursor): preserve shell quoting on Windows --- tests/test_skills.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/tests/test_skills.py b/tests/test_skills.py index c38cf0f6..969be1dd 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -1306,6 +1306,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 @@ -1352,6 +1357,11 @@ def test_commands_point_to_home_cursor_hooks(self): 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()) == { @@ -1393,6 +1403,11 @@ def test_pre_commit_script_runs_detect_changes(self): 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() @@ -1547,11 +1562,13 @@ def test_windows_install_migrates_unix_entries_without_data_loss(self, tmp_path) @staticmethod def _run_windows_hook(command, *, cwd, payload, env): - comspec = os.environ.get("COMSPEC", "cmd.exe") + # Cursor hands this command string to the Windows shell. Passing it as + # a sequence makes Python backslash-escape quotes that cmd treats literally. proc = subprocess.Popen( - [comspec, "/d", "/s", "/c", command], + command, cwd=cwd, env=env, + shell=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, From 8a24a0259a68d72e36fa56d026eee0263c551917 Mon Sep 17 00:00:00 2001 From: Tirth Kanani Date: Fri, 17 Jul 2026 20:38:35 +0100 Subject: [PATCH 3/3] fix(cursor): clean up hooks across platforms --- code_review_graph/skills.py | 55 +++++++++++++++++++++----- code_review_graph/uninstall.py | 4 +- tests/test_skills.py | 37 +++++++++++++++++ tests/test_uninstall.py | 72 ++++++++++++++++++++++++++++++++++ 4 files changed, 156 insertions(+), 12 deletions(-) diff --git a/code_review_graph/skills.py b/code_review_graph/skills.py index 47e4e23a..d3d9039f 100644 --- a/code_review_graph/skills.py +++ b/code_review_graph/skills.py @@ -1379,6 +1379,9 @@ def inject_platform_instructions(repo_root: Path, target: str = "all") -> list[s # --- Cursor hooks --- +_CURSOR_HOOK_STEMS = ("crg-update", "crg-session-start", "crg-pre-commit") + + def _is_windows() -> bool: """Return whether hook installation is running on Windows.""" return platform.system() == "Windows" @@ -1389,17 +1392,49 @@ def _cursor_hooks_dir() -> Path: return Path.home() / ".cursor" / "hooks" +def _cursor_windows_hook_command(script_path: Path) -> str: + """Return Cursor's native Windows command for a PowerShell hook.""" + resolved = script_path.resolve() + return ( + "powershell.exe -NoLogo -NoProfile -NonInteractive " + f'-ExecutionPolicy Bypass -File "{resolved}"' + ) + + def _cursor_hook_command(script_path: Path) -> str: """Return the platform-native command used by Cursor to run a hook.""" if _is_windows(): - resolved = script_path.resolve() - return ( - "powershell.exe -NoLogo -NoProfile -NonInteractive " - f'-ExecutionPolicy Bypass -File "{resolved}"' - ) + return _cursor_windows_hook_command(script_path) return str(script_path) +def _cursor_owned_hook_commands(script_stem: str) -> set[str]: + """Return both platform commands owned for one user-level Cursor hook.""" + hooks_dir = _cursor_hooks_dir() + return { + str(hooks_dir / f"{script_stem}.sh"), + _cursor_windows_hook_command(hooks_dir / f"{script_stem}.ps1"), + } + + +def _all_cursor_owned_hook_commands() -> set[str]: + """Return every Unix and Windows Cursor command owned by CRG.""" + return { + command + for script_stem in _CURSOR_HOOK_STEMS + for command in _cursor_owned_hook_commands(script_stem) + } + + +def _all_cursor_owned_hook_filenames() -> set[str]: + """Return every platform-specific Cursor hook filename owned by CRG.""" + return { + f"{script_stem}{suffix}" + for script_stem in _CURSOR_HOOK_STEMS + for suffix in (".sh", ".ps1") + } + + def generate_cursor_hooks_config() -> dict[str, Any]: """Generate Cursor hooks.json configuration. @@ -1609,11 +1644,11 @@ def _is_crg_cursor_hook(command: Any, script_stem: str) -> bool: """Return whether a command targets CRG's exact user-level hook path.""" if not isinstance(command, str): return False - normalized = command.strip().removesuffix('"').replace("\\", "/") - return any( - normalized.endswith(f"/.cursor/hooks/{script_stem}{suffix}") - for suffix in (".sh", ".ps1") - ) + normalized = command.strip().replace("\\", "/") + return normalized in { + owned.replace("\\", "/") + for owned in _cursor_owned_hook_commands(script_stem) + } def install_cursor_hooks() -> Path: diff --git a/code_review_graph/uninstall.py b/code_review_graph/uninstall.py index a5ac1c57..5ca171c7 100644 --- a/code_review_graph/uninstall.py +++ b/code_review_graph/uninstall.py @@ -1140,12 +1140,12 @@ def _process_user( ) _remove_hooks( home / ".cursor" / "hooks.json", - _commands(skills.generate_cursor_hooks_config()), + skills._all_cursor_owned_hook_commands(), home, report, dry_run=dry_run, ) - for filename in skills._cursor_hook_scripts(): + for filename in skills._all_cursor_owned_hook_filenames(): _remove_file( home / ".cursor" / "hooks" / filename, home, diff --git a/tests/test_skills.py b/tests/test_skills.py index 969be1dd..796a1c1d 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -1560,6 +1560,43 @@ def test_windows_install_migrates_unix_entries_without_data_loss(self, tmp_path) assert (hooks_dir / f"{name}.ps1").exists() assert (hooks_dir / f"{name}.sh").read_text() == "user-preserved-old-script" + def test_windows_install_preserves_foreign_same_named_cursor_hook(self, tmp_path): + home = tmp_path / "User" + cursor_dir = home / ".cursor" + cursor_dir.mkdir(parents=True) + foreign_command = str( + tmp_path / "workspace" / ".cursor" / "hooks" / "crg-update.sh" + ) + (cursor_dir / "hooks.json").write_text( + json.dumps( + { + "version": 1, + "hooks": { + "afterFileEdit": [ + {"command": foreign_command, "timeout": 17} + ] + }, + } + ), + encoding="utf-8", + ) + + with ( + patch("code_review_graph.skills.Path.home", return_value=home), + patch("code_review_graph.skills.platform.system", return_value="Windows"), + ): + install_cursor_hooks() + expected_owned = generate_cursor_hooks_config()["hooks"]["afterFileEdit"][0] + + entries = json.loads((cursor_dir / "hooks.json").read_text())["hooks"][ + "afterFileEdit" + ] + assert {entry["command"] for entry in entries} == { + foreign_command, + expected_owned["command"], + } + assert {entry["timeout"] for entry in entries} == {5, 17} + @staticmethod def _run_windows_hook(command, *, cwd, payload, env): # Cursor hands this command string to the Windows shell. Passing it as diff --git a/tests/test_uninstall.py b/tests/test_uninstall.py index ce0a584c..40f67812 100644 --- a/tests/test_uninstall.py +++ b/tests/test_uninstall.py @@ -251,6 +251,78 @@ def test_cursor_shared_hooks_directory_keeps_unrelated_scripts( assert data["hooks"]["sessionStart"] == [{"command": "user-session-hook"}] +@pytest.mark.parametrize( + ("installed_platform", "uninstall_platform"), + [("Linux", "Windows"), ("Windows", "Linux")], +) +def test_cursor_uninstall_removes_owned_hooks_from_other_platform( + installed_platform: str, + uninstall_platform: str, + fake_repo: Path, + fake_home: Path, +) -> None: + cursor_dir = fake_home / ".cursor" + with patch( + "code_review_graph.skills.platform.system", return_value=installed_platform + ): + config = skills.generate_cursor_hooks_config() + script_filenames = set(skills._cursor_hook_scripts()) + + owned_commands = { + entry["command"] + for entries in config["hooks"].values() + for entry in entries + } + config["hooks"]["sessionStart"].append({"command": "user-session-hook"}) + _write_json(cursor_dir / "hooks.json", config) + for filename in script_filenames: + _write(cursor_dir / "hooks" / filename, "owned\n") + _write(cursor_dir / "hooks" / "my-company-hook.ps1", "unrelated\n") + + with patch( + "code_review_graph.skills.platform.system", return_value=uninstall_platform + ): + uninstall.run(repo=fake_repo, keep_data=True) + + data = _read_jsonc(cursor_dir / "hooks.json") + remaining_commands = { + entry.get("command") + for entries in data["hooks"].values() + for entry in entries + if isinstance(entry, dict) + } + assert owned_commands.isdisjoint(remaining_commands) + assert "user-session-hook" in remaining_commands + for filename in script_filenames: + assert not (cursor_dir / "hooks" / filename).exists() + assert (cursor_dir / "hooks" / "my-company-hook.ps1").read_text() == "unrelated\n" + + +def test_cursor_uninstall_removes_stale_scripts_after_platform_migration( + fake_repo: Path, + fake_home: Path, +) -> None: + cursor_dir = fake_home / ".cursor" + with patch("code_review_graph.skills.platform.system", return_value="Linux"): + skills.install_cursor_hooks() + with patch("code_review_graph.skills.platform.system", return_value="Windows"): + skills.install_cursor_hooks() + + owned_filenames = { + f"crg-{hook}{suffix}" + for hook in ("update", "session-start", "pre-commit") + for suffix in (".sh", ".ps1") + } + assert all((cursor_dir / "hooks" / name).exists() for name in owned_filenames) + _write(cursor_dir / "hooks" / "keep-me.sh", "unrelated\n") + + with patch("code_review_graph.skills.platform.system", return_value="Windows"): + uninstall.run(repo=fake_repo, keep_data=True) + + assert all(not (cursor_dir / "hooks" / name).exists() for name in owned_filenames) + assert (cursor_dir / "hooks" / "keep-me.sh").read_text() == "unrelated\n" + + def test_hook_cleanup_handles_owned_entries_and_mixed_nested_groups( fake_repo: Path, fake_home: Path,