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
165 changes: 142 additions & 23 deletions code_review_graph/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -1147,57 +1147,75 @@ 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,
},
],
},
}


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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
135 changes: 135 additions & 0 deletions tests/test_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()) == {
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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."""

Expand Down