# Anthropic OAuth Browser Flow Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Replace the manual auth-token paste with a PKCE OAuth2 browser flow that spins up a local HTTP callback server, opens the user's browser, and captures the Anthropic token automatically.

**Architecture:** New pure functions (`generateCodeVerifier`, `generateCodeChallenge`, `startCallbackServer`, `exchangeCodeForToken`, `loginAnthropicOAuth`) added to `src/auth/anthropic.ts`. TUI option 2 ("Paste auth token") replaced with "Browser OAuth" calling `loginAnthropicOAuth`. CLI `--browser` flag added alongside existing `--token`. No new npm dependencies — uses Node.js `crypto`, `http`, and `child_process` builtins.

**Tech Stack:** TypeScript, Node.js `crypto`/`http`/`child_process`, Vitest, existing `storeAnthropicAuthToken`.

---

### Task 1: PKCE helpers and one-shot callback server

**Files:**
- Modify: `src/auth/anthropic.ts`
- Modify: `src/auth/anthropic.test.ts`

**OAuth constants (add near top of `src/auth/anthropic.ts` after existing constants):**

```typescript
const ANTHROPIC_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
const ANTHROPIC_AUTH_URL = 'https://claude.ai/oauth/authorize';
const ANTHROPIC_TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token';
const ANTHROPIC_OAUTH_SCOPES = 'user:inference user:profile';
const OAUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
```

**Step 1: Write the failing tests**

Add a new `describe('PKCE helpers', ...)` block in `src/auth/anthropic.test.ts`.
The test file uses `vi.resetModules()` in `beforeEach` and dynamic imports — add the new describe block at the bottom, after the existing describe, with its own `beforeEach` / `afterEach` for `HOME`:

```typescript
import { createHash, randomBytes } from 'crypto';

describe('PKCE helpers', () => {
  it('generateCodeVerifier returns a 43-char base64url string', async () => {
    const { generateCodeVerifier } = await import('./anthropic.js');
    const v = generateCodeVerifier();
    expect(v).toMatch(/^[A-Za-z0-9\-_]+$/);
    expect(v.length).toBe(43);
    expect(v).not.toContain('=');
  });

  it('generateCodeChallenge returns correct SHA256 base64url', async () => {
    const { generateCodeVerifier, generateCodeChallenge } = await import('./anthropic.js');
    const verifier = generateCodeVerifier();
    const challenge = generateCodeChallenge(verifier);
    const expected = createHash('sha256').update(verifier).digest('base64url');
    expect(challenge).toBe(expected);
    expect(challenge).not.toContain('=');
    expect(challenge).toMatch(/^[A-Za-z0-9\-_]+$/);
  });
});
```

**Step 2: Run to verify they fail**

```bash
cd /home/will/lab/flynn && pnpm test:run src/auth/anthropic.test.ts
```

Expected: FAIL — `generateCodeVerifier` not exported.

**Step 3: Implement the PKCE helpers in `src/auth/anthropic.ts`**

Add after the existing imports (add `createHash`, `randomBytes` to the node:crypto import):

```typescript
import { createHash, randomBytes } from 'crypto';
import { createServer, IncomingMessage, ServerResponse } from 'http';
import { AddressInfo } from 'net';
import { spawn } from 'child_process';
```

Add after the constants block (before `AnthropicAuthInfo` interface):

```typescript
/** Generate a PKCE code_verifier: 32 random bytes as base64url (43 chars, no padding). */
export function generateCodeVerifier(): string {
  return randomBytes(32).toString('base64url');
}

/** Generate a PKCE code_challenge: SHA256 of verifier, base64url-encoded (no padding). */
export function generateCodeChallenge(verifier: string): string {
  return createHash('sha256').update(verifier).digest('base64url');
}
```

**Step 4: Write the failing callback server test**

Add to the `describe('PKCE helpers', ...)` block:

