# 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 { 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( '' + '

Authentication complete

' + '

You can close this tab and return to Flynn.

' + '', ); 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 ``` 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 { 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; 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 { 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(); 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(); 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