From 6e63e00b84936cc6188e86ddf82e30afbc13509c Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 26 Feb 2026 10:30:22 -0800 Subject: [PATCH] docs: Anthropic OAuth browser flow implementation plan --- ...2026-02-26-anthropic-oauth-browser-flow.md | 903 ++++++++++++++++++ 1 file changed, 903 insertions(+) create mode 100644 docs/plans/2026-02-26-anthropic-oauth-browser-flow.md diff --git a/docs/plans/2026-02-26-anthropic-oauth-browser-flow.md b/docs/plans/2026-02-26-anthropic-oauth-browser-flow.md new file mode 100644 index 0000000..eacad5b --- /dev/null +++ b/docs/plans/2026-02-26-anthropic-oauth-browser-flow.md @@ -0,0 +1,903 @@ +# 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