diff --git a/packages/cli/src/ui/commands/authCommand.test.ts b/packages/cli/src/ui/commands/authCommand.test.ts index 6df88c3f79..ba1e369b14 100644 --- a/packages/cli/src/ui/commands/authCommand.test.ts +++ b/packages/cli/src/ui/commands/authCommand.test.ts @@ -4,19 +4,44 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { authCommand } from './authCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { SettingScope } from '../../config/settings.js'; + +vi.mock('@google/gemini-cli-core', async () => { + const actual = await vi.importActual('@google/gemini-cli-core'); + return { + ...actual, + clearCachedCredentialFile: vi.fn().mockResolvedValue(undefined), + }; +}); describe('authCommand', () => { let mockContext: CommandContext; beforeEach(() => { - mockContext = createMockCommandContext(); + mockContext = createMockCommandContext({ + services: { + config: { + getGeminiClient: vi.fn(), + }, + }, + }); + // Add setValue mock to settings + mockContext.services.settings.setValue = vi.fn(); + vi.clearAllMocks(); }); - it('should return a dialog action to open the auth dialog', () => { + it('should have subcommands: login and logout', () => { + expect(authCommand.subCommands).toBeDefined(); + expect(authCommand.subCommands).toHaveLength(2); + expect(authCommand.subCommands?.[0]?.name).toBe('login'); + expect(authCommand.subCommands?.[1]?.name).toBe('logout'); + }); + + it('should return a dialog action to open the auth dialog when called with no args', () => { if (!authCommand.action) { throw new Error('The auth command must have an action.'); } @@ -31,6 +56,76 @@ describe('authCommand', () => { it('should have the correct name and description', () => { expect(authCommand.name).toBe('auth'); - expect(authCommand.description).toBe('Change the auth method'); + expect(authCommand.description).toBe('Manage authentication'); + }); + + describe('auth login subcommand', () => { + it('should return auth dialog action', () => { + const loginCommand = authCommand.subCommands?.[0]; + expect(loginCommand?.name).toBe('login'); + const result = loginCommand!.action!(mockContext, ''); + expect(result).toEqual({ type: 'dialog', dialog: 'auth' }); + }); + }); + + describe('auth logout subcommand', () => { + it('should clear cached credentials', async () => { + const logoutCommand = authCommand.subCommands?.[1]; + expect(logoutCommand?.name).toBe('logout'); + + const { clearCachedCredentialFile } = await import( + '@google/gemini-cli-core' + ); + + await logoutCommand!.action!(mockContext, ''); + + expect(clearCachedCredentialFile).toHaveBeenCalledOnce(); + }); + + it('should clear selectedAuthType setting', async () => { + const logoutCommand = authCommand.subCommands?.[1]; + + await logoutCommand!.action!(mockContext, ''); + + expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'security.auth.selectedType', + undefined, + ); + }); + + it('should strip thoughts from history', async () => { + const logoutCommand = authCommand.subCommands?.[1]; + const mockStripThoughts = vi.fn(); + const mockClient = { + stripThoughtsFromHistory: mockStripThoughts, + } as unknown as ReturnType< + NonNullable['getGeminiClient'] + >; + + if (mockContext.services.config) { + mockContext.services.config.getGeminiClient = vi.fn(() => mockClient); + } + + await logoutCommand!.action!(mockContext, ''); + + expect(mockStripThoughts).toHaveBeenCalled(); + }); + + it('should return logout action to signal explicit state change', async () => { + const logoutCommand = authCommand.subCommands?.[1]; + const result = await logoutCommand!.action!(mockContext, ''); + + expect(result).toEqual({ type: 'logout' }); + }); + + it('should handle missing config gracefully', async () => { + const logoutCommand = authCommand.subCommands?.[1]; + mockContext.services.config = null; + + const result = await logoutCommand!.action!(mockContext, ''); + + expect(result).toEqual({ type: 'logout' }); + }); }); }); diff --git a/packages/cli/src/ui/commands/authCommand.ts b/packages/cli/src/ui/commands/authCommand.ts index dfb113e504..0314555baf 100644 --- a/packages/cli/src/ui/commands/authCommand.ts +++ b/packages/cli/src/ui/commands/authCommand.ts @@ -4,12 +4,18 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { OpenDialogActionReturn, SlashCommand } from './types.js'; +import type { + OpenDialogActionReturn, + SlashCommand, + LogoutActionReturn, +} from './types.js'; import { CommandKind } from './types.js'; +import { clearCachedCredentialFile } from '@google/gemini-cli-core'; +import { SettingScope } from '../../config/settings.js'; -export const authCommand: SlashCommand = { - name: 'auth', - description: 'Change the auth method', +const authLoginCommand: SlashCommand = { + name: 'login', + description: 'Login or change the auth method', kind: CommandKind.BUILT_IN, autoExecute: true, action: (_context, _args): OpenDialogActionReturn => ({ @@ -17,3 +23,34 @@ export const authCommand: SlashCommand = { dialog: 'auth', }), }; + +const authLogoutCommand: SlashCommand = { + name: 'logout', + description: 'Log out and clear all cached credentials', + kind: CommandKind.BUILT_IN, + action: async (context, _args): Promise => { + await clearCachedCredentialFile(); + // Clear the selected auth type so user sees the auth selection menu + context.services.settings.setValue( + SettingScope.User, + 'security.auth.selectedType', + undefined, + ); + // Strip thoughts from history instead of clearing completely + context.services.config?.getGeminiClient()?.stripThoughtsFromHistory(); + // Return logout action to signal explicit state change + return { + type: 'logout', + }; + }, +}; + +export const authCommand: SlashCommand = { + name: 'auth', + description: 'Manage authentication', + kind: CommandKind.BUILT_IN, + subCommands: [authLoginCommand, authLogoutCommand], + action: (context, args) => + // Default to login if no subcommand is provided + authLoginCommand.action!(context, args), +}; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 99d98f4009..6f00695dc4 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -142,13 +142,22 @@ export interface OpenCustomDialogActionReturn { component: ReactNode; } +/** + * The return type for a command action that specifically handles logout logic, + * signaling the application to explicitly transition to an unauthenticated state. + */ +export interface LogoutActionReturn { + type: 'logout'; +} + export type SlashCommandActionReturn = | CommandActionReturn | QuitActionReturn | OpenDialogActionReturn | ConfirmShellCommandsActionReturn | ConfirmActionReturn - | OpenCustomDialogActionReturn; + | OpenCustomDialogActionReturn + | LogoutActionReturn; export enum CommandKind { BUILT_IN = 'built-in', diff --git a/packages/cli/src/ui/components/LogoutConfirmationDialog.test.tsx b/packages/cli/src/ui/components/LogoutConfirmationDialog.test.tsx new file mode 100644 index 0000000000..f51116f5e7 --- /dev/null +++ b/packages/cli/src/ui/components/LogoutConfirmationDialog.test.tsx @@ -0,0 +1,82 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../test-utils/render.js'; +import { act } from 'react'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { + LogoutConfirmationDialog, + LogoutChoice, +} from './LogoutConfirmationDialog.js'; +import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; + +vi.mock('./shared/RadioButtonSelect.js', () => ({ + RadioButtonSelect: vi.fn(() => null), +})); + +describe('LogoutConfirmationDialog', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render the dialog with title, description, and hint', () => { + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('You are now logged out.'); + expect(lastFrame()).toContain( + 'Login again to continue using Gemini CLI, or exit the application.', + ); + expect(lastFrame()).toContain('(Use Enter to select, Esc to close)'); + }); + + it('should render RadioButtonSelect with Login and Exit options', () => { + renderWithProviders(); + + expect(RadioButtonSelect).toHaveBeenCalled(); + const mockCall = vi.mocked(RadioButtonSelect).mock.calls[0][0]; + expect(mockCall.items).toEqual([ + { label: 'Login', value: LogoutChoice.LOGIN, key: 'login' }, + { label: 'Exit', value: LogoutChoice.EXIT, key: 'exit' }, + ]); + expect(mockCall.isFocused).toBe(true); + }); + + it('should call onSelect with LOGIN when Login is selected', () => { + const onSelect = vi.fn(); + renderWithProviders(); + + const mockCall = vi.mocked(RadioButtonSelect).mock.calls[0][0]; + mockCall.onSelect(LogoutChoice.LOGIN); + + expect(onSelect).toHaveBeenCalledWith(LogoutChoice.LOGIN); + }); + + it('should call onSelect with EXIT when Exit is selected', () => { + const onSelect = vi.fn(); + renderWithProviders(); + + const mockCall = vi.mocked(RadioButtonSelect).mock.calls[0][0]; + mockCall.onSelect(LogoutChoice.EXIT); + + expect(onSelect).toHaveBeenCalledWith(LogoutChoice.EXIT); + }); + + it('should call onSelect with EXIT when escape key is pressed', () => { + const onSelect = vi.fn(); + const { stdin } = renderWithProviders( + , + ); + + act(() => { + // Send kitty escape key sequence + stdin.write('\u001b[27u'); + }); + + expect(onSelect).toHaveBeenCalledWith(LogoutChoice.EXIT); + }); +}); diff --git a/packages/cli/src/ui/components/LogoutConfirmationDialog.tsx b/packages/cli/src/ui/components/LogoutConfirmationDialog.tsx new file mode 100644 index 0000000000..97c73a96ed --- /dev/null +++ b/packages/cli/src/ui/components/LogoutConfirmationDialog.tsx @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import type React from 'react'; +import { theme } from '../semantic-colors.js'; +import type { RadioSelectItem } from './shared/RadioButtonSelect.js'; +import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; +import { useKeypress } from '../hooks/useKeypress.js'; + +export enum LogoutChoice { + LOGIN = 'login', + EXIT = 'exit', +} + +interface LogoutConfirmationDialogProps { + onSelect: (choice: LogoutChoice) => void; +} + +export const LogoutConfirmationDialog: React.FC< + LogoutConfirmationDialogProps +> = ({ onSelect }) => { + // Handle escape key to exit (consistent with other dialogs) + useKeypress( + (key) => { + if (key.name === 'escape') { + onSelect(LogoutChoice.EXIT); + } + }, + { isActive: true }, + ); + + const options: Array> = [ + { + label: 'Login', + value: LogoutChoice.LOGIN, + key: 'login', + }, + { + label: 'Exit', + value: LogoutChoice.EXIT, + key: 'exit', + }, + ]; + + return ( + + + + + You are now logged out. + + + Login again to continue using Gemini CLI, or exit the application. + + + + + + + + (Use Enter to select, Esc to close) + + + + + ); +}; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 8296b88947..f85ec21c6b 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -4,7 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useCallback, useMemo, useEffect, useState } from 'react'; +import { + useCallback, + useMemo, + useEffect, + useState, + createElement, +} from 'react'; import { type PartListUnion } from '@google/genai'; import process from 'node:process'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; @@ -45,6 +51,11 @@ import { } from '../state/extensions.js'; import { appEvents } from '../../utils/events.js'; import { useAlternateBuffer } from './useAlternateBuffer.js'; +import { + LogoutConfirmationDialog, + LogoutChoice, +} from '../components/LogoutConfirmationDialog.js'; +import { runExitCleanup } from '../../utils/cleanup.js'; interface SlashCommandProcessorActions { openAuthDialog: () => void; @@ -400,6 +411,22 @@ export const useSlashCommandProcessor = ( Date.now(), ); return { type: 'handled' }; + case 'logout': + // Show logout confirmation dialog with Login/Exit options + setCustomDialog( + createElement(LogoutConfirmationDialog, { + onSelect: async (choice: LogoutChoice) => { + setCustomDialog(null); + if (choice === LogoutChoice.LOGIN) { + actions.openAuthDialog(); + } else { + await runExitCleanup(); + process.exit(0); + } + }, + }), + ); + return { type: 'handled' }; case 'dialog': switch (result.dialog) { case 'auth':