```typescript
it('startCallbackServer resolves with code and state on redirect', async () => {
  const { startCallbackServer } = await import('./anthropic.js');
  const serverPromise = startCallbackServer(5000);

  // Get the port from the returned object — we need to peek at it.
  // startCallbackServer returns { port, waitForCode }
  // (see implementation below for shape)
  const { port, waitForCode } = await serverPromise;

  // Simulate browser redirect
  const res = await fetch(`http://127.0.0.1:${port}/callback?code=test-code&state=test-state`);
  expect(res.status).toBe(200);
  const text = await res.text();
  expect(text).toContain('close this tab');

  const { code, state } = await waitForCode;
  expect(code).toBe('test-code');
  expect(state).toBe('test-state');
});

it('startCallbackServer rejects on timeout', async () => {
  const { startCallbackServer } = await import('./anthropic.js');
  const { waitForCode } = await startCallbackServer(50); // 50ms timeout
  await expect(waitForCode).rejects.toThrow('timed out');
});
```

**Step 5: Run to verify they fail**

```bash
cd /home/will/lab/flynn && pnpm test:run src/auth/anthropic.test.ts
```

Expected: FAIL — `startCallbackServer` not exported.

**Step 6: Implement `startCallbackServer`**

```typescript
export interface CallbackServer {
  port: number;
  waitForCode: Promise<{ code: string; state: string }>;
}

/**
 * Start a one-shot HTTP server on a random port.
 * Resolves immediately with { port, waitForCode }.
 * waitForCode resolves when the browser hits /callback?code=...&state=...
 * and rejects if timeoutMs elapses first.
 */
