feat(cli): add --browser flag to anthropic-auth command
This commit is contained in:
@@ -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
@@ -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.');
|
||||||
|
|||||||
Reference in New Issue
Block a user