feat(tui): replace Anthropic token paste with browser OAuth flow
This commit is contained in:
@@ -7,7 +7,14 @@ export {
|
||||
storeAnthropicAuthToken,
|
||||
clearAnthropicAuthToken,
|
||||
getAnthropicAuthToken,
|
||||
generateCodeVerifier,
|
||||
generateCodeChallenge,
|
||||
startCallbackServer,
|
||||
openBrowser,
|
||||
exchangeCodeForToken,
|
||||
loginAnthropicOAuth,
|
||||
type AnthropicAuthInfo,
|
||||
type CallbackServer,
|
||||
} from './anthropic.js';
|
||||
|
||||
export {
|
||||
|
||||
@@ -7,10 +7,14 @@ const {
|
||||
mockLoadStoredAnthropicAuth,
|
||||
mockLoadStoredAnthropicAuthToken,
|
||||
mockStoreAnthropicAuth,
|
||||
mockLoginAnthropicOAuth,
|
||||
mockOpenBrowser,
|
||||
} = vi.hoisted(() => ({
|
||||
mockLoadStoredAnthropicAuth: vi.fn(),
|
||||
mockLoadStoredAnthropicAuthToken: vi.fn(),
|
||||
mockStoreAnthropicAuth: vi.fn(),
|
||||
mockLoginAnthropicOAuth: vi.fn(),
|
||||
mockOpenBrowser: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockCreateInterface } = vi.hoisted(() => ({
|
||||
@@ -23,8 +27,10 @@ vi.mock('../../auth/index.js', () => ({
|
||||
loadStoredOpenAIApiKey: vi.fn(),
|
||||
loadStoredOpenAIAuth: vi.fn(),
|
||||
loadStoredZaiAuth: vi.fn(),
|
||||
loginAnthropicOAuth: mockLoginAnthropicOAuth,
|
||||
loginGitHub: vi.fn(),
|
||||
loginOpenAI: vi.fn(),
|
||||
openBrowser: mockOpenBrowser,
|
||||
storeAnthropicAuth: mockStoreAnthropicAuth,
|
||||
storeAnthropicAuthToken: vi.fn(),
|
||||
storeOpenAIApiKey: vi.fn(),
|
||||
@@ -152,3 +158,115 @@ describe('MinimalTui login re-auth confirmation', () => {
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MinimalTui anthropic browser OAuth', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockLoadStoredAnthropicAuth.mockReturnValue(null);
|
||||
mockLoadStoredAnthropicAuthToken.mockReturnValue(null);
|
||||
mockCreateInterface.mockReset();
|
||||
});
|
||||
|
||||
it('calls loginAnthropicOAuth when user selects option 2', async () => {
|
||||
mockLoginAnthropicOAuth.mockResolvedValue('tok-from-browser');
|
||||
|
||||
const { MinimalTui } = await import('./minimal.js');
|
||||
|
||||
const mockSession = {
|
||||
id: 'test',
|
||||
getHistory: () => [],
|
||||
addMessage: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
replaceHistory: vi.fn(),
|
||||
};
|
||||
|
||||
const tui = new MinimalTui({
|
||||
session: asSession(mockSession),
|
||||
modelClient: asModelClient({}),
|
||||
modelRouter: asModelRouter({}),
|
||||
systemPrompt: 'test',
|
||||
});
|
||||
|
||||
minimalTuiPrivates(tui).rl = { pause: vi.fn(), resume: vi.fn() };
|
||||
vi.spyOn(minimalTuiPrivates(tui), 'prompt').mockResolvedValue('2');
|
||||
|
||||
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
|
||||
await minimalTuiPrivates(tui).handleLoginCommand('anthropic');
|
||||
|
||||
expect(mockLoginAnthropicOAuth).toHaveBeenCalled();
|
||||
expect(consoleLog).toHaveBeenCalledWith(expect.stringContaining('auth token stored'));
|
||||
|
||||
consoleLog.mockRestore();
|
||||
});
|
||||
|
||||
it('shows error message when OAuth fails', async () => {
|
||||
mockLoginAnthropicOAuth.mockRejectedValue(new Error('OAuth timed out'));
|
||||
|
||||
const { MinimalTui } = await import('./minimal.js');
|
||||
|
||||
const mockSession = {
|
||||
id: 'test',
|
||||
getHistory: () => [],
|
||||
addMessage: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
replaceHistory: vi.fn(),
|
||||
};
|
||||
|
||||
const tui = new MinimalTui({
|
||||
session: asSession(mockSession),
|
||||
modelClient: asModelClient({}),
|
||||
modelRouter: asModelRouter({}),
|
||||
systemPrompt: 'test',
|
||||
});
|
||||
|
||||
minimalTuiPrivates(tui).rl = { pause: vi.fn(), resume: vi.fn() };
|
||||
vi.spyOn(minimalTuiPrivates(tui), 'prompt').mockResolvedValue('2');
|
||||
|
||||
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
|
||||
await minimalTuiPrivates(tui).handleLoginCommand('anthropic');
|
||||
|
||||
expect(mockLoginAnthropicOAuth).toHaveBeenCalled();
|
||||
expect(consoleLog).toHaveBeenCalledWith(
|
||||
expect.stringContaining('OAuth failed'),
|
||||
);
|
||||
|
||||
consoleLog.mockRestore();
|
||||
});
|
||||
|
||||
it('cancels when token exists and user answers no', async () => {
|
||||
mockLoadStoredAnthropicAuthToken.mockReturnValue('existing-tok');
|
||||
|
||||
const { MinimalTui } = await import('./minimal.js');
|
||||
|
||||
const mockSession = {
|
||||
id: 'test',
|
||||
getHistory: () => [],
|
||||
addMessage: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
replaceHistory: vi.fn(),
|
||||
};
|
||||
|
||||
const tui = new MinimalTui({
|
||||
session: asSession(mockSession),
|
||||
modelClient: asModelClient({}),
|
||||
modelRouter: asModelRouter({}),
|
||||
systemPrompt: 'test',
|
||||
});
|
||||
|
||||
minimalTuiPrivates(tui).rl = { pause: vi.fn(), resume: vi.fn() };
|
||||
vi.spyOn(minimalTuiPrivates(tui), 'prompt')
|
||||
.mockResolvedValueOnce('2') // choose option 2
|
||||
.mockResolvedValueOnce('n'); // decline re-auth
|
||||
|
||||
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
|
||||
await minimalTuiPrivates(tui).handleLoginCommand('anthropic');
|
||||
|
||||
expect(mockLoginAnthropicOAuth).not.toHaveBeenCalled();
|
||||
expect(consoleLog).toHaveBeenCalledWith(expect.stringContaining('Cancelled.'));
|
||||
|
||||
consoleLog.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,8 +16,10 @@ import {
|
||||
loadStoredOpenAIApiKey,
|
||||
loadStoredOpenAIAuth,
|
||||
loadStoredZaiAuth,
|
||||
loginAnthropicOAuth,
|
||||
loginGitHub,
|
||||
loginOpenAI,
|
||||
openBrowser,
|
||||
storeAnthropicAuth,
|
||||
storeAnthropicAuthToken,
|
||||
storeOpenAIApiKey,
|
||||
@@ -1254,14 +1256,14 @@ export class MinimalTui {
|
||||
|
||||
if (target === 'anthropic') {
|
||||
console.log(`${colors.gray}Anthropic login:${colors.reset}`);
|
||||
console.log(`${colors.gray} 1) Paste API key 2) Paste auth token${colors.reset}`);
|
||||
console.log(`${colors.gray} 1) Paste API key 2) Browser OAuth (Claude Pro/Max)${colors.reset}`);
|
||||
const choice = (await this.prompt(`${colors.orange}Choose [1-2] (default 1):${colors.reset} `)).trim();
|
||||
|
||||
const existing = loadStoredAnthropicAuth();
|
||||
const hasApiKey = Boolean(existing?.api_key);
|
||||
const hasToken = Boolean(loadStoredAnthropicAuthToken());
|
||||
|
||||
// 2) Auth token
|
||||
// 2) Browser OAuth
|
||||
if (choice === '2') {
|
||||
if (hasToken) {
|
||||
console.log(`${colors.gray}Anthropic auth token already exists.${colors.reset}`);
|
||||
@@ -1271,22 +1273,21 @@ export class MinimalTui {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`${colors.gray}Anthropic supports token-style auth (provider-specific).${colors.reset}`);
|
||||
console.log('');
|
||||
console.log(`${colors.gray}Starting Anthropic browser OAuth...${colors.reset}`);
|
||||
|
||||
let credentialStored = false;
|
||||
try {
|
||||
this.rl.pause();
|
||||
const token = await promptHidden('Enter Anthropic auth token: ');
|
||||
storeAnthropicAuthToken(token);
|
||||
console.log('');
|
||||
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 auth failed:${colors.reset} ${message}\n`);
|
||||
} finally {
|
||||
this.rl.resume();
|
||||
console.log(`${colors.gray}Anthropic OAuth failed:${colors.reset} ${message}\n`);
|
||||
}
|
||||
|
||||
// Offer to set auth_mode if config is available
|
||||
|
||||
Reference in New Issue
Block a user