pull-request-review 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,336 @@
1
+ # pr-review
2
+
3
+ > AI-powered Pull Request reviews — pick your AI, get a structured Markdown report, right from your terminal.
4
+
5
+ `pr-review` diffs two branches, sends the diff to your chosen AI, and saves a structured Markdown review to a file. If a GitHub PR is open, the file is automatically named `pr-<number>-review.md`.
6
+
7
+ **No API keys required.** All providers use their official CLI tools with your existing authenticated sessions.
8
+
9
+ ---
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install -g pull-request-review
15
+ ```
16
+
17
+ Or from source:
18
+
19
+ ```bash
20
+ git clone https://github.com/hardik-143/pull-request-review
21
+ cd pr-review
22
+ npm install
23
+ npm install -g .
24
+ ```
25
+
26
+ ---
27
+
28
+ ## Requirements
29
+
30
+ - Node.js ≥ 18
31
+ - Git in `PATH`
32
+ - At least one AI provider CLI installed and authenticated (see [Providers](#providers))
33
+
34
+ ---
35
+
36
+ ## Quickstart
37
+
38
+ ```bash
39
+ # Run inside any git repository
40
+ cd your-project
41
+ pr-review
42
+ ```
43
+
44
+ You'll be prompted to pick an AI provider, a model, and your branches. That's it.
45
+
46
+ ---
47
+
48
+ ## Providers
49
+
50
+ All five providers use their official CLI — no API keys, no environment variables.
51
+
52
+ | Provider | CLI binary | Auth |
53
+ |---|---|---|
54
+ | **Claude** | `claude` | Already logged in via Claude Code |
55
+ | **GitHub Copilot** | `copilot` | `copilot /login` |
56
+ | **OpenAI Codex** | `codex` | Sign in on first `codex` run |
57
+ | **Google Gemini** | `gemini` | Sign in on first `gemini` run |
58
+ | **Cursor** | `agent` | Install Cursor → Cmd+Shift+P → *Install agent in PATH* |
59
+
60
+ ### Installing provider CLIs
61
+
62
+ ```bash
63
+ # Claude
64
+ # Already installed if you're using Claude Code
65
+
66
+ # GitHub Copilot
67
+ npm install -g @github/copilot
68
+ copilot /login
69
+
70
+ # OpenAI Codex
71
+ npm install -g @openai/codex
72
+ codex # sign in on first launch
73
+
74
+ # Google Gemini
75
+ npm install -g @google/gemini-cli
76
+ gemini # sign in on first launch
77
+
78
+ # Cursor
79
+ # Install Cursor app → Cmd+Shift+P → "Install cursor/agent in PATH"
80
+ ```
81
+
82
+ ---
83
+
84
+ ## Usage
85
+
86
+ ### Interactive review
87
+
88
+ ```bash
89
+ pr-review
90
+ ```
91
+
92
+ Flow:
93
+
94
+ ```
95
+ Select AI provider:
96
+
97
+ 1) Claude (Anthropic) — Uses local claude CLI — no API key needed
98
+ 2) GitHub Copilot — Uses copilot CLI — run `copilot /login` first
99
+ 3) OpenAI Codex — Uses codex CLI — run `codex` to sign in
100
+ 4) Google Gemini — Uses gemini CLI — run `gemini` to authenticate
101
+ 5) Cursor — Uses agent CLI — install Cursor + add agent to PATH
102
+
103
+ Choice [1]:
104
+
105
+ Select Claude model:
106
+
107
+ 1) claude-sonnet-4-5 · Recommended
108
+ 2) claude-opus-4-5 · Most capable
109
+ 3) claude-haiku-3-5 · Fastest
110
+ ...
111
+
112
+ Choice [1]:
113
+
114
+ Source branch (default: feature/auth):
115
+ Destination branch (default: main):
116
+
117
+ 🔗 PR #42 detected → output: pr-42-review.md
118
+
119
+ ✔ Diff fetched — 4,821 chars
120
+ ✔ Claude review received
121
+ ✔ Report saved → /your/project/pr-42-review.md
122
+
123
+ ✅ PR Review complete!
124
+ PR #42 · Claude · claude-sonnet-4-5
125
+ ```
126
+
127
+ ---
128
+
129
+ ### Commands & flags
130
+
131
+ ```
132
+ pr-review Interactive PR review
133
+ pr-review review Explicit review subcommand (same as above)
134
+ pr-review config View and edit global config
135
+ pr-review review --staged Review staged (uncommitted) changes
136
+ pr-review review --provider=<provider> Skip provider prompt
137
+ pr-review review --model=<model> Skip model prompt
138
+ pr-review review --base=<branch> Override destination/base branch
139
+ pr-review review --focus=<area> Focus: security, performance, etc.
140
+ pr-review review --output=<file> Override output file name
141
+ pr-review --help Show help
142
+ pr-review --version Show version
143
+ ```
144
+
145
+ ### Examples
146
+
147
+ ```bash
148
+ # Standard interactive review
149
+ pr-review
150
+
151
+ # Explicit review subcommand
152
+ pr-review review
153
+
154
+ # Non-interactive — skip all prompts
155
+ pr-review review --provider=claude --model=claude-sonnet-4-5 --base=main
156
+
157
+ # Security-focused review of staged changes
158
+ pr-review review --staged --focus=security
159
+
160
+ # Use Gemini's most capable model
161
+ pr-review review --provider=gemini --model=pro
162
+
163
+ # Review with GitHub Copilot, custom output file
164
+ pr-review review --provider=copilot --output=review-$(date +%Y%m%d).md
165
+
166
+ # View and update config
167
+ pr-review config
168
+ ```
169
+
170
+ ---
171
+
172
+ ## PR Number Detection
173
+
174
+ If a GitHub PR is open for the current branch, `pr-review` automatically detects it via `gh pr view` and names the output file:
175
+
176
+ ```
177
+ pr-42-review.md
178
+ ```
179
+
180
+ Falls back to `pr-review-review.md` (from config) if no PR is found or `gh` is not installed.
181
+
182
+ ---
183
+
184
+ ## Models
185
+
186
+ ### Claude
187
+ | Model | Description |
188
+ |---|---|
189
+ | `claude-sonnet-4-5` | Recommended (default) |
190
+ | `claude-opus-4-5` | Most capable |
191
+ | `claude-haiku-3-5` | Fastest |
192
+ | `claude-sonnet-4` | Previous Sonnet |
193
+ | `claude-opus-4` | Previous Opus |
194
+
195
+ ### GitHub Copilot
196
+ | Model | Description |
197
+ |---|---|
198
+ | `default` | Your Copilot plan's default (recommended) |
199
+ | `gpt-4.1` | GPT-4.1 |
200
+ | `gpt-4o` | GPT-4o |
201
+ | `claude-sonnet-4-5` | Claude via Copilot |
202
+ | `o3` | Reasoning model |
203
+ | `o4-mini` | Fast reasoning |
204
+
205
+ ### OpenAI Codex
206
+ | Model | Description |
207
+ |---|---|
208
+ | `default` | Your Codex plan's default (recommended) |
209
+ | `gpt-4o` | GPT-4o |
210
+ | `o3` | Reasoning |
211
+ | `o4-mini` | Fast reasoning |
212
+
213
+ ### Google Gemini
214
+ | Model alias | Resolves to | Description |
215
+ |---|---|---|
216
+ | `flash` | gemini-2.5-flash | Recommended (default) |
217
+ | `pro` | gemini-2.5-pro | Most capable |
218
+ | `flash-lite` | gemini-2.5-flash-lite | Fastest |
219
+ | `auto` | auto-select | Best available |
220
+
221
+ ### Cursor
222
+ | Model | Description |
223
+ |---|---|
224
+ | `default` | Your Cursor plan's default (recommended) |
225
+ | `gpt-4o` | GPT-4o |
226
+ | `claude-sonnet-4-5` | Claude via Cursor |
227
+ | `gemini-2.5-pro` | Gemini via Cursor |
228
+ | `o3` | Reasoning |
229
+
230
+ ---
231
+
232
+ ## Global Config
233
+
234
+ Auto-created at `~/.pr-review/config.json` on first run.
235
+
236
+ **Default config:**
237
+
238
+ ```json
239
+ {
240
+ "defaultProvider": "claude",
241
+ "defaultModels": {
242
+ "claude": "claude-sonnet-4-5",
243
+ "copilot": "default",
244
+ "codex": "default",
245
+ "gemini": "flash",
246
+ "cursor": "default"
247
+ },
248
+ "defaultBaseBranch": "main",
249
+ "maxDiffLength": 100000,
250
+ "ignoreFiles": ["package-lock.json", "yarn.lock"],
251
+ "outputFile": "pr-review-review.md",
252
+ "strictMode": true
253
+ }
254
+ ```
255
+
256
+ The last-used provider and model per provider are automatically remembered.
257
+
258
+ **Edit interactively:**
259
+
260
+ ```bash
261
+ pr-review config
262
+ ```
263
+
264
+ **Or edit directly:**
265
+
266
+ ```bash
267
+ nano ~/.pr-review/config.json
268
+ ```
269
+
270
+ ### Config options
271
+
272
+ | Key | Description | Default |
273
+ |---|---|---|
274
+ | `defaultProvider` | AI provider to pre-select | `claude` |
275
+ | `defaultModels` | Last-used model per provider | see above |
276
+ | `defaultBaseBranch` | Branch to diff against | `main` |
277
+ | `maxDiffLength` | Max diff characters sent to AI | `100000` |
278
+ | `ignoreFiles` | Files excluded from diff | `[package-lock.json, yarn.lock]` |
279
+ | `outputFile` | Fallback report filename | `pr-review-review.md` |
280
+ | `strictMode` | Strict review mode in prompt | `true` |
281
+
282
+ ---
283
+
284
+ ## Output format
285
+
286
+ Every generated report has this structure:
287
+
288
+ ```markdown
289
+ ---
290
+ <!-- Generated by pr-review on 2026-04-03T18:00:00.000Z -->
291
+ <!-- Provider: Claude | Model: claude-sonnet-4-5 | Diff: 4821 chars | feature/auth → main -->
292
+ <!-- PR: #42 -->
293
+ ---
294
+
295
+ # PR Review: `feature/auth` → `main`
296
+
297
+ ## Summary
298
+ ## Critical Issues
299
+ ## Security
300
+ ## Performance
301
+ ## Code Quality & Improvements
302
+ ## Test Coverage
303
+ ## Suggestions
304
+ ## Final Verdict
305
+ ```
306
+
307
+ ---
308
+
309
+ ## Project structure
310
+
311
+ ```
312
+ pr-review/
313
+ ├── src/
314
+ │ ├── cli.js Main entry point, argument parsing, user prompts
315
+ │ ├── ai.js Provider dispatcher
316
+ │ ├── config.js Config manager (~/.pr-review/config.json)
317
+ │ ├── git.js Git diff + PR number detection
318
+ │ ├── prompt.js PR review prompt builder + system prompt
319
+ │ ├── file.js Report file writer
320
+ │ └── providers/
321
+ │ ├── index.js Provider registry (models, labels, descriptions)
322
+ │ ├── claude.js claude CLI adapter
323
+ │ ├── copilot.js copilot CLI adapter
324
+ │ ├── codex.js codex CLI adapter
325
+ │ ├── gemini.js gemini CLI adapter
326
+ │ └── cursor.js agent CLI adapter (Cursor)
327
+ ├── package.json
328
+ └── README.md
329
+ ```
330
+
331
+ ---
332
+
333
+ ## License
334
+
335
+ MIT
336
+
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "pull-request-review",
3
+ "version": "1.0.0",
4
+ "description": "AI-powered Pull Request reviews using Claude",
5
+ "type": "module",
6
+ "author": "Hardik <dhardik1430@gmail.com>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/hardik-143/pull-request-review.git"
10
+ },
11
+ "homepage": "https://github.com/hardik-143/pull-request-review#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/hardik-143/pull-request-review/issues"
14
+ },
15
+ "files": [
16
+ "src"
17
+ ],
18
+ "bin": {
19
+ "pr-review": "./src/cli.js"
20
+ },
21
+ "scripts": {
22
+ "start": "node src/cli.js"
23
+ },
24
+ "dependencies": {
25
+ "chalk": "^5.3.0",
26
+ "ora": "^8.1.1"
27
+ },
28
+ "engines": {
29
+ "node": ">=18.0.0"
30
+ },
31
+ "keywords": [
32
+ "pr-review",
33
+ "code-review",
34
+ "claude",
35
+ "anthropic",
36
+ "ai",
37
+ "git",
38
+ "cli"
39
+ ],
40
+ "license": "MIT"
41
+ }
package/src/ai.js ADDED
@@ -0,0 +1,23 @@
1
+ import { callClaude } from './providers/claude.js';
2
+ import { callCopilot } from './providers/copilot.js';
3
+ import { callCodex } from './providers/codex.js';
4
+ import { callGemini } from './providers/gemini.js';
5
+ import { callCursor } from './providers/cursor.js';
6
+
7
+ const DISPATCH = {
8
+ claude: callClaude,
9
+ copilot: callCopilot,
10
+ codex: callCodex,
11
+ gemini: callGemini,
12
+ cursor: callCursor,
13
+ };
14
+
15
+ export async function callAI(prompt, provider, model) {
16
+ const fn = DISPATCH[provider];
17
+ if (!fn) {
18
+ throw new Error(
19
+ `Unknown provider: "${provider}". Valid providers: ${Object.keys(DISPATCH).join(', ')}`
20
+ );
21
+ }
22
+ return fn(prompt, model);
23
+ }
package/src/claude.js ADDED
@@ -0,0 +1,59 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { SYSTEM_PROMPT } from './prompt.js';
3
+
4
+ // Map friendly short names → claude CLI model aliases
5
+ const MODEL_ALIASES = {
6
+ 'sonnet-4.5': 'claude-sonnet-4-5',
7
+ 'sonnet-4': 'claude-sonnet-4',
8
+ 'opus-4.5': 'claude-opus-4-5',
9
+ 'opus-4': 'claude-opus-4',
10
+ 'haiku-3.5': 'claude-haiku-3-5',
11
+ 'haiku-3': 'claude-haiku-3',
12
+ // short aliases the claude CLI itself accepts
13
+ 'sonnet': 'sonnet',
14
+ 'opus': 'opus',
15
+ 'haiku': 'haiku',
16
+ };
17
+
18
+ function resolveModel(model) {
19
+ return MODEL_ALIASES[model] ?? model;
20
+ }
21
+
22
+ export async function callClaude(prompt, model) {
23
+ const resolvedModel = resolveModel(model);
24
+
25
+ // Full prompt = system prompt + user prompt, joined clearly
26
+ const fullPrompt = `${SYSTEM_PROMPT}\n\n---\n\n${prompt}`;
27
+
28
+ let output;
29
+ try {
30
+ output = execFileSync(
31
+ 'claude',
32
+ [
33
+ '--print', // non-interactive, print and exit
34
+ '--output-format', 'text', // plain text output
35
+ '--model', resolvedModel,
36
+ '--no-session-persistence', // don't pollute session history
37
+ fullPrompt,
38
+ ],
39
+ {
40
+ encoding: 'utf8',
41
+ maxBuffer: 50 * 1024 * 1024, // 50 MB
42
+ timeout: 120_000, // 2 min timeout
43
+ }
44
+ );
45
+ } catch (err) {
46
+ if (err.code === 'ENOENT') {
47
+ throw new Error(
48
+ '`claude` command not found. Install Claude Code: https://claude.ai/code'
49
+ );
50
+ }
51
+ if (err.signal === 'SIGTERM') {
52
+ throw new Error('Claude timed out after 2 minutes. Try reducing the diff size.');
53
+ }
54
+ const stderr = err.stderr?.trim();
55
+ throw new Error(`claude CLI error: ${stderr || err.message}`);
56
+ }
57
+
58
+ return output.trim();
59
+ }
package/src/cli.js ADDED
@@ -0,0 +1,356 @@
1
+ #!/usr/bin/env node
2
+
3
+ import readline from 'readline';
4
+ import chalk from 'chalk';
5
+ import ora from 'ora';
6
+
7
+ import { loadConfig, saveConfig, showAndEditConfig } from './config.js';
8
+ import { isGitRepo, getCurrentBranch, getDiff, getStagedDiff, getPRNumber } from './git.js';
9
+ import { buildReviewPrompt } from './prompt.js';
10
+ import { callAI } from './ai.js';
11
+ import { saveReport, addMetadata } from './file.js';
12
+ import { PROVIDERS, PROVIDER_LIST } from './providers/index.js';
13
+
14
+ // ─── Argument parsing ────────────────────────────────────────────────────────
15
+
16
+ const args = process.argv.slice(2);
17
+
18
+ const flags = {
19
+ staged: args.includes('--staged'),
20
+ help: args.includes('--help') || args.includes('-h'),
21
+ version: args.includes('--version') || args.includes('-v'),
22
+ config: args[0] === 'config',
23
+ review: args[0] === 'review', // explicit subcommand (optional, same as default)
24
+ provider: args.find((a) => a.startsWith('--provider='))?.split('=')[1] ?? null,
25
+ model: args.find((a) => a.startsWith('--model='))?.split('=')[1] ?? null,
26
+ focus: args.find((a) => a.startsWith('--focus='))?.split('=')[1] ?? null,
27
+ output: args.find((a) => a.startsWith('--output='))?.split('=')[1] ?? null,
28
+ base: args.find((a) => a.startsWith('--base='))?.split('=')[1] ?? null,
29
+ };
30
+
31
+ // ─── Subcommand routing ───────────────────────────────────────────────────────
32
+
33
+ if (flags.version) {
34
+ const { createRequire } = await import('module');
35
+ const require = createRequire(import.meta.url);
36
+ const pkg = require('../package.json');
37
+ console.log(`pr-review v${pkg.version}`);
38
+ process.exit(0);
39
+ }
40
+
41
+ if (flags.help) {
42
+ printHelp();
43
+ process.exit(0);
44
+ }
45
+
46
+ if (flags.config) {
47
+ await showAndEditConfig(chalk);
48
+ process.exit(0);
49
+ }
50
+
51
+ // ─── Main flow ────────────────────────────────────────────────────────────────
52
+
53
+ await main();
54
+
55
+ async function main() {
56
+ console.log(chalk.bold.cyan('\n┌─────────────────────────────────────┐'));
57
+ console.log(chalk.bold.cyan(' 🔍 pr-review — AI Code Review '));
58
+ console.log(chalk.bold.cyan('└─────────────────────────────────────┘\n'));
59
+
60
+ if (!isGitRepo()) {
61
+ fatal('Not inside a git repository. Run pr-review from your project root.');
62
+ }
63
+
64
+ const config = loadConfig();
65
+ console.log(chalk.dim(' Config: ~/.pr-review/config.json\n'));
66
+
67
+ // ── Select AI provider ─────────────────────────────────────────────────────
68
+
69
+ const selectedProviderKey = flags.provider
70
+ ? flags.provider
71
+ : await selectProvider(config.defaultProvider ?? 'claude');
72
+
73
+ const provider = PROVIDERS[selectedProviderKey];
74
+ if (!provider) fatal(`Unknown provider: "${selectedProviderKey}". Valid: ${Object.keys(PROVIDERS).join(', ')}`);
75
+
76
+ // ── Select model ───────────────────────────────────────────────────────────
77
+
78
+ const defaultModelForProvider =
79
+ config.defaultModels?.[selectedProviderKey] ?? provider.defaultModel;
80
+
81
+ const selectedModel = flags.model
82
+ ? flags.model
83
+ : await selectModel(provider, defaultModelForProvider);
84
+
85
+ // Persist last-used provider + model per provider
86
+ const updatedModels = { ...(config.defaultModels ?? {}), [selectedProviderKey]: selectedModel };
87
+ saveConfig({ ...config, defaultProvider: selectedProviderKey, defaultModels: updatedModels });
88
+
89
+ console.log('');
90
+
91
+ // ── Select branches ────────────────────────────────────────────────────────
92
+
93
+ let sourceBranch, destBranch;
94
+ const currentBranch = getCurrentBranch();
95
+
96
+ if (flags.staged) {
97
+ sourceBranch = '(staged changes)';
98
+ destBranch = '(index)';
99
+ } else {
100
+ sourceBranch = await prompt(
101
+ ` Source branch ${chalk.dim(`(default: ${chalk.yellow(currentBranch)})`)}: `,
102
+ currentBranch
103
+ );
104
+ destBranch = flags.base ?? await prompt(
105
+ ` Destination branch ${chalk.dim(`(default: ${chalk.yellow(config.defaultBaseBranch)})`)}: `,
106
+ config.defaultBaseBranch
107
+ );
108
+ }
109
+
110
+ // ── Detect PR number → determine output filename ───────────────────────────
111
+
112
+ const prNumber = getPRNumber();
113
+ const outputFile = flags.output
114
+ ?? (prNumber ? `pr-${prNumber}-review.md` : config.outputFile);
115
+
116
+ if (prNumber) {
117
+ console.log(chalk.dim(`\n 🔗 PR #${prNumber} detected → output: ${chalk.yellow(outputFile)}`));
118
+ }
119
+
120
+ console.log('');
121
+
122
+ // ── Fetch diff ─────────────────────────────────────────────────────────────
123
+
124
+ let diff;
125
+ const spinner1 = ora({ text: 'Fetching git diff…', color: 'cyan' }).start();
126
+
127
+ try {
128
+ diff = flags.staged
129
+ ? getStagedDiff(config.ignoreFiles)
130
+ : getDiff(sourceBranch, destBranch, config.ignoreFiles);
131
+ } catch (err) {
132
+ spinner1.fail(chalk.red(`Diff failed: ${err.message}`));
133
+ process.exit(1);
134
+ }
135
+
136
+ if (!diff || diff.trim().length === 0) {
137
+ spinner1.warn(chalk.yellow('No changes found between the selected branches.'));
138
+ process.exit(0);
139
+ }
140
+
141
+ let truncated = false;
142
+ if (diff.length > config.maxDiffLength) {
143
+ diff = diff.slice(0, config.maxDiffLength) + '\n\n[DIFF TRUNCATED — exceeded maxDiffLength]\n';
144
+ truncated = true;
145
+ }
146
+
147
+ spinner1.succeed(
148
+ chalk.green('Diff fetched') +
149
+ chalk.dim(` — ${diff.length.toLocaleString()} chars${truncated ? ' (truncated)' : ''}`)
150
+ );
151
+
152
+ // ── Build prompt ───────────────────────────────────────────────────────────
153
+
154
+ const reviewPrompt = buildReviewPrompt(sourceBranch, destBranch, diff, flags.focus);
155
+
156
+ // ── Call AI ────────────────────────────────────────────────────────────────
157
+
158
+ const spinner2 = ora({
159
+ text: `Calling ${provider.name} (${selectedModel})…`,
160
+ color: 'cyan',
161
+ }).start();
162
+
163
+ let reviewText;
164
+ try {
165
+ reviewText = await callAI(reviewPrompt, selectedProviderKey, selectedModel);
166
+ spinner2.succeed(chalk.green(`${provider.name} review received`));
167
+ } catch (err) {
168
+ spinner2.fail(chalk.red(`${provider.name} error: ${err.message}`));
169
+ process.exit(1);
170
+ }
171
+
172
+ // ── Write report ───────────────────────────────────────────────────────────
173
+
174
+ const spinner3 = ora({ text: 'Writing report…', color: 'cyan' }).start();
175
+ let outputPath;
176
+
177
+ try {
178
+ const finalContent = addMetadata(reviewText, {
179
+ sourceBranch,
180
+ destBranch,
181
+ provider: provider.name,
182
+ model: selectedModel,
183
+ prNumber,
184
+ diffLength: diff.length,
185
+ generatedAt: new Date().toISOString(),
186
+ });
187
+
188
+ outputPath = saveReport(finalContent, outputFile);
189
+ spinner3.succeed(chalk.green('Report saved') + chalk.dim(` → ${outputPath}`));
190
+ } catch (err) {
191
+ spinner3.fail(chalk.red(`Failed to write report: ${err.message}`));
192
+ process.exit(1);
193
+ }
194
+
195
+ // ── Done ───────────────────────────────────────────────────────────────────
196
+
197
+ console.log('');
198
+ console.log(chalk.bold.green('✅ PR Review complete!'));
199
+ if (prNumber) {
200
+ console.log(chalk.dim(` PR #${prNumber} · ${provider.name} · ${selectedModel}`));
201
+ }
202
+ console.log(chalk.dim(` Open ${outputPath} to read the review.\n`));
203
+ }
204
+
205
+ // ─── Provider selection ───────────────────────────────────────────────────────
206
+
207
+ async function selectProvider(defaultProvider) {
208
+ const defaultIndex = PROVIDER_LIST.findIndex((p) => p.key === defaultProvider);
209
+ const safeDefault = defaultIndex >= 0 ? defaultIndex : 0;
210
+
211
+ const items = PROVIDER_LIST.map((p) => ({ label: p.label, description: p.description }));
212
+ const idx = await arrowSelect('Select AI provider:', items, safeDefault);
213
+
214
+ const chosen = PROVIDER_LIST[idx];
215
+ console.log(chalk.dim(` → ${chosen.label}\n`));
216
+ return chosen.key;
217
+ }
218
+
219
+ // ─── Model selection ──────────────────────────────────────────────────────────
220
+
221
+ async function selectModel(provider, defaultModel) {
222
+ const defaultIndex = provider.models.findIndex((m) => m.id === defaultModel);
223
+ const safeDefault = defaultIndex >= 0 ? defaultIndex : 0;
224
+
225
+ const items = provider.models.map((m) => ({ label: m.label }));
226
+ const idx = await arrowSelect(`Select ${provider.name} model:`, items, safeDefault);
227
+
228
+ const chosen = provider.models[idx];
229
+ console.log(chalk.dim(` → ${chosen.id}\n`));
230
+ return chosen.id;
231
+ }
232
+
233
+ // ─── Arrow-key list selector ──────────────────────────────────────────────────
234
+
235
+ function arrowSelect(title, items, defaultIdx = 0) {
236
+ console.log(chalk.bold(` ${title}\n`));
237
+
238
+ // Fallback to plain prompt when stdin is not a TTY (e.g. piped)
239
+ if (!process.stdin.isTTY) {
240
+ items.forEach((item, i) => {
241
+ const marker = i === defaultIdx ? chalk.cyan('❯') : ' ';
242
+ const label = chalk.dim(item.label) + (item.description ? chalk.dim(` — ${item.description}`) : '');
243
+ console.log(` ${marker} ${label}`);
244
+ });
245
+ return Promise.resolve(defaultIdx);
246
+ }
247
+
248
+ let idx = defaultIdx;
249
+
250
+ const render = (first) => {
251
+ if (!first) process.stdout.write(`\x1b[${items.length}A`);
252
+ for (let i = 0; i < items.length; i++) {
253
+ const active = i === idx;
254
+ const cursor = active ? chalk.cyan('❯') : ' ';
255
+ const label = active ? chalk.bold.white(items[i].label) : chalk.dim(items[i].label);
256
+ const desc = items[i].description ? chalk.dim(` — ${items[i].description}`) : '';
257
+ process.stdout.write(` ${cursor} ${label}${desc}\x1b[K\n`);
258
+ }
259
+ };
260
+
261
+ render(true);
262
+
263
+ return new Promise((resolve) => {
264
+ process.stdin.setRawMode(true);
265
+ process.stdin.resume();
266
+ process.stdin.setEncoding('utf8');
267
+
268
+ const onData = (key) => {
269
+ if (key === '\u0003') { // Ctrl-C
270
+ process.stdin.setRawMode(false);
271
+ process.exit(0);
272
+ } else if (key === '\u001b[A' || key === 'k') { // Up / k
273
+ idx = (idx - 1 + items.length) % items.length;
274
+ render(false);
275
+ } else if (key === '\u001b[B' || key === 'j') { // Down / j
276
+ idx = (idx + 1) % items.length;
277
+ render(false);
278
+ } else if (key === '\r' || key === '\n') { // Enter
279
+ process.stdin.setRawMode(false);
280
+ process.stdin.removeListener('data', onData);
281
+ process.stdin.pause();
282
+ process.stdout.write('\n');
283
+ resolve(idx);
284
+ }
285
+ };
286
+
287
+ process.stdin.on('data', onData);
288
+ });
289
+ }
290
+
291
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
292
+
293
+ function prompt(question, defaultValue = '') {
294
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
295
+ return new Promise((resolve) => {
296
+ rl.question(question, (answer) => {
297
+ rl.close();
298
+ resolve(answer.trim() || defaultValue);
299
+ });
300
+ });
301
+ }
302
+
303
+ function fatal(message) {
304
+ console.error('\n' + chalk.red('✖ ') + message + '\n');
305
+ process.exit(1);
306
+ }
307
+
308
+ function printHelp() {
309
+ console.log(`
310
+ ${chalk.bold.cyan('pr-review')} — AI-powered Pull Request reviews
311
+
312
+ ${chalk.bold('USAGE')}
313
+ ${chalk.cyan('pr-review')} Interactive PR review
314
+ ${chalk.cyan('pr-review review')} Explicit review subcommand (same as above)
315
+ ${chalk.cyan('pr-review config')} View and edit global config
316
+ ${chalk.cyan('pr-review review --staged')} Review staged (uncommitted) changes
317
+ ${chalk.cyan('pr-review review --provider=<provider>')} Skip provider prompt
318
+ ${chalk.cyan('pr-review review --model=<model>')} Skip model prompt
319
+ ${chalk.cyan('pr-review review --base=<branch>')} Override destination/base branch
320
+ ${chalk.cyan('pr-review review --focus=<area>')} Focus: security, performance, etc.
321
+ ${chalk.cyan('pr-review review --output=<file>')} Override output file name
322
+ ${chalk.cyan('pr-review --help')} Show this help
323
+ ${chalk.cyan('pr-review --version')} Show version
324
+
325
+ ${chalk.bold('PROVIDERS')}
326
+ ${chalk.cyan('claude')} claude CLI — claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5
327
+ ${chalk.cyan('copilot')} copilot CLI — claude-sonnet-4.6, gpt-5.4, claude-opus-4.6, gpt-5.4-mini
328
+ ${chalk.cyan('codex')} codex CLI — gpt-5.4, o3, o4-mini, gpt-4.1
329
+ ${chalk.cyan('gemini')} gemini CLI — flash, pro, flash-lite
330
+ ${chalk.cyan('cursor')} agent CLI — install Cursor + add \`agent\` to PATH
331
+
332
+ ${chalk.bold('PR NUMBER')}
333
+ If a GitHub PR is open for the current branch (\`gh pr view\`),
334
+ the output file is automatically named ${chalk.yellow('pr-<number>-review.md')}.
335
+
336
+ ${chalk.bold('CONFIG')}
337
+ ${chalk.dim('~/.pr-review/config.json')} Auto-created on first run
338
+ Last-used provider and model per provider are remembered.
339
+
340
+ ${chalk.bold('EXAMPLES')}
341
+ ${chalk.dim('# Standard interactive review')}
342
+ pr-review
343
+
344
+ ${chalk.dim('# Explicit review subcommand')}
345
+ pr-review review
346
+
347
+ ${chalk.dim('# Skip all prompts')}
348
+ pr-review review --provider=copilot --model=gpt-5.4 --base=develop
349
+
350
+ ${chalk.dim('# Security-focused review of staged changes')}
351
+ pr-review review --staged --focus=security
352
+
353
+ ${chalk.dim('# Most capable model with custom output file')}
354
+ pr-review review --provider=claude --model=claude-opus-4-6 --output=deep-review.md
355
+ `);
356
+ }
package/src/config.js ADDED
@@ -0,0 +1,102 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import readline from 'readline';
5
+
6
+ export const CONFIG_DIR = path.join(os.homedir(), '.pr-review');
7
+ export const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
8
+
9
+ export const DEFAULT_CONFIG = {
10
+ defaultProvider: 'claude',
11
+ defaultModels: {
12
+ claude: 'claude-sonnet-4-5',
13
+ copilot: 'default',
14
+ codex: 'default',
15
+ gemini: 'flash',
16
+ cursor: 'default',
17
+ },
18
+ defaultBaseBranch: 'main',
19
+ maxDiffLength: 100000,
20
+ ignoreFiles: ['package-lock.json', 'yarn.lock'],
21
+ outputFile: 'pr-review-review.md',
22
+ strictMode: true,
23
+ };
24
+
25
+ export function ensureConfigDir() {
26
+ if (!fs.existsSync(CONFIG_DIR)) {
27
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
28
+ }
29
+ }
30
+
31
+ export function loadConfig() {
32
+ ensureConfigDir();
33
+
34
+ if (!fs.existsSync(CONFIG_FILE)) {
35
+ saveConfig(DEFAULT_CONFIG);
36
+ return { ...DEFAULT_CONFIG };
37
+ }
38
+
39
+ try {
40
+ const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
41
+ const parsed = JSON.parse(raw);
42
+ // Merge with defaults so new keys are always present
43
+ return { ...DEFAULT_CONFIG, ...parsed };
44
+ } catch {
45
+ return { ...DEFAULT_CONFIG };
46
+ }
47
+ }
48
+
49
+ export function saveConfig(config) {
50
+ ensureConfigDir();
51
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
52
+ }
53
+
54
+ export function getConfigPath() {
55
+ return CONFIG_FILE;
56
+ }
57
+
58
+ function ask(rl, question, defaultValue) {
59
+ return new Promise((resolve) => {
60
+ rl.question(` ${question} [${defaultValue}]: `, (answer) => {
61
+ const trimmed = answer.trim();
62
+ resolve(trimmed === '' ? String(defaultValue) : trimmed);
63
+ });
64
+ });
65
+ }
66
+
67
+ export async function showAndEditConfig(chalk) {
68
+ const config = loadConfig();
69
+
70
+ console.log(chalk.bold('\nCurrent configuration:'));
71
+ console.log(chalk.dim(` File: ${CONFIG_FILE}\n`));
72
+ console.log(JSON.stringify(config, null, 2));
73
+ console.log('');
74
+
75
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
76
+
77
+ console.log(chalk.bold('Update values') + chalk.dim(' (press Enter to keep current):\n'));
78
+
79
+ const newModel = await ask(rl, 'Default model ', config.defaultModel);
80
+ const newBranch = await ask(rl, 'Default base branch', config.defaultBaseBranch);
81
+ const newMaxDiff = await ask(rl, 'Max diff length ', config.maxDiffLength);
82
+ const newOutputFile = await ask(rl, 'Output file ', config.outputFile);
83
+ const newStrictMode = await ask(rl, 'Strict mode ', config.strictMode);
84
+ const newIgnore = await ask(rl, 'Ignore files (csv) ', config.ignoreFiles.join(','));
85
+
86
+ rl.close();
87
+
88
+ const updated = {
89
+ ...config,
90
+ defaultModel: newModel,
91
+ defaultBaseBranch: newBranch,
92
+ maxDiffLength: parseInt(newMaxDiff, 10) || config.maxDiffLength,
93
+ outputFile: newOutputFile,
94
+ strictMode: newStrictMode === 'true',
95
+ ignoreFiles: newIgnore.split(',').map((f) => f.trim()).filter(Boolean),
96
+ };
97
+
98
+ saveConfig(updated);
99
+ console.log('\n' + chalk.green('✔ Config saved.\n'));
100
+ console.log(JSON.stringify(updated, null, 2));
101
+ console.log('');
102
+ }
package/src/file.js ADDED
@@ -0,0 +1,22 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ export function saveReport(content, outputFile) {
5
+ const outputPath = path.resolve(process.cwd(), outputFile);
6
+ fs.writeFileSync(outputPath, content, 'utf8');
7
+ return outputPath;
8
+ }
9
+
10
+ export function addMetadata(reviewText, meta) {
11
+ const { sourceBranch, destBranch, provider, model, prNumber, diffLength, generatedAt } = meta;
12
+
13
+ const lines = [
14
+ '---',
15
+ `<!-- Generated by pr-review on ${generatedAt} -->`,
16
+ `<!-- Provider: ${provider} | Model: ${model} | Diff: ${diffLength} chars | ${sourceBranch} → ${destBranch} -->`,
17
+ ];
18
+ if (prNumber) lines.push(`<!-- PR: #${prNumber} -->`);
19
+ lines.push('---', '');
20
+
21
+ return lines.join('\n') + reviewText;
22
+ }
package/src/git.js ADDED
@@ -0,0 +1,83 @@
1
+ import { execSync } from 'child_process';
2
+
3
+ export function getPRNumber() {
4
+ try {
5
+ const result = execSync('gh pr view --json number --jq .number', {
6
+ encoding: 'utf8',
7
+ stdio: 'pipe',
8
+ }).trim();
9
+ const num = parseInt(result, 10);
10
+ return isNaN(num) ? null : num;
11
+ } catch {
12
+ return null; // No open PR, gh not installed, or not authenticated
13
+ }
14
+ }
15
+
16
+ export function isGitRepo() {
17
+ try {
18
+ execSync('git rev-parse --is-inside-work-tree', { encoding: 'utf8', stdio: 'pipe' });
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ export function getCurrentBranch() {
26
+ try {
27
+ return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8', stdio: 'pipe' }).trim();
28
+ } catch {
29
+ throw new Error('Unable to detect current branch. Is this a git repository?');
30
+ }
31
+ }
32
+
33
+ export function branchExists(branch) {
34
+ try {
35
+ execSync(`git rev-parse --verify "${branch}"`, { encoding: 'utf8', stdio: 'pipe' });
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ export function getDiff(sourceBranch, destBranch, ignoreFiles = []) {
43
+ if (!branchExists(sourceBranch)) {
44
+ throw new Error(`Branch not found: "${sourceBranch}"`);
45
+ }
46
+ if (!branchExists(destBranch)) {
47
+ throw new Error(`Branch not found: "${destBranch}"`);
48
+ }
49
+
50
+ try {
51
+ let cmd = `git diff "${destBranch}...${sourceBranch}"`;
52
+
53
+ if (ignoreFiles.length > 0) {
54
+ const exclusions = ignoreFiles.map((f) => `':(exclude)${f}'`).join(' ');
55
+ cmd += ` -- . ${exclusions}`;
56
+ }
57
+
58
+ return execSync(cmd, {
59
+ encoding: 'utf8',
60
+ maxBuffer: 100 * 1024 * 1024,
61
+ });
62
+ } catch (err) {
63
+ throw new Error(`git diff failed: ${err.message}`);
64
+ }
65
+ }
66
+
67
+ export function getStagedDiff(ignoreFiles = []) {
68
+ try {
69
+ let cmd = 'git diff --staged';
70
+
71
+ if (ignoreFiles.length > 0) {
72
+ const exclusions = ignoreFiles.map((f) => `':(exclude)${f}'`).join(' ');
73
+ cmd += ` -- . ${exclusions}`;
74
+ }
75
+
76
+ return execSync(cmd, {
77
+ encoding: 'utf8',
78
+ maxBuffer: 100 * 1024 * 1024,
79
+ });
80
+ } catch (err) {
81
+ throw new Error(`git diff --staged failed: ${err.message}`);
82
+ }
83
+ }
package/src/prompt.js ADDED
@@ -0,0 +1,68 @@
1
+ export const SYSTEM_PROMPT = `You are a principal software engineer performing a rigorous pull request review.
2
+
3
+ Your reviews are:
4
+ - Precise, technical, and evidence-based — cite specific file paths and line context from the diff
5
+ - Comprehensive — cover correctness, security, performance, maintainability, and test coverage
6
+ - Actionable — every issue must include a clear recommendation
7
+ - Honest — if code is well-written, say so; do not invent issues
8
+
9
+ You output reviews in strict Markdown with exactly the sections specified. No preamble, no postamble.`;
10
+
11
+ export function buildReviewPrompt(sourceBranch, destBranch, diff, focus = null) {
12
+ const focusInstruction = focus
13
+ ? `\n> **Special focus requested:** Prioritize **${focus}**-related issues throughout the review.\n`
14
+ : '';
15
+
16
+ return `You are reviewing a Pull Request merging \`${sourceBranch}\` into \`${destBranch}\`.
17
+ ${focusInstruction}
18
+ Analyze the git diff below and produce a structured code review in the exact Markdown format specified.
19
+
20
+ <git_diff>
21
+ \`\`\`diff
22
+ ${diff}
23
+ \`\`\`
24
+ </git_diff>
25
+
26
+ ---
27
+
28
+ Respond using **exactly** this structure:
29
+
30
+ # PR Review: \`${sourceBranch}\` → \`${destBranch}\`
31
+
32
+ ## Summary
33
+ _2–4 sentences describing what this PR does, its scope, and overall quality._
34
+
35
+ ## Critical Issues
36
+ _Bugs, breaking changes, data-loss risks, or anything that MUST be fixed before merge._
37
+ _Format each item as:_ **[CRITICAL]** \`path/to/file.ext\` — Description and fix recommendation.
38
+ _If none: "No critical issues found."_
39
+
40
+ ## Security
41
+ _Auth bypasses, injection risks, secret exposure, insecure defaults, etc._
42
+ _Format each item as:_ **[HIGH|MEDIUM|LOW]** \`path/to/file.ext\` — Description and fix recommendation.
43
+ _If none: "No security issues found."_
44
+
45
+ ## Performance
46
+ _Algorithmic complexity, unnecessary re-renders, N+1 queries, blocking I/O, memory leaks._
47
+ _Format each item as:_ **[HIGH|MEDIUM|LOW]** \`path/to/file.ext\` — Description and fix recommendation.
48
+ _If none: "No performance issues found."_
49
+
50
+ ## Code Quality & Improvements
51
+ _Anti-patterns, duplication, poor naming, overly complex logic, missing error handling._
52
+ - Description with specific recommendation.
53
+ _If none: "No code quality issues found."_
54
+
55
+ ## Test Coverage
56
+ _Missing unit tests, edge cases not covered, untested error paths._
57
+ - Description of what should be tested.
58
+ _If none: "Test coverage appears adequate."_
59
+
60
+ ## Suggestions
61
+ _Non-blocking improvements, best practices, minor style notes._
62
+ - Suggestion.
63
+
64
+ ## Final Verdict
65
+ **[APPROVE | REQUEST CHANGES | NEEDS DISCUSSION]**
66
+
67
+ _2–3 sentence justification for your verdict._`;
68
+ }
@@ -0,0 +1,23 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { SYSTEM_PROMPT } from '../prompt.js';
3
+
4
+ export async function callClaude(prompt, model) {
5
+ const fullPrompt = `${SYSTEM_PROMPT}\n\n---\n\n${prompt}`;
6
+ let output;
7
+ try {
8
+ output = execFileSync(
9
+ 'claude',
10
+ ['--print', '--output-format', 'text', '--model', model, '--no-session-persistence', fullPrompt],
11
+ { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024, timeout: 120_000 }
12
+ );
13
+ } catch (err) {
14
+ if (err.code === 'ENOENT') throw new Error('`claude` not found. Install Claude Code: https://claude.ai/code');
15
+ if (err.signal === 'SIGTERM') throw new Error('Claude timed out after 2 minutes. Try a smaller diff.');
16
+ const detail = err.stderr?.trim() || err.stdout?.trim() || '';
17
+ if (/auth|token|expired|401|login/i.test(detail)) {
18
+ throw new Error('Claude authentication expired. Run `claude` to re-authenticate, then try again.');
19
+ }
20
+ throw new Error(`claude CLI error: ${detail || 'unknown error (run `claude` to check status)'}`);
21
+ }
22
+ return output.trim();
23
+ }
@@ -0,0 +1,39 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { SYSTEM_PROMPT } from '../prompt.js';
3
+
4
+ export async function callCodex(prompt, model) {
5
+ const fullPrompt = `${SYSTEM_PROMPT}\n\n---\n\n${prompt}`;
6
+
7
+ // -q quiet / non-interactive mode (no TUI, outputs response to stdout)
8
+ // --approval-mode suggest read-only agent: proposes but never auto-executes
9
+ const cliArgs = ['-q', '--approval-mode', 'suggest'];
10
+
11
+ if (model && model !== 'default') {
12
+ cliArgs.push('--model', model);
13
+ }
14
+
15
+ cliArgs.push(fullPrompt);
16
+
17
+ let output;
18
+ try {
19
+ output = execFileSync('codex', cliArgs, {
20
+ encoding: 'utf8',
21
+ maxBuffer: 50 * 1024 * 1024,
22
+ timeout: 180_000, // 3 min — codex reasoning models can be slow
23
+ });
24
+ } catch (err) {
25
+ if (err.code === 'ENOENT') {
26
+ throw new Error(
27
+ '`codex` not found. Install: npm install -g @openai/codex\n' +
28
+ ' Then run: codex (and sign in on first launch)'
29
+ );
30
+ }
31
+ if (err.signal === 'SIGTERM') {
32
+ throw new Error('Codex timed out after 3 minutes. Try reducing the diff size.');
33
+ }
34
+ const stderr = err.stderr?.trim();
35
+ throw new Error(`codex CLI error: ${stderr || err.message}`);
36
+ }
37
+
38
+ return output.trim();
39
+ }
@@ -0,0 +1,38 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { SYSTEM_PROMPT } from '../prompt.js';
3
+
4
+ export async function callCopilot(prompt, model) {
5
+ const fullPrompt = `${SYSTEM_PROMPT}\n\n---\n\n${prompt}`;
6
+
7
+ // -p non-interactive (exits after response)
8
+ // -s output only the agent response (no stats/spinners) — perfect for scripting
9
+ const cliArgs = ['-p', fullPrompt, '-s'];
10
+
11
+ if (model && model !== 'default') {
12
+ cliArgs.push('--model', model);
13
+ }
14
+
15
+ let output;
16
+ try {
17
+ output = execFileSync('copilot', cliArgs, {
18
+ encoding: 'utf8',
19
+ maxBuffer: 50 * 1024 * 1024,
20
+ timeout: 120_000,
21
+ });
22
+ } catch (err) {
23
+ if (err.code === 'ENOENT') {
24
+ throw new Error(
25
+ '`copilot` not found. Install: npm install -g @github/copilot\n' +
26
+ ' Then run: copilot /login'
27
+ );
28
+ }
29
+ if (err.signal === 'SIGTERM') {
30
+ throw new Error('Copilot CLI timed out after 2 minutes. Try reducing the diff size.');
31
+ }
32
+ const stderr = err.stderr?.trim();
33
+ throw new Error(`copilot CLI error: ${stderr || err.message}`);
34
+ }
35
+
36
+ return output.trim();
37
+ }
38
+
@@ -0,0 +1,39 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { SYSTEM_PROMPT } from '../prompt.js';
3
+
4
+ export async function callCursor(prompt, model) {
5
+ const fullPrompt = `${SYSTEM_PROMPT}\n\n---\n\n${prompt}`;
6
+
7
+ // -p / --print non-interactive, prints response to stdout
8
+ // --mode=ask read-only exploration — agent won't edit any files
9
+ // --output-format text plain text (not JSON)
10
+ const cliArgs = ['-p', fullPrompt, '--mode=ask', '--output-format', 'text'];
11
+
12
+ if (model && model !== 'default') {
13
+ cliArgs.push('--model', model);
14
+ }
15
+
16
+ let output;
17
+ try {
18
+ output = execFileSync('agent', cliArgs, {
19
+ encoding: 'utf8',
20
+ maxBuffer: 50 * 1024 * 1024,
21
+ timeout: 120_000,
22
+ });
23
+ } catch (err) {
24
+ if (err.code === 'ENOENT') {
25
+ throw new Error(
26
+ '`agent` not found. Install Cursor and ensure the CLI is in your PATH.\n' +
27
+ ' macOS: open Cursor → Cmd+Shift+P → "Install cursor/agent in PATH"'
28
+ );
29
+ }
30
+ if (err.signal === 'SIGTERM') {
31
+ throw new Error('Cursor agent timed out after 2 minutes. Try reducing the diff size.');
32
+ }
33
+ const stderr = err.stderr?.trim();
34
+ throw new Error(`agent CLI error: ${stderr || err.message}`);
35
+ }
36
+
37
+ return output.trim();
38
+ }
39
+
@@ -0,0 +1,38 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { SYSTEM_PROMPT } from '../prompt.js';
3
+
4
+ export async function callGemini(prompt, model) {
5
+ const fullPrompt = `${SYSTEM_PROMPT}\n\n---\n\n${prompt}`;
6
+
7
+ // -p non-interactive (exits after response)
8
+ // -o text plain text output (default, but explicit is safer)
9
+ const cliArgs = ['-p', fullPrompt, '-o', 'text'];
10
+
11
+ if (model && model !== 'default') {
12
+ cliArgs.push('-m', model);
13
+ }
14
+
15
+ let output;
16
+ try {
17
+ output = execFileSync('gemini', cliArgs, {
18
+ encoding: 'utf8',
19
+ maxBuffer: 50 * 1024 * 1024,
20
+ timeout: 120_000,
21
+ });
22
+ } catch (err) {
23
+ if (err.code === 'ENOENT') {
24
+ throw new Error(
25
+ '`gemini` not found. Install: npm install -g @google/gemini-cli\n' +
26
+ ' Then run: gemini (and authenticate on first launch)'
27
+ );
28
+ }
29
+ if (err.signal === 'SIGTERM') {
30
+ throw new Error('Gemini CLI timed out after 2 minutes. Try reducing the diff size.');
31
+ }
32
+ const stderr = err.stderr?.trim();
33
+ throw new Error(`gemini CLI error: ${stderr || err.message}`);
34
+ }
35
+
36
+ return output.trim();
37
+ }
38
+
@@ -0,0 +1,80 @@
1
+ export const PROVIDERS = {
2
+ // claude CLI: model IDs use dashes (e.g. claude-sonnet-4-6)
3
+ claude: {
4
+ key: 'claude',
5
+ name: 'Claude',
6
+ label: 'Claude (Anthropic)',
7
+ description: 'Uses local claude CLI — no API key needed',
8
+ defaultModel: 'claude-sonnet-4-6',
9
+ models: [
10
+ { id: 'claude-sonnet-4-6', label: 'claude-sonnet-4-6 · Recommended' },
11
+ { id: 'claude-opus-4-6', label: 'claude-opus-4-6 · Most capable' },
12
+ { id: 'claude-haiku-4-5', label: 'claude-haiku-4-5 · Fastest' },
13
+ { id: 'claude-sonnet-4-5', label: 'claude-sonnet-4-5' },
14
+ { id: 'claude-sonnet-4', label: 'claude-sonnet-4' },
15
+ ],
16
+ },
17
+ // copilot CLI: model IDs use periods (e.g. claude-sonnet-4.6, gpt-5.4)
18
+ copilot: {
19
+ key: 'copilot',
20
+ name: 'GitHub Copilot',
21
+ label: 'GitHub Copilot',
22
+ description: 'Uses copilot CLI — run `copilot /login` first',
23
+ defaultModel: 'claude-sonnet-4.6',
24
+ models: [
25
+ { id: 'claude-sonnet-4.6', label: 'claude-sonnet-4.6 · Recommended' },
26
+ { id: 'claude-opus-4.6', label: 'claude-opus-4.6 · Most capable' },
27
+ { id: 'gpt-5.4', label: 'gpt-5.4 · Standard' },
28
+ { id: 'gpt-5.2', label: 'gpt-5.2 · Standard' },
29
+ { id: 'claude-haiku-4.5', label: 'claude-haiku-4.5 · Fast/cheap' },
30
+ { id: 'gpt-5.4-mini', label: 'gpt-5.4-mini · Fast/cheap' },
31
+ { id: 'gpt-4.1', label: 'gpt-4.1 · Fast/cheap' },
32
+ ],
33
+ },
34
+ // codex CLI: model IDs from ~/.codex/config.toml and codex help examples
35
+ codex: {
36
+ key: 'codex',
37
+ name: 'Codex',
38
+ label: 'OpenAI Codex',
39
+ description: 'Uses codex CLI — run `codex` once to sign in',
40
+ defaultModel: 'gpt-5.4',
41
+ models: [
42
+ { id: 'gpt-5.4', label: 'gpt-5.4 · Recommended' },
43
+ { id: 'o3', label: 'o3 · Reasoning' },
44
+ { id: 'o4-mini', label: 'o4-mini · Fast reasoning' },
45
+ { id: 'gpt-4.1', label: 'gpt-4.1 · Cheaper' },
46
+ ],
47
+ },
48
+ // gemini CLI: uses short aliases (flash, pro, flash-lite, auto)
49
+ gemini: {
50
+ key: 'gemini',
51
+ name: 'Gemini',
52
+ label: 'Google Gemini',
53
+ description: 'Uses gemini CLI — run `gemini` once to authenticate',
54
+ defaultModel: 'flash',
55
+ models: [
56
+ { id: 'flash', label: 'flash · gemini-2.5-flash (Recommended)' },
57
+ { id: 'pro', label: 'pro · gemini-2.5-pro (Most capable)' },
58
+ { id: 'flash-lite', label: 'flash-lite · gemini-2.5-flash-lite (Fastest)' },
59
+ { id: 'auto', label: 'auto · Auto-select best available' },
60
+ ],
61
+ },
62
+ // cursor agent CLI: model IDs (install Cursor + add agent binary to PATH)
63
+ cursor: {
64
+ key: 'cursor',
65
+ name: 'Cursor',
66
+ label: 'Cursor',
67
+ description: 'Uses agent CLI — install Cursor + add agent to PATH',
68
+ defaultModel: 'default',
69
+ models: [
70
+ { id: 'default', label: 'Default (your Cursor plan model)' },
71
+ { id: 'claude-sonnet-4-6', label: 'claude-sonnet-4-6' },
72
+ { id: 'gpt-5.4', label: 'gpt-5.4' },
73
+ { id: 'gemini-2.5-pro', label: 'gemini-2.5-pro' },
74
+ { id: 'o3', label: 'o3 · Reasoning' },
75
+ ],
76
+ },
77
+ };
78
+
79
+ export const PROVIDER_LIST = Object.values(PROVIDERS);
80
+
@@ -0,0 +1,3 @@
1
+ // openai.js has been superseded by codex.js which uses the Codex CLI.
2
+ // This file is kept for reference only and is not imported anywhere.
3
+