Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/review-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@nanocollective/nanocoder': minor
---

Add /review slash command and `nanocoder review` CLI subcommand for AI-powered code review of branch diffs and PRs
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,14 @@ nanocoder --mode plan run "audit the auth module"

# Fullscreen mode with in-app scrolling instead of the inline default
nanocoder --alt-screen

# Review a branch or PR for bugs, security issues, and style violations
nanocoder review main
nanocoder review 42
```

> **Note:** `nanocoder review` requires an interactive terminal (TTY). It cannot be used with pipes or redirection.

### Screen Modes

Nanocoder supports two rendering modes, mirroring what Claude Code and Codex ship:
Expand Down
1 change: 1 addition & 0 deletions docs/features/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Type `/` in the chat input to see available commands. All commands start with `/
| `/export` | Export current session to markdown file |
| `/copy` | Copy the last assistant response to the system clipboard |
| `/commit` | Generate a Conventional Commit message from staged Git changes. Add `--copy` (or `-c`) to also copy the message to the system clipboard. A spinner shows while the model is working |
| `/review` | Review a branch or PR diff for bugs, security issues, and style violations. Usage: `/review <branch-or-pr-number>` (e.g. `/review main`, `/review 42`) |
| `/doctor` | Show environment health report for bug reports |
| `/update` | Update Nanocoder to the latest version |
| `/usage` | Get current model context usage visually |
Expand Down
18 changes: 18 additions & 0 deletions docs/getting-started/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ nanocoder -h
| `--resume [id]` | `-r` | Resume a [saved session](../features/session-management.md) by session ID, 1-based list index, or `last`. With no ID, opens the session picker at startup. Errors if the session is not found. Interactive only — errors with `run`. |
| `init [--preset <type>]` | | Initialize the current project. Bundled presets: `react`, `nextjs`, and `rust` |
| `run` | | Run in non-interactive mode |
| `review` | | Review a branch or PR diff for bugs, security, and style violations |

**Provider/Model Flags:**

Expand Down Expand Up @@ -178,6 +179,23 @@ nanocoder --provider ollama --model llama3.1 --context-max 128k run "analyze src
nanocoder run --provider openrouter --model anthropic/claude-sonnet-4-20250514 "refactor database module"
```

## Code Review

Nanocoder provides AI-powered code review for branches and pull requests:

```bash
# Review a branch
nanocoder review main
nanocoder review feature/auth

