From 807659492b5452d8bece141e68eef865abf7516b Mon Sep 17 00:00:00 2001 From: Mason Huang Date: Sun, 28 Jun 2026 10:03:03 +0800 Subject: [PATCH] feat(autoreview): support cursor-agent engine --- .agents/skills/autoreview/SKILL.md | 9 +- .agents/skills/autoreview/scripts/autoreview | 109 +++++++++++++++++- .../scripts/test-review-harness.ps1 | 2 +- .../autoreview/scripts/test-review-harness.py | 2 +- 4 files changed, 111 insertions(+), 11 deletions(-) diff --git a/.agents/skills/autoreview/SKILL.md b/.agents/skills/autoreview/SKILL.md index a2ad388eef2f..9272875499c5 100644 --- a/.agents/skills/autoreview/SKILL.md +++ b/.agents/skills/autoreview/SKILL.md @@ -29,7 +29,7 @@ Use when: - For security-audit suppression changes, verify accepted findings remain auditable: suppressed findings stay in structured output, active output keeps an unsuppressible suppression notice, and aggregate findings cannot hide unrelated active risk. - Never switch or override the requested review engine/model. If the review hits model capacity, retry the same command a few times with the same engine/model. - Be patient with large bundles. Structured review can take up to 30 minutes while the model call is active, especially with Codex tools or web search. -- Treat heartbeat lines like `review still running: ... elapsed=... pid=...` as healthy progress, not a hang. Let the helper continue while heartbeats are advancing. Pass `--stream-engine-output` when live engine text is useful; Codex and Claude filter tool/file chatter, other engines pass raw output through. +- Treat heartbeat lines like `review still running: ... elapsed=... pid=...` as healthy progress, not a hang. Let the helper continue while heartbeats are advancing. Pass `--stream-engine-output` when live engine text is useful; Codex, Claude, and cursor-agent filter tool/file chatter, other engines pass raw output through. - Do not kill a review just because it has been quiet for 2-5 minutes, or because it is still running under the 30-minute window. Inspect the process only after missing multiple expected heartbeats, after 30 minutes, or after an obviously failed subprocess; prefer letting the same helper command finish. - Tools are useful in review mode. The helper allows read-only inspection tools and web search by default so reviewers can check dependency contracts, upstream docs, and current behavior. - Security perspective is always included, but it should not cripple legitimate functionality. Report security findings only when the change creates a concrete, actionable risk or removes an important safety check. @@ -226,15 +226,16 @@ The helper: - accepts `--mode uncommitted` as an alias for `--mode local` - otherwise uses current PR base if `gh pr view` works - otherwise uses `origin/main` for non-main branches -- supports `--engine codex`, `claude`, `droid`, and `copilot`; default is `AUTOREVIEW_ENGINE` or `codex`; Codex should remain the default when nothing is set +- supports `--engine codex`, `claude`, `droid`, `copilot`, and `cursor-agent`; default is `AUTOREVIEW_ENGINE` or `codex`; Codex should remain the default when nothing is set - resolves bare `git`, `gh`, reviewer, and PowerShell shell commands from absolute `PATH` entries only, never from the reviewed checkout; explicit relative `--*-bin` paths are resolved from the reviewed repository root - use `--mode commit --commit ` for already-committed work, especially clean `main` after landing - should be left in `--mode auto` or forced to `--mode branch` for PR/branch work; do not force `--mode local` after committing - writes only to stdout unless `--output`, `--json-output`, or live streamed engine stderr is set - supports `--dry-run`, `--parallel-tests`, `--parallel-tests-shell`, `--prompt`, `--prompt-file`, `--dataset`, `--no-tools`, `--no-web-search`, and commit refs -- supports `--stream-engine-output` or `AUTOREVIEW_STREAM_ENGINE_OUTPUT=1` for live engine text while preserving structured validation; Codex and Claude hide tool/file event details, emit compact activity summaries, and report usage at turn completion +- supports `--stream-engine-output` or `AUTOREVIEW_STREAM_ENGINE_OUTPUT=1` for live engine text while preserving structured validation; Codex, Claude, and cursor-agent hide tool/file event details, emit compact activity summaries, and report usage at turn completion - supports opt-in review panels with `--panel` / `--reviewers`, plus per-engine `--model` and `--thinking` -- allows read-only tools and web search by default where the selected CLI supports them; forbids nested review in the prompt; Codex is run through `codex exec` with read-only sandbox and structured output +- allows read-only tools and web search by default where the selected CLI supports them; forbids nested review in the prompt; Codex is run through `codex exec` with read-only sandbox and structured output; cursor-agent is run through headless `--print` in ask mode with sandboxing enabled from a helper-owned temporary workspace +- rejects `--no-web-search` for cursor-agent because the Cursor CLI does not expose a CLI-level web-search disable switch - prints `review still running: elapsed=s pid=` to stderr at long-running intervals while waiting for the selected review engine, unless streamed output or compact Codex activity has been visible recently - prints `autoreview clean: no accepted/actionable findings reported` when the selected review command exits 0 - exits nonzero when accepted/actionable findings are present diff --git a/.agents/skills/autoreview/scripts/autoreview b/.agents/skills/autoreview/scripts/autoreview index 7e5f142c18a1..3f0a29bb3a31 100755 --- a/.agents/skills/autoreview/scripts/autoreview +++ b/.agents/skills/autoreview/scripts/autoreview @@ -17,12 +17,13 @@ from pathlib import Path from typing import Any, Callable -ENGINES = ("codex", "claude", "droid", "copilot") +ENGINES = ("codex", "claude", "droid", "copilot", "cursor-agent") THINKING_LEVELS_BY_ENGINE = { "codex": {"low", "medium", "high", "xhigh"}, "claude": {"low", "medium", "high", "xhigh", "max"}, "droid": set(), "copilot": set(), + "cursor-agent": set(), } @@ -480,7 +481,7 @@ def build_prompt(repo: Path, target: str, target_ref: str | None, bundle: str, e {json.dumps(SCHEMA, indent=2)} - Do not modify files. - Do not invoke nested reviewers or review tools. - - Forbidden nested review commands include: codex review, autoreview, claude review, oracle review. + - Forbidden nested review commands include: codex review, autoreview, claude review, cursor-agent, oracle review. - You may use read-only tools and web search to inspect files, dependency contracts, upstream docs, current behavior, and security implications. - Shell commands, if available, must be read-only inspection commands. Do not run tests, formatters, package installs, generators, network mutation commands, git mutation commands, or commands that write files. - Report only actionable defects introduced or exposed by this change. @@ -660,6 +661,44 @@ def run_copilot(args: argparse.Namespace, repo: Path, prompt: str) -> str: return result.stdout +def run_cursor_agent(args: argparse.Namespace, repo: Path, prompt: str) -> str: + if args.thinking: + raise SystemExit("--thinking is not supported by the cursor-agent engine") + if not args.tools: + raise SystemExit("--no-tools is not supported by the cursor-agent engine; use --engine claude --no-tools for a no-tools run") + if not args.web_search: + raise SystemExit("--no-web-search is not supported by the cursor-agent engine; use an engine with a CLI-level web-search disable switch") + with tempfile.TemporaryDirectory(prefix="autoreview-cursor-agent.") as tempdir: + # Trust only the helper-owned empty workspace, never the reviewed repo. + # Cursor may load trusted project hooks/config before model instructions apply. + cmd = [ + resolve_command(args.cursor_agent_bin, repo), + "--print", + "--output-format", + "stream-json" if args.stream_engine_output else "json", + "--trust", + "--workspace", + tempdir, + "--mode", + "ask", + "--sandbox", + "enabled", + ] + if args.model: + cmd.extend(["--model", args.model]) + result = run_with_heartbeat( + cmd, + Path(tempdir), + input_text=prompt, + label="cursor-agent", + stream_output=args.stream_engine_output, + stream_display=CursorAgentStreamDisplay() if args.stream_engine_output else None, + ) + if result.returncode != 0: + raise SystemExit(f"cursor-agent engine failed ({result.returncode})\n{result.stderr or result.stdout}") + return result.stdout + + class CodexStreamDisplay: def __init__(self, *, activity_seconds: int = 20) -> None: self.activity_seconds = activity_seconds @@ -779,6 +818,41 @@ class ClaudeStreamDisplay: return text +class CursorAgentStreamDisplay(ClaudeStreamDisplay): + def __call__(self, name: str, line: str) -> str | None: + if name != "stdout": + return line + try: + event = json.loads(line) + except json.JSONDecodeError: + return self.visible(line) + event_type = event.get("type") + if event_type == "system": + return self.visible(f"cursor-agent session: {event.get('session_id', '')}\n") + if event_type == "assistant": + return self.assistant_message(event) + if event_type == "result": + return self.visible(self.flush_hidden() + self.result_summary(event)) + return self.hidden_activity() + + def result_summary(self, event: dict[str, Any]) -> str: + usage = event.get("usage") + fields: list[str] = [] + if isinstance(usage, dict): + for key in ("inputTokens", "cacheReadTokens", "cacheWriteTokens", "outputTokens"): + value = usage.get(key) + if isinstance(value, int): + fields.append(f"{key}={value}") + return "cursor-agent usage: " + " ".join(fields) + "\n" if fields else "cursor-agent turn completed\n" + + def flush_hidden(self) -> str: + if not self.hidden_events: + return "" + count = self.hidden_events + self.hidden_events = 0 + return f"cursor-agent activity: {count} hidden tool/status events\n" + + def format_codex_usage(usage: dict[str, Any]) -> str: fields = [ "input_tokens", @@ -868,13 +942,35 @@ def parse_json_candidate(text: str) -> Any | None: try: parsed = json.loads(stripped) except json.JSONDecodeError: - return None + return parse_embedded_json_object(stripped) if isinstance(parsed, str) and parsed != text: nested = parse_json_candidate(parsed) return nested if nested is not None else parsed return parsed +def parse_embedded_json_object(text: str) -> Any | None: + decoder = json.JSONDecoder() + candidates: list[Any] = [] + for index, char in enumerate(text): + if char not in "[{": + continue + try: + parsed, _end = decoder.raw_decode(text[index:]) + except json.JSONDecodeError: + continue + if isinstance(parsed, str): + nested = parse_json_candidate(parsed) + if nested is not None: + candidates.append(nested) + else: + candidates.append(parsed) + for candidate in reversed(candidates): + if isinstance(candidate, dict) and "findings" in candidate: + return candidate + return candidates[-1] if candidates else None + + def validate_report(report: dict[str, Any], repo: Path, changed_paths: set[str], required: list[str]) -> None: allowed_top = {"findings", "overall_correctness", "overall_explanation", "overall_confidence"} extra_top = set(report) - allowed_top @@ -1016,7 +1112,8 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--claude-bin", default=os.environ.get("CLAUDE_BIN", "claude")) parser.add_argument("--droid-bin", default=os.environ.get("DROID_BIN", "droid")) parser.add_argument("--copilot-bin", default=os.environ.get("COPILOT_BIN", "copilot")) - parser.add_argument("--no-tools", dest="tools", action="store_false", default=True, help="Disable tools for engines that support it. Codex and copilot reject no-tools review.") + parser.add_argument("--cursor-agent-bin", default=os.environ.get("CURSOR_AGENT_BIN", "cursor-agent")) + parser.add_argument("--no-tools", dest="tools", action="store_false", default=True, help="Disable tools for engines that support it. Codex, copilot, and cursor-agent reject no-tools review.") parser.add_argument("--no-web-search", dest="web_search", action="store_false", default=True) parser.add_argument( "--claude-allowed-tools", @@ -1034,7 +1131,7 @@ def parse_args() -> argparse.Namespace: "--stream-engine-output", action="store_true", default=os.environ.get("AUTOREVIEW_STREAM_ENGINE_OUTPUT") == "1", - help="Stream review engine output while preserving buffered output for validation. Codex output is filtered to hide tool/file chatter.", + help="Stream review engine output while preserving buffered output for validation. Codex, Claude, and cursor-agent output is filtered to hide tool/file chatter.", ) parser.add_argument("--parallel-tests", help="Run a test command concurrently with review; failure fails the helper.") parser.add_argument( @@ -1061,6 +1158,8 @@ def run_engine(args: argparse.Namespace, repo: Path, prompt: str) -> str: return run_droid(args, repo, prompt) if args.engine == "copilot": return run_copilot(args, repo, prompt) + if args.engine == "cursor-agent": + return run_cursor_agent(args, repo, prompt) raise SystemExit(f"unsupported engine: {args.engine}") diff --git a/.agents/skills/autoreview/scripts/test-review-harness.ps1 b/.agents/skills/autoreview/scripts/test-review-harness.ps1 index ffca686e0f2a..15dc3d2bf420 100644 --- a/.agents/skills/autoreview/scripts/test-review-harness.ps1 +++ b/.agents/skills/autoreview/scripts/test-review-harness.ps1 @@ -3,7 +3,7 @@ param( [ValidateSet('malicious', 'benign')] [string] $Fixture, - [ValidateSet('codex', 'claude', 'droid', 'copilot')] + [ValidateSet('codex', 'claude', 'droid', 'copilot', 'cursor-agent')] [string[]] $Engine, [Alias('h')] diff --git a/.agents/skills/autoreview/scripts/test-review-harness.py b/.agents/skills/autoreview/scripts/test-review-harness.py index 364568e9b68c..77e09e179dfe 100644 --- a/.agents/skills/autoreview/scripts/test-review-harness.py +++ b/.agents/skills/autoreview/scripts/test-review-harness.py @@ -13,7 +13,7 @@ from collections.abc import Callable from pathlib import Path -ENGINES = ("codex", "claude", "droid", "copilot") +ENGINES = ("codex", "claude", "droid", "copilot", "cursor-agent") DEFAULT_ENGINES = ("codex", "claude") MALICIOUS_INITIAL = """export function uploadPath(name) {