export function startCallbackServer(timeoutMs: number): Promise<CallbackServer> {
  return new Promise((resolveServer, rejectServer) => {
    let resolveCb: (v: { code: string; state: string }) => void;
    let rejectCb: (e: Error) => void;

    const waitForCode = new Promise<{ code: string; state: string }>((res, rej) => {
      resolveCb = res;
      rejectCb = rej;
    });

    const server = createServer((req: IncomingMessage, res: ServerResponse) => {
      const url = new URL(req.url ?? '/', `http://127.0.0.1`);
      if (url.pathname !== '/callback') {
        res.writeHead(404).end('Not found');
        return;
      }

      const code = url.searchParams.get('code');
      const state = url.searchParams.get('state');

      if (!code || !state) {
        res.writeHead(400).end('Missing code or state');
        return;
      }

      res.writeHead(200, { 'Content-Type': 'text/html' }).end(
        '<!DOCTYPE html><html><body>' +
        '<h2>Authentication complete</h2>' +
        '<p>You can close this tab and return to Flynn.</p>' +
        '</body></html>',
      );

      server.close();
      clearTimeout(timer);
      resolveCb({ code, state });
    });

    const timer = setTimeout(() => {
      server.close();
      rejectCb(new Error('Anthropic OAuth timed out — browser flow was not completed.'));
    }, timeoutMs);

    server.listen(0, '127.0.0.1', () => {
      const port = (server.address() as AddressInfo).port;
      resolveServer({ port, waitForCode });
    });

    server.on('error', rejectServer);
  });
}
```

**Step 7: Run tests to verify they pass**

```bash
cd /home/will/lab/flynn && pnpm test:run src/auth/anthropic.test.ts
```

Expected: all PASS.

**Step 8: Commit**

```bash
cd /home/will/lab/flynn && git add src/auth/anthropic.ts src/auth/anthropic.test.ts && git commit -m "feat(auth): add PKCE helpers and OAuth callback server for Anthropic"
```

---

### Task 2: Token exchange and `loginAnthropicOAuth`

**Files:**
- Modify: `src/auth/anthropic.ts`
- Modify: `src/auth/anthropic.test.ts`

**Step 1: Write the failing tests**

Add a new `describe('loginAnthropicOAuth', ...)` block in `src/auth/anthropic.test.ts`.
This test needs to mock `fetch`, `startCallbackServer`, and `storeAnthropicAuthToken`.
Use `vi.hoisted` for mocks at module level (required for ESM).

Add at the top of the file (alongside the other `vi.hoisted` calls):

```typescript
const { mockFetch, mockStartCallbackServer } = vi.hoisted(() => ({
  mockFetch: vi.fn(),
  mockStartCallbackServer: vi.fn(),
}));
```

Then add a new describe block at the bottom:

```typescript
describe('loginAnthropicOAuth', () => {
  beforeEach(() => {
    vi.stubGlobal('fetch', mockFetch);
    mockStartCallbackServer.mockReset();
    mockFetch.mockReset();
    vi.resetModules();
  });

  afterEach(() => {
    vi.unstubAllGlobals();
  });

  it('builds correct auth URL and exchanges code for token', async () => {
    // Arrange: mock callback server to immediately return a code
    const fakeCode = 'auth-code-abc';
    const fakeState = 'state-xyz';
    mockStartCallbackServer.mockResolvedValue({
      port: 9999,
      waitForCode: Promise.resolve({ code: fakeCode, state: fakeState }),
    });

    // Mock token exchange response
    mockFetch.mockResolvedValue({
      ok: true,
      json: async () => ({ access_token: 'tok-from-oauth' }),
    });

    const capturedUrls: string[] = [];
    const { loginAnthropicOAuth } = await import('./anthropic.js');

    // Use vi.spyOn to intercept startCallbackServer
    // (injected via the module's own reference — see impl note below)
    const token = await loginAnthropicOAuth((url) => capturedUrls.push(url));

    // Auth URL assertions
    expect(capturedUrls).toHaveLength(1);
    const authUrl = new URL(capturedUrls[0]);
    expect(authUrl.hostname).toBe('claude.ai');
    expect(authUrl.searchParams.get('client_id')).toBe('9d1c250a-e61b-44d9-88ed-5944d1962f5e');
    expect(authUrl.searchParams.get('code_challenge_method')).toBe('S256');
    expect(authUrl.searchParams.get('response_type')).toBe('code');
    expect(authUrl.searchParams.get('scope')).toContain('user:inference');

    // Token stored
    expect(token).toBe('tok-from-oauth');

    // Verify token exchange fetch call
    expect(mockFetch).toHaveBeenCalledWith(
      'https://console.anthropic.com/v1/oauth/token',
      expect.objectContaining({ method: 'POST' }),
    );
  });

  it('throws on state mismatch', async () => {
    mockStartCallbackServer.mockResolvedValue({
      port: 9999,
      waitForCode: Promise.resolve({ code: 'code', state: 'WRONG' }),
    });

    const { loginAnthropicOAuth } = await import('./anthropic.js');

    await expect(
      loginAnthropicOAuth(() => undefined),
    ).rejects.toThrow('state mismatch');
  });

  it('throws with subscription message on 403', async () => {
    mockStartCallbackServer.mockResolvedValue({
      port: 9999,
      waitForCode: Promise.resolve({ code: 'code', state: '__STATE__' }),
    });
    mockFetch.mockResolvedValue({
      ok: false,
      status: 403,
      text: async () => 'Forbidden',
    });

    const { loginAnthropicOAuth } = await import('./anthropic.js');

    await expect(
      loginAnthropicOAuth(() => undefined),
    ).rejects.toThrow(/Pro\/Max subscription/);
  });

  it('throws with status on non-2xx token exchange', async () => {
    mockStartCallbackServer.mockResolvedValue({
      port: 9999,
      waitForCode: Promise.resolve({ code: 'code', state: '__STATE__' }),
    });
    mockFetch.mockResolvedValue({
      ok: false,
      status: 500,
      text: async () => 'Internal Server Error',
    });

    const { loginAnthropicOAuth } = await import('./anthropic.js');

    await expect(
      loginAnthropicOAuth(() => undefined),
    ).rejects.toThrow(/500/);
  });
});
```

> **Implementation note on testability:** `loginAnthropicOAuth` calls `startCallbackServer` internally. To allow mocking in tests, we use the **same-module call** pattern: tests that need to control the callback server must mock `fetch` globally and intercept `startCallbackServer` via `vi.spyOn` on the module after import. Alternatively, `loginAnthropicOAuth` accepts an optional `_startServer` parameter (defaulting to the real `startCallbackServer`) for easy test injection. Use the optional injection pattern — it avoids complex spy setup.

Update the function signature accordingly:
```typescript
export async function loginAnthropicOAuth(
  onOpen: (url: string) => void,
  _startServer = startCallbackServer,
): Promise<string>
```

Also update the state assertion: when `loginAnthropicOAuth` generates `state` internally, the test mock that returns `state: '__STATE__'` needs to match. The simplest approach: generate state, pass it to `startCallbackServer` as part of the redirect URI validation. See implementation below — the state is generated before the server starts and checked after.

**Step 2: Run to verify they fail**

```bash
cd /home/will/lab/flynn && pnpm test:run src/auth/anthropic.test.ts
```

Expected: FAIL — `loginAnthropicOAuth` not exported.

**Step 3: Implement `openBrowser`, `exchangeCodeForToken`, and `loginAnthropicOAuth`**

Add to `src/auth/anthropic.ts`:

```typescript
/** Open a URL in the user's default browser (platform-specific). Failures are silent. */
export function openBrowser(url: string): void {
  const cmd = process.platform === 'win32' ? 'start'
    : process.platform === 'darwin' ? 'open'
    : 'xdg-open';
  try {
    spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
  } catch {
    // Caller should already be printing the URL as fallback
  }
}

