Skip to content

Commit d3f4fc0

Browse files
committed
feat(review): add /cursor:review — read-only diff review by a Cursor model
Adds a code-review command modelled on openai/codex-plugin-cc's /codex:review, adapted to the Cursor CLI. The plugin collects the git diff itself (working tree, or a branch vs --base <ref>), embeds it in a strict review-only prompt, runs cursor-agent over it, and returns the findings (Blocking / Should-fix / Nits + verdict) verbatim. - Targets: auto (dirty tree → working-tree, clean → branch vs default), plus --scope auto|working-tree|branch and --base <ref>. - --adversarial challenges the design, not only implementation defects. - Tracked as a normal job: /cursor:status, /cursor:result, /cursor:cancel all apply; supports --background/--wait, --model, --timeout, focus text. - Read-only guarantee: a post-flight check marks the job failed if the run touches the working tree, so a review can never silently become an edit. - New collectReviewContext helpers in scripts/lib/git.mjs; tests + fixtures. Docs: README "What you get" + Usage subsection, reworked the old "no review command" section into "A second opinion", CHANGELOG entry.
1 parent 9d05f96 commit d3f4fc0

10 files changed

Lines changed: 768 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## Unreleased
8+
9+
### Added
10+
11+
- **`/cursor:review`** — read-only code review of your git diff by a Cursor model, modelled on `openai/codex-plugin-cc`'s `/codex:review`. The plugin collects the diff itself (working tree, or branch vs a `--base <ref>`), embeds it in a strict review-only prompt, runs `cursor-agent` over it, and returns the findings (Blocking / Should-fix / Nits + verdict) verbatim. Supports `--scope auto|working-tree|branch`, `--adversarial` (challenge the design), `--model`, `--background`/`--wait`, `--timeout`, and free-form focus text. Tracked as a normal job, so `/cursor:status`, `/cursor:result`, and `/cursor:cancel` apply. A post-flight check marks the job `failed` if the run touches the working tree, so a review can never silently become an edit. New `collectReviewContext` helpers in `scripts/lib/git.mjs`.
12+
713
## 0.2.2 — resume bug fix + safer default model
814

915
### Fixed

README.md

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,10 +117,11 @@ What lives where after a run:
117117

118118
## What you get
119119

120-
Nine slash commands under the `cursor:` namespace:
120+
Ten slash commands under the `cursor:` namespace:
121121

122122
- **`/cursor:delegate`** — hand a coding task to Cursor, foreground or background.
123123
- **`/cursor:from-plan`** — turn a Claude Code plan (from plan mode) into a `tasks/<file>.md` and hand it off to Cursor.
124+
- **`/cursor:review`** — read-only code review of your git diff by a Cursor model. Reports findings; never edits files.
124125
- **`/cursor:browser`** — verify a URL / flow in a real browser via Cursor's `chrome-devtools` MCP.
125126
- **`/cursor:status`** — list recent jobs or inspect a specific one.
126127
- **`/cursor:result`** — print the final output of a finished job.
@@ -141,9 +142,11 @@ Cursor CLI has its own plan mode and it is fine, but execution is where Cursor r
141142

142143
So: Claude plans, Cursor writes, Claude reviews, repeat. Glued together by seven slash commands and one subagent.
143144

144-
## Why not just `/codex:review`-style?
145+
## A second opinion: `/cursor:review`
145146

