Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@ To check your configured providers:

## Slash Commands

- `/opencode:review` -- Normal OpenCode code review (read-only). Supports `--base <ref>`, `--pr <number>`, `--model <provider/model>`, `--free`, `--wait`, and `--background`. Uses the saved default model when configured and no runtime model flag is supplied.
- `/opencode:adversarial-review` -- Steerable review that challenges implementation and design decisions. Supports `--base <ref>`, `--pr <number>`, `--model <provider/model>`, `--free`, `--wait`, `--background`, and custom focus text. Uses the saved default model when configured and no runtime model flag is supplied.
- `/opencode:review` -- Normal OpenCode code review (read-only). Supports `--base <ref>`, `--pr <number>`, `--path <path>`, `--model <provider/model>`, `--free`, `--wait`, and `--background`. Uses the saved default model when configured and no runtime model flag is supplied.
- `/opencode:adversarial-review` -- Steerable review that challenges implementation and design decisions. Supports `--base <ref>`, `--pr <number>`, `--path <path>`, `--model <provider/model>`, `--free`, `--wait`, `--background`, and custom focus text. Uses the saved default model when configured and no runtime model flag is supplied.
- `/opencode:rescue` -- Delegates a task to OpenCode via the `safe-command.mjs` bridge, which validates flags and feeds the task text through a shell-insulated heredoc. Supports `--model`, `--free`, `--agent`, `--resume`, `--fresh`, `--worktree`, `--wait`, and `--background`. Foreground is the default; `--wait` is an explicit no-op alias for foreground; `--background` detaches a worker and returns a job id you can poll with `/opencode:status`. Uses saved default model/agent values when configured and no runtime flag is supplied.
- `/opencode:status` -- Shows running/recent OpenCode jobs for the current repo.
- `/opencode:result` -- Shows final output for a finished job, including OpenCode session ID for resuming.
Expand Down
3 changes: 2 additions & 1 deletion plugins/opencode/commands/adversarial-review.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
description: Run a steerable adversarial OpenCode review that challenges implementation and design decisions
argument-hint: '[--wait|--background] [--base <ref>] [--model <id> | --free] [--pr <number>] [focus area or custom review instructions]'
argument-hint: '[--wait|--background] [--base <ref>] [--model <id> | --free] [--pr <number>] [--path <path>] [--path <path2>] [focus area or custom review instructions]'
disable-model-invocation: true
allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), Bash(gh:*), AskUserQuestion
---
Expand Down Expand Up @@ -40,6 +40,7 @@ Argument handling:
- `--model <id>` overrides the saved setup default model and OpenCode's own default model for this single review (e.g. `--model openrouter/anthropic/claude-opus-4-6`). Pass it through verbatim if the user supplied it.
- `--free` tells the companion script to shell out to `opencode models`, filter for first-party `opencode/*` free-tier models (those ending in `:free` or `-free`), and pick one at random for this review. Restricted to the `opencode/*` provider because OpenRouter free-tier models have inconsistent tool-use support, and the review agent needs `read`/`grep`/`glob`/`list`. Pass it through verbatim if the user supplied it. `--free` and `--model` are mutually exclusive — the companion will error if both are given.
- `--pr <number>` reviews a GitHub pull request via `gh pr diff` instead of the local working tree. The cwd must be a git repo whose remote points at the PR's repository, and `gh` must be installed and authenticated.
- `--path <path>` reviews a specific file or directory instead of git diff. Can be specified multiple times (`--path src --path lib`). When `--path` is set, the review is assembled from the actual file contents at those paths rather than from `git diff`. This is useful for reviewing specific directories, fixed sets of files, or large untracked/imported code drops. Mutually exclusive with `--pr` (paths take precedence over PR mode).

