diff --git a/packages/docs/src/pages/guide.astro b/packages/docs/src/pages/guide.astro
index 6e15eaf0..0283eadb 100644
--- a/packages/docs/src/pages/guide.astro
+++ b/packages/docs/src/pages/guide.astro
@@ -97,7 +97,21 @@ export WARDEN_ANTHROPIC_API_KEY=sk-ant-...`}
/>
-
Warden analyzes staged and unstaged changes, running any skills that match via your configured triggers.
+ Warden analyzes all uncommitted changes (staged and unstaged) against HEAD, running any skills that match via your configured triggers.
+
+ Review Only Staged Changes
+
+ For pre-commit workflows, analyze only what you're about to commit:
+
+
+
+
+
+ This uses git diff --cached so only staged files are analyzed.
Review Before Pushing
diff --git a/src/cli/args.ts b/src/cli/args.ts
index 7c3ca53c..badc3ee1 100644
--- a/src/cli/args.ts
+++ b/src/cli/args.ts
@@ -37,6 +37,8 @@ export const CLIOptionsSchema = z.object({
list: z.boolean().default(false),
/** Force interpretation of ambiguous targets as git refs */
git: z.boolean().default(false),
+ /** Analyze only staged changes (git diff --cached) */
+ staged: z.boolean().default(false),
/** Remote repository reference for skills (e.g., "owner/repo" or "owner/repo@sha") */
remote: z.string().optional(),
/** Skip network operations - only use cached remote skills */
@@ -109,6 +111,7 @@ Options:
--fix Automatically apply all suggested fixes
--parallel Max concurrent trigger/skill executions (default: 4)
-x, --fail-fast Stop after first finding
+ --staged Analyze only staged changes
--git Force ambiguous targets to be treated as git refs
--quiet Errors and final summary only
-v, --verbose Show real-time findings and hunk details
@@ -291,6 +294,7 @@ export function parseCliArgs(argv: string[] = process.argv.slice(2)): ParsedArgs
'fail-fast': { type: 'boolean', short: 'x', default: false },
parallel: { type: 'string' },
git: { type: 'boolean', default: false },
+ staged: { type: 'boolean', default: false },
log: { type: 'boolean', default: false },
help: { type: 'boolean', short: 'h', default: false },
version: { type: 'boolean', short: 'V', default: false },
@@ -465,6 +469,7 @@ export function parseCliArgs(argv: string[] = process.argv.slice(2)): ParsedArgs
force: values.force,
parallel: values.parallel ? parseInt(values.parallel, 10) : undefined,
git: values.git,
+ staged: values.staged,
log: values.log,
offline: values.offline,
failFast: values['fail-fast'],
diff --git a/src/cli/commands/init.test.ts b/src/cli/commands/init.test.ts
index 2c4cc66b..82b24d19 100644
--- a/src/cli/commands/init.test.ts
+++ b/src/cli/commands/init.test.ts
@@ -26,6 +26,7 @@ function createOptions(overrides: Partial = {}): CLIOptions {
force: false,
list: false,
git: false,
+ staged: false,
offline: false,
failFast: false,
...overrides,
diff --git a/src/cli/commands/logs.test.ts b/src/cli/commands/logs.test.ts
index fdcb4be4..7e77d8a9 100644
--- a/src/cli/commands/logs.test.ts
+++ b/src/cli/commands/logs.test.ts
@@ -27,6 +27,7 @@ function createDefaultOptions(overrides: Partial = {}): CLIOptions {
force: false,
list: false,
git: false,
+ staged: false,
offline: false,
failFast: false,
log: false,
diff --git a/src/cli/commands/setup-app.test.ts b/src/cli/commands/setup-app.test.ts
new file mode 100644
index 00000000..1f9aecbe
--- /dev/null
+++ b/src/cli/commands/setup-app.test.ts
@@ -0,0 +1,222 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { EventEmitter } from 'node:events';
+import { runSetupApp } from './setup-app.js';
+import { Reporter, parseVerbosity } from '../output/index.js';
+import type { SetupAppOptions } from '../args.js';
+
+// Mock all external dependencies so we never hit the network or filesystem.
+vi.mock('./setup-app/manifest.js', () => ({
+ buildManifest: () => ({ name: 'test-app', url: 'http://localhost', public: false }),
+}));
+
+vi.mock('./setup-app/browser.js', () => ({
+ openBrowser: vi.fn().mockResolvedValue(undefined),
+}));
+
+vi.mock('./setup-app/credentials.js', () => ({
+ exchangeCodeForCredentials: vi.fn().mockResolvedValue({
+ id: 12345,
+ name: 'test-app',
+ pem: '-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----',
+ htmlUrl: 'https://github.com/apps/test-app',
+ }),
+}));
+
+vi.mock('../git.js', () => ({
+ getGitHubRepoUrl: () => 'https://github.com/test/repo',
+}));
+
+// We need fine-grained control over the callback server mock, so we build it
+// per-test in a factory that the mock delegates to.
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+let serverFactory: () => any;
+
+vi.mock('./setup-app/server.js', () => ({
+ startCallbackServer: (..._args: unknown[]) => serverFactory(),
+}));
+
+function createTestReporter(): Reporter {
+ const mode = { isTTY: false, supportsColor: false, columns: 80 };
+ return new Reporter(mode, parseVerbosity(false, 0, false));
+}
+
+function createOptions(overrides: Partial = {}): SetupAppOptions {
+ return {
+ port: 3456,
+ timeout: 60,
+ open: false,
+ ...overrides,
+ };
+}
+
+/**
+ * Build a fake server handle whose `server` is an EventEmitter so we can
+ * simulate 'error' events. The `waitForCallback` promise can be resolved or
+ * rejected externally.
+ */
+function createMockServerHandle() {
+ const emitter = new EventEmitter();
+ let resolveCallback!: (v: { code: string }) => void;
+ let rejectCallback!: (e: Error) => void;
+ const waitForCallback = new Promise<{ code: string }>((resolve, reject) => {
+ resolveCallback = resolve;
+ rejectCallback = reject;
+ });
+ const close = vi.fn();
+
+ return {
+ handle: {
+ server: emitter,
+ waitForCallback,
+ close,
+ startUrl: 'http://localhost:3456/start',
+ },
+ resolveCallback,
+ rejectCallback,
+ close,
+ };
+}
+
+describe('runSetupApp', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('server error handling', () => {
+ it('returns exit code 1 and calls close() when the server emits EADDRINUSE', async () => {
+ const mock = createMockServerHandle();
+ serverFactory = () => mock.handle;
+
+ const reporter = createTestReporter();
+ const errorSpy = vi.spyOn(reporter, 'error');
+
+ // Emit the error on the next microtask so `runSetupApp` has time to
+ // register its listener and start awaiting the promise race.
+ const errorObj: NodeJS.ErrnoException = new Error('listen EADDRINUSE: address already in use');
+ errorObj.code = 'EADDRINUSE';
+
+ setTimeout(() => mock.handle.server.emit('error', errorObj), 5);
+
+ const exitCode = await runSetupApp(createOptions(), reporter);
+
+ expect(exitCode).toBe(1);
+ expect(mock.close).toHaveBeenCalled();
+ expect(errorSpy).toHaveBeenCalledWith(
+ expect.stringContaining('already in use'),
+ );
+ });
+
+ it('returns exit code 1 for generic server errors', async () => {
+ const mock = createMockServerHandle();
+ serverFactory = () => mock.handle;
+
+ const reporter = createTestReporter();
+ const errorSpy = vi.spyOn(reporter, 'error');
+
+ const errorObj: NodeJS.ErrnoException = new Error('something broke');
+ errorObj.code = 'EACCES';
+
+ setTimeout(() => mock.handle.server.emit('error', errorObj), 5);
+
+ const exitCode = await runSetupApp(createOptions(), reporter);
+
+ expect(exitCode).toBe(1);
+ expect(mock.close).toHaveBeenCalled();
+ expect(errorSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Server error: something broke'),
+ );
+ });
+
+ it('does not call process.exit on server error', async () => {
+ const mock = createMockServerHandle();
+ serverFactory = () => mock.handle;
+
+ const reporter = createTestReporter();
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
+
+ const errorObj: NodeJS.ErrnoException = new Error('port in use');
+ errorObj.code = 'EADDRINUSE';
+
+ setTimeout(() => mock.handle.server.emit('error', errorObj), 5);
+
+ await runSetupApp(createOptions(), reporter);
+
+ expect(exitSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('cleanup on errors', () => {
+ it('calls serverHandle.close() even when waitForCallback rejects', async () => {
+ const mock = createMockServerHandle();
+ serverFactory = () => mock.handle;
+
+ const reporter = createTestReporter();
+
+ setTimeout(() => mock.rejectCallback(new Error('Timeout')), 5);
+
+ const exitCode = await runSetupApp(createOptions(), reporter);
+
+ expect(exitCode).toBe(1);
+ expect(mock.close).toHaveBeenCalled();
+ });
+ });
+
+ describe('happy path', () => {
+ it('returns exit code 0 on successful callback exchange', async () => {
+ const mock = createMockServerHandle();
+ serverFactory = () => mock.handle;
+
+ const reporter = createTestReporter();
+
+ setTimeout(() => mock.resolveCallback({ code: 'test-code' }), 5);
+
+ const exitCode = await runSetupApp(createOptions(), reporter);
+
+ expect(exitCode).toBe(0);
+ expect(mock.close).toHaveBeenCalled();
+ });
+ });
+
+ describe('URL display', () => {
+ it('shows URL when open is false', async () => {
+ const mock = createMockServerHandle();
+ serverFactory = () => mock.handle;
+
+ const reporter = createTestReporter();
+ const textSpy = vi.spyOn(reporter, 'text');
+
+ setTimeout(() => mock.resolveCallback({ code: 'test-code' }), 5);
+
+ await runSetupApp(createOptions({ open: false }), reporter);
+
+ const textCalls = textSpy.mock.calls.map((c) => c[0]);
+ expect(textCalls).toContainEqual(
+ expect.stringContaining('Open this URL in your browser'),
+ );
+ });
+
+ it('shows URL when browser open fails', async () => {
+ const { openBrowser } = await import('./setup-app/browser.js');
+ vi.mocked(openBrowser).mockRejectedValueOnce(new Error('xdg-open not found'));
+
+ const mock = createMockServerHandle();
+ serverFactory = () => mock.handle;
+
+ const reporter = createTestReporter();
+ const textSpy = vi.spyOn(reporter, 'text');
+ const warnSpy = vi.spyOn(reporter, 'warning');
+
+ setTimeout(() => mock.resolveCallback({ code: 'test-code' }), 5);
+
+ await runSetupApp(createOptions({ open: true }), reporter);
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Could not open browser'),
+ );
+ const textCalls = textSpy.mock.calls.map((c) => c[0]);
+ expect(textCalls).toContainEqual(
+ expect.stringContaining('Open this URL in your browser'),
+ );
+ });
+ });
+});
diff --git a/src/cli/commands/setup-app.ts b/src/cli/commands/setup-app.ts
index 3d88d504..a59cd1cb 100644
--- a/src/cli/commands/setup-app.ts
+++ b/src/cli/commands/setup-app.ts
@@ -48,29 +48,35 @@ export async function runSetupApp(options: SetupAppOptions, reporter: Reporter):
org,
});
- // Handle server errors (e.g., port already in use)
- serverHandle.server.on('error', (error: NodeJS.ErrnoException) => {
- if (error.code === 'EADDRINUSE') {
- reporter.error(`Port ${port} is already in use. Try a different port with --port `);
- } else {
- reporter.error(`Server error: ${error.message}`);
- }
- process.exit(1);
+ // Handle server errors (e.g., port already in use) by racing a rejection
+ // against the callback promise so the error flows into the catch block below.
+ const serverError = new Promise((_, reject) => {
+ serverHandle.server.on('error', (error: NodeJS.ErrnoException) => {
+ if (error.code === 'EADDRINUSE') {
+ reject(new Error(`Port ${port} is already in use. Try a different port with --port `));
+ } else {
+ reject(new Error(`Server error: ${error.message}`));
+ }
+ });
});
+ // Prevent unhandled rejection if the server errors before Promise.race is reached
+ serverError.catch((_e: unknown) => undefined);
try {
// Open browser to our local server (which will POST to GitHub)
+ let showUrl = !open;
if (open) {
reporter.step('Opening browser...');
try {
await openBrowser(serverHandle.startUrl);
} catch {
reporter.warning('Could not open browser automatically.');
- reporter.blank();
- reporter.text('Open this URL in your browser:');
- reporter.text(chalk.cyan(serverHandle.startUrl));
+ showUrl = true;
}
- } else {
+ }
+
+ // Show the URL if the browser was not opened (or failed to open)
+ if (showUrl) {
reporter.blank();
reporter.text('Open this URL in your browser:');
reporter.text(chalk.cyan(serverHandle.startUrl));
@@ -81,8 +87,8 @@ export async function runSetupApp(options: SetupAppOptions, reporter: Reporter):
reporter.blank();
reporter.text(chalk.dim('Waiting for GitHub callback... (Ctrl+C to cancel)'));
- // Wait for callback
- const { code } = await serverHandle.waitForCallback;
+ // Wait for callback, but also abort if the server errors out
+ const { code } = await Promise.race([serverHandle.waitForCallback, serverError]);
// Exchange code for credentials
reporter.blank();
diff --git a/src/cli/context.ts b/src/cli/context.ts
index fe8c4e4a..88b22b45 100644
--- a/src/cli/context.ts
+++ b/src/cli/context.ts
@@ -34,6 +34,8 @@ export interface LocalContextOptions {
cwd?: string;
/** Override auto-detected default branch (from config) */
defaultBranch?: string;
+ /** Analyze only staged changes (git diff --cached) */
+ staged?: boolean;
}
/**
@@ -49,12 +51,14 @@ export function buildLocalEventContext(options: LocalContextOptions = {}): Event
const { owner, name } = getRepoName(cwd);
const defaultBranch = options.defaultBranch ?? getDefaultBranch(cwd);
- const base = options.base ?? defaultBranch;
+ // When staged, always diff against HEAD (index vs HEAD)
+ const staged = options.staged ?? false;
+ const base = staged ? 'HEAD' : (options.base ?? defaultBranch);
const head = options.head; // undefined means working tree
const currentBranch = getCurrentBranch(cwd);
const headSha = head ? head : getHeadSha(cwd);
- const changedFiles = getChangedFilesWithPatches(base, head, cwd);
+ const changedFiles = getChangedFilesWithPatches(base, head, cwd, { staged });
const files = changedFiles.map(toFileChange);
// Use actual commit message when analyzing a specific commit
@@ -64,6 +68,9 @@ export function buildLocalEventContext(options: LocalContextOptions = {}): Event
const commitMsg = getCommitMessage(head, cwd);
title = commitMsg.subject || `Commit ${head}`;
body = commitMsg.body || `Analyzing changes in ${head}`;
+ } else if (staged) {
+ title = `Staged changes: ${currentBranch}`;
+ body = `Analyzing staged changes`;
} else {
title = `Local changes: ${currentBranch}`;
body = `Analyzing local changes from ${base} to working tree`;
diff --git a/src/cli/git.ts b/src/cli/git.ts
index 4cbcf02a..3ac59ae6 100644
--- a/src/cli/git.ts
+++ b/src/cli/git.ts
@@ -170,18 +170,36 @@ function mapStatus(status: string): GitFileChange['status'] {
}
}
+export interface DiffOptions {
+ /** Use --cached to diff only staged changes against HEAD */
+ staged?: boolean;
+}
+
+/**
+ * Build the git diff arguments for a given base/head/staged configuration.
+ */
+function buildDiffArgs(base: string, head: string | undefined, options?: DiffOptions): string[] {
+ if (options?.staged) {
+ return ['diff', '--cached'];
+ }
+ const diffRef = head ? `${base}...${head}` : base;
+ return ['diff', diffRef];
+}
+
/**
* Get list of changed files between two refs.
* If head is undefined, compares against the working tree.
+ * If options.staged is true, compares only staged changes against HEAD.
*/
export function getChangedFiles(
base: string,
head?: string,
- cwd: string = process.cwd()
+ cwd: string = process.cwd(),
+ options?: DiffOptions
): GitFileChange[] {
// Get file statuses
- const diffRef = head ? `${base}...${head}` : base;
- const nameStatusOutput = git(['diff', '--name-status', diffRef], cwd);
+ const baseArgs = buildDiffArgs(base, head, options);
+ const nameStatusOutput = git([...baseArgs, '--name-status'], cwd);
if (!nameStatusOutput) {
return [];
@@ -207,7 +225,7 @@ export function getChangedFiles(
}
// Get numstat for additions/deletions
- const numstatOutput = git(['diff', '--numstat', diffRef], cwd);
+ const numstatOutput = git([...baseArgs, '--numstat'], cwd);
if (numstatOutput) {
for (const line of numstatOutput.split('\n')) {
if (!line.trim()) continue;
@@ -233,11 +251,12 @@ export function getFilePatch(
base: string,
head: string | undefined,
filename: string,
- cwd: string = process.cwd()
+ cwd: string = process.cwd(),
+ options?: DiffOptions
): string | undefined {
try {
- const diffRef = head ? `${base}...${head}` : base;
- return git(['diff', diffRef, '--', filename], cwd);
+ const baseArgs = buildDiffArgs(base, head, options);
+ return git([...baseArgs, '--', filename], cwd);
} catch {
return undefined;
}
@@ -276,9 +295,10 @@ function parseCombinedDiff(diffOutput: string): Map {
export function getChangedFilesWithPatches(
base: string,
head?: string,
- cwd: string = process.cwd()
+ cwd: string = process.cwd(),
+ options?: DiffOptions
): GitFileChange[] {
- const files = getChangedFiles(base, head, cwd);
+ const files = getChangedFiles(base, head, cwd, options);
if (files.length === 0) {
return files;
@@ -286,8 +306,8 @@ export function getChangedFilesWithPatches(
// Get all patches in a single git diff command
try {
- const diffRef = head ? `${base}...${head}` : base;
- const combinedDiff = git(['diff', diffRef], cwd);
+ const baseArgs = buildDiffArgs(base, head, options);
+ const combinedDiff = git(baseArgs, cwd);
const patches = parseCombinedDiff(combinedDiff);
for (const file of files) {
@@ -297,7 +317,7 @@ export function getChangedFilesWithPatches(
} catch {
// Fall back to per-file patches if combined diff fails
for (const file of files) {
- file.patch = getFilePatch(base, head, file.filename, cwd);
+ file.patch = getFilePatch(base, head, file.filename, cwd, options);
file.chunks = countPatchChunks(file.patch);
}
}
diff --git a/src/cli/main.ts b/src/cli/main.ts
index a7e321fc..ad034e0e 100644
--- a/src/cli/main.ts
+++ b/src/cli/main.ts
@@ -582,11 +582,14 @@ async function runConfigMode(options: CLIOptions, reporter: Reporter): Promise'
- : undefined;
- reporter.renderEmptyState('No changes found', tip);
+ if (options.staged) {
+ reporter.renderEmptyState('No staged changes found');
+ } else {
+ const tip = !hasUncommittedChanges(repoPath)
+ ? 'Specify a git ref: warden HEAD~3 --skill '
+ : undefined;
+ reporter.renderEmptyState('No uncommitted changes found', tip);
+ }
reporter.blank();
}
return 0;
@@ -736,11 +743,13 @@ async function runDirectSkillMode(options: CLIOptions, reporter: Reporter): Prom
const config = existsSync(configPath) ? loadWardenConfig(dirname(configPath)) : null;
// Build context from local git - compare against HEAD for true uncommitted changes
- reporter.startContext('Analyzing uncommitted changes...');
+ const statusMessage = options.staged ? 'Analyzing staged changes...' : 'Analyzing uncommitted changes...';
+ reporter.startContext(statusMessage);
const context = buildLocalEventContext({
base: 'HEAD',
cwd: repoPath,
defaultBranch: config?.defaults?.defaultBranch,
+ staged: options.staged,
});
const pullRequest = context.pullRequest;
@@ -755,8 +764,12 @@ async function runDirectSkillMode(options: CLIOptions, reporter: Reporter): Prom
process.stdout.write(content);
} else {
writeEmptyRunLog(repoPath, { traceId: getTraceId(), outputPath: options.output });
- const tip = 'Specify a git ref to analyze committed changes: warden main --skill ';
- reporter.renderEmptyState('No uncommitted changes found', tip);
+ if (options.staged) {
+ reporter.renderEmptyState('No staged changes found');
+ } else {
+ const tip = 'Specify a git ref to analyze committed changes: warden main --skill ';
+ reporter.renderEmptyState('No uncommitted changes found', tip);
+ }
reporter.blank();
}
return 0;
@@ -770,6 +783,11 @@ async function runDirectSkillMode(options: CLIOptions, reporter: Reporter): Prom
async function runCommand(options: CLIOptions, reporter: Reporter): Promise {
const targets = options.targets ?? [];
+ // --staged is only meaningful without explicit targets
+ if (options.staged && targets.length > 0) {
+ reporter.warning('--staged is ignored when targets are specified');
+ }
+
// No targets with --skill → run skill directly on uncommitted changes
if (targets.length === 0 && options.skill) {
return runDirectSkillMode(options, reporter);