Skip to content
Draft
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
204 changes: 190 additions & 14 deletions code_review_graph/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -1379,45 +1379,106 @@ 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"


def _cursor_hooks_dir() -> Path:
"""Return Cursor's user-level hook script directory."""
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():
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.

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,
},
],
},
}


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
Expand Down Expand Up @@ -1502,11 +1563,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().replace("\\", "/")
return normalized in {
owned.replace("\\", "/")
for owned in _cursor_owned_hook_commands(script_stem)
}


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:
Expand Down Expand Up @@ -1538,11 +1687,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
Expand All @@ -1561,8 +1730,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
Expand Down
4 changes: 2 additions & 2 deletions code_review_graph/uninstall.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/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` |
Expand Down
Loading