/** Exchange an authorization code for an Anthropic access token. */
export async function exchangeCodeForToken(
  code: string,
  codeVerifier: string,
  redirectUri: string,
): Promise<string> {
  const response = await fetch(ANTHROPIC_TOKEN_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: ANTHROPIC_CLIENT_ID,
      code,
      code_verifier: codeVerifier,
      redirect_uri: redirectUri,
    }).toString(),
  });

  if (!response.ok) {
    const body = await response.text();
    if (response.status === 403) {
      throw new Error(
        `Anthropic OAuth requires an active Claude Pro/Max subscription (403): ${body}`,
      );
    }
    throw new Error(`Anthropic token exchange failed (${response.status}): ${body}`);
  }

  const data = await response.json() as Record<string, unknown>;
  if (typeof data.access_token !== 'string') {
    throw new Error('Anthropic token exchange returned no access_token');
  }
  return data.access_token;
}

/**
 * Run the full Anthropic OAuth PKCE browser flow.
 * Calls onOpen with the authorization URL (caller should open browser or print URL).
 * Stores the resulting token via storeAnthropicAuthToken.
 * Returns the access token.
 */
export async function loginAnthropicOAuth(
  onOpen: (url: string) => void,
  _startServer: typeof startCallbackServer = startCallbackServer,
): Promise<string> {
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = generateCodeChallenge(codeVerifier);
  const state = randomBytes(16).toString('hex');

  const { port, waitForCode } = await _startServer(OAUTH_TIMEOUT_MS);
  const redirectUri = `http://127.0.0.1:${port}/callback`;

  const authUrl = new URL(ANTHROPIC_AUTH_URL);
  authUrl.searchParams.set('response_type', 'code');
  authUrl.searchParams.set('client_id', ANTHROPIC_CLIENT_ID);
  authUrl.searchParams.set('redirect_uri', redirectUri);
  authUrl.searchParams.set('scope', ANTHROPIC_OAUTH_SCOPES);
  authUrl.searchParams.set('code_challenge', codeChallenge);
  authUrl.searchParams.set('code_challenge_method', 'S256');
  authUrl.searchParams.set('state', state);

  onOpen(authUrl.toString());

  const { code, state: returnedState } = await waitForCode;

  if (returnedState !== state) {
    throw new Error('Anthropic OAuth state mismatch — possible CSRF attack.');
  }

  const token = await exchangeCodeForToken(code, codeVerifier, redirectUri);
  storeAnthropicAuthToken(token);
  return token;
}
```

> **Test note on state:** The tests that mock `_startServer` to return `state: '__STATE__'` will fail the state check because the internally generated state won't match. Fix: in those tests (state mismatch, 403, 500), provide a custom `_startServer` that also captures the generated state. The simplest approach: generate state externally and pass it in. BUT — that breaks encapsulation. Instead, make the state-checking tests use a spy approach:

Update the state-mismatch test to use a mock that returns the wrong state explicitly:
```typescript
// state mismatch test: mock returns a state that won't match the generated one
mockStartCallbackServer.mockResolvedValue({
  port: 9999,
  waitForCode: Promise.resolve({ code: 'code', state: 'DEFINITELY-WRONG-STATE' }),
});
```

For the 403 and 500 tests, we need the returned state to match. Use a custom `_startServer` that captures the state:
```typescript
// In the 403 test, pass a custom _startServer:
const customStartServer = async (_timeout: number) => {
  // We don't know the state yet — use a trick: return a promise that resolves later
  // Actually simplest: just test exchangeCodeForToken directly (separate test)
  // and test loginAnthropicOAuth with a helper that bypasses state check
};
```

**Simpler approach:** Test `exchangeCodeForToken` directly (not through `loginAnthropicOAuth`) for the 403 and 500 cases. Only the state mismatch test needs `loginAnthropicOAuth`. The token exchange error tests should call `exchangeCodeForToken` directly:

```typescript
it('exchangeCodeForToken throws subscription message on 403', async () => {
  mockFetch.mockResolvedValue({
    ok: false, status: 403, text: async () => 'Forbidden',
  });
  const { exchangeCodeForToken } = await import('./anthropic.js');
  await expect(
    exchangeCodeForToken('code', 'verifier', 'http://127.0.0.1:1234/callback'),
  ).rejects.toThrow(/Pro\/Max subscription/);
});

