From 7d0c59b16f152b642866d2520304a84d12d62374 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 26 Feb 2026 09:35:50 -0800 Subject: [PATCH] feat(tui): implement /login mode auth mode switching Export AUTH_MODE_PROVIDERS and applyAuthModeToConfig from minimal.ts. Wire mode fast-path into handleLoginCommand so /login anthropic mode oauth persists auth_mode to config without entering the credential flow. After successful credential entry for anthropic/openai, prompt to set auth_mode immediately. Co-Authored-By: Claude Sonnet 4.6 --- src/frontends/tui/minimal.test.ts | 64 ++++++++++++++++- src/frontends/tui/minimal.ts | 112 +++++++++++++++++++++++++++++- 2 files changed, 173 insertions(+), 3 deletions(-) diff --git a/src/frontends/tui/minimal.test.ts b/src/frontends/tui/minimal.test.ts index 5a168fc..e08ce9d 100644 --- a/src/frontends/tui/minimal.test.ts +++ b/src/frontends/tui/minimal.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, vi } from 'vitest'; -import { formatPrompt, parseCommand } from './minimal.js'; + +const { mockPersistConfig } = vi.hoisted(() => ({ mockPersistConfig: vi.fn() })); +vi.mock('../../config/persistence.js', () => ({ persistConfig: mockPersistConfig })); + +import { formatPrompt, parseCommand, applyAuthModeToConfig, AUTH_MODE_PROVIDERS } from './minimal.js'; import type { ModelConfig } from '../../config/schema.js'; import type { ManagedSession } from '../../session/index.js'; import type { ModelClient } from '../../models/types.js'; @@ -796,3 +800,61 @@ describe('MinimalTui prompt cancellation', () => { } }); }); + +describe('applyAuthModeToConfig', () => { + it('sets auth_mode on all tiers whose provider matches', () => { + const config = { + models: { + default: { provider: 'anthropic', model: 'claude-sonnet-4' }, + fast: { provider: 'openai', model: 'gpt-4o-mini' }, + complex: { provider: 'anthropic', model: 'claude-opus-4' }, + }, + } as unknown as import('../../config/schema.js').Config; + + const updated = applyAuthModeToConfig(config, 'anthropic', 'oauth'); + + expect(updated.models.default.auth_mode).toBe('oauth'); + expect(updated.models.complex!.auth_mode).toBe('oauth'); + expect((updated.models.fast as { auth_mode?: string }).auth_mode).toBeUndefined(); + }); + + it('updates local_providers entries that match', () => { + const config = { + models: { + default: { provider: 'openai', model: 'gpt-4o' }, + local_providers: { + myAnthropic: { provider: 'anthropic', model: 'claude-haiku' }, + }, + }, + } as unknown as import('../../config/schema.js').Config; + + const updated = applyAuthModeToConfig(config, 'anthropic', 'api_key'); + + expect(updated.models.local_providers!['myAnthropic'].auth_mode).toBe('api_key'); + expect((updated.models.default as { auth_mode?: string }).auth_mode).toBeUndefined(); + }); + + it('does not mutate the original config', () => { + const config = { + models: { + default: { provider: 'anthropic', model: 'claude-sonnet-4' }, + }, + } as unknown as import('../../config/schema.js').Config; + + applyAuthModeToConfig(config, 'anthropic', 'oauth'); + + expect((config.models.default as { auth_mode?: string }).auth_mode).toBeUndefined(); + }); +}); + +describe('AUTH_MODE_PROVIDERS', () => { + it('contains anthropic and openai', () => { + expect(AUTH_MODE_PROVIDERS.has('anthropic')).toBe(true); + expect(AUTH_MODE_PROVIDERS.has('openai')).toBe(true); + }); + + it('does not contain zhipuai, gemini, etc.', () => { + expect(AUTH_MODE_PROVIDERS.has('zhipuai')).toBe(false); + expect(AUTH_MODE_PROVIDERS.has('gemini')).toBe(false); + }); +}); diff --git a/src/frontends/tui/minimal.ts b/src/frontends/tui/minimal.ts index e2ba707..7c4322f 100644 --- a/src/frontends/tui/minimal.ts +++ b/src/frontends/tui/minimal.ts @@ -31,6 +31,41 @@ import { getElevationStatusMessage, setElevationFromInput } from '../../security export { parseCommand, type Command }; +/** Providers that honour auth_mode at runtime. All others get a warning. */ +export const AUTH_MODE_PROVIDERS: ReadonlySet = new Set(['anthropic', 'openai']); + +/** + * Return a new Config with auth_mode set on every model tier whose provider + * matches targetProvider. Does not mutate the original. + */ +export function applyAuthModeToConfig( + config: Config, + targetProvider: string, + mode: 'api_key' | 'oauth' | 'auto', +): Config { + const applyToTier = (tier: ModelConfig): ModelConfig => + tier.provider === targetProvider ? { ...tier, auth_mode: mode } : tier; + + const updatedModels = { ...config.models }; + + updatedModels.default = applyToTier(updatedModels.default); + for (const key of ['fast', 'complex', 'local'] as const) { + const tier = updatedModels[key]; + if (tier) { + updatedModels[key] = applyToTier(tier); + } + } + if (updatedModels.local_providers) { + const updatedLocalProviders: Record = {}; + for (const [name, tier] of Object.entries(updatedModels.local_providers)) { + updatedLocalProviders[name] = applyToTier(tier); + } + updatedModels.local_providers = updatedLocalProviders; + } + + return { ...config, models: updatedModels }; +} + // ANSI color codes const colors = { reset: '\x1b[0m', @@ -537,7 +572,7 @@ export class MinimalTui { break; case 'login': - await this.handleLoginCommand(command.provider); + await this.handleLoginCommand(command.provider, command.mode); break; case 'pair': @@ -983,7 +1018,32 @@ export class MinimalTui { } } - private async handleLoginCommand(provider?: string): Promise { + private async handleLoginCommand( + provider?: string, + mode?: 'api_key' | 'oauth' | 'auto', + ): Promise { + if (mode !== undefined) { + const resolvedProvider = provider ?? 'anthropic'; + if (!AUTH_MODE_PROVIDERS.has(resolvedProvider)) { + console.log( + `${colors.gray}auth_mode has no effect for ${resolvedProvider}. ` + + `It is only supported for: ${[...AUTH_MODE_PROVIDERS].join(', ')}.${colors.reset}\n`, + ); + return; + } + if (!this.config.currentConfig || !this.config.configPath) { + console.log(`${colors.gray}Config not available — cannot persist auth_mode.${colors.reset}\n`); + return; + } + const updated = applyAuthModeToConfig(this.config.currentConfig, resolvedProvider, mode); + persistConfig(this.config.configPath, updated); + console.log( + `${colors.gray}auth_mode for ${resolvedProvider} set to ${colors.reset}${mode}` + + `${colors.gray}. Restart Flynn for the change to take effect.${colors.reset}\n`, + ); + return; + } + const target = provider ?? 'github'; const confirmReplace = async (): Promise => { const answer = (await this.prompt( @@ -1131,6 +1191,18 @@ export class MinimalTui { this.rl.resume(); } + // Offer to set auth_mode if config is available + if (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, 'openai', modeInput); + persistConfig(this.config.configPath, updated); + console.log(`${colors.gray}auth_mode set to ${modeInput}. Restart Flynn to apply.${colors.reset}\n`); + } + } + return; } @@ -1161,6 +1233,18 @@ export class MinimalTui { console.log(`${colors.gray}OpenAI login failed:${colors.reset} ${message}\n`); } + // Offer to set auth_mode if config is available + if (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, 'openai', modeInput); + persistConfig(this.config.configPath, updated); + console.log(`${colors.gray}auth_mode set to ${modeInput}. Restart Flynn to apply.${colors.reset}\n`); + } + } + return; } @@ -1199,6 +1283,18 @@ export class MinimalTui { this.rl.resume(); } + // Offer to set auth_mode if config is available + if (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; } @@ -1228,6 +1324,18 @@ export class MinimalTui { this.rl.resume(); } + // Offer to set auth_mode if config is available + if (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; }