diff --git a/packages/cli/src/gemini.renderOptions.test.tsx b/packages/cli/src/gemini.renderOptions.test.tsx index 6febf6493..713b9167c 100644 --- a/packages/cli/src/gemini.renderOptions.test.tsx +++ b/packages/cli/src/gemini.renderOptions.test.tsx @@ -90,6 +90,7 @@ describe('startInteractiveUI ink render options', () => { debugMode: false, model: 'gemini-2.5-flash-lite', accessibility: { screenReader: false }, + continueSession: false, }); await startInteractiveUI(config, createLoadedSettings(), [], tempDir); @@ -117,6 +118,7 @@ describe('startInteractiveUI ink render options', () => { debugMode: false, model: 'gemini-2.5-flash-lite', accessibility: { screenReader: true }, + continueSession: false, }); await startInteractiveUI(config, createLoadedSettings(), [], tempDir); diff --git a/packages/cli/src/ui/commands/__tests__/authCommand.autoswitch.spec.ts b/packages/cli/src/ui/commands/__tests__/authCommand.autoswitch.spec.ts new file mode 100644 index 000000000..2dd7bd570 --- /dev/null +++ b/packages/cli/src/ui/commands/__tests__/authCommand.autoswitch.spec.ts @@ -0,0 +1,191 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { AuthCommandExecutor } from '../authCommand.js'; +import { OAuthManager } from '../../../auth/oauth-manager.js'; +import { CommandContext } from '../types.js'; + +// Mock the runtime settings module (partial mock) +vi.mock('../../../runtime/runtimeSettings.js', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('../../../runtime/runtimeSettings.js') + >(); + return { + ...actual, + switchActiveProvider: vi.fn(), + getEphemeralSetting: vi.fn(), + }; +}); + +import { + switchActiveProvider, + getEphemeralSetting, +} from '../../../runtime/runtimeSettings.js'; + +const mockSwitchActiveProvider = switchActiveProvider as ReturnType< + typeof vi.fn +>; +const mockGetEphemeralSetting = getEphemeralSetting as ReturnType; + +describe('Auth Command Auto-Switch Integration', () => { + let executor: AuthCommandExecutor; + let mockOAuthManager: OAuthManager; + let mockContext: CommandContext; + + beforeEach(() => { + vi.clearAllMocks(); + + mockOAuthManager = { + authenticate: vi.fn().mockResolvedValue(undefined), + getSupportedProviders: vi + .fn() + .mockReturnValue(['gemini', 'qwen', 'anthropic', 'codex']), + } as unknown as OAuthManager; + + executor = new AuthCommandExecutor(mockOAuthManager); + + mockContext = { + services: { + config: null, + settings: {} as never, + git: undefined, + logger: {} as never, + }, + ui: {} as never, + session: {} as never, + }; + + // Default: auto-switch enabled + mockGetEphemeralSetting.mockReturnValue(true); + mockSwitchActiveProvider.mockResolvedValue({ + changed: true, + previousProvider: 'gemini', + nextProvider: 'anthropic', + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('@requirement REQ-001: Auto-switch after auth', () => { + it('switches provider after successful OAuth login', async () => { + const result = await executor.execute(mockContext, 'anthropic login'); + + expect(mockOAuthManager.authenticate).toHaveBeenCalledWith( + 'anthropic', + undefined, + ); + expect(mockSwitchActiveProvider).toHaveBeenCalledWith('anthropic'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: '[OK] Authenticated with anthropic and set as active provider', + }); + }); + + it('includes bucket info in success message', async () => { + const result = await executor.execute( + mockContext, + 'anthropic login work', + ); + + expect(mockOAuthManager.authenticate).toHaveBeenCalledWith( + 'anthropic', + 'work', + ); + expect(mockSwitchActiveProvider).toHaveBeenCalledWith('anthropic'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + '[OK] Authenticated with anthropic (bucket: work) and set as active provider', + }); + }); + }); + + describe('@requirement REQ-008: Configurable auto-switch', () => { + it('skips auto-switch when setting is disabled', async () => { + mockGetEphemeralSetting.mockReturnValue(false); + + const result = await executor.execute(mockContext, 'anthropic login'); + + expect(mockOAuthManager.authenticate).toHaveBeenCalledWith( + 'anthropic', + undefined, + ); + expect(mockSwitchActiveProvider).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Successfully authenticated anthropic', + }); + }); + + it('defaults to enabled when setting is undefined', async () => { + mockGetEphemeralSetting.mockReturnValue(undefined); + + await executor.execute(mockContext, 'anthropic login'); + + expect(mockSwitchActiveProvider).toHaveBeenCalledWith('anthropic'); + }); + }); + + describe('@requirement REQ-010: Graceful error handling', () => { + it('succeeds auth even when switch fails', async () => { + mockSwitchActiveProvider.mockRejectedValue( + new Error('Provider not found'), + ); + + const result = await executor.execute(mockContext, 'anthropic login'); + + expect(mockOAuthManager.authenticate).toHaveBeenCalled(); + expect(result.type).toBe('message'); + expect((result as { messageType: string }).messageType).toBe('info'); + expect((result as { content: string }).content).toContain( + 'Successfully authenticated', + ); + expect((result as { content: string }).content).toContain( + 'auto-switch to provider failed', + ); + }); + }); + + describe('@requirement REQ-004.2: Override existing provider', () => { + it('switches even when provider already set', async () => { + mockSwitchActiveProvider.mockResolvedValue({ + changed: true, + previousProvider: 'openai', + nextProvider: 'anthropic', + }); + + const result = await executor.execute(mockContext, 'anthropic login'); + + expect(mockSwitchActiveProvider).toHaveBeenCalledWith('anthropic'); + expect((result as { content: string }).content).toContain( + 'set as active provider', + ); + }); + + it('shows simple message when already on same provider', async () => { + mockSwitchActiveProvider.mockResolvedValue({ + changed: false, + previousProvider: 'anthropic', + nextProvider: 'anthropic', + }); + + const result = await executor.execute(mockContext, 'anthropic login'); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Successfully authenticated anthropic', + }); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/__tests__/authCommand.bucket.spec.ts b/packages/cli/src/ui/commands/__tests__/authCommand.bucket.spec.ts index ee20d7790..ac24a1f06 100644 --- a/packages/cli/src/ui/commands/__tests__/authCommand.bucket.spec.ts +++ b/packages/cli/src/ui/commands/__tests__/authCommand.bucket.spec.ts @@ -18,6 +18,23 @@ import { AuthCommandExecutor } from '../authCommand.js'; import { OAuthManager } from '../../../auth/oauth-manager.js'; import { CommandContext } from '../types.js'; +// Mock the runtime settings module to avoid needing full CLI runtime context +vi.mock('../../../runtime/runtimeSettings.js', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('../../../runtime/runtimeSettings.js') + >(); + return { + ...actual, + switchActiveProvider: vi.fn().mockResolvedValue({ + changed: false, + previousProvider: 'anthropic', + nextProvider: 'anthropic', + }), + getEphemeralSetting: vi.fn().mockReturnValue(true), + }; +}); + // Mock OAuth manager for bucket operations const mockOAuthManager = { registerProvider: vi.fn(), diff --git a/packages/cli/src/ui/commands/authCommand.ts b/packages/cli/src/ui/commands/authCommand.ts index 78f2b304d..e61b70e69 100644 --- a/packages/cli/src/ui/commands/authCommand.ts +++ b/packages/cli/src/ui/commands/authCommand.ts @@ -26,6 +26,10 @@ import { type CompleterFn, } from './schema/types.js'; import { withFuzzyFilter } from '../utils/fuzzyFilter.js'; +import { + switchActiveProvider, + getEphemeralSetting, +} from '../../runtime/runtimeSettings.js'; const logger = new DebugLogger('llxprt:ui:auth-command'); @@ -404,6 +408,7 @@ export class AuthCommandExecutor { /** * Login to a provider with optional bucket parameter + * @requirement REQ-001 Auto-switch provider after successful auth */ private async loginWithBucket( provider: string, @@ -414,6 +419,44 @@ export class AuthCommandExecutor { await this.oauthManager.authenticate(provider, bucket); const bucketInfo = bucket ? ` (bucket: ${bucket})` : ''; + + // Check if auto-switch is enabled (default: true) + const autoSwitchEnabled = + getEphemeralSetting('auth.autoSwitchProvider') ?? true; + + if (autoSwitchEnabled) { + try { + // Attempt to switch to the authenticated provider + const switchResult = await switchActiveProvider(provider); + + if (switchResult.changed) { + return { + type: 'message', + messageType: 'info', + content: `[OK] Authenticated with ${provider}${bucketInfo} and set as active provider`, + }; + } + // Provider was already active, just show auth success + return { + type: 'message', + messageType: 'info', + content: `Successfully authenticated ${provider}${bucketInfo}`, + }; + } catch (switchError) { + // Log warning but don't fail auth + logger.debug( + `Auto-switch to ${provider} failed after auth:`, + switchError, + ); + return { + type: 'message', + messageType: 'info', + content: `Successfully authenticated ${provider}${bucketInfo} (Note: auto-switch to provider failed, use /provider ${provider} to switch manually)`, + }; + } + } + + // Auto-switch disabled, just return auth success return { type: 'message', messageType: 'info',