it('exchangeCodeForToken throws with status on 500', async () => {
  mockFetch.mockResolvedValue({
    ok: false, status: 500, text: async () => 'Server error',
  });
  const { exchangeCodeForToken } = await import('./anthropic.js');
  await expect(
    exchangeCodeForToken('code', 'verifier', 'http://127.0.0.1:1234/callback'),
  ).rejects.toThrow(/500/);
});

it('exchangeCodeForToken throws when no access_token in response', async () => {
  mockFetch.mockResolvedValue({
    ok: true, json: async () => ({ something_else: 'x' }),
  });
  const { exchangeCodeForToken } = await import('./anthropic.js');
  await expect(
    exchangeCodeForToken('code', 'verifier', 'http://127.0.0.1:1234/callback'),
  ).rejects.toThrow(/no access_token/);
});
```

Rewrite the `loginAnthropicOAuth` tests accordingly (keep only: success path, state mismatch).

**Step 4: Run tests to verify they pass**

```bash
cd /home/will/lab/flynn && pnpm test:run src/auth/anthropic.test.ts
```

Expected: all PASS.

**Step 5: Run typecheck**

```bash
cd /home/will/lab/flynn && pnpm typecheck
```

Expected: no errors.

**Step 6: Commit**

```bash
cd /home/will/lab/flynn && git add src/auth/anthropic.ts src/auth/anthropic.test.ts && git commit -m "feat(auth): implement Anthropic OAuth PKCE browser flow"
```

---

### Task 3: Wire browser OAuth into TUI `/login anthropic` option 2

**Files:**
- Modify: `src/frontends/tui/minimal.ts`
- Modify: `src/frontends/tui/minimal.test.ts`

**Context:** The current Anthropic branch (around line 1255) offers:
```
  1) Paste API key  2) Paste auth token
