Files
flynn/docs/plans/2026-02-26-anthropic-oauth-browser-flow.md
T
2026-02-26 10:30:22 -08:00

30 KiB
Raw Blame History

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):

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:

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

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):

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):

/** 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:

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

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

Expected: FAIL — startCallbackServer not exported.

Step 6: Implement startCallbackServer

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

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

Expected: all PASS.

Step 8: Commit

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):

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

Then add a new describe block at the bottom:

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:

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

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:

/** 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:

// 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:

// 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:

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

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

Expected: all PASS.

Step 5: Run typecheck

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

Expected: no errors.

Step 6: Commit

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 12641304) 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):

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):

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):

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

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:

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 12561257):

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

Change to:

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 ~12641304, the "Paste auth token" path) with:

// 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

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

Expected: PASS.

Step 6: Run full suite and typecheck

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

Step 7: Commit

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:

// 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', ...):

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

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:

type AnthropicAuthMode = 'api' | 'token' | 'browser';

Update parseAnthropicAuthMode:

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:

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:

.option('--browser', 'Obtain auth token via browser OAuth flow (Claude Pro/Max)')

Add browser mode handler in the action:

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

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

Expected: all PASS.

Step 5: Run full suite and typecheck

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

Step 6: Commit

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

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