feat(cli): add --browser flag to anthropic-auth command

This commit is contained in:
William Valentin
2026-02-26 11:33:23 -08:00
parent 0c500855c5
commit ed66dc98d3
2 changed files with 117 additions and 10 deletions
+72
View File
@@ -6,11 +6,15 @@ const {
mockLoadStoredAnthropicAuthToken, mockLoadStoredAnthropicAuthToken,
mockStoreAnthropicAuth, mockStoreAnthropicAuth,
mockStoreAnthropicAuthToken, mockStoreAnthropicAuthToken,
mockLoginAnthropicOAuth,
mockOpenBrowser,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
mockLoadStoredAnthropicAuth: vi.fn(), mockLoadStoredAnthropicAuth: vi.fn(),
mockLoadStoredAnthropicAuthToken: vi.fn(), mockLoadStoredAnthropicAuthToken: vi.fn(),
mockStoreAnthropicAuth: vi.fn(), mockStoreAnthropicAuth: vi.fn(),
mockStoreAnthropicAuthToken: vi.fn(), mockStoreAnthropicAuthToken: vi.fn(),
mockLoginAnthropicOAuth: vi.fn(),
mockOpenBrowser: vi.fn(),
})); }));
const { mockCreateInterface } = vi.hoisted(() => ({ const { mockCreateInterface } = vi.hoisted(() => ({
@@ -20,6 +24,8 @@ const { mockCreateInterface } = vi.hoisted(() => ({
vi.mock('../auth/index.js', () => ({ vi.mock('../auth/index.js', () => ({
loadStoredAnthropicAuth: mockLoadStoredAnthropicAuth, loadStoredAnthropicAuth: mockLoadStoredAnthropicAuth,
loadStoredAnthropicAuthToken: mockLoadStoredAnthropicAuthToken, loadStoredAnthropicAuthToken: mockLoadStoredAnthropicAuthToken,
loginAnthropicOAuth: mockLoginAnthropicOAuth,
openBrowser: mockOpenBrowser,
storeAnthropicAuth: mockStoreAnthropicAuth, storeAnthropicAuth: mockStoreAnthropicAuth,
storeAnthropicAuthToken: mockStoreAnthropicAuthToken, storeAnthropicAuthToken: mockStoreAnthropicAuthToken,
})); }));
@@ -181,4 +187,70 @@ describe('anthropic-auth command', () => {
program.parseAsync(['node', 'test', 'anthropic-auth', '--token', '--mode', 'api']), program.parseAsync(['node', 'test', 'anthropic-auth', '--token', '--mode', 'api']),
).rejects.toThrow(/Conflicting options/); ).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/);
});
}); });
+45 -10
View File
@@ -3,11 +3,13 @@ import readline from 'readline';
import { import {
loadStoredAnthropicAuth, loadStoredAnthropicAuth,
loadStoredAnthropicAuthToken, loadStoredAnthropicAuthToken,
loginAnthropicOAuth,
openBrowser,
storeAnthropicAuth, storeAnthropicAuth,
storeAnthropicAuthToken, storeAnthropicAuthToken,
} from '../auth/index.js'; } from '../auth/index.js';
type AnthropicAuthMode = 'api' | 'token'; type AnthropicAuthMode = 'api' | 'token' | 'browser';
async function promptHidden(question: string): Promise<string> { async function promptHidden(question: string): Promise<string> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true }); const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
@@ -43,22 +45,27 @@ async function promptYesNo(question: string): Promise<boolean> {
function parseAnthropicAuthMode(value: string): AnthropicAuthMode { function parseAnthropicAuthMode(value: string): AnthropicAuthMode {
const mode = value.trim().toLowerCase(); const mode = value.trim().toLowerCase();
if (mode === 'api' || mode === 'token') { if (mode === 'api' || mode === 'token' || mode === 'browser') {
return mode; 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.mode) {
if (opts.token && opts.mode !== 'token') { 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; return opts.mode;
} }
if (opts.token) { if (opts.browser) return 'browser';
return 'token'; if (opts.token) return 'token';
}
return 'api'; return 'api';
} }
@@ -66,11 +73,39 @@ export function registerAnthropicAuthCommand(program: Command): void {
program program
.command('anthropic-auth') .command('anthropic-auth')
.description('Store an Anthropic API key or auth token (auth.json)') .description('Store an Anthropic API key or auth token (auth.json)')
.option('--mode <mode>', 'Credential mode: api or token', parseAnthropicAuthMode) .option('--mode <mode>', 'Credential mode: api, token, or browser', parseAnthropicAuthMode)
.option('--token', 'Store an Anthropic auth token instead of an API key') .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); 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 (mode === 'token') {
if (loadStoredAnthropicAuthToken()) { if (loadStoredAnthropicAuthToken()) {
console.log('Anthropic auth token already exists.'); console.log('Anthropic auth token already exists.');