```
Option 2 needs to become "Browser OAuth". The existing paste-token block (lines 1264–1304) is replaced entirely.

**Step 1: Write the failing test**

In `src/frontends/tui/minimal.test.ts`, add at the top (with other `vi.hoisted` mocks):

```typescript
const { mockLoginAnthropicOAuth } = vi.hoisted(() => ({
  mockLoginAnthropicOAuth: vi.fn(),
}));
```

Add to the `vi.mock` for `../../auth/index.js` (or wherever `loginAnthropicOAuth` is imported from in minimal.ts — it will be from `../../auth/anthropic.js`):

```typescript
vi.mock('../../auth/anthropic.js', async (importOriginal) => {
  const actual = await importOriginal<typeof import('../../auth/anthropic.js')>();
  return {
    ...actual,
    loginAnthropicOAuth: mockLoginAnthropicOAuth,
  };
});
```

Add a test (check existing `minimal.login.test.ts` for pattern — the login tests may be in a separate file):

```typescript
describe('handleLoginCommand anthropic browser oauth', () => {
  it('calls loginAnthropicOAuth when user selects option 2', async () => {
    mockLoginAnthropicOAuth.mockResolvedValue('tok-from-browser');
    // ... set up MinimalTui with a prompt mock that answers '2'
    // ... trigger handleLoginCommand('anthropic')
    // ... assert mockLoginAnthropicOAuth was called
    // (follow existing login test patterns in the file)
  });
});
```

**Note:** Check `src/frontends/tui/minimal.login.test.ts` — login tests may live there. Add the test in whichever file already tests `handleLoginCommand`.

**Step 2: Run to verify test fails**

```bash
cd /home/will/lab/flynn && pnpm test:run src/frontends/tui/minimal.login.test.ts
```

Expected: FAIL.

**Step 3: Update `minimal.ts` imports**

Find the import of `storeAnthropicAuth`, `storeAnthropicAuthToken`, etc. near the top of `minimal.ts`. Add `loginAnthropicOAuth` and `openBrowser` to that import:

```typescript
import {
  // ... existing imports ...
  loginAnthropicOAuth,
  openBrowser,
} from '../../auth/anthropic.js';
```

**Step 4: Replace option 2 in the Anthropic branch**

Find and replace these lines in `handleLoginCommand` (around line 1256–1257):

```typescript
console.log(`${colors.gray}Anthropic login:${colors.reset}`);
console.log(`${colors.gray}  1) Paste API key  2) Paste auth token${colors.reset}`);
```

Change to:

```typescript
console.log(`${colors.gray}Anthropic login:${colors.reset}`);
console.log(`${colors.gray}  1) Paste API key  2) Browser OAuth (Claude Pro/Max)${colors.reset}`);
```

Replace the option 2 block (lines ~1264–1304, the "Paste auth token" path) with:

```typescript
// 2) Browser OAuth
if (choice === '2') {
  if (hasToken) {
    console.log(`${colors.gray}Anthropic auth token already exists.${colors.reset}`);
    if (!await confirmReplace()) {
      console.log(`${colors.gray}Cancelled.${colors.reset}\n`);
      return;
    }
  }

  console.log(`${colors.gray}Starting Anthropic browser OAuth...${colors.reset}`);

  let credentialStored = false;
  try {
    await loginAnthropicOAuth((url) => {
      openBrowser(url);
      console.log(`${colors.gray}Opening browser. If it didn't open, visit:${colors.reset}`);
      console.log(url);
      console.log(`${colors.gray}Waiting for authentication (up to 5 minutes)...${colors.reset}`);
    });
    console.log(`${colors.gray}Anthropic auth token stored in ~/.config/flynn/auth.json${colors.reset}\n`);
    credentialStored = true;
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    console.log(`${colors.gray}Anthropic OAuth failed:${colors.reset} ${message}\n`);
  }

  // Offer to set auth_mode if config is available
  if (credentialStored && this.config.currentConfig && this.config.configPath) {
    const modeInput = (await this.prompt(
      `${colors.orange}Set active auth mode?${colors.reset} ${colors.gray}[api_key/oauth/auto/skip] (default: skip):${colors.reset} `,
    )).trim().toLowerCase();
    if (modeInput === 'api_key' || modeInput === 'oauth' || modeInput === 'auto') {
      const updated = applyAuthModeToConfig(this.config.currentConfig, 'anthropic', modeInput);
      persistConfig(this.config.configPath, updated);
      console.log(`${colors.gray}auth_mode set to ${modeInput}. Restart Flynn to apply.${colors.reset}\n`);
    }
  }

  return;
}
```

**Step 5: Run tests**

```bash
cd /home/will/lab/flynn && pnpm test:run src/frontends/tui/minimal.login.test.ts
```

Expected: PASS.

**Step 6: Run full suite and typecheck**

```bash
cd /home/will/lab/flynn && pnpm test:run && pnpm typecheck
```

**Step 7: Commit**

```bash
cd /home/will/lab/flynn && git add src/frontends/tui/minimal.ts src/frontends/tui/minimal.login.test.ts && git commit -m "feat(tui): replace Anthropic token paste with browser OAuth flow"
```

---

### Task 4: Add `--browser` flag to `flynn anthropic-auth` CLI

**Files:**
- Modify: `src/cli/anthropic-auth.ts`
- Modify: `src/cli/anthropic-auth.test.ts`

**Context:** `anthropic-auth.ts` currently has `type AnthropicAuthMode = 'api' | 'token'`. We add `'browser'`. The `--browser` flag is shorthand for `--mode browser` (mirrors `--token` → `--mode token`).

**Step 1: Write the failing tests**

Add to `src/cli/anthropic-auth.test.ts`:

```typescript
// Add to vi.hoisted at the top:
const { mockLoginAnthropicOAuth: mockCLILoginAnthropicOAuth, mockOpenBrowser } = vi.hoisted(() => ({
  mockLoginAnthropicOAuth: vi.fn(),
  mockOpenBrowser: vi.fn(),
}));

