diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index d14a789d10..55ccc7438f 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -582,6 +582,13 @@ Logging in with Google... Restarting Gemini CLI to continue. settings.merged.security?.auth?.selectedType && !settings.merged.security?.auth?.useExternal ) { + // We skip validation for Gemini API key here because it might be stored + // in the keychain, which we can't check synchronously. + // The useAuth hook handles validation for this case. + if (settings.merged.security.auth.selectedType === AuthType.USE_GEMINI) { + return; + } + const error = validateAuthMethod( settings.merged.security.auth.selectedType, ); diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx index 1250c1d54e..a17c2708bf 100644 --- a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx @@ -12,8 +12,18 @@ import { useTextBuffer, type TextBuffer, } from '../components/shared/text-buffer.js'; +import { clearApiKey } from '@google/gemini-cli-core'; // Mocks +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + clearApiKey: vi.fn().mockResolvedValue(undefined), + }; +}); + vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn(), })); @@ -37,7 +47,8 @@ describe('ApiAuthDialog', () => { let mockBuffer: TextBuffer; beforeEach(() => { - vi.resetAllMocks(); + vi.clearAllMocks(); + vi.stubEnv('GEMINI_API_KEY', ''); mockBuffer = { text: '', lines: [''], @@ -91,7 +102,9 @@ describe('ApiAuthDialog', () => { ({ keyName, sequence, expectedCall, args }) => { mockBuffer.text = 'submitted-key'; // Set for the onSubmit case render(); - const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + // calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler) + // calls[1] is the TextInput's useKeypress (typing handler) + const keypressHandler = mockedUseKeypress.mock.calls[1][0]; keypressHandler({ name: keyName, @@ -117,4 +130,20 @@ describe('ApiAuthDialog', () => { expect(lastFrame()).toContain('Invalid API Key'); }); + + it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => { + render(); + // calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler) + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + + await keypressHandler({ + name: 'c', + ctrl: true, + meta: false, + shift: false, + }); + + expect(clearApiKey).toHaveBeenCalled(); + expect(mockBuffer.setText).toHaveBeenCalledWith(''); + }); }); diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.tsx index a1723efa2f..6345599634 100644 --- a/packages/cli/src/ui/auth/ApiAuthDialog.tsx +++ b/packages/cli/src/ui/auth/ApiAuthDialog.tsx @@ -5,11 +5,15 @@ */ import type React from 'react'; +import { useRef, useEffect } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { TextInput } from '../components/shared/TextInput.js'; import { useTextBuffer } from '../components/shared/text-buffer.js'; import { useUIState } from '../contexts/UIStateContext.js'; +import { clearApiKey, debugLogger } from '@google/gemini-cli-core'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { keyMatchers, Command } from '../keyMatchers.js'; interface ApiAuthDialogProps { onSubmit: (apiKey: string) => void; @@ -27,9 +31,20 @@ export function ApiAuthDialog({ const { mainAreaWidth } = useUIState(); const viewportWidth = mainAreaWidth - 8; + const pendingPromise = useRef<{ cancel: () => void } | null>(null); + + useEffect( + () => () => { + pendingPromise.current?.cancel(); + }, + [], + ); + + const initialApiKey = defaultValue; + const buffer = useTextBuffer({ - initialText: defaultValue || '', - initialCursorOffset: defaultValue?.length || 0, + initialText: initialApiKey || '', + initialCursorOffset: initialApiKey?.length || 0, viewport: { width: viewportWidth, height: 4, @@ -44,6 +59,41 @@ export function ApiAuthDialog({ onSubmit(value); }; + const handleClear = () => { + pendingPromise.current?.cancel(); + + let isCancelled = false; + const wrappedPromise = new Promise((resolve, reject) => { + clearApiKey().then( + () => !isCancelled && resolve(), + (error) => !isCancelled && reject(error), + ); + }); + + pendingPromise.current = { + cancel: () => { + isCancelled = true; + }, + }; + + return wrappedPromise + .then(() => { + buffer.setText(''); + }) + .catch((err) => { + debugLogger.debug('Failed to clear API key:', err); + }); + }; + + useKeypress( + async (key) => { + if (keyMatchers[Command.CLEAR_INPUT](key)) { + await handleClear(); + } + }, + { isActive: true }, + ); + return ( - (Press Enter to submit, Esc to cancel) + (Press Enter to submit, Esc to cancel, Ctrl+C to clear stored key) diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index eb6ac512b1..16f0f9cbe8 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -232,6 +232,21 @@ describe('AuthDialog', () => { ); }); + it('skips API key dialog if env var is present but empty', async () => { + mockedValidateAuthMethod.mockReturnValue(null); + process.env['GEMINI_API_KEY'] = ''; // Empty string + // props.settings.merged.security.auth.selectedType is undefined here + + renderWithProviders(); + const { onSelect: handleAuthSelect } = + mockedRadioButtonSelect.mock.calls[0][0]; + await handleAuthSelect(AuthType.USE_GEMINI); + + expect(props.setAuthState).toHaveBeenCalledWith( + AuthState.Unauthenticated, + ); + }); + it('shows API key dialog on initial setup if no env var is present', async () => { mockedValidateAuthMethod.mockReturnValue(null); // process.env['GEMINI_API_KEY'] is not set @@ -247,7 +262,7 @@ describe('AuthDialog', () => { ); }); - it('shows API key dialog on re-auth to allow editing', async () => { + it('skips API key dialog on re-auth if env var is present (cannot edit)', async () => { mockedValidateAuthMethod.mockReturnValue(null); process.env['GEMINI_API_KEY'] = 'test-key-from-env'; // Simulate that the user has already authenticated once @@ -260,7 +275,7 @@ describe('AuthDialog', () => { await handleAuthSelect(AuthType.USE_GEMINI); expect(props.setAuthState).toHaveBeenCalledWith( - AuthState.AwaitingApiKeyInput, + AuthState.Unauthenticated, ); }); diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index a1ee28ec91..b133acf52b 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -116,9 +116,6 @@ export function AuthDialog({ return; } if (authType) { - const isInitialAuthSelection = - !settings.merged.security?.auth?.selectedType; - await clearCachedCredentialFile(); settings.setValue(scope, 'security.auth.selectedType', authType); @@ -135,7 +132,7 @@ export function AuthDialog({ } if (authType === AuthType.USE_GEMINI) { - if (isInitialAuthSelection && process.env['GEMINI_API_KEY']) { + if (process.env['GEMINI_API_KEY'] !== undefined) { setAuthState(AuthState.Unauthenticated); return; } else { diff --git a/packages/cli/src/ui/auth/__snapshots__/ApiAuthDialog.test.tsx.snap b/packages/cli/src/ui/auth/__snapshots__/ApiAuthDialog.test.tsx.snap index 5f0f58b6b4..3b89525633 100644 --- a/packages/cli/src/ui/auth/__snapshots__/ApiAuthDialog.test.tsx.snap +++ b/packages/cli/src/ui/auth/__snapshots__/ApiAuthDialog.test.tsx.snap @@ -12,7 +12,7 @@ exports[`ApiAuthDialog > renders correctly 1`] = ` │ │ Paste your API key here │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ │ │ -│ (Press Enter to submit, Esc to cancel) │ +│ (Press Enter to submit, Esc to cancel, Ctrl+C to clear stored key) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; diff --git a/packages/cli/src/ui/auth/useAuth.test.tsx b/packages/cli/src/ui/auth/useAuth.test.tsx index 89560e58f7..c606bc76de 100644 --- a/packages/cli/src/ui/auth/useAuth.test.tsx +++ b/packages/cli/src/ui/auth/useAuth.test.tsx @@ -40,8 +40,8 @@ vi.mock('../../config/auth.js', () => ({ describe('useAuth', () => { beforeEach(() => { vi.resetAllMocks(); - process.env['GEMINI_API_KEY'] = ''; - process.env['GEMINI_DEFAULT_AUTH_TYPE'] = ''; + delete process.env['GEMINI_API_KEY']; + delete process.env['GEMINI_DEFAULT_AUTH_TYPE']; }); afterEach(() => { diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index ec7365074c..004e362d10 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -55,11 +55,15 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => { ); const reloadApiKey = useCallback(async () => { + const envKey = process.env['GEMINI_API_KEY']; + if (envKey !== undefined) { + setApiKeyDefaultValue(envKey); + return envKey; + } + const storedKey = (await loadApiKey()) ?? ''; - const envKey = process.env['GEMINI_API_KEY'] ?? ''; - const key = envKey || storedKey; - setApiKeyDefaultValue(key); - return key; // Return the key for immediate use + setApiKeyDefaultValue(storedKey); + return storedKey; }, []); useEffect(() => { diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 2d04e533db..325009db67 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -166,6 +166,7 @@ export const DialogManager = ({ return ( { const geminiApiKey = - (await loadApiKey()) || process.env['GEMINI_API_KEY'] || undefined; + process.env['GEMINI_API_KEY'] || (await loadApiKey()) || undefined; const googleApiKey = process.env['GOOGLE_API_KEY'] || undefined; const googleCloudProject = process.env['GOOGLE_CLOUD_PROJECT'] ||