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:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user