# Review a PR (requires gh CLI)
nanocoder review 42
```

This fetches the diff against the default branch and runs an architect-level review identifying bugs, security issues, and style violations. You can also use `/review <target>` inside the interactive TUI.

**Note:** `nanocoder review` requires an interactive terminal (TTY). It cannot be used with pipes or redirection (e.g. `nanocoder review main > review.md` will error). Use the `/review` slash command inside the interactive TUI for scripting workflows.

**Non-interactive mode behavior:**

- Automatically executes the given prompt
Expand Down
35 changes: 35 additions & 0 deletions source/app/prompts/sections/review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
You are a senior software engineer performing an architect-level code review.

Review the provided diff and identify actionable issues. Prioritize findings by severity.

## What to Look For

- **Correctness bugs**: logic errors, wrong assumptions, incorrect behavior
- **Edge cases**: missing null checks, boundary conditions, race conditions
- **Security vulnerabilities**: injection, auth issues, data exposure, validation gaps
- **Error handling**: swallowed errors, missing try/catch, poor recovery
- **Performance**: unnecessary allocations, N+1 patterns, blocking operations
- **Type safety**: any casts, missing type guards, unsafe assertions
- **API compatibility**: breaking changes, signature mismatches, deprecations
- **Resource leaks**: unclosed handles, missing cleanup, event listener leaks
- **Maintainability**: excessive complexity, poor naming, duplicated logic

## What to Avoid

- Trivial formatting or style preferences
- Personal taste disagreements
- Hypothetical problems not supported by the code
- Hallucinated issues that don't appear in the diff
- Unrelated suggestions

## Output Format

For each finding, provide:

1. **Severity**: Critical / Major / Minor
2. **File and line**: Where the issue occurs
3. **Issue**: What is wrong
4. **Impact**: Why it matters
5. **Fix**: How to resolve it

If the diff is clean with no significant issues, say so explicitly. Do not invent problems.
178 changes: 147 additions & 31 deletions source/cli.spec.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,21 @@
import test from 'ava';
import {filterCliFlags} from '@/utils/cli-flags';

// Test CLI argument parsing for non-interactive mode
// These tests verify that the CLI correctly parses the 'run' command

// Helper function to parse prompt from args (mimics the logic in cli.tsx)
function parsePrompt(args: string[]): string | undefined {
const runCommandIndex = args.findIndex(arg => arg === 'run');
if (runCommandIndex !== -1 && args[runCommandIndex + 1]) {
// Filter out known flags after 'run' when constructing the prompt
const promptArgs: string[] = [];
const afterRunArgs = args.slice(runCommandIndex + 1);
for (let i = 0; i < afterRunArgs.length; i++) {
const arg = afterRunArgs[i];
if (arg === '--vscode') {
continue; // skip this flag
} else if (arg === '--vscode-port') {
i++; // skip this flag and its value
continue;
} else if (arg === '--provider') {
i++; // skip this flag and its value
continue;
} else if (arg === '--model') {
i++; // skip this flag and its value
continue;
} else if (arg === '--context-max') {
i++; // skip this flag and its value
continue;
} else if (arg === '--plain' || arg === '--no-plain') {
continue; // skip this flag
} else {
promptArgs.push(arg);
}
}
return promptArgs.join(' ');
if (runCommandIndex === -1) {
return undefined;
}
const afterRunArgs = args.slice(runCommandIndex + 1);
if (afterRunArgs.length === 0) {
return undefined;
}
return undefined;
const positionals = filterCliFlags(afterRunArgs);
return positionals.length > 0 ? positionals.join(' ') : undefined;
}

test('CLI parsing: detects run command with single word prompt', t => {
Expand Down Expand Up @@ -236,7 +217,7 @@ function resolvePlainMode(opts: {
env: NodeJS.ProcessEnv;
}): {plainMode: boolean; vscodeMode: boolean} {
const {args, stdoutIsTTY, env} = opts;
const nonInteractiveMode = args.includes('run');
const nonInteractiveMode = args.findIndex(arg => arg === 'run') !== -1;
const vscodeMode = args.includes('--vscode');
const plainRequested = args.includes('--plain');
const noPlainRequested = args.includes('--no-plain');
Expand Down Expand Up @@ -435,8 +416,7 @@ function resolveResumeFlags(args: string[]): {
mutuallyExclusiveError: boolean;
nonInteractiveError: boolean;
} {
const runCommandIndex = args.findIndex(arg => arg === 'run');
const nonInteractiveMode = runCommandIndex !== -1;
const nonInteractiveMode = args.findIndex(arg => arg === 'run') !== -1;

const continueRequested =
args.includes('--continue') || args.includes('-c');
Expand Down Expand Up @@ -556,3 +536,139 @@ test('resume flags: --continue without `run` is not a non-interactive error', t
const {nonInteractiveError} = resolveResumeFlags(['--continue']);
t.false(nonInteractiveError);
});

// Run command with flags before 'run' (the blocker fix)
test('CLI parsing: handles flags before run command', t => {
const args = ['--plain', 'run', 'say', 'hi'];
const prompt = parsePrompt(args);
t.is(prompt, 'say hi');
});

test('CLI parsing: handles --provider before run command', t => {
const args = ['--provider', 'ollama', 'run', 'analyze', 'code'];
const prompt = parsePrompt(args);
t.is(prompt, 'analyze code');
});

test('CLI parsing: handles --mode before run command', t => {
const args = ['--mode', 'plan', 'run', 'audit', 'module'];
const prompt = parsePrompt(args);
t.is(prompt, 'audit module');
});

// Review guard tests — mirrors the guards in cli.tsx
function resolveReviewGuards(opts: {
args: string[];
stdoutIsTTY: boolean;
outputFormat: string;
}): {ttyError: boolean; jsonError: boolean; collisionError: boolean} {
const {args, stdoutIsTTY, outputFormat} = opts;
const isRunCommand = args.findIndex(arg => arg === 'run') !== -1;
const isReviewCommand = args[0] === 'review';
const ttyError = isReviewCommand && !stdoutIsTTY;
const jsonError = isReviewCommand && outputFormat === 'json';
const collisionError = isRunCommand && isReviewCommand;
return {ttyError, jsonError, collisionError};
}

test('review guard: errors when stdout is not a TTY', t => {
const {ttyError} = resolveReviewGuards({
args: ['review', 'main'],
stdoutIsTTY: false,
outputFormat: 'text',
});
t.true(ttyError);
});

test('review guard: passes on a TTY', t => {
const {ttyError} = resolveReviewGuards({
args: ['review', 'main'],
stdoutIsTTY: true,
outputFormat: 'text',
});
t.false(ttyError);
});

test('review guard: --json is rejected with review', t => {
const {jsonError} = resolveReviewGuards({
args: ['review', 'main'],
stdoutIsTTY: true,
outputFormat: 'json',
});
t.true(jsonError);
});

test('review guard: --json is not rejected with run', t => {
const {jsonError} = resolveReviewGuards({
args: ['run', 'hello'],
stdoutIsTTY: true,
outputFormat: 'json',
});
t.false(jsonError);
});

test('review guard: run and review collision is detected', t => {
const {collisionError} = resolveReviewGuards({
args: ['review', 'run'],
stdoutIsTTY: true,
outputFormat: 'text',
});
t.true(collisionError);
});

test('review guard: review alone has no collision', t => {
const {collisionError} = resolveReviewGuards({
args: ['review', 'main'],
stdoutIsTTY: true,
outputFormat: 'text',
});
t.false(collisionError);
});

test('review guard: run alone has no collision', t => {
const {collisionError} = resolveReviewGuards({
args: ['run', 'hello'],
stdoutIsTTY: true,
outputFormat: 'text',
});
t.false(collisionError);
});

// filterCliFlags: shared flag filter produces single source of truth
test('filterCliFlags: filters all known flags', t => {
const result = filterCliFlags([
'--vscode',
'--json',
'--trust-directory',
'--plain',
'--no-plain',
'--no-alt-screen',
'--alt-screen',
'--vscode-port',
'3000',
'--provider',
'ollama',
'--model',
'llama3',
'--context-max',
'128k',
'--output-format',
'json',
'--output-format=json',
'--mode',
'plan',
'--mode=plan',
'my-prompt',
]);
t.deepEqual(result, ['my-prompt']);
});

test('filterCliFlags: returns all args when no flags present', t => {
const result = filterCliFlags(['hello', 'world']);
t.deepEqual(result, ['hello', 'world']);
});

test('filterCliFlags: returns empty array for empty input', t => {
const result = filterCliFlags([]);
t.deepEqual(result, []);
});
Loading
Loading