// Add to the vi.mock block (or add a new one):
vi.mock('../auth/anthropic.js', async (importOriginal) => {
  const actual = await importOriginal<typeof import('../auth/anthropic.js')>();
  return {
    ...actual,
    loginAnthropicOAuth: mockCLILoginAnthropicOAuth,
    openBrowser: mockOpenBrowser,
  };
});
```

Add test cases inside `describe('anthropic-auth command', ...)`:

```typescript
it('--browser triggers browser OAuth flow', async () => {
  mockCLILoginAnthropicOAuth.mockResolvedValue('tok-browser');
  mockLoadStoredAnthropicAuthToken.mockReturnValue(null);

  const program = new Command();
  const { registerAnthropicAuthCommand } = await import('./anthropic-auth.js');
  registerAnthropicAuthCommand(program);

  const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined);

  await program.parseAsync(['node', 'test', 'anthropic-auth', '--browser']);

  expect(mockCLILoginAnthropicOAuth).toHaveBeenCalled();
  consoleLog.mockRestore();
});

it('--browser with existing token prompts for confirmation', async () => {
  mockLoadStoredAnthropicAuthToken.mockReturnValue('existing-tok');
  mockReadlineAnswers(['n']);

  const program = new Command();
  const { registerAnthropicAuthCommand } = await import('./anthropic-auth.js');
  registerAnthropicAuthCommand(program);

  const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined);
  const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
    throw new Error(`EXIT:${code ?? 0}`);
  }) as never);

  await expect(program.parseAsync(['node', 'test', 'anthropic-auth', '--browser'])).rejects.toThrow('EXIT:0');
  expect(mockCLILoginAnthropicOAuth).not.toHaveBeenCalled();

  exitSpy.mockRestore();
  consoleLog.mockRestore();
});

it('--mode browser triggers browser OAuth flow', async () => {
  mockCLILoginAnthropicOAuth.mockResolvedValue('tok-mode-browser');
  mockLoadStoredAnthropicAuthToken.mockReturnValue(null);

  const program = new Command();
  const { registerAnthropicAuthCommand } = await import('./anthropic-auth.js');
  registerAnthropicAuthCommand(program);

  const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined);

  await program.parseAsync(['node', 'test', 'anthropic-auth', '--mode', 'browser']);

  expect(mockCLILoginAnthropicOAuth).toHaveBeenCalled();
  consoleLog.mockRestore();
});

