diff --git a/src/cli/anthropic-auth.test.ts b/src/cli/anthropic-auth.test.ts index f4d6e97..8b9e4a0 100644 --- a/src/cli/anthropic-auth.test.ts +++ b/src/cli/anthropic-auth.test.ts @@ -6,11 +6,15 @@ const { mockLoadStoredAnthropicAuthToken, mockStoreAnthropicAuth, mockStoreAnthropicAuthToken, + mockLoginAnthropicOAuth, + mockOpenBrowser, } = vi.hoisted(() => ({ mockLoadStoredAnthropicAuth: vi.fn(), mockLoadStoredAnthropicAuthToken: vi.fn(), mockStoreAnthropicAuth: vi.fn(), mockStoreAnthropicAuthToken: vi.fn(), + mockLoginAnthropicOAuth: vi.fn(), + mockOpenBrowser: vi.fn(), })); const { mockCreateInterface } = vi.hoisted(() => ({ @@ -20,6 +24,8 @@ const { mockCreateInterface } = vi.hoisted(() => ({ vi.mock('../auth/index.js', () => ({ loadStoredAnthropicAuth: mockLoadStoredAnthropicAuth, loadStoredAnthropicAuthToken: mockLoadStoredAnthropicAuthToken, + loginAnthropicOAuth: mockLoginAnthropicOAuth, + openBrowser: mockOpenBrowser, storeAnthropicAuth: mockStoreAnthropicAuth, storeAnthropicAuthToken: mockStoreAnthropicAuthToken, })); @@ -181,4 +187,70 @@ describe('anthropic-auth command', () => { program.parseAsync(['node', 'test', 'anthropic-auth', '--token', '--mode', 'api']), ).rejects.toThrow(/Conflicting options/); }); + + it('--browser triggers browser OAuth flow', async () => { + mockLoginAnthropicOAuth.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(mockLoginAnthropicOAuth).toHaveBeenCalled(); + expect(consoleLog).toHaveBeenCalledWith('Anthropic auth token stored in ~/.config/flynn/auth.json'); + + consoleLog.mockRestore(); + }); + + it('--browser with existing token prompts for confirmation and cancels on no', 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(mockLoginAnthropicOAuth).not.toHaveBeenCalled(); + expect(consoleLog).toHaveBeenCalledWith('Cancelled.'); + + exitSpy.mockRestore(); + consoleLog.mockRestore(); + }); + + it('--mode browser triggers browser OAuth flow', async () => { + mockLoginAnthropicOAuth.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(mockLoginAnthropicOAuth).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/); + }); }); diff --git a/src/cli/anthropic-auth.ts b/src/cli/anthropic-auth.ts index fa1f93d..e39d9f5 100644 --- a/src/cli/anthropic-auth.ts +++ b/src/cli/anthropic-auth.ts @@ -3,11 +3,13 @@ import readline from 'readline'; import { loadStoredAnthropicAuth, loadStoredAnthropicAuthToken, + loginAnthropicOAuth, + openBrowser, storeAnthropicAuth, storeAnthropicAuthToken, } from '../auth/index.js'; -type AnthropicAuthMode = 'api' | 'token'; +type AnthropicAuthMode = 'api' | 'token' | 'browser'; async function promptHidden(question: string): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true }); @@ -43,22 +45,27 @@ async function promptYesNo(question: string): Promise { function parseAnthropicAuthMode(value: string): AnthropicAuthMode { const mode = value.trim().toLowerCase(); - if (mode === 'api' || mode === 'token') { + if (mode === 'api' || mode === 'token' || mode === 'browser') { return mode; } - throw new Error(`Invalid mode "${value}". Expected: api or token.`); + throw new Error(`Invalid mode "${value}". Expected: api, token, or browser.`); } -function resolveAuthMode(opts: { token?: boolean; mode?: AnthropicAuthMode }): AnthropicAuthMode { +function resolveAuthMode(opts: { token?: boolean; browser?: boolean; mode?: AnthropicAuthMode }): AnthropicAuthMode { + if (opts.token && opts.browser) { + throw new Error('Conflicting options: --token and --browser cannot be used together.'); + } if (opts.mode) { if (opts.token && opts.mode !== 'token') { - throw new Error('Conflicting options: --token implies --mode token, but --mode api was provided.'); + throw new Error('Conflicting options: --token implies --mode token, but a different mode was provided.'); + } + if (opts.browser && opts.mode !== 'browser') { + throw new Error('Conflicting options: --browser implies --mode browser, but a different mode was provided.'); } return opts.mode; } - if (opts.token) { - return 'token'; - } + if (opts.browser) return 'browser'; + if (opts.token) return 'token'; return 'api'; } @@ -66,11 +73,39 @@ export function registerAnthropicAuthCommand(program: Command): void { program .command('anthropic-auth') .description('Store an Anthropic API key or auth token (auth.json)') - .option('--mode ', 'Credential mode: api or token', parseAnthropicAuthMode) + .option('--mode ', 'Credential mode: api, token, or browser', parseAnthropicAuthMode) .option('--token', 'Store an Anthropic auth token instead of an API key') - .action(async (opts: { token?: boolean; mode?: AnthropicAuthMode }) => { + .option('--browser', 'Obtain auth token via browser OAuth flow (Claude Pro/Max)') + .action(async (opts: { token?: boolean; browser?: boolean; mode?: AnthropicAuthMode }) => { const mode = resolveAuthMode(opts); + 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; + } + if (mode === 'token') { if (loadStoredAnthropicAuthToken()) { console.log('Anthropic auth token already exists.');