Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cli/src/gemini.renderOptions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;

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',
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
43 changes: 43 additions & 0 deletions packages/cli/src/ui/commands/authCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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,
Expand All @@ -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',
Expand Down
Loading