146-
This plugin is built around delegating _execution_ — writing code — to Cursor's Composer 2 for speed. Claude Code stays the orchestrator, planner, and reviewer. There is intentionally **no** `/cursor:review` or `/cursor:adversarial-review` command: Cursor is the "doer" here, not the critic. If you want review, ask Claude to review Cursor's diff in the usual way.
147+
The core loop stays **Claude plans, Cursor writes, Claude reviews** — that is where Claude Code earns its keep. But sometimes you want a _second_ reviewer on the same diff: a different model with a fresh perspective, or a deeper pass from `gpt`/`opus`/`gemini` while Claude keeps its context for orchestration. That is what `/cursor:review` is for.
148+
149+
It is modelled on [`openai/codex-plugin-cc`](https://github.com/openai/codex-plugin-cc)'s `/codex:review`, adapted to the Cursor CLI: the plugin collects the git diff itself (working tree or branch vs a base), hands it to a Cursor model with a strict **review-only** prompt, and returns the findings verbatim. It never edits your files — a post-flight check fails the job if the run touches the working tree, so a review can't quietly turn into an edit. See [`/cursor:review`](#cursorreview-flags-focus) under Usage for flags and examples.
147150

148151
## Usage
149152

@@ -196,6 +199,35 @@ Examples:
196199

197200
This is the closest thing to "plan in Claude, execute in Cursor" in one session: Claude does the thinking, Cursor does the typing, and the task file is a durable contract between the two.
198201

202+
### `/cursor:review [flags] [focus...]`
203+
204+
Read-only code review of your git diff by a Cursor model. The plugin collects the diff itself, embeds it in a strict review-only prompt, runs `cursor-agent` over it, and prints the findings verbatim — grouped Blocking / Should-fix / Nits with a one-line verdict. It does **not** edit files; if the run touches the working tree anyway, a post-flight check marks the job `failed` and flags it. Tracked as a normal job, so `/cursor:status`, `/cursor:result` and `/cursor:cancel` all work on it.
205+
206+
By default it picks the target automatically: a dirty working tree is reviewed as-is; a clean tree falls back to a branch diff against the detected default branch. Any trailing text is passed as a reviewer **focus**.
207+
208+
| Flag | Default | Effect |
209+
| ------------------------------------ | ----------------- | ------------------------------------------------------------------------------------------------- |
210+
| `--base <ref>` | auto | Review the branch diff `<ref>...HEAD` (merge-base) instead of the working tree. |
211+
| `--scope auto\|working-tree\|branch` | `auto` | Force the target. `working-tree` = uncommitted changes; `branch` = vs the detected default branch. |
212+
| `--adversarial` | off | Challenge the design and assumptions, not just implementation defects. |
213+
| `--model <id>` | `auto` | Same aliases as `/cursor:delegate`. Use `gpt`/`opus`/`gemini` for a deeper review. |
214+
| `--background` | off | Detach; returns a job id immediately. Read it later with `/cursor:result`. |
215+
| `--wait` | on | Block until the review finishes (default unless `--background`). |
216+
| `--timeout <sec>` | `1800` | Kill the review if it exceeds this. |
217+
| `--no-git-check` | off | Allow running outside a git repo (rarely useful — there is no diff to review). |
218+
219+
Examples:
220+
221+
```
222+
/cursor:review # review the current working-tree diff
223+
/cursor:review --base main # review this branch vs main
224+
/cursor:review --scope branch --model gpt # branch diff, deeper model
225+
/cursor:review --adversarial "is the retry/backoff design sound under load?"
226+
/cursor:review --background --model opus # detach; /cursor:result when ready
227+
```
228+
229+
This is a **second opinion**, not a replacement for Claude reviewing the diff in-session. Reach for it when you want a different model's eyes on the change, or to offload a large review while Claude keeps orchestrating.
230+
199231
### `/cursor:browser <url> <what to verify...>`
200232

201233
Verify a page or a flow in a **real browser** via Cursor's `chrome-devtools` MCP. This is read-only by design — Cursor navigates, interacts, checks console/network and reports back; it will not modify your source files.

plugins/cursor/commands/review.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
description: Read-only code review of your git diff by a Cursor model. Reports findings; never edits files.
3+
argument-hint: '[--background] [--wait] [--adversarial] [--base <ref>] [--scope auto|working-tree|branch] [--model <id>] [--timeout <sec>] [focus...]'
4+
allowed-tools: Bash(node:*)
5+
---
6+
7+
!`node "${CLAUDE_PLUGIN_ROOT}/scripts/review.mjs" -- "$ARGUMENTS"`
8+
9+
Render the tool output to the user verbatim — it is a code review, do not paraphrase or summarise it, and do not act on the findings yourself. If the job ran in the foreground, present the **Review** section as-is. If it was started in the background, show the returned job id and the `/cursor:status` / `/cursor:result` hints. This command is review-only: never apply the fixes it suggests unless the user explicitly asks in a follow-up.

plugins/cursor/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@
4646
"resume": "node scripts/resume.mjs",
4747
"sessions": "node scripts/sessions.mjs",
4848
"setup": "node scripts/setup.mjs",
49-
"browser": "node scripts/browser.mjs"
49+
"browser": "node scripts/browser.mjs",
50+
"review": "node scripts/review.mjs"
5051
},
5152
"devDependencies": {
5253
"@eslint/js": "^9.16.0",

plugins/cursor/scripts/lib/git.mjs

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { readFileSync, statSync } from 'node:fs';
2+
import { join } from 'node:path';
13
import { run } from './run.mjs';
24

35
/**
@@ -24,3 +26,246 @@ export async function repoRoot(cwd = process.cwd()) {
2426
if (res.exitCode === 0) return res.stdout.trim() || cwd;
2527
return cwd;
2628
}
29+
30+
// --- review-context collection ---------------------------------------------
31+
// Helpers below gather the git state a code review needs (status, diff stat,
32+
// diff body, untracked file contents) so the review prompt is self-contained
33+
// and the reviewer never has to mutate the tree to see what changed.
34+
35+
const MAX_DIFF_BYTES = 256 * 1024;
36+
const MAX_UNTRACKED_BYTES = 32 * 1024;
37+
38+
function git(cwd, args) {
39+
return run('git', args, { cwd, timeoutMs: 15_000 });
40+
}
41+
42+
/**
43+
* @param {string} [cwd]
44+
* @returns {Promise<string>}
45+
*/
46+
export async function currentBranch(cwd = process.cwd()) {
47+
const res = await git(cwd, ['branch', '--show-current']);
48+
return res.stdout.trim() || 'HEAD';
49+
}
50+
51+
/**
52+
* Best-effort detection of the repo's default branch. Returns null when none
53+
* of the usual suspects exist, so callers can ask the user for `--base`.
54+
*
55+
* @param {string} [cwd]
56+
* @returns {Promise<string|null>}
57+
*/
58+
export async function detectDefaultBranch(cwd = process.cwd()) {
59+
const sym = await git(cwd, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']);
60+
if (sym.exitCode === 0) {
61+
const head = sym.stdout.trim();
62+
if (head.startsWith('refs/remotes/origin/')) {
63+
return head.replace('refs/remotes/origin/', 'origin/');
64+
}
65+
}
66+
for (const cand of ['main', 'master', 'trunk']) {
67+
const local = await git(cwd, ['show-ref', '--verify', '--quiet', `refs/heads/${cand}`]);
68+
if (local.exitCode === 0) return cand;
69+
const remote = await git(cwd, [
70+
'show-ref',
71+
'--verify',
72+
'--quiet',
73+
`refs/remotes/origin/${cand}`,
74+
]);
75+
if (remote.exitCode === 0) return `origin/${cand}`;
76+
}
77+
return null;
78+
}
79+
80+
/**
81+
* @param {string} [cwd]
82+
* @returns {Promise<{staged: string[], unstaged: string[], untracked: string[], isDirty: boolean}>}
83+
*/
84+
export async function workingTreeStatus(cwd = process.cwd()) {
85+
const split = (s) => s.trim().split('\n').filter(Boolean);
86+
const staged = split((await git(cwd, ['diff', '--cached', '--name-only'])).stdout);
87+
const unstaged = split((await git(cwd, ['diff', '--name-only'])).stdout);
88+
const untracked = split((await git(cwd, ['ls-files', '--others', '--exclude-standard'])).stdout);
89+
return {
90+
staged,
91+
unstaged,
92+
untracked,
93+
isDirty: staged.length + unstaged.length + untracked.length > 0,
94+
};
95+
}
96+
97+
function looksBinary(buf) {
98+
const len = Math.min(buf.length, 8_000);
99+
for (let i = 0; i < len; i += 1) if (buf[i] === 0) return true;
100+
return false;
101+
}
102+
103+
function section(title, body) {
104+
const trimmed = (body ?? '').trim();
105+
return `## ${title}\n\n${trimmed ? trimmed : '(none)'}\n`;
106+
}
107+
108+
function capDiff(diff, maxBytes) {
109+
if (Buffer.byteLength(diff, 'utf8') <= maxBytes) return { text: diff, truncated: false };
110+
const slice = Buffer.from(diff, 'utf8').subarray(0, maxBytes).toString('utf8');
111+
const lastNl = slice.lastIndexOf('\n');
112+
return { text: slice.slice(0, lastNl > 0 ? lastNl : slice.length), truncated: true };
113+
}
114+
115+
function untrackedSection(cwd, files) {
116+
if (files.length === 0) return '(none)';
117+
const parts = [];
118+
for (const rel of files) {
119+
const abs = join(cwd, rel);
120+
let st;
121+
try {
122+
st = statSync(abs);
123+
} catch {
124+
parts.push(`### ${rel}\n(unreadable)`);
125+
continue;
126+
}
127+
if (st.isDirectory()) {
128+
parts.push(`### ${rel}/\n(directory — contents omitted)`);
129+
continue;
130+
}
131+
if (st.size > MAX_UNTRACKED_BYTES) {
132+
parts.push(
133+
`### ${rel}\n(skipped: ${st.size} bytes exceeds ${MAX_UNTRACKED_BYTES} byte limit)`,
134+
);
135+
continue;
136+
}
137+
let buf;
138+
try {
139+
buf = readFileSync(abs);
140+
} catch {
141+
parts.push(`### ${rel}\n(unreadable)`);
142+
continue;
143+
}
144+
if (looksBinary(buf)) {
145+
parts.push(`### ${rel}\n(binary file)`);
146+
continue;
147+
}
148+
parts.push([`### ${rel}`, '```', buf.toString('utf8').trimEnd(), '```'].join('\n'));
149+
}
150+
return parts.join('\n\n');
151+
}
152+
153+
/**
154+
* @typedef {Object} ReviewContext
155+
* @property {'working-tree'|'branch'} [mode]
156+
* @property {string} [label] Human-readable target description.
157+
* @property {string} [baseRef]
158+
* @property {string} [body] Markdown diff context for the prompt.
159+
* @property {string[]} [changedFiles]
160+
* @property {boolean} [truncated]
161+
* @property {boolean} [isEmpty] True when there is nothing to review.
162+
* @property {string} [error] Set when the target could not be resolved.
163+
*/
164+
165+
/**
166+
* Resolve and collect the git context for a review.
167+
*
168+
* @param {string} cwd
169+
* @param {{scope?: 'auto'|'working-tree'|'branch', base?: string|null, maxDiffBytes?: number}} [opts]
170+
* @returns {Promise<ReviewContext>}
171+
*/
172+
export async function collectReviewContext(cwd, opts = {}) {
173+
const scope = opts.scope ?? 'auto';
174+
const base = opts.base ?? null;
175+
const maxDiffBytes = opts.maxDiffBytes ?? MAX_DIFF_BYTES;
176+
const branch = await currentBranch(cwd);
177+
178+
let mode;
179+
let baseRef = base;
180+
if (base) {
181+
mode = 'branch';
182+
} else if (scope === 'working-tree') {
183+
mode = 'working-tree';
184+
} else if (scope === 'branch') {
185+
mode = 'branch';
186+
baseRef = await detectDefaultBranch(cwd);
187+
} else {
188+
const st = await workingTreeStatus(cwd);
189+
if (st.isDirty) {
190+
mode = 'working-tree';
191+
} else {
192+
mode = 'branch';
193+
baseRef = await detectDefaultBranch(cwd);
194+
}
195+
}
196+
197+
if (mode === 'branch' && !baseRef) {
198+
return {
199+
error: 'Could not detect a default branch. Pass --base <ref> or use --scope working-tree.',
200+
};
201+
}
202+
203+
if (mode === 'working-tree') {
204+
const st = await workingTreeStatus(cwd);
205+
const changedFiles = [...new Set([...st.staged, ...st.unstaged, ...st.untracked])].sort();
206+
if (!st.isDirty) {
207+
return { mode, label: `working tree on ${branch}`, changedFiles: [], isEmpty: true };
208+
}
209+
const status = (await git(cwd, ['status', '--short', '--untracked-files=all'])).stdout.trim();
210+
const stat = (await git(cwd, ['diff', '--stat', 'HEAD'])).stdout.trim();
211+
const { text: diff, truncated } = capDiff(
212+
(await git(cwd, ['diff', '--no-ext-diff', 'HEAD'])).stdout,
213+
maxDiffBytes,
214+
);
215+
const body = [
216+
section('Status', status),
217+
section('Diff stat', stat),
218+
section(
219+
'Diff (tracked files vs HEAD)',
220+
diff + (truncated ? '\n\n…[diff truncated — inspect the remaining files read-only]…' : ''),
221+
),
222+
section('Untracked files', untrackedSection(cwd, st.untracked)),
223+
].join('\n');
224+
return {
225+
mode,
226+
label: `working tree on ${branch} (${changedFiles.length} file(s))`,
227+
body,
228+
changedFiles,
229+
truncated,
230+
isEmpty: false,
231+
};
232+
}
233+
234+
const mb = await git(cwd, ['merge-base', 'HEAD', baseRef]);
235+
if (mb.exitCode !== 0) {
236+
return { error: `Could not compute a merge-base with "${baseRef}". Is it a valid ref?` };
237+
}
238+
const range = `${baseRef}...HEAD`;
239+
const changedFiles = (await git(cwd, ['diff', '--name-only', range])).stdout
240+
.trim()
241+
.split('\n')
242+
.filter(Boolean);
243+
if (changedFiles.length === 0) {
244+
return { mode, label: `${branch} vs ${baseRef}`, baseRef, changedFiles: [], isEmpty: true };
245+
}
246+
const log = (
247+
await git(cwd, ['log', '--oneline', '--no-decorate', `${baseRef}..HEAD`])
248+
).stdout.trim();
249+
const stat = (await git(cwd, ['diff', '--stat', range])).stdout.trim();
250+
const { text: diff, truncated } = capDiff(
251+
(await git(cwd, ['diff', '--no-ext-diff', range])).stdout,
252+
maxDiffBytes,
253+
);
254+
const body = [
255+
section('Commits', log),
256+
section('Diff stat', stat),
257+
section(
258+
`Diff (${range})`,
259+
diff + (truncated ? '\n\n…[diff truncated — inspect the remaining files read-only]…' : ''),
260+
),
261+
].join('\n');
262+
return {
263+
mode,
264+
label: `${branch} vs ${baseRef} (${changedFiles.length} file(s))`,
265+
baseRef,
266+
body,
267+
changedFiles,
268+
truncated,
269+
isEmpty: false,
270+
};
271+
}

0 commit comments

Comments
 (0)