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

904 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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