diff --git a/integration-tests/globalSetup.ts b/integration-tests/globalSetup.ts index 4fab2e261c..189d15f188 100644 --- a/integration-tests/globalSetup.ts +++ b/integration-tests/globalSetup.ts @@ -52,6 +52,8 @@ export async function setup() { process.env['INTEGRATION_TEST_FILE_DIR'] = runDir; process.env['GEMINI_CLI_INTEGRATION_TEST'] = 'true'; + // Force file storage to avoid keychain prompts/hangs in CI, especially on macOS + process.env['GEMINI_FORCE_FILE_STORAGE'] = 'true'; process.env['TELEMETRY_LOG_FILE'] = join(runDir, 'telemetry.log'); if (process.env['KEEP_OUTPUT']) { diff --git a/packages/cli/src/config/auth.test.ts b/packages/cli/src/config/auth.test.ts index faae662825..ae1af240a3 100644 --- a/packages/cli/src/config/auth.test.ts +++ b/packages/cli/src/config/auth.test.ts @@ -44,11 +44,7 @@ describe('validateAuthMethod', () => { it('should return an error message if GEMINI_API_KEY is not set', () => { vi.stubEnv('GEMINI_API_KEY', undefined); - expect(validateAuthMethod(AuthType.USE_GEMINI)).toBe( - 'GEMINI_API_KEY not found. Find your existing key or generate a new one at: https://aistudio.google.com/apikey\n' + - '\n' + - 'To continue, please set the GEMINI_API_KEY environment variable or add it to a .env file.', - ); + expect(validateAuthMethod(AuthType.USE_GEMINI)).toBeNull(); }); }); diff --git a/packages/cli/src/config/auth.ts b/packages/cli/src/config/auth.ts index 4a0fd4fce0..7492e09b7b 100644 --- a/packages/cli/src/config/auth.ts +++ b/packages/cli/src/config/auth.ts @@ -17,13 +17,6 @@ export function validateAuthMethod(authMethod: string): string | null { } if (authMethod === AuthType.USE_GEMINI) { - if (!process.env['GEMINI_API_KEY']) { - return ( - 'GEMINI_API_KEY not found. Find your existing key or generate a new one at: https://aistudio.google.com/apikey\n' + - '\n' + - 'To continue, please set the GEMINI_API_KEY environment variable or add it to a .env file.' - ); - } return null; } diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index eef68e4e03..8ba5fb3ed8 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -44,6 +44,7 @@ import { clearCachedCredentialFile, recordExitFail, ShellExecutionService, + saveApiKey, debugLogger, coreEvents, CoreEvent, @@ -356,10 +357,14 @@ export const AppContainer = (props: AppContainerProps) => { initializationResult.themeError, ); - const { authState, setAuthState, authError, onAuthError } = useAuthCommand( - settings, - config, - ); + const { + authState, + setAuthState, + authError, + onAuthError, + apiKeyDefaultValue, + reloadApiKey, + } = useAuthCommand(settings, config); const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({ config, @@ -408,6 +413,34 @@ Logging in with Google... Please restart Gemini CLI to continue. [settings, config, setAuthState, onAuthError], ); + const handleApiKeySubmit = useCallback( + async (apiKey: string) => { + try { + if (!apiKey.trim() && apiKey.length > 1) { + onAuthError( + 'API key cannot be empty string with length greater than 1.', + ); + return; + } + + await saveApiKey(apiKey); + await reloadApiKey(); + await config.refreshAuth(AuthType.USE_GEMINI); + setAuthState(AuthState.Authenticated); + } catch (e) { + onAuthError( + `Failed to save API key: ${e instanceof Error ? e.message : String(e)}`, + ); + } + }, + [setAuthState, onAuthError, reloadApiKey, config], + ); + + const handleApiKeyCancel = useCallback(() => { + // Go back to auth method selection + setAuthState(AuthState.Updating); + }, [setAuthState]); + // Sync user tier from config when authentication changes useEffect(() => { // Only sync when not currently authenticating @@ -1163,7 +1196,9 @@ Logging in with Google... Please restart Gemini CLI to continue. isEditorDialogOpen || showPrivacyNotice || showIdeRestartPrompt || - !!proQuotaRequest; + !!proQuotaRequest || + isAuthDialogOpen || + authState === AuthState.AwaitingApiKeyInput; const pendingHistoryItems = useMemo( () => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems], @@ -1180,6 +1215,8 @@ Logging in with Google... Please restart Gemini CLI to continue. isConfigInitialized, authError, isAuthDialogOpen, + isAwaitingApiKeyInput: authState === AuthState.AwaitingApiKeyInput, + apiKeyDefaultValue, editorError, isEditorDialogOpen, showPrivacyNotice, @@ -1335,6 +1372,8 @@ Logging in with Google... Please restart Gemini CLI to continue. historyManager, embeddedShellFocused, showDebugProfiler, + apiKeyDefaultValue, + authState, ], ); @@ -1369,6 +1408,8 @@ Logging in with Google... Please restart Gemini CLI to continue. handleProQuotaChoice, setQueueErrorMessage, popAllMessages, + handleApiKeySubmit, + handleApiKeyCancel, }), [ handleThemeSelect, @@ -1395,6 +1436,8 @@ Logging in with Google... Please restart Gemini CLI to continue. handleProQuotaChoice, setQueueErrorMessage, popAllMessages, + handleApiKeySubmit, + handleApiKeyCancel, ], ); diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx new file mode 100644 index 0000000000..e170afb5e2 --- /dev/null +++ b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { ApiAuthDialog } from './ApiAuthDialog.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { + useTextBuffer, + type TextBuffer, +} from '../components/shared/text-buffer.js'; + +// Mocks +vi.mock('../hooks/useKeypress.js', () => ({ + useKeypress: vi.fn(), +})); + +vi.mock('../components/shared/text-buffer.js', () => ({ + useTextBuffer: vi.fn(), +})); + +vi.mock('../contexts/UIStateContext.js', () => ({ + useUIState: vi.fn(() => ({ + mainAreaWidth: 80, + })), +})); + +const mockedUseKeypress = useKeypress as Mock; +const mockedUseTextBuffer = useTextBuffer as Mock; + +describe('ApiAuthDialog', () => { + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + let mockBuffer: TextBuffer; + + beforeEach(() => { + vi.resetAllMocks(); + mockBuffer = { + text: '', + lines: [''], + cursor: [0, 0], + visualCursor: [0, 0], + viewportVisualLines: [''], + handleInput: vi.fn(), + setText: vi.fn((newText) => { + mockBuffer.text = newText; + mockBuffer.viewportVisualLines = [newText]; + }), + } as unknown as TextBuffer; + mockedUseTextBuffer.mockReturnValue(mockBuffer); + }); + + it('renders correctly', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders with a defaultValue', () => { + render( + , + ); + expect(mockedUseTextBuffer).toHaveBeenCalledWith( + expect.objectContaining({ + initialText: 'test-key', + viewport: expect.objectContaining({ + height: 4, + }), + }), + ); + }); + + it('calls onSubmit when the text input is submitted', () => { + mockBuffer.text = 'submitted-key'; + render(); + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + + keypressHandler({ + name: 'return', + sequence: '\r', + ctrl: false, + meta: false, + shift: false, + paste: false, + }); + + expect(onSubmit).toHaveBeenCalledWith('submitted-key'); + }); + + it('calls onCancel when the text input is cancelled', () => { + render(); + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + + keypressHandler({ + name: 'escape', + sequence: '\u001b', + ctrl: false, + meta: false, + shift: false, + paste: false, + }); + + expect(onCancel).toHaveBeenCalled(); + }); + + it('displays an error message', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('Invalid API Key'); + }); +}); diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.tsx new file mode 100644 index 0000000000..a1723efa2f --- /dev/null +++ b/packages/cli/src/ui/auth/ApiAuthDialog.tsx @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React 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'; + +interface ApiAuthDialogProps { + onSubmit: (apiKey: string) => void; + onCancel: () => void; + error?: string | null; + defaultValue?: string; +} + +export function ApiAuthDialog({ + onSubmit, + onCancel, + error, + defaultValue = '', +}: ApiAuthDialogProps): React.JSX.Element { + const { mainAreaWidth } = useUIState(); + const viewportWidth = mainAreaWidth - 8; + + const buffer = useTextBuffer({ + initialText: defaultValue || '', + initialCursorOffset: defaultValue?.length || 0, + viewport: { + width: viewportWidth, + height: 4, + }, + isValidPath: () => false, // No path validation needed for API key + inputFilter: (text) => + text.replace(/[^a-zA-Z0-9_-]/g, '').replace(/[\r\n]/g, ''), + singleLine: true, + }); + + const handleSubmit = (value: string) => { + onSubmit(value); + }; + + return ( + + + Enter Gemini API Key + + + + Please enter your Gemini API key. It will be securely stored in your + system keychain. + + + You can get an API key from{' '} + + https://aistudio.google.com/app/apikey + + + + + + + + + {error && ( + + {error} + + )} + + + (Press Enter to submit, Esc to cancel) + + + + ); +} diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index 5a16acd47d..0059e8202a 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -191,7 +191,7 @@ describe('AuthDialog', () => { AuthType.USE_GEMINI, ); expect(props.setAuthState).toHaveBeenCalledWith( - AuthState.Unauthenticated, + AuthState.AwaitingApiKeyInput, ); }); diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index 4c1c5b330b..c024dd255e 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -119,6 +119,10 @@ Logging in with Google... Please restart Gemini CLI to continue. process.exit(0); } } + if (authType === AuthType.USE_GEMINI) { + setAuthState(AuthState.AwaitingApiKeyInput); + return; + } setAuthState(AuthState.Unauthenticated); }, [settings, config, setAuthState], @@ -157,50 +161,52 @@ Logging in with Google... Please restart Gemini CLI to continue. return ( - - Get started - - - - How would you like to authenticate for this project? + ? + + + Get started - - - { - onAuthError(null); - }} - /> - - {authError && ( - {authError} + + How would you like to authenticate for this project? + + + + { + onAuthError(null); + }} + /> + + {authError && ( + + {authError} + + )} + + (Use Enter to select) + + + + Terms of Services and Privacy Notice for Gemini CLI + + + + + { + 'https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md' + } + - )} - - - (Use Enter to select, Esc to close) - - - - - Terms of Services and Privacy Notice for Gemini CLI - - - - - { - 'https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md' - } - ); diff --git a/packages/cli/src/ui/auth/__snapshots__/ApiAuthDialog.test.tsx.snap b/packages/cli/src/ui/auth/__snapshots__/ApiAuthDialog.test.tsx.snap new file mode 100644 index 0000000000..5f0f58b6b4 --- /dev/null +++ b/packages/cli/src/ui/auth/__snapshots__/ApiAuthDialog.test.tsx.snap @@ -0,0 +1,18 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ApiAuthDialog > renders correctly 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Enter Gemini API Key │ +│ │ +│ Please enter your Gemini API key. It will be securely stored in your system keychain. │ +│ You can get an API key from https://aistudio.google.com/app/apikey │ +│ │ +│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ +│ │ Paste your API key here │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ (Press Enter to submit, Esc to cancel) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 8ea0520610..336fe3efbe 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -6,7 +6,12 @@ import { useState, useEffect, useCallback } from 'react'; import type { LoadedSettings } from '../../config/settings.js'; -import { AuthType, debugLogger, type Config } from '@google/gemini-cli-core'; +import { + AuthType, + type Config, + loadApiKey, + debugLogger, +} from '@google/gemini-cli-core'; import { getErrorMessage } from '@google/gemini-cli-core'; import { AuthState } from '../types.js'; import { validateAuthMethod } from '../../config/auth.js'; @@ -22,6 +27,10 @@ export function validateAuthMethodWithSettings( if (settings.merged.security?.auth?.useExternal) { return null; } + // If using Gemini API key, we don't validate it here as we might need to prompt for it. + if (authType === AuthType.USE_GEMINI) { + return null; + } return validateAuthMethod(authType); } @@ -31,6 +40,9 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => { ); const [authError, setAuthError] = useState(null); + const [apiKeyDefaultValue, setApiKeyDefaultValue] = useState< + string | undefined + >(undefined); const onAuthError = useCallback( (error: string | null) => { @@ -42,6 +54,14 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => { [setAuthError, setAuthState], ); + const reloadApiKey = useCallback(async () => { + const storedKey = (await loadApiKey()) ?? ''; + const envKey = process.env['GEMINI_API_KEY'] ?? ''; + const key = storedKey || envKey; + setApiKeyDefaultValue(key); + return key; // Return the key for immediate use + }, []); + useEffect(() => { (async () => { if (authState !== AuthState.Unauthenticated) { @@ -59,6 +79,15 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => { } return; } + + if (authType === AuthType.USE_GEMINI) { + const key = await reloadApiKey(); // Use the unified function + if (!key) { + setAuthState(AuthState.AwaitingApiKeyInput); + return; + } + } + const error = validateAuthMethodWithSettings(authType, settings); if (error) { onAuthError(error); @@ -87,12 +116,22 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => { onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`); } })(); - }, [settings, config, authState, setAuthState, setAuthError, onAuthError]); + }, [ + settings, + config, + authState, + setAuthState, + setAuthError, + onAuthError, + reloadApiKey, + ]); return { authState, setAuthState, authError, onAuthError, + apiKeyDefaultValue, + reloadApiKey, }; }; diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 0e5fc47eba..4fb3a26ccf 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -14,6 +14,7 @@ import { ThemeDialog } from './ThemeDialog.js'; import { SettingsDialog } from './SettingsDialog.js'; import { AuthInProgress } from '../auth/AuthInProgress.js'; import { AuthDialog } from '../auth/AuthDialog.js'; +import { ApiAuthDialog } from '../auth/ApiAuthDialog.js'; import { EditorSettingsDialog } from './EditorSettingsDialog.js'; import { PrivacyNotice } from '../privacy/PrivacyNotice.js'; import { ProQuotaDialog } from './ProQuotaDialog.js'; @@ -150,6 +151,18 @@ export const DialogManager = ({ /> ); } + if (uiState.isAwaitingApiKeyInput) { + return ( + + + + ); + } if (uiState.isAuthDialogOpen) { return ( diff --git a/packages/cli/src/ui/components/shared/TextInput.test.tsx b/packages/cli/src/ui/components/shared/TextInput.test.tsx new file mode 100644 index 0000000000..49491b2db3 --- /dev/null +++ b/packages/cli/src/ui/components/shared/TextInput.test.tsx @@ -0,0 +1,311 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { TextInput } from './TextInput.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { useTextBuffer, type TextBuffer } from './text-buffer.js'; + +// Mocks +vi.mock('../../hooks/useKeypress.js', () => ({ + useKeypress: vi.fn(), +})); + +vi.mock('./text-buffer.js', () => { + const mockTextBuffer = { + text: '', + lines: [''], + cursor: [0, 0], + visualCursor: [0, 0], + viewportVisualLines: [''], + handleInput: vi.fn((key) => { + // Simulate basic input for testing + if (key.sequence) { + mockTextBuffer.text += key.sequence; + mockTextBuffer.viewportVisualLines = [mockTextBuffer.text]; + mockTextBuffer.visualCursor[1] = mockTextBuffer.text.length; + } else if (key.name === 'backspace') { + mockTextBuffer.text = mockTextBuffer.text.slice(0, -1); + mockTextBuffer.viewportVisualLines = [mockTextBuffer.text]; + mockTextBuffer.visualCursor[1] = mockTextBuffer.text.length; + } else if (key.name === 'left') { + mockTextBuffer.visualCursor[1] = Math.max( + 0, + mockTextBuffer.visualCursor[1] - 1, + ); + } else if (key.name === 'right') { + mockTextBuffer.visualCursor[1] = Math.min( + mockTextBuffer.text.length, + mockTextBuffer.visualCursor[1] + 1, + ); + } + }), + setText: vi.fn((newText) => { + mockTextBuffer.text = newText; + mockTextBuffer.viewportVisualLines = [newText]; + mockTextBuffer.visualCursor[1] = newText.length; + }), + }; + + return { + useTextBuffer: vi.fn(() => mockTextBuffer as unknown as TextBuffer), + TextBuffer: vi.fn(() => mockTextBuffer as unknown as TextBuffer), + }; +}); + +const mockedUseKeypress = useKeypress as Mock; +const mockedUseTextBuffer = useTextBuffer as Mock; + +describe('TextInput', () => { + const onCancel = vi.fn(); + const onSubmit = vi.fn(); + let mockBuffer: TextBuffer; + + beforeEach(() => { + vi.resetAllMocks(); + // Reset the internal state of the mock buffer for each test + const buffer = { + text: '', + lines: [''], + cursor: [0, 0], + visualCursor: [0, 0], + viewportVisualLines: [''], + handleInput: vi.fn((key) => { + if (key.sequence) { + buffer.text += key.sequence; + buffer.viewportVisualLines = [buffer.text]; + buffer.visualCursor[1] = buffer.text.length; + } else if (key.name === 'backspace') { + buffer.text = buffer.text.slice(0, -1); + buffer.viewportVisualLines = [buffer.text]; + buffer.visualCursor[1] = buffer.text.length; + } else if (key.name === 'left') { + buffer.visualCursor[1] = Math.max(0, buffer.visualCursor[1] - 1); + } else if (key.name === 'right') { + buffer.visualCursor[1] = Math.min( + buffer.text.length, + buffer.visualCursor[1] + 1, + ); + } + }), + setText: vi.fn((newText) => { + buffer.text = newText; + buffer.viewportVisualLines = [newText]; + buffer.visualCursor[1] = newText.length; + }), + }; + mockBuffer = buffer as unknown as TextBuffer; + mockedUseTextBuffer.mockReturnValue(mockBuffer); + }); + + it('renders with an initial value', () => { + const buffer = { + text: 'test', + lines: ['test'], + cursor: [0, 4], + visualCursor: [0, 4], + viewportVisualLines: ['test'], + handleInput: vi.fn(), + setText: vi.fn(), + }; + const { lastFrame } = render( + , + ); + expect(lastFrame()).toContain('test'); + }); + + it('renders a placeholder', () => { + const buffer = { + text: '', + lines: [''], + cursor: [0, 0], + visualCursor: [0, 0], + viewportVisualLines: [''], + handleInput: vi.fn(), + setText: vi.fn(), + }; + const { lastFrame } = render( + , + ); + expect(lastFrame()).toContain('testing'); + }); + + it('handles character input', () => { + render( + , + ); + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + + keypressHandler({ + name: 'a', + sequence: 'a', + ctrl: false, + meta: false, + shift: false, + paste: false, + }); + + expect(mockBuffer.handleInput).toHaveBeenCalledWith({ + name: 'a', + sequence: 'a', + ctrl: false, + meta: false, + shift: false, + paste: false, + }); + expect(mockBuffer.text).toBe('a'); + }); + + it('handles backspace', () => { + mockBuffer.setText('test'); + render( + , + ); + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + + keypressHandler({ + name: 'backspace', + sequence: '', + ctrl: false, + meta: false, + shift: false, + paste: false, + }); + + expect(mockBuffer.handleInput).toHaveBeenCalledWith({ + name: 'backspace', + sequence: '', + ctrl: false, + meta: false, + shift: false, + paste: false, + }); + expect(mockBuffer.text).toBe('tes'); + }); + + it('handles left arrow', () => { + mockBuffer.setText('test'); + render( + , + ); + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + + keypressHandler({ + name: 'left', + sequence: '', + ctrl: false, + meta: false, + shift: false, + paste: false, + }); + + // Cursor moves from end to before 't' + expect(mockBuffer.visualCursor[1]).toBe(3); + }); + + it('handles right arrow', () => { + mockBuffer.setText('test'); + mockBuffer.visualCursor[1] = 2; // Set initial cursor for right arrow test + render( + , + ); + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + + keypressHandler({ + name: 'right', + sequence: '', + ctrl: false, + meta: false, + shift: false, + paste: false, + }); + + expect(mockBuffer.visualCursor[1]).toBe(3); + }); + + it('calls onSubmit on return', () => { + mockBuffer.setText('test'); + render( + , + ); + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + + keypressHandler({ + name: 'return', + sequence: '', + ctrl: false, + meta: false, + shift: false, + paste: false, + }); + + expect(onSubmit).toHaveBeenCalledWith('test'); + }); + + it('calls onCancel on escape', async () => { + vi.useFakeTimers(); + render( + , + ); + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + + keypressHandler({ + name: 'escape', + sequence: '', + ctrl: false, + meta: false, + shift: false, + paste: false, + }); + await vi.runAllTimersAsync(); + + expect(onCancel).toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('renders the input value', () => { + mockBuffer.setText('secret'); + const { lastFrame } = render( + , + ); + expect(lastFrame()).toContain('secret'); + }); + + it('does not show cursor when not focused', () => { + mockBuffer.setText('test'); + const { lastFrame } = render( + , + ); + expect(lastFrame()).not.toContain('\u001b[7m'); // Inverse video chalk + }); + + it('renders multiple lines when text wraps', () => { + mockBuffer.text = 'line1\nline2'; + mockBuffer.viewportVisualLines = ['line1', 'line2']; + + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('line1'); + expect(lastFrame()).toContain('line2'); + }); +}); diff --git a/packages/cli/src/ui/components/shared/TextInput.tsx b/packages/cli/src/ui/components/shared/TextInput.tsx new file mode 100644 index 0000000000..e6c867f96c --- /dev/null +++ b/packages/cli/src/ui/components/shared/TextInput.tsx @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useCallback } from 'react'; +import type { Key } from '../../hooks/useKeypress.js'; +import { Text, Box } from 'ink'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import chalk from 'chalk'; +import { theme } from '../../semantic-colors.js'; +import type { TextBuffer } from './text-buffer.js'; +import { cpSlice } from '../../utils/textUtils.js'; + +export interface TextInputProps { + buffer: TextBuffer; + placeholder?: string; + onSubmit?: (value: string) => void; + onCancel?: () => void; + focus?: boolean; +} + +export function TextInput({ + buffer, + placeholder = '', + onSubmit, + onCancel, + focus = true, +}: TextInputProps): React.JSX.Element { + const { + text, + handleInput, + visualCursor, + viewportVisualLines, + visualScrollRow, + } = buffer; + const [cursorVisualRowAbsolute, cursorVisualColAbsolute] = visualCursor; + + const handleKeyPress = useCallback( + (key: Key) => { + if (key.name === 'escape') { + onCancel?.(); + return; + } + + if (key.name === 'return') { + onSubmit?.(text); + return; + } + + handleInput(key); + }, + [handleInput, onCancel, onSubmit, text], + ); + + useKeypress(handleKeyPress, { isActive: focus }); + + const showPlaceholder = text.length === 0 && placeholder; + + if (showPlaceholder) { + return ( + + {focus ? ( + + {chalk.inverse(placeholder[0] || ' ')} + {placeholder.slice(1)} + + ) : ( + {placeholder} + )} + + ); + } + + return ( + + {viewportVisualLines.map((lineText, idx) => { + const currentVisualRow = visualScrollRow + idx; + const isCursorLine = + focus && currentVisualRow === cursorVisualRowAbsolute; + + const lineDisplay = isCursorLine + ? cpSlice(lineText, 0, cursorVisualColAbsolute) + + chalk.inverse( + cpSlice( + lineText, + cursorVisualColAbsolute, + cursorVisualColAbsolute + 1, + ) || ' ', + ) + + cpSlice(lineText, cursorVisualColAbsolute + 1) + : lineText; + + return ( + + {lineDisplay} + + ); + })} + + ); +} diff --git a/packages/cli/src/ui/components/shared/text-buffer.test.ts b/packages/cli/src/ui/components/shared/text-buffer.test.ts index fa68800f87..ac828959f7 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -14,6 +14,7 @@ import type { TextBufferState, TextBufferAction, VisualLayout, + TextBufferOptions, } from './text-buffer.js'; import { useTextBuffer, @@ -101,6 +102,43 @@ describe('textBufferReducer', () => { }); }); + describe('insert action with options', () => { + it('should filter input using inputFilter option', () => { + const action: TextBufferAction = { type: 'insert', payload: 'a1b2c3' }; + const options: TextBufferOptions = { + inputFilter: (text) => text.replace(/[0-9]/g, ''), + }; + const state = textBufferReducer(initialState, action, options); + expect(state.lines).toEqual(['abc']); + expect(state.cursorCol).toBe(3); + }); + + it('should strip newlines when singleLine option is true', () => { + const action: TextBufferAction = { + type: 'insert', + payload: 'hello\nworld', + }; + const options: TextBufferOptions = { singleLine: true }; + const state = textBufferReducer(initialState, action, options); + expect(state.lines).toEqual(['helloworld']); + expect(state.cursorCol).toBe(10); + }); + + it('should apply both inputFilter and singleLine options', () => { + const action: TextBufferAction = { + type: 'insert', + payload: 'h\ne\nl\nl\no\n1\n2\n3', + }; + const options: TextBufferOptions = { + singleLine: true, + inputFilter: (text) => text.replace(/[0-9]/g, ''), + }; + const state = textBufferReducer(initialState, action, options); + expect(state.lines).toEqual(['hello']); + expect(state.cursorCol).toBe(5); + }); + }); + describe('backspace action', () => { it('should remove a character', () => { const stateWithText: TextBufferState = { @@ -1520,6 +1558,75 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots }); }); + describe('inputFilter', () => { + it('should filter input based on the provided filter function', () => { + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: () => false, + inputFilter: (text) => text.replace(/[^0-9]/g, ''), + }), + ); + + act(() => result.current.insert('a1b2c3')); + expect(getBufferState(result).text).toBe('123'); + }); + + it('should handle empty result from filter', () => { + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: () => false, + inputFilter: (text) => text.replace(/[^0-9]/g, ''), + }), + ); + + act(() => result.current.insert('abc')); + expect(getBufferState(result).text).toBe(''); + }); + + it('should filter pasted text', () => { + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: () => false, + inputFilter: (text) => text.toUpperCase(), + }), + ); + + act(() => result.current.insert('hello', { paste: true })); + expect(getBufferState(result).text).toBe('HELLO'); + }); + + it('should not filter newlines if they are allowed by the filter', () => { + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: () => false, + inputFilter: (text) => text, // Allow everything including newlines + }), + ); + + act(() => result.current.insert('a\nb')); + // The insert function splits by newline and inserts separately if it detects them. + // If the filter allows them, they should be handled correctly by the subsequent logic in insert. + expect(getBufferState(result).text).toBe('a\nb'); + }); + + it('should filter before newline check in insert', () => { + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: () => false, + inputFilter: (text) => text.replace(/\n/g, ''), // Filter out newlines + }), + ); + + act(() => result.current.insert('a\nb')); + expect(getBufferState(result).text).toBe('ab'); + }); + }); + describe('stripAnsi', () => { it('should correctly strip ANSI escape codes', () => { const textWithAnsi = '\x1B[31mHello\x1B[0m World'; @@ -1587,6 +1694,74 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots expect(getBufferState(result).text).toBe('hello world'); }); }); + + describe('singleLine mode', () => { + it('should not insert a newline character when singleLine is true', () => { + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: () => false, + singleLine: true, + }), + ); + act(() => result.current.insert('\n')); + const state = getBufferState(result); + expect(state.text).toBe(''); + expect(state.lines).toEqual(['']); + }); + + it('should not create a new line when newline() is called and singleLine is true', () => { + const { result } = renderHook(() => + useTextBuffer({ + initialText: 'ab', + viewport, + isValidPath: () => false, + singleLine: true, + }), + ); + act(() => result.current.move('end')); // cursor at [0,2] + act(() => result.current.newline()); + const state = getBufferState(result); + expect(state.text).toBe('ab'); + expect(state.lines).toEqual(['ab']); + expect(state.cursor).toEqual([0, 2]); + }); + + it('should not handle "Enter" key as newline when singleLine is true', () => { + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: () => false, + singleLine: true, + }), + ); + act(() => + result.current.handleInput({ + name: 'return', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '\r', + }), + ); + expect(getBufferState(result).lines).toEqual(['']); + }); + + it('should strip newlines from pasted text when singleLine is true', () => { + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: () => false, + singleLine: true, + }), + ); + act(() => result.current.insert('hello\nworld', { paste: true })); + const state = getBufferState(result); + expect(state.text).toBe('helloworld'); + expect(state.lines).toEqual(['helloworld']); + }); + }); }); describe('offsetToLogicalPos', () => { diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 861017ce03..8b5792e5da 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -518,6 +518,8 @@ interface UseTextBufferProps { onChange?: (text: string) => void; // Callback for when text changes isValidPath: (path: string) => boolean; shellModeActive?: boolean; // Whether the text buffer is in shell mode + inputFilter?: (text: string) => string; // Optional filter for input text + singleLine?: boolean; } interface UndoHistoryEntry { @@ -949,9 +951,15 @@ export type TextBufferAction = | { type: 'vim_move_to_line'; payload: { lineNumber: number } } | { type: 'vim_escape_insert_mode' }; +export interface TextBufferOptions { + inputFilter?: (text: string) => string; + singleLine?: boolean; +} + function textBufferReducerLogic( state: TextBufferState, action: TextBufferAction, + options: TextBufferOptions = {}, ): TextBufferState { const pushUndoLocal = pushUndo; @@ -986,8 +994,20 @@ function textBufferReducerLogic( const currentLine = (r: number) => newLines[r] ?? ''; + let payload = action.payload; + if (options.singleLine) { + payload = payload.replace(/[\r\n]/g, ''); + } + if (options.inputFilter) { + payload = options.inputFilter(payload); + } + + if (payload.length === 0) { + return state; + } + const str = stripUnsafeCharacters( - action.payload.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), + payload.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), ); const parts = str.split('\n'); const lineContent = currentLine(newCursorRow); @@ -1498,8 +1518,9 @@ function textBufferReducerLogic( export function textBufferReducer( state: TextBufferState, action: TextBufferAction, + options: TextBufferOptions = {}, ): TextBufferState { - const newState = textBufferReducerLogic(state, action); + const newState = textBufferReducerLogic(state, action, options); if ( newState.lines !== state.lines || @@ -1525,6 +1546,8 @@ export function useTextBuffer({ onChange, isValidPath, shellModeActive = false, + inputFilter, + singleLine = false, }: UseTextBufferProps): TextBuffer { const initialState = useMemo((): TextBufferState => { const lines = initialText.split('\n'); @@ -1551,7 +1574,11 @@ export function useTextBuffer({ }; }, [initialText, initialCursorOffset, viewport.width, viewport.height]); - const [state, dispatch] = useReducer(textBufferReducer, initialState); + const [state, dispatch] = useReducer( + (s: TextBufferState, a: TextBufferAction) => + textBufferReducer(s, a, { inputFilter, singleLine }), + initialState, + ); const { lines, cursorRow, @@ -1609,7 +1636,7 @@ export function useTextBuffer({ const insert = useCallback( (ch: string, { paste = false }: { paste?: boolean } = {}): void => { - if (/[\n\r]/.test(ch)) { + if (!singleLine && /[\n\r]/.test(ch)) { dispatch({ type: 'insert', payload: ch }); return; } @@ -1648,12 +1675,15 @@ export function useTextBuffer({ dispatch({ type: 'insert', payload: currentText }); } }, - [isValidPath, shellModeActive], + [isValidPath, shellModeActive, singleLine], ); const newline = useCallback((): void => { + if (singleLine) { + return; + } dispatch({ type: 'insert', payload: '\n' }); - }, []); + }, [singleLine]); const backspace = useCallback((): void => { dispatch({ type: 'backspace' }); @@ -1895,10 +1925,11 @@ export function useTextBuffer({ } if ( - key.name === 'return' || - input === '\r' || - input === '\n' || - input === '\\\r' // VSCode terminal represents shift + enter this way + !singleLine && + (key.name === 'return' || + input === '\r' || + input === '\n' || + input === '\\r') // VSCode terminal represents shift + enter this way ) newline(); else if (key.name === 'left' && !key.meta && !key.ctrl) move('left'); @@ -1947,6 +1978,7 @@ export function useTextBuffer({ insert, undo, redo, + singleLine, ], ); diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index e055147c32..31a0ec2a34 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -43,6 +43,8 @@ export interface UIActions { handleProQuotaChoice: (choice: 'auth' | 'continue') => void; setQueueErrorMessage: (message: string | null) => void; popAllMessages: (onPop: (messages: string | undefined) => void) => void; + handleApiKeySubmit: (apiKey: string) => Promise; + handleApiKeyCancel: () => void; } export const UIActionsContext = createContext(null); diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index ba45504ff0..90a35c185f 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -47,6 +47,8 @@ export interface UIState { isConfigInitialized: boolean; authError: string | null; isAuthDialogOpen: boolean; + isAwaitingApiKeyInput: boolean; + apiKeyDefaultValue?: string; editorError: string | null; isEditorDialogOpen: boolean; showPrivacyNotice: boolean; diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 7a4c09f2cb..f870ed6fb4 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -23,6 +23,8 @@ export enum AuthState { Unauthenticated = 'unauthenticated', // Auth dialog is open for user to select auth method Updating = 'updating', + // Waiting for user to input API key + AwaitingApiKeyInput = 'awaiting_api_key_input', // Successfully authenticated Authenticated = 'authenticated', } diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 7bcf1ff941..11d208297d 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -226,7 +226,7 @@ describe('Server Config (config.ts)', () => { apiKey: 'test-key', }; - vi.mocked(createContentGeneratorConfig).mockReturnValue( + vi.mocked(createContentGeneratorConfig).mockResolvedValue( mockContentConfig, ); @@ -251,7 +251,7 @@ describe('Server Config (config.ts)', () => { const config = new Config(baseParams); vi.mocked(createContentGeneratorConfig).mockImplementation( - (_: Config, authType: AuthType | undefined) => + async (_: Config, authType: AuthType | undefined) => ({ authType }) as unknown as ContentGeneratorConfig, ); @@ -268,7 +268,7 @@ describe('Server Config (config.ts)', () => { const config = new Config(baseParams); vi.mocked(createContentGeneratorConfig).mockImplementation( - (_: Config, authType: AuthType | undefined) => + async (_: Config, authType: AuthType | undefined) => ({ authType }) as unknown as ContentGeneratorConfig, ); @@ -1105,7 +1105,9 @@ describe('BaseLlmClient Lifecycle', () => { const authType = AuthType.USE_GEMINI; const mockContentConfig = { model: 'gemini-flash', apiKey: 'test-key' }; - vi.mocked(createContentGeneratorConfig).mockReturnValue(mockContentConfig); + vi.mocked(createContentGeneratorConfig).mockResolvedValue( + mockContentConfig, + ); await config.refreshAuth(authType); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 878c2fe782..c1bcf9e592 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -579,7 +579,7 @@ export class Config { this.geminiClient.stripThoughtsFromHistory(); } - const newContentGeneratorConfig = createContentGeneratorConfig( + const newContentGeneratorConfig = await createContentGeneratorConfig( this, authMethod, ); diff --git a/packages/core/src/core/apiKeyCredentialStorage.test.ts b/packages/core/src/core/apiKeyCredentialStorage.test.ts new file mode 100644 index 0000000000..703884c6aa --- /dev/null +++ b/packages/core/src/core/apiKeyCredentialStorage.test.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + loadApiKey, + saveApiKey, + clearApiKey, +} from './apiKeyCredentialStorage.js'; + +const getCredentialsMock = vi.hoisted(() => vi.fn()); +const setCredentialsMock = vi.hoisted(() => vi.fn()); +const deleteCredentialsMock = vi.hoisted(() => vi.fn()); + +vi.mock('../mcp/token-storage/hybrid-token-storage.js', () => ({ + HybridTokenStorage: vi.fn().mockImplementation(() => ({ + getCredentials: getCredentialsMock, + setCredentials: setCredentialsMock, + deleteCredentials: deleteCredentialsMock, + })), +})); + +describe('ApiKeyCredentialStorage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should load an API key', async () => { + getCredentialsMock.mockResolvedValue({ + serverName: 'default-api-key', + token: { + accessToken: 'test-key', + tokenType: 'ApiKey', + }, + updatedAt: Date.now(), + }); + + const apiKey = await loadApiKey(); + expect(apiKey).toBe('test-key'); + expect(getCredentialsMock).toHaveBeenCalledWith('default-api-key'); + }); + + it('should return null if no API key is stored', async () => { + getCredentialsMock.mockResolvedValue(null); + const apiKey = await loadApiKey(); + expect(apiKey).toBeNull(); + expect(getCredentialsMock).toHaveBeenCalledWith('default-api-key'); + }); + + it('should save an API key', async () => { + await saveApiKey('new-key'); + expect(setCredentialsMock).toHaveBeenCalledWith( + expect.objectContaining({ + serverName: 'default-api-key', + token: expect.objectContaining({ + accessToken: 'new-key', + tokenType: 'ApiKey', + }), + }), + ); + }); + + it('should clear an API key when saving empty key', async () => { + await saveApiKey(''); + expect(deleteCredentialsMock).toHaveBeenCalledWith('default-api-key'); + expect(setCredentialsMock).not.toHaveBeenCalled(); + }); + + it('should clear an API key when saving null key', async () => { + await saveApiKey(null); + expect(deleteCredentialsMock).toHaveBeenCalledWith('default-api-key'); + expect(setCredentialsMock).not.toHaveBeenCalled(); + }); + + it('should clear an API key', async () => { + await clearApiKey(); + expect(deleteCredentialsMock).toHaveBeenCalledWith('default-api-key'); + }); +}); diff --git a/packages/core/src/core/apiKeyCredentialStorage.ts b/packages/core/src/core/apiKeyCredentialStorage.ts new file mode 100644 index 0000000000..691ce949d2 --- /dev/null +++ b/packages/core/src/core/apiKeyCredentialStorage.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { HybridTokenStorage } from '../mcp/token-storage/hybrid-token-storage.js'; +import type { OAuthCredentials } from '../mcp/token-storage/types.js'; +import { debugLogger } from '../utils/debugLogger.js'; + +const KEYCHAIN_SERVICE_NAME = 'gemini-cli-api-key'; +const DEFAULT_API_KEY_ENTRY = 'default-api-key'; + +const storage = new HybridTokenStorage(KEYCHAIN_SERVICE_NAME); + +/** + * Load cached API key + */ +export async function loadApiKey(): Promise { + try { + const credentials = await storage.getCredentials(DEFAULT_API_KEY_ENTRY); + + if (credentials?.token?.accessToken) { + return credentials.token.accessToken; + } + + return null; + } catch (error: unknown) { + // Ignore "file not found" error from FileTokenStorage, it just means no key is saved yet. + // This is common in fresh environments like e2e tests. + if ( + error instanceof Error && + error.message === 'Token file does not exist' + ) { + return null; + } + + // Log other errors but don't crash, just return null so user can re-enter key + debugLogger.error('Failed to load API key from storage:', error); + return null; + } +} + +/** + * Save API key + */ +export async function saveApiKey( + apiKey: string | null | undefined, +): Promise { + if (!apiKey || apiKey.trim() === '') { + await storage.deleteCredentials(DEFAULT_API_KEY_ENTRY); + return; + } + + // Wrap API key in OAuthCredentials format as required by HybridTokenStorage + const credentials: OAuthCredentials = { + serverName: DEFAULT_API_KEY_ENTRY, + token: { + accessToken: apiKey, + tokenType: 'ApiKey', + }, + updatedAt: Date.now(), + }; + + await storage.setCredentials(credentials); +} + +/** + * Clear cached API key + */ +export async function clearApiKey(): Promise { + try { + await storage.deleteCredentials(DEFAULT_API_KEY_ENTRY); + } catch (error: unknown) { + debugLogger.error('Failed to clear API key from storage:', error); + } +} diff --git a/packages/core/src/core/contentGenerator.test.ts b/packages/core/src/core/contentGenerator.test.ts index fbc350a884..ac5cb2f13e 100644 --- a/packages/core/src/core/contentGenerator.test.ts +++ b/packages/core/src/core/contentGenerator.test.ts @@ -15,11 +15,16 @@ import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js'; import { GoogleGenAI } from '@google/genai'; import type { Config } from '../config/config.js'; import { LoggingContentGenerator } from './loggingContentGenerator.js'; +import { loadApiKey } from './apiKeyCredentialStorage.js'; import { FakeContentGenerator } from './fakeContentGenerator.js'; import { RecordingContentGenerator } from './recordingContentGenerator.js'; vi.mock('../code_assist/codeAssist.js'); vi.mock('@google/genai'); +vi.mock('./apiKeyCredentialStorage.js', () => ({ + loadApiKey: vi.fn(), +})); + vi.mock('./fakeContentGenerator.js'); const mockConfig = {} as unknown as Config; @@ -184,6 +189,17 @@ describe('createContentGeneratorConfig', () => { expect(config.vertexai).toBeUndefined(); }); + it('should not configure for Gemini if GEMINI_API_KEY is not set and storage is empty', async () => { + vi.stubEnv('GEMINI_API_KEY', ''); + vi.mocked(loadApiKey).mockResolvedValue(null); + const config = await createContentGeneratorConfig( + mockConfig, + AuthType.USE_GEMINI, + ); + expect(config.apiKey).toBeUndefined(); + expect(config.vertexai).toBeUndefined(); + }); + it('should configure for Vertex AI using GOOGLE_API_KEY when set', async () => { vi.stubEnv('GOOGLE_API_KEY', 'env-google-key'); const config = await createContentGeneratorConfig( diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 68b30bf936..6fac941e01 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -15,6 +15,7 @@ import type { import { GoogleGenAI } from '@google/genai'; import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js'; import type { Config } from '../config/config.js'; +import { loadApiKey } from './apiKeyCredentialStorage.js'; import type { UserTierId } from '../code_assist/types.js'; import { LoggingContentGenerator } from './loggingContentGenerator.js'; @@ -57,11 +58,12 @@ export type ContentGeneratorConfig = { proxy?: string; }; -export function createContentGeneratorConfig( +export async function createContentGeneratorConfig( config: Config, authType: AuthType | undefined, -): ContentGeneratorConfig { - const geminiApiKey = process.env['GEMINI_API_KEY'] || undefined; +): Promise { + const geminiApiKey = + (await loadApiKey()) || process.env['GEMINI_API_KEY'] || undefined; const googleApiKey = process.env['GOOGLE_API_KEY'] || undefined; const googleCloudProject = process.env['GOOGLE_CLOUD_PROJECT'] || diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f9b2377d63..00b570fab4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -37,6 +37,7 @@ export * from './code_assist/codeAssist.js'; export * from './code_assist/oauth2.js'; export * from './code_assist/server.js'; export * from './code_assist/types.js'; +export * from './core/apiKeyCredentialStorage.js'; // Export utilities export * from './utils/paths.js';