diff --git a/code_review_graph/cli.py b/code_review_graph/cli.py index 3fb4f93..d2912ea 100644 --- a/code_review_graph/cli.py +++ b/code_review_graph/cli.py @@ -35,6 +35,8 @@ from importlib.metadata import version as pkg_version from pathlib import Path +logger = logging.getLogger(__name__) + def _get_version() -> str: """Get the installed package version.""" @@ -59,12 +61,12 @@ def _print_banner() -> None: version = _get_version() # ANSI escape codes - c = "\033[36m" if color else "" # cyan — graph art - y = "\033[33m" if color else "" # yellow — center node - b = "\033[1m" if color else "" # bold - d = "\033[2m" if color else "" # dim - g = "\033[32m" if color else "" # green — commands - r = "\033[0m" if color else "" # reset + c = "\033[36m" if color else "" # cyan — graph art + y = "\033[33m" if color else "" # yellow — center node + b = "\033[1m" if color else "" # bold + d = "\033[2m" if color else "" # dim + g = "\033[32m" if color else "" # green — commands + r = "\033[0m" if color else "" # reset print(f""" {c} ●──●──●{r} @@ -127,9 +129,11 @@ def _handle_init(args: argparse.Namespace) -> None: # Legacy: --skills/--hooks/--all still accepted (no-op, everything is default) from .skills import ( + PLATFORMS, generate_skills, inject_claude_md, inject_platform_instructions, + install_cursor_hooks, install_hooks, ) @@ -145,6 +149,14 @@ def _handle_init(args: argparse.Namespace) -> None: install_hooks(repo_root) print(f"Installed hooks in {repo_root / '.claude' / 'settings.json'}") + # 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) + print() print("Next steps:") print(" 1. code-review-graph build # build the knowledge graph") @@ -157,68 +169,82 @@ def main() -> None: prog="code-review-graph", description="Persistent incremental knowledge graph for code reviews", ) - ap.add_argument( - "-v", "--version", action="store_true", help="Show version and exit" - ) + ap.add_argument("-v", "--version", action="store_true", help="Show version and exit") sub = ap.add_subparsers(dest="command") # install (primary) + init (alias) - install_cmd = sub.add_parser( - "install", help="Register MCP server with AI coding platforms" - ) + install_cmd = sub.add_parser("install", help="Register MCP server with AI coding platforms") install_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)") install_cmd.add_argument( - "--dry-run", action="store_true", + "--dry-run", + action="store_true", help="Show what would be done without writing files", ) install_cmd.add_argument( - "--no-skills", action="store_true", + "--no-skills", + action="store_true", help="Skip generating Claude Code skill files", ) install_cmd.add_argument( - "--no-hooks", action="store_true", + "--no-hooks", + action="store_true", help="Skip installing Claude Code hooks", ) # Legacy flags (kept for backwards compat, now no-ops since all is default) install_cmd.add_argument("--skills", action="store_true", help=argparse.SUPPRESS) install_cmd.add_argument("--hooks", action="store_true", help=argparse.SUPPRESS) - install_cmd.add_argument("--all", action="store_true", dest="install_all", - help=argparse.SUPPRESS) + install_cmd.add_argument( + "--all", action="store_true", dest="install_all", help=argparse.SUPPRESS + ) install_cmd.add_argument( "--platform", choices=[ - "claude", "claude-code", "cursor", "windsurf", "zed", - "continue", "opencode", "antigravity", "all", + "claude", + "claude-code", + "cursor", + "windsurf", + "zed", + "continue", + "opencode", + "antigravity", + "all", ], default="all", help="Target platform for MCP config (default: all detected)", ) - init_cmd = sub.add_parser( - "init", help="Alias for install" - ) + init_cmd = sub.add_parser("init", help="Alias for install") init_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)") init_cmd.add_argument( - "--dry-run", action="store_true", + "--dry-run", + action="store_true", help="Show what would be done without writing files", ) init_cmd.add_argument( - "--no-skills", action="store_true", + "--no-skills", + action="store_true", help="Skip generating Claude Code skill files", ) init_cmd.add_argument( - "--no-hooks", action="store_true", + "--no-hooks", + action="store_true", help="Skip installing Claude Code hooks", ) init_cmd.add_argument("--skills", action="store_true", help=argparse.SUPPRESS) init_cmd.add_argument("--hooks", action="store_true", help=argparse.SUPPRESS) - init_cmd.add_argument("--all", action="store_true", dest="install_all", - help=argparse.SUPPRESS) + init_cmd.add_argument("--all", action="store_true", dest="install_all", help=argparse.SUPPRESS) init_cmd.add_argument( "--platform", choices=[ - "claude", "claude-code", "cursor", "windsurf", "zed", - "continue", "opencode", "antigravity", "all", + "claude", + "claude-code", + "cursor", + "windsurf", + "zed", + "continue", + "opencode", + "antigravity", + "all", ], default="all", help="Target platform for MCP config (default: all detected)", @@ -228,11 +254,13 @@ def main() -> None: build_cmd = sub.add_parser("build", help="Full graph build (re-parse all files)") build_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)") build_cmd.add_argument( - "--skip-flows", action="store_true", + "--skip-flows", + action="store_true", help="Skip flow/community detection (signatures + FTS only)", ) build_cmd.add_argument( - "--skip-postprocess", action="store_true", + "--skip-postprocess", + action="store_true", help="Skip all post-processing (raw parse only)", ) @@ -241,11 +269,13 @@ def main() -> None: update_cmd.add_argument("--base", default="HEAD~1", help="Git diff base (default: HEAD~1)") update_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)") update_cmd.add_argument( - "--skip-flows", action="store_true", + "--skip-flows", + action="store_true", help="Skip flow/community detection (signatures + FTS only)", ) update_cmd.add_argument( - "--skip-postprocess", action="store_true", + "--skip-postprocess", + action="store_true", help="Skip all post-processing (raw parse only)", ) @@ -277,7 +307,8 @@ def main() -> None: help="Rendering mode: auto (default), full, community, or file", ) vis_cmd.add_argument( - "--serve", action="store_true", + "--serve", + action="store_true", help="Start a local HTTP server to view the visualization (localhost:8765)", ) @@ -285,7 +316,8 @@ def main() -> None: wiki_cmd = sub.add_parser("wiki", help="Generate markdown wiki from community structure") wiki_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)") wiki_cmd.add_argument( - "--force", action="store_true", + "--force", + action="store_true", help="Regenerate all pages even if content unchanged", ) @@ -308,9 +340,10 @@ def main() -> None: # eval eval_cmd = sub.add_parser("eval", help="Run evaluation benchmarks") eval_cmd.add_argument( - "--benchmark", default=None, + "--benchmark", + default=None, help="Comma-separated benchmarks to run (token_efficiency, impact_accuracy, " - "flow_completeness, search_quality, build_performance)", + "flow_completeness, search_quality, build_performance)", ) eval_cmd.add_argument("--repo", default=None, help="Comma-separated repo config names") eval_cmd.add_argument("--all", action="store_true", dest="run_all", help="Run all benchmarks") @@ -319,12 +352,8 @@ def main() -> None: # detect-changes detect_cmd = sub.add_parser("detect-changes", help="Analyze change impact") - detect_cmd.add_argument( - "--base", default="HEAD~1", help="Git diff base (default: HEAD~1)" - ) - detect_cmd.add_argument( - "--brief", action="store_true", help="Show brief summary only" - ) + detect_cmd.add_argument("--base", default="HEAD~1", help="Git diff base (default: HEAD~1)") + detect_cmd.add_argument("--brief", action="store_true", help="Show brief summary only") detect_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)") # serve @@ -343,6 +372,7 @@ def main() -> None: if args.command == "serve": from .main import main as serve_main + serve_main(repo_root=args.repo) return @@ -351,9 +381,7 @@ def main() -> None: from .eval.runner import run_eval if getattr(args, "report", False): - output_dir = Path( - getattr(args, "output_dir", None) or "evaluate/results" - ) + output_dir = Path(getattr(args, "output_dir", None) or "evaluate/results") report = generate_full_report(output_dir) report_path = Path("evaluate/reports/summary.md") report_path.parent.mkdir(parents=True, exist_ok=True) @@ -365,9 +393,7 @@ def main() -> None: print(tables) else: repos = ( - [r.strip() for r in args.repo.split(",")] - if getattr(args, "repo", None) - else None + [r.strip() for r in args.repo.split(",")] if getattr(args, "repo", None) else None ) benchmarks = ( [b.strip() for b in args.benchmark.split(",")] @@ -439,6 +465,7 @@ def main() -> None: store = GraphStore(db_path) try: from .tools.build import run_postprocess + result = run_postprocess( flows=not getattr(args, "no_flows", False), communities=not getattr(args, "no_communities", False), @@ -475,32 +502,38 @@ def main() -> None: try: if args.command == "build": - pp = "none" if getattr(args, "skip_postprocess", False) else ( - "minimal" if getattr(args, "skip_flows", False) else "full" + pp = ( + "none" + if getattr(args, "skip_postprocess", False) + else ("minimal" if getattr(args, "skip_flows", False) else "full") ) from .tools.build import build_or_update_graph + result = build_or_update_graph( - full_rebuild=True, repo_root=str(repo_root), postprocess=pp, + full_rebuild=True, + repo_root=str(repo_root), + postprocess=pp, ) parsed = result.get("files_parsed", 0) nodes = result.get("total_nodes", 0) edges = result.get("total_edges", 0) - print( - f"Full build: {parsed} files, " - f"{nodes} nodes, {edges} edges" - f" (postprocess={pp})" - ) + print(f"Full build: {parsed} files, {nodes} nodes, {edges} edges (postprocess={pp})") if result.get("errors"): print(f"Errors: {len(result['errors'])}") elif args.command == "update": - pp = "none" if getattr(args, "skip_postprocess", False) else ( - "minimal" if getattr(args, "skip_flows", False) else "full" + pp = ( + "none" + if getattr(args, "skip_postprocess", False) + else ("minimal" if getattr(args, "skip_flows", False) else "full") ) from .tools.build import build_or_update_graph + result = build_or_update_graph( - full_rebuild=False, repo_root=str(repo_root), - base=args.base, postprocess=pp, + full_rebuild=False, + repo_root=str(repo_root), + base=args.base, + postprocess=pp, ) updated = result.get("files_updated", 0) nodes = result.get("total_nodes", 0) @@ -526,6 +559,7 @@ def main() -> None: if stored_sha: print(f"Built at commit: {stored_sha[:12]}") from .incremental import _git_branch_info + current_branch, current_sha = _git_branch_info(repo_root) if stored_branch and current_branch and stored_branch != current_branch: print( @@ -539,6 +573,7 @@ def main() -> None: elif args.command == "visualize": from .visualization import generate_html + html_path = repo_root / ".code-review-graph" / "graph.html" vis_mode = getattr(args, "mode", "auto") or "auto" generate_html(store, html_path, mode=vis_mode) @@ -565,6 +600,7 @@ def main() -> None: elif args.command == "wiki": from .wiki import generate_wiki + wiki_dir = repo_root / ".code-review-graph" / "wiki" result = generate_wiki(store, wiki_dir, force=args.force) total = result["pages_generated"] + result["pages_updated"] + result["pages_unchanged"] diff --git a/code_review_graph/skills.py b/code_review_graph/skills.py index 18c555c..a864b40 100644 --- a/code_review_graph/skills.py +++ b/code_review_graph/skills.py @@ -2,7 +2,8 @@ Generates Claude Code agent skill files, hooks configuration, and CLAUDE.md integration for seamless code-review-graph usage. -Also supports multi-platform MCP server installation. +Also supports multi-platform MCP server installation and +Cursor hooks / OpenCode plugin generation. """ from __future__ import annotations @@ -11,6 +12,7 @@ import logging import platform import shutil +import stat from pathlib import Path from typing import Any @@ -19,6 +21,7 @@ # --- Multi-platform MCP install --- + def _zed_settings_path() -> Path: """Return the Zed settings.json path for the current OS.""" if platform.system() == "Darwin": @@ -121,9 +124,7 @@ def install_platform_configs( List of platform names that were configured. """ if target == "all": - platforms_to_install = { - k: v for k, v in PLATFORMS.items() if v["detect"]() - } + platforms_to_install = {k: v for k, v in PLATFORMS.items() if v["detect"]()} else: if target not in PLATFORMS: logger.error("Unknown platform: %s", target) @@ -151,10 +152,7 @@ def install_platform_configs( if not isinstance(arr, list): arr = [] # Check if already present - if any( - isinstance(s, dict) and s.get("name") == "code-review-graph" - for s in arr - ): + if any(isinstance(s, dict) and s.get("name") == "code-review-graph" for s in arr): print(f" {plat['name']}: already configured in {config_path}") configured.append(plat["name"]) continue @@ -183,6 +181,7 @@ def install_platform_configs( return configured + # --- Skill file contents --- _SKILLS: dict[str, dict[str, str]] = { @@ -206,10 +205,10 @@ def install_platform_configs( "- Use `children_of` on a file to see all its functions and classes.\n" "- Use `find_large_functions` to identify complex code.\n\n" "## Token Efficiency Rules\n" - "- ALWAYS start with `get_minimal_context(task=\"\")` " + '- ALWAYS start with `get_minimal_context(task="")` ' "before any other graph tool.\n" - "- Use `detail_level=\"minimal\"` on all calls. Only escalate to " - "\"standard\" when minimal is insufficient.\n" + '- Use `detail_level="minimal"` on all calls. Only escalate to ' + '"standard" when minimal is insufficient.\n' "- Target: complete any review/debug/refactor task in ≤5 tool calls " "and ≤800 total output tokens." ), @@ -224,7 +223,7 @@ def install_platform_configs( "1. Run `detect_changes` to get risk-scored change analysis.\n" "2. Run `get_affected_flows` to find impacted execution paths.\n" "3. For each high-risk function, run `query_graph` with " - "pattern=\"tests_for\" to check test coverage.\n" + 'pattern="tests_for" to check test coverage.\n' "4. Run `get_impact_radius` to understand the blast radius.\n" "5. For any untested changes, suggest specific test cases.\n\n" "### Output Format\n\n" @@ -234,10 +233,10 @@ def install_platform_configs( "- Suggested improvements\n" "- Overall merge recommendation\n\n" "## Token Efficiency Rules\n" - "- ALWAYS start with `get_minimal_context(task=\"\")` " + '- ALWAYS start with `get_minimal_context(task="")` ' "before any other graph tool.\n" - "- Use `detail_level=\"minimal\"` on all calls. Only escalate to " - "\"standard\" when minimal is insufficient.\n" + '- Use `detail_level="minimal"` on all calls. Only escalate to ' + '"standard" when minimal is insufficient.\n' "- Target: complete any review/debug/refactor task in ≤5 tool calls " "and ≤800 total output tokens." ), @@ -260,10 +259,10 @@ def install_platform_configs( "- Look at affected flows to find the entry point that triggers the bug.\n" "- Recent changes are the most common source of new issues.\n\n" "## Token Efficiency Rules\n" - "- ALWAYS start with `get_minimal_context(task=\"\")` " + '- ALWAYS start with `get_minimal_context(task="")` ' "before any other graph tool.\n" - "- Use `detail_level=\"minimal\"` on all calls. Only escalate to " - "\"standard\" when minimal is insufficient.\n" + '- Use `detail_level="minimal"` on all calls. Only escalate to ' + '"standard" when minimal is insufficient.\n' "- Target: complete any review/debug/refactor task in ≤5 tool calls " "and ≤800 total output tokens." ), @@ -275,10 +274,10 @@ def install_platform_configs( "## Refactor Safely\n\n" "Use the knowledge graph to plan and execute refactoring with confidence.\n\n" "### Steps\n\n" - "1. Use `refactor_tool` with mode=\"suggest\" for community-driven " + '1. Use `refactor_tool` with mode="suggest" for community-driven ' "refactoring suggestions.\n" - "2. Use `refactor_tool` with mode=\"dead_code\" to find unreferenced code.\n" - "3. For renames, use `refactor_tool` with mode=\"rename\" to preview all " + '2. Use `refactor_tool` with mode="dead_code" to find unreferenced code.\n' + '3. For renames, use `refactor_tool` with mode="rename" to preview all ' "affected locations.\n" "4. Use `apply_refactor_tool` with the refactor_id to apply renames.\n" "5. After changes, run `detect_changes` to verify the refactoring impact.\n\n" @@ -288,10 +287,10 @@ def install_platform_configs( "- Use `get_affected_flows` to ensure no critical paths are broken.\n" "- Run `find_large_functions` to identify decomposition targets.\n\n" "## Token Efficiency Rules\n" - "- ALWAYS start with `get_minimal_context(task=\"\")` " + '- ALWAYS start with `get_minimal_context(task="")` ' "before any other graph tool.\n" - "- Use `detail_level=\"minimal\"` on all calls. Only escalate to " - "\"standard\" when minimal is insufficient.\n" + '- Use `detail_level="minimal"` on all calls. Only escalate to ' + '"standard" when minimal is insufficient.\n' "- Target: complete any review/debug/refactor task in ≤5 tool calls " "and ≤800 total output tokens." ), @@ -461,14 +460,16 @@ def _inject_instructions(file_path: Path, marker: str, section: str) -> bool: def inject_claude_md(repo_root: Path) -> None: """Append MCP tools section to CLAUDE.md.""" _inject_instructions( - repo_root / "CLAUDE.md", _CLAUDE_MD_SECTION_MARKER, _CLAUDE_MD_SECTION, + repo_root / "CLAUDE.md", + _CLAUDE_MD_SECTION_MARKER, + _CLAUDE_MD_SECTION, ) # Cross-platform instruction files so every AI coding tool uses the graph. _PLATFORM_INSTRUCTION_FILES = { - "AGENTS.md": "AGENTS.md", # Cursor, OpenCode, Antigravity - "GEMINI.md": "GEMINI.md", # Antigravity / Gemini CLI + "AGENTS.md": "AGENTS.md", # Cursor, OpenCode, Antigravity + "GEMINI.md": "GEMINI.md", # Antigravity / Gemini CLI ".cursorrules": ".cursorrules", # Cursor (legacy, widely used) ".windsurfrules": ".windsurfrules", # Windsurf } @@ -489,3 +490,195 @@ def inject_platform_instructions(repo_root: Path) -> list[str]: if _inject_instructions(path, _CLAUDE_MD_SECTION_MARKER, _CLAUDE_MD_SECTION): updated.append(label) return updated + + +# --- Cursor hooks --- + + +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/. + + Returns: + Dict suitable for writing as ~/.cursor/hooks.json. + """ + hooks_dir = str(Path.home() / ".cursor" / "hooks") + return { + "version": 1, + "hooks": { + "afterFileEdit": [ + { + "command": f"{hooks_dir}/crg-update.sh", + "timeout": 5, + }, + ], + "sessionStart": [ + { + "command": f"{hooks_dir}/crg-session-start.sh", + "timeout": 5, + }, + ], + "beforeShellExecution": [ + { + "matcher": "^git\\s+commit", + "command": f"{hooks_dir}/crg-pre-commit.sh", + "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 + """ + update_script = """\ +#!/usr/bin/env bash +# code-review-graph: auto-update graph after file edits (Cursor hook) +# Fails gracefully — never blocks the editor. +set -euo pipefail + +# Consume stdin (Cursor sends JSON context) +cat > /dev/null + +# Run update; swallow errors so the hook always succeeds. +output=$(code-review-graph update --skip-flows 2>&1) || true + +# Emit valid JSON on stdout per Cursor hooks protocol. +python3 -c " +import json, sys +print(json.dumps({'message': 'graph updated', 'passed': True})) +" 2>/dev/null || echo '{"passed":true}' + +exit 0 +""" + + session_start_script = """\ +#!/usr/bin/env bash +# code-review-graph: show graph status on session start (Cursor hook) +# Fails gracefully — never blocks the editor. +set -euo pipefail + +# Consume stdin +cat > /dev/null + +# Capture status output +output=$(code-review-graph status 2>&1) || output="graph not built yet" + +# Emit valid JSON on stdout +python3 -c " +import json, sys +msg = sys.stdin.read() +print(json.dumps({'message': msg, 'passed': True})) +" <<< "$output" 2>/dev/null || echo '{"passed":true}' + +exit 0 +""" + + pre_commit_script = """\ +#!/usr/bin/env bash +# code-review-graph: detect changes before git commit (Cursor hook) +# Fails gracefully — never blocks the editor. +set -euo pipefail + +# Consume stdin +cat > /dev/null + +# Run detect-changes; swallow errors +output=$(code-review-graph detect-changes --brief 2>&1) || output="" + +# Emit valid JSON on stdout +python3 -c " +import json, sys +msg = sys.stdin.read() +print(json.dumps({'message': msg, 'passed': True})) +" <<< "$output" 2>/dev/null || echo '{"passed":true}' + +exit 0 +""" + + return { + "crg-update.sh": update_script, + "crg-session-start.sh": session_start_script, + "crg-pre-commit.sh": pre_commit_script, + } + + +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/``. + + Returns: + Path to the hooks.json file that was written. + """ + cursor_dir = Path.home() / ".cursor" + hooks_json_path = cursor_dir / "hooks.json" + hooks_script_dir = cursor_dir / "hooks" + + # --- Merge hooks.json --- + existing: dict[str, Any] = {} + if hooks_json_path.exists(): + try: + existing = json.loads(hooks_json_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + logger.warning("Could not read existing %s: %s", hooks_json_path, exc) + + new_config = generate_cursor_hooks_config() + + # Preserve version (use ours if absent) + existing.setdefault("version", new_config["version"]) + + # Merge hook arrays per event type + existing_hooks = existing.get("hooks", {}) + if not isinstance(existing_hooks, dict): + existing_hooks = {} + + for event, entries in new_config["hooks"].items(): + 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) + existing_hooks[event] = event_hooks + + existing["hooks"] = existing_hooks + + cursor_dir.mkdir(parents=True, exist_ok=True) + hooks_json_path.write_text( + json.dumps(existing, indent=2) + "\n", + encoding="utf-8", + ) + logger.info("Wrote Cursor hooks config: %s", hooks_json_path) + + # --- Write hook scripts --- + hooks_script_dir.mkdir(parents=True, exist_ok=True) + scripts = _cursor_hook_scripts() + + 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) + 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 bad8b8b..7afc51c 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -1,14 +1,18 @@ """Tests for skills and hooks auto-install.""" import json +import stat from unittest.mock import patch from code_review_graph.skills import ( _CLAUDE_MD_SECTION_MARKER, PLATFORMS, + _cursor_hook_scripts, + generate_cursor_hooks_config, generate_hooks_config, generate_skills, inject_claude_md, + install_cursor_hooks, install_hooks, install_platform_configs, ) @@ -64,9 +68,7 @@ def test_skill_content_includes_detail_level(self, tmp_path): skills_dir = generate_skills(tmp_path) for path in skills_dir.iterdir(): content = path.read_text() - assert "detail_level" in content, ( - f"{path.name} missing detail_level reference" - ) + assert "detail_level" in content, f"{path.name} missing detail_level reference" def test_idempotent(self, tmp_path): """Running twice should not fail and files should still be valid.""" @@ -184,9 +186,12 @@ def test_idempotent_with_existing_content(self, tmp_path): class TestInstallPlatformConfigs: def test_install_cursor_config(self, tmp_path): - with patch.dict(PLATFORMS, { - "cursor": {**PLATFORMS["cursor"], "detect": lambda: True}, - }): + with patch.dict( + PLATFORMS, + { + "cursor": {**PLATFORMS["cursor"], "detect": lambda: True}, + }, + ): configured = install_platform_configs(tmp_path, target="cursor") assert "Cursor" in configured config_path = tmp_path / ".cursor" / "mcp.json" @@ -199,32 +204,39 @@ def test_install_windsurf_config(self, tmp_path): windsurf_dir = tmp_path / ".codeium" / "windsurf" windsurf_dir.mkdir(parents=True) config_path = windsurf_dir / "mcp_config.json" - with patch.dict(PLATFORMS, { - "windsurf": { - **PLATFORMS["windsurf"], - "config_path": lambda root: config_path, - "detect": lambda: True, + with patch.dict( + PLATFORMS, + { + "windsurf": { + **PLATFORMS["windsurf"], + "config_path": lambda root: config_path, + "detect": lambda: True, + }, }, - }): + ): configured = install_platform_configs(tmp_path, target="windsurf") assert "Windsurf" in configured data = json.loads(config_path.read_text()) entry = data["mcpServers"]["code-review-graph"] assert "type" not in entry import shutil + expected_cmd = "uvx" if shutil.which("uvx") else "code-review-graph" assert entry["command"] == expected_cmd def test_install_zed_config(self, tmp_path): zed_settings = tmp_path / "zed" / "settings.json" zed_settings.parent.mkdir(parents=True) - with patch.dict(PLATFORMS, { - "zed": { - **PLATFORMS["zed"], - "config_path": lambda root: zed_settings, - "detect": lambda: True, + with patch.dict( + PLATFORMS, + { + "zed": { + **PLATFORMS["zed"], + "config_path": lambda root: zed_settings, + "detect": lambda: True, + }, }, - }): + ): configured = install_platform_configs(tmp_path, target="zed") assert "Zed" in configured data = json.loads(zed_settings.read_text()) @@ -235,13 +247,16 @@ def test_install_continue_config(self, tmp_path): continue_dir = tmp_path / ".continue" continue_dir.mkdir() config_path = continue_dir / "config.json" - with patch.dict(PLATFORMS, { - "continue": { - **PLATFORMS["continue"], - "config_path": lambda root: config_path, - "detect": lambda: True, + with patch.dict( + PLATFORMS, + { + "continue": { + **PLATFORMS["continue"], + "config_path": lambda root: config_path, + "detect": lambda: True, + }, }, - }): + ): configured = install_platform_configs(tmp_path, target="continue") assert "Continue" in configured data = json.loads(config_path.read_text()) @@ -277,9 +292,7 @@ def test_merge_existing_servers(self, tmp_path): assert "code-review-graph" in data["mcpServers"] def test_dry_run_no_write(self, tmp_path): - configured = install_platform_configs( - tmp_path, target="claude", dry_run=True - ) + configured = install_platform_configs(tmp_path, target="claude", dry_run=True) assert "Claude Code" in configured assert not (tmp_path / ".mcp.json").exists() @@ -292,18 +305,184 @@ def test_continue_array_no_duplicate(self, tmp_path): config_path = tmp_path / ".continue" / "config.json" config_path.parent.mkdir(parents=True) existing = { - "mcpServers": [ - {"name": "code-review-graph", "command": "uvx", "args": ["serve"]} - ] + "mcpServers": [{"name": "code-review-graph", "command": "uvx", "args": ["serve"]}] } config_path.write_text(json.dumps(existing)) - with patch.dict(PLATFORMS, { - "continue": { - **PLATFORMS["continue"], - "config_path": lambda root: config_path, - "detect": lambda: True, + with patch.dict( + PLATFORMS, + { + "continue": { + **PLATFORMS["continue"], + "config_path": lambda root: config_path, + "detect": lambda: True, + }, }, - }): + ): install_platform_configs(tmp_path, target="continue") data = json.loads(config_path.read_text()) assert len(data["mcpServers"]) == 1 + + +class TestCursorHooksConfig: + """Tests for generate_cursor_hooks_config().""" + + def test_has_version_1(self): + config = generate_cursor_hooks_config() + assert config["version"] == 1 + + def test_has_after_file_edit(self): + config = generate_cursor_hooks_config() + hooks = config["hooks"]["afterFileEdit"] + assert len(hooks) >= 1 + assert "crg-update.sh" in hooks[0]["command"] + assert hooks[0]["timeout"] == 5 + + def test_has_session_start(self): + config = generate_cursor_hooks_config() + hooks = config["hooks"]["sessionStart"] + assert len(hooks) >= 1 + assert "crg-session-start.sh" in hooks[0]["command"] + assert hooks[0]["timeout"] == 5 + + def test_has_before_shell_execution(self): + config = generate_cursor_hooks_config() + hooks = config["hooks"]["beforeShellExecution"] + assert len(hooks) >= 1 + assert "crg-pre-commit.sh" in hooks[0]["command"] + assert hooks[0]["timeout"] == 10 + assert hooks[0]["matcher"] == "^git\\s+commit" + + def test_has_all_three_hook_types(self): + config = generate_cursor_hooks_config() + hook_types = set(config["hooks"].keys()) + assert hook_types == {"afterFileEdit", "sessionStart", "beforeShellExecution"} + + def test_commands_point_to_home_cursor_hooks(self): + config = generate_cursor_hooks_config() + from pathlib import Path + + hooks_dir = str(Path.home() / ".cursor" / "hooks") + for event, entries in config["hooks"].items(): + for entry in entries: + assert entry["command"].startswith(hooks_dir), ( + f"{event} command does not start with {hooks_dir}" + ) + + +class TestCursorHookScripts: + """Tests for _cursor_hook_scripts().""" + + def test_returns_three_scripts(self): + scripts = _cursor_hook_scripts() + assert set(scripts.keys()) == { + "crg-update.sh", + "crg-session-start.sh", + "crg-pre-commit.sh", + } + + def test_scripts_start_with_shebang(self): + scripts = _cursor_hook_scripts() + for name, content in scripts.items(): + assert content.startswith("#!/usr/bin/env bash"), f"{name} missing shebang line" + + def test_scripts_exit_zero(self): + """Each script must end with exit 0 for graceful failure.""" + 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): + """Each script must consume stdin (Cursor protocol).""" + scripts = _cursor_hook_scripts() + for name, content in scripts.items(): + assert "cat > /dev/null" in content, f"{name} missing stdin consumption" + + def test_update_script_runs_update(self): + scripts = _cursor_hook_scripts() + assert "code-review-graph update --skip-flows" in scripts["crg-update.sh"] + + def test_session_start_script_runs_status(self): + scripts = _cursor_hook_scripts() + assert "code-review-graph status" in scripts["crg-session-start.sh"] + + 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.sh"] + + +class TestInstallCursorHooks: + """Tests for install_cursor_hooks().""" + + def test_creates_hooks_json(self, tmp_path): + with patch("code_review_graph.skills.Path.home", return_value=tmp_path): + result = install_cursor_hooks() + hooks_json = tmp_path / ".cursor" / "hooks.json" + assert hooks_json.exists() + assert result == hooks_json + data = json.loads(hooks_json.read_text()) + assert data["version"] == 1 + assert "afterFileEdit" in data["hooks"] + + def test_creates_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.sh").exists() + assert (hooks_dir / "crg-session-start.sh").exists() + assert (hooks_dir / "crg-pre-commit.sh").exists() + + def test_scripts_are_executable(self, tmp_path): + with patch("code_review_graph.skills.Path.home", return_value=tmp_path): + install_cursor_hooks() + hooks_dir = tmp_path / ".cursor" / "hooks" + for script in hooks_dir.iterdir(): + mode = script.stat().st_mode + assert mode & stat.S_IXUSR, f"{script.name} not executable by owner" + assert mode & stat.S_IXGRP, f"{script.name} not executable by group" + + def test_merges_with_existing_hooks_json(self, tmp_path): + cursor_dir = tmp_path / ".cursor" + cursor_dir.mkdir(parents=True) + existing = { + "version": 1, + "hooks": { + "afterFileEdit": [{"command": "/some/other/hook.sh", "timeout": 3}], + "stop": [{"command": "/some/stop-hook.sh", "timeout": 2}], + }, + } + (cursor_dir / "hooks.json").write_text(json.dumps(existing)) + + with patch("code_review_graph.skills.Path.home", return_value=tmp_path): + install_cursor_hooks() + + data = json.loads((cursor_dir / "hooks.json").read_text()) + # Original hook preserved + commands = [h["command"] for h in data["hooks"]["afterFileEdit"]] + assert "/some/other/hook.sh" in commands + # Our hook added + assert any("crg-update.sh" in c for c in commands) + # Unrelated hook type preserved + assert "stop" in data["hooks"] + + 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()) + # Each event type should have exactly 1 crg hook + 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" + + def test_handles_corrupt_existing_json(self, tmp_path): + cursor_dir = tmp_path / ".cursor" + cursor_dir.mkdir(parents=True) + (cursor_dir / "hooks.json").write_text("not valid json{{{") + + with patch("code_review_graph.skills.Path.home", return_value=tmp_path): + result = install_cursor_hooks() + + assert result.exists() + data = json.loads(result.read_text()) + assert data["version"] == 1