feat(tui): implement /login <provider> mode <value> 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 <noreply@anthropic.com>
This commit is contained in:
William Valentin
2026-02-26 09:35:50 -08:00
parent c456d34bf1
commit 7d0c59b16f
2 changed files with 173 additions and 3 deletions
+63 -1
View File
@@ -1,5 +1,9 @@
import { describe, it, expect, vi } from 'vitest'; 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 { ModelConfig } from '../../config/schema.js';
import type { ManagedSession } from '../../session/index.js'; import type { ManagedSession } from '../../session/index.js';
import type { ModelClient } from '../../models/types.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);
});
});
+110 -2
View File
@@ -31,6 +31,41 @@ import { getElevationStatusMessage, setElevationFromInput } from '../../security
export { parseCommand, type Command }; export { parseCommand, type Command };
/** Providers that honour auth_mode at runtime. All others get a warning. */
export const AUTH_MODE_PROVIDERS: ReadonlySet<string> = 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<string, ModelConfig> = {};
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 // ANSI color codes
const colors = { const colors = {
reset: '\x1b[0m', reset: '\x1b[0m',
@@ -537,7 +572,7 @@ export class MinimalTui {
break; break;
case 'login': case 'login':
await this.handleLoginCommand(command.provider); await this.handleLoginCommand(command.provider, command.mode);
break; break;
case 'pair': case 'pair':
@@ -983,7 +1018,32 @@ export class MinimalTui {
} }
} }
private async handleLoginCommand(provider?: string): Promise<void> { private async handleLoginCommand(
provider?: string,
mode?: 'api_key' | 'oauth' | 'auto',
): Promise<void> {
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 target = provider ?? 'github';
const confirmReplace = async (): Promise<boolean> => { const confirmReplace = async (): Promise<boolean> => {
const answer = (await this.prompt( const answer = (await this.prompt(
@@ -1131,6 +1191,18 @@ export class MinimalTui {
this.rl.resume(); 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; return;
} }
@@ -1161,6 +1233,18 @@ export class MinimalTui {
console.log(`${colors.gray}OpenAI login failed:${colors.reset} ${message}\n`); 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; return;
} }
@@ -1199,6 +1283,18 @@ export class MinimalTui {
this.rl.resume(); 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; return;
} }
@@ -1228,6 +1324,18 @@ export class MinimalTui {
this.rl.resume(); 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; return;
} }