it('--browser and --token conflict', async () => {
  const program = new Command();
  const { registerAnthropicAuthCommand } = await import('./anthropic-auth.js');
  registerAnthropicAuthCommand(program);

  await expect(
    program.parseAsync(['node', 'test', 'anthropic-auth', '--browser', '--token']),
  ).rejects.toThrow(/Conflicting/);
});
```

**Step 2: Run to verify they fail**

```bash
cd /home/will/lab/flynn && pnpm test:run src/cli/anthropic-auth.test.ts
```

Expected: FAIL.

**Step 3: Update `anthropic-auth.ts`**

Add `loginAnthropicOAuth` and `openBrowser` imports from `'../auth/index.js'` (check what's exported from auth/index.ts — may need to add exports there too).

Change the type:
```typescript
type AnthropicAuthMode = 'api' | 'token' | 'browser';
```

Update `parseAnthropicAuthMode`:
```typescript
function parseAnthropicAuthMode(value: string): AnthropicAuthMode {
  const mode = value.trim().toLowerCase();
  if (mode === 'api' || mode === 'token' || mode === 'browser') {
    return mode;
  }
  throw new Error(`Invalid mode "${value}". Expected: api, token, or browser.`);
}
```

Update `resolveAuthMode` to handle `--browser`:
```typescript
function resolveAuthMode(opts: { token?: boolean; browser?: boolean; mode?: AnthropicAuthMode }): AnthropicAuthMode {
  if (opts.mode) {
    if (opts.token && opts.mode !== 'token') {
      throw new Error('Conflicting options: --token implies --mode token.');
    }
    if (opts.browser && opts.mode !== 'browser') {
      throw new Error('Conflicting options: --browser implies --mode browser.');
    }
    return opts.mode;
  }
  if (opts.token && opts.browser) {
    throw new Error('Conflicting options: --token and --browser cannot be used together.');
  }
  if (opts.browser) return 'browser';
  if (opts.token) return 'token';
  return 'api';
}
```

Add `--browser` option to the command:
```typescript
.option('--browser', 'Obtain auth token via browser OAuth flow (Claude Pro/Max)')
```

Add `browser` mode handler in the action:
```typescript
if (mode === 'browser') {
  if (loadStoredAnthropicAuthToken()) {
    console.log('Anthropic auth token already exists.');
    const confirmed = await promptYesNo('Re-authenticate and replace it? (y/N): ');
    if (!confirmed) {
      console.log('Cancelled.');
      process.exit(0);
    }
  }

  console.log('Starting Anthropic browser OAuth...');
  try {
    await loginAnthropicOAuth((url) => {
      openBrowser(url);
      console.log(`If browser didn't open, visit:\n${url}`);
      console.log('Waiting for authentication...');
    });
    console.log('');
    console.log('Anthropic auth token stored in ~/.config/flynn/auth.json');
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    console.error(`Anthropic browser OAuth failed: ${message}`);
    process.exit(1);
  }
  return;
}
```

Also check `src/auth/index.ts` — if `loginAnthropicOAuth` and `openBrowser` aren't re-exported there, add them.

**Step 4: Run tests**

```bash
cd /home/will/lab/flynn && pnpm test:run src/cli/anthropic-auth.test.ts
```

Expected: all PASS.

**Step 5: Run full suite and typecheck**

```bash
cd /home/will/lab/flynn && pnpm test:run && pnpm typecheck
```

**Step 6: Commit**

```bash
cd /home/will/lab/flynn && git add src/cli/anthropic-auth.ts src/cli/anthropic-auth.test.ts src/auth/index.ts && git commit -m "feat(cli): add --browser flag to anthropic-auth command"
```

---

### Verification

```bash
pnpm test:run     # all pass
pnpm typecheck    # no errors
pnpm lint         # no new errors
```

Manual smoke test:
1. `pnpm tui` → `/login anthropic` → choose `2` → browser opens to `claude.ai/oauth/authorize`
2. `flynn anthropic-auth --browser` → same flow from CLI
3. `flynn anthropic-auth --browser --token` → error: conflicting options
