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';