PR reference extraction (REQUIRED — read this carefully):
- If the user's input contains a PR reference like `PR #390`, `pr #390`, `PR 390`, or `pr 390`, you MUST extract the number yourself and pass it as `--pr 390`. Then strip the matched PR phrase from whatever you put in the focus text.
Expand Down
3 changes: 2 additions & 1 deletion plugins/opencode/commands/review.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
description: Run an OpenCode code review against local git state or a GitHub PR
argument-hint: '[--wait|--background] [--base <ref>] [--scope auto|working-tree|branch] [--model <id> | --free] [--pr <number>]'
argument-hint: '[--wait|--background] [--base <ref>] [--scope auto|working-tree|branch] [--model <id> | --free] [--pr <number>] [--path <path>] [--path <path2>]'
disable-model-invocation: true
allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), Bash(gh:*), AskUserQuestion
---
Expand Down Expand Up @@ -42,6 +42,7 @@ Argument handling:
- `--model <id>` overrides the saved setup default model and OpenCode's own default model for this single review (e.g. `--model openrouter/anthropic/claude-opus-4-6`). Pass it through verbatim if the user supplied it.
- `--free` tells the companion script to shell out to `opencode models`, filter for first-party `opencode/*` free-tier models (those ending in `:free` or `-free`), and pick one at random for this review. Restricted to the `opencode/*` provider because OpenRouter free-tier models have inconsistent tool-use support, and the review agent needs `read`/`grep`/`glob`/`list`. Pass it through verbatim if the user supplied it. `--free` and `--model` are mutually exclusive — the companion will error if both are given.
- `--pr <number>` reviews a GitHub pull request via `gh pr diff` instead of the local working tree. The cwd must be a git repo whose remote points at the PR's repository, and `gh` must be installed and authenticated. Pass it through verbatim if the user supplied it.
- `--path <path>` reviews a specific file or directory instead of git diff. Can be specified multiple times (`--path src --path lib`). When `--path` is set, the review is assembled from the actual file contents at those paths rather than from `git diff`. This is useful for reviewing specific directories, fixed sets of files, or large untracked/imported code drops. Mutually exclusive with `--pr` (paths take precedence over PR mode).
- **PR reference extraction (REQUIRED)**: if the user's input contains a PR reference like `PR #390`, `pr #390`, `PR 390`, or `pr 390` (e.g. `/opencode:review on PR #390`), you MUST extract the number yourself and pass it as `--pr 390`. Do not pass `PR #390` literally to bash — bash strips unquoted `#NNN` tokens as comments before they reach the companion script. Example: `node ... review --pr 390`, NOT `node ... review on PR #390`.

Foreground flow:
Expand Down
16 changes: 14 additions & 2 deletions plugins/opencode/scripts/lib/args.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
/**
* Parse CLI arguments into options and positional args.
* @param {string[]} argv
* @param {{ valueOptions?: string[], booleanOptions?: string[] }} schema
* @param {{ valueOptions?: string[], booleanOptions?: string[], multiValueOptions?: string[] }} schema
* @returns {{ options: Record<string, string|boolean>, positional: string[] }}
*/
export function parseArgs(argv, schema = {}) {
const valueSet = new Set(schema.valueOptions ?? []);
const multiValueSet = new Set(schema.multiValueOptions ?? []);
const boolSet = new Set(schema.booleanOptions ?? []);
const options = {};
const positional = [];
Expand All @@ -20,7 +21,18 @@ export function parseArgs(argv, schema = {}) {
}
const key = arg.slice(2);
if (valueSet.has(key)) {
options[key] = argv[++i] ?? "";
const value = argv[++i] ?? "";
if (multiValueSet.has(key)) {
if (options[key] === undefined) {
options[key] = [value];
} else if (Array.isArray(options[key])) {
options[key].push(value);
} else {
options[key] = [options[key], value];
}
} else {
options[key] = value;
}
} else if (boolSet.has(key) || !valueSet.has(key)) {
options[key] = true;
}
Expand Down
179 changes: 179 additions & 0 deletions plugins/opencode/scripts/lib/fs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,185 @@

import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";

const DEFAULT_MAX_BYTES = 256 * 1024;
const DEFAULT_MAX_FILES = 50;

function toGitPath(filePath) {
return filePath.split(path.sep).join("/");
}

function isInsidePath(parent, candidate) {
const relative = path.relative(parent, candidate);
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
}

function isGitignored(filePath, cwd) {
try {
const result = fs.statSync(filePath);
if (!result.isFile()) return false;

const relativePath = path.relative(cwd, filePath);
if (!isInsidePath(cwd, filePath)) return false;

const checked = spawnSync("git", ["check-ignore", "-q", "--", toGitPath(relativePath)], {
cwd,
stdio: "ignore",
});
return checked.status === 0;
} catch {
return false;
}
}

function isBinaryFile(filePath) {
let fd = null;
try {
const buffer = Buffer.alloc(8192);
fd = fs.openSync(filePath, "r");
const bytesRead = fs.readSync(fd, buffer, 0, 8192, 0);
for (let i = 0; i < bytesRead; i++) {
if (buffer[i] === 0) return true;
}
return false;
} catch {
return false;
} finally {
if (fd !== null) {
try {
fs.closeSync(fd);
} catch {
// best-effort
}
}
}
}

/**
* Collect file contents for a set of paths within cwd.
* Respects per-file and total size caps, skips binary files and broken symlinks.
*
* @param {string} cwd - Working directory
* @param {string[]} targetPaths - Relative paths to include
* @param {{ maxBytes?: number, maxFiles?: number }} opts
* @returns {Promise<{ content: string, files: string[], totalBytes: number, overflowed: boolean, overflowedBytes: boolean, overflowedFiles: boolean }>}
*/
export async function collectFolderContext(cwd, targetPaths, opts = {}) {
const maxBytes = Number.isFinite(opts.maxBytes) ? opts.maxBytes : DEFAULT_MAX_BYTES;
const maxFiles = Number.isFinite(opts.maxFiles) ? opts.maxFiles : DEFAULT_MAX_FILES;
const root = path.resolve(cwd);
let realRoot;
try {
realRoot = fs.realpathSync(root);
} catch {
realRoot = root;
}

const result = {
content: "",
files: [],
totalBytes: 0,
overflowed: false,
overflowedBytes: false,
overflowedFiles: false,
};

const visited = new Set();
const pending = [];

for (const targetPath of targetPaths) {
const resolvedPath = path.resolve(cwd, targetPath);
if (!isInsidePath(root, resolvedPath)) continue;
pending.push(resolvedPath);
}

while (pending.length > 0) {
if (result.files.length >= maxFiles) {
result.overflowed = true;
result.overflowedFiles = true;
break;
}

const resolvedPath = pending.shift();

try {
const stat = fs.lstatSync(resolvedPath);
let realPath = resolvedPath;

if (stat.isSymbolicLink()) {
try {
realPath = fs.realpathSync(resolvedPath);
} catch {
continue;
}
} else {
realPath = fs.realpathSync(resolvedPath);
}

if (!isInsidePath(realRoot, realPath)) continue;
if (visited.has(realPath)) continue;
visited.add(realPath);
if (path.basename(realPath) === ".git") continue;

const realStat = fs.statSync(realPath);
if (realStat.isDirectory()) {
const entries = fs.readdirSync(realPath, { withFileTypes: true })
.sort((a, b) => a.name.localeCompare(b.name));
for (let i = entries.length - 1; i >= 0; i -= 1) {
pending.unshift(path.join(realPath, entries[i].name));
}
} else if (realStat.isFile()) {
if (isBinaryFile(realPath)) continue;

const relativePath = path.relative(root, realPath);
if (!isInsidePath(root, realPath)) continue;
if (isGitignored(realPath, root)) continue;

const content = fs.readFileSync(realPath, "utf8");
const fileBytes = Buffer.byteLength(content, "utf8");

if (result.totalBytes + fileBytes > maxBytes) {
result.overflowed = true;
result.overflowedBytes = true;
const remaining = maxBytes - result.totalBytes;
if (remaining > 0) {
const truncated = truncateUtf8(content, remaining);
result.content += `// File: ${toGitPath(relativePath)} (truncated)\n${truncated}\n\n`;
result.totalBytes += Buffer.byteLength(truncated, "utf8");
result.files.push(toGitPath(relativePath));
}
break;
}

result.content += `// File: ${toGitPath(relativePath)}\n${content}\n\n`;
result.totalBytes += fileBytes;
result.files.push(toGitPath(relativePath));

if (result.files.length >= maxFiles) {
if (pending.length > 0) {
result.overflowed = true;
result.overflowedFiles = true;
}
break;
}
}
} catch (err) {
if (err?.code !== "ENOENT") {
// Skip files that don't exist
}
}
}

return result;
}

function truncateUtf8(text, maxBytes) {
if (!text) return text;
const buf = Buffer.from(text, "utf8");
if (buf.length <= maxBytes) return text;
return buf.subarray(0, maxBytes).toString("utf8").replace(/\uFFFD$/, "");
}

/**
* Ensure a directory exists (recursive mkdir).
Expand Down
90 changes: 90 additions & 0 deletions plugins/opencode/scripts/lib/prompts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
getPrInfo,
getPrDiff,
} from "./git.mjs";
import { collectFolderContext } from "./fs.mjs";

// Inline-diff thresholds. When a review exceeds either, we keep the prompt
// bounded by including a diff excerpt instead of the full diff. The review
Expand Down Expand Up @@ -44,6 +45,7 @@ function truncateUtf8(text, maxBytes) {
* @param {number} [opts.pr] - GitHub PR number to review (uses `gh pr diff`)
* @param {boolean} [opts.adversarial] - use adversarial review prompt
* @param {string} [opts.focus] - user-supplied focus text
* @param {string[]} [opts.paths] - specific paths to review instead of git diff
* @param {string} pluginRoot - CLAUDE_PLUGIN_ROOT for reading prompt templates
* @returns {Promise<string>}
*/
Expand All @@ -61,6 +63,53 @@ export async function buildReviewPrompt(cwd, opts, pluginRoot) {
let prInfo = null;
let diffStat = "";
let overByteLimit = false;
let folderContext = null;

// Step 1: When --path is specified, collect path context instead of git diff.
// Paths take precedence over PR mode so a command that includes both remains
// local and does not require gh/auth.
if (opts.paths && opts.paths.length > 0) {
folderContext = await collectFolderContext(cwd, opts.paths, {
maxBytes,
maxFiles,
});
changedFiles = folderContext.files;
overByteLimit = folderContext.overflowedBytes;
const diffBytes = folderContext.totalBytes;
const diffIsComplete = !folderContext.overflowed;
const collectionGuidance = buildCollectionGuidance(diffIsComplete);

const targetLabel = `Review of ${opts.paths.join(", ")}`;

const reviewContext = buildFolderContext(folderContext, {
diffIsComplete,
originalDiffBytes: diffBytes,
maxInlineDiffBytes: maxBytes,
maxInlineDiffFiles: maxFiles,
overFileLimit: folderContext.overflowedFiles,
overByteLimit: folderContext.overflowedBytes,
});

let systemPrompt;
if (opts.adversarial) {
const templatePath = path.join(pluginRoot, "prompts", "adversarial-review.md");
systemPrompt = fs.readFileSync(templatePath, "utf8")
.replace("{{TARGET_LABEL}}", targetLabel)
.replace("{{USER_FOCUS}}", opts.focus || "General review")
.replace("{{REVIEW_COLLECTION_GUIDANCE}}", collectionGuidance)
.replace("{{REVIEW_INPUT}}", reviewContext);
} else {
systemPrompt = buildStandardReviewPrompt(folderContext.content, status, changedFiles, {
...opts,
targetLabel,
prInfo,
reviewContext,
collectionGuidance,
});
}

return systemPrompt;
}

// Step 1: cheap metadata. The status / changed-file list / shortstat
// reads do not materialize the full diff and are safe on any size.
Expand Down Expand Up @@ -227,6 +276,47 @@ function buildReviewContext(diff, status, changedFiles, prInfo, opts = {}) {
return sections.join("\n\n");
}

/**
* Build the repository context block for folder/path-based review prompts.
* Uses <files> section instead of <diff> when context is collected from paths.
*/
function buildFolderContext(folderContext, opts = {}) {
const sections = [];

if (folderContext.files.length > 0) {
sections.push(`<files_reviewed>\n${folderContext.files.join("\n")}\n</files_reviewed>`);
}

if (opts.overFileLimit || opts.overByteLimit) {
const reasons = [];
if (opts.overFileLimit) {
const max = opts.maxInlineDiffFiles;
reasons.push(max ? `file count limit ${max} reached` : "file count limit reached");
}
if (opts.overByteLimit) {
reasons.push(`content size ${opts.originalDiffBytes} bytes`);
}
const budget = opts.overByteLimit && opts.maxInlineDiffBytes
? `; excerpt budget ${opts.maxInlineDiffBytes} bytes`
: "";
const note = opts.diffIsComplete === false
? "File content is bounded"
: "Review spans multiple files, but all content is included";
sections.push(
`<content_note>\n` +
`${note} (${reasons.join(", ")}${budget}). ` +
`Findings must be supported by the file evidence below.\n` +
`</content_note>`
);
}

if (folderContext.content) {
sections.push(`<files>\n${folderContext.content}\n</files>`);
}

return sections.join("\n\n");
}

/**
* Build a task prompt from user input.
* @param {string} taskText
Expand Down
Loading