docs: Anthropic OAuth browser flow implementation plan

This commit is contained in:
William Valentin
2026-02-26 10:30:22 -08:00
parent 8d41d14266
commit 6e63e00b84
@@ -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<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 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):
```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 12561257):
```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 ~12641304, 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