summaryrefslogtreecommitdiff
path: root/packages/cli/src
diff options
context:
space:
mode:
authorYuki Okita <[email protected]>2025-08-20 10:55:47 +0900
committerGitHub <[email protected]>2025-08-20 01:55:47 +0000
commit21c6480b65528a98ac0e1e3855f3c78c1f9b7cbe (patch)
tree5555ec429209e87e0c21483c9e5fddd53ac01dbc /packages/cli/src
parent1049d388451120587a8643a401fd71430a8cd5fe (diff)
Refac: Centralize storage file management (#4078)
Co-authored-by: Taylor Mullen <[email protected]>
Diffstat (limited to 'packages/cli/src')
-rw-r--r--packages/cli/src/commands/mcp/list.test.ts24
-rw-r--r--packages/cli/src/config/config.test.ts62
-rw-r--r--packages/cli/src/config/extension.test.ts3
-rw-r--r--packages/cli/src/config/extension.ts10
-rw-r--r--packages/cli/src/config/settings.ts18
-rw-r--r--packages/cli/src/services/FileCommandLoader.test.ts89
-rw-r--r--packages/cli/src/services/FileCommandLoader.ts12
-rw-r--r--packages/cli/src/ui/App.tsx2
-rw-r--r--packages/cli/src/ui/commands/chatCommand.test.ts5
-rw-r--r--packages/cli/src/ui/commands/chatCommand.ts3
-rw-r--r--packages/cli/src/ui/commands/ideCommand.test.ts9
-rw-r--r--packages/cli/src/ui/commands/restoreCommand.test.ts11
-rw-r--r--packages/cli/src/ui/commands/restoreCommand.ts8
-rw-r--r--packages/cli/src/ui/components/InputPrompt.tsx2
-rw-r--r--packages/cli/src/ui/hooks/shellCommandProcessor.test.ts13
-rw-r--r--packages/cli/src/ui/hooks/slashCommandProcessor.ts8
-rw-r--r--packages/cli/src/ui/hooks/useGeminiStream.ts21
-rw-r--r--packages/cli/src/ui/hooks/useLogger.ts8
-rw-r--r--packages/cli/src/ui/hooks/useShellHistory.test.ts34
-rw-r--r--packages/cli/src/ui/hooks/useShellHistory.ts21
-rw-r--r--packages/cli/src/utils/cleanup.ts5
21 files changed, 226 insertions, 142 deletions
diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts
index daf2e3d7..f3eb72ea 100644
--- a/packages/cli/src/commands/mcp/list.test.ts
+++ b/packages/cli/src/commands/mcp/list.test.ts
@@ -11,9 +11,27 @@ import { loadExtensions } from '../../config/extension.js';
import { createTransport } from '@google/gemini-cli-core';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
-vi.mock('../../config/settings.js');
-vi.mock('../../config/extension.js');
-vi.mock('@google/gemini-cli-core');
+vi.mock('../../config/settings.js', () => ({
+ loadSettings: vi.fn(),
+}));
+vi.mock('../../config/extension.js', () => ({
+ loadExtensions: vi.fn(),
+}));
+vi.mock('@google/gemini-cli-core', () => ({
+ createTransport: vi.fn(),
+ MCPServerStatus: {
+ CONNECTED: 'CONNECTED',
+ CONNECTING: 'CONNECTING',
+ DISCONNECTED: 'DISCONNECTED',
+ },
+ Storage: vi.fn().mockImplementation((_cwd: string) => ({
+ getGlobalSettingsPath: () => '/tmp/gemini/settings.json',
+ getWorkspaceSettingsPath: () => '/tmp/gemini/workspace-settings.json',
+ getProjectTempDir: () => '/test/home/.gemini/tmp/mocked_hash',
+ })),
+ GEMINI_CONFIG_DIR: '.gemini',
+ getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)),
+}));
vi.mock('@modelcontextprotocol/sdk/client/index.js');
const mockedLoadSettings = loadSettings as vi.Mock;
diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts
index 727f6fe1..c9426379 100644
--- a/packages/cli/src/config/config.test.ts
+++ b/packages/cli/src/config/config.test.ts
@@ -6,7 +6,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as os from 'os';
-import * as fs from 'fs';
import * as path from 'path';
import { ShellTool, EditTool, WriteFileTool } from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments } from './config.js';
@@ -19,6 +18,38 @@ vi.mock('./trustedFolders.js', () => ({
isWorkspaceTrusted: vi.fn(),
}));
+vi.mock('fs', async (importOriginal) => {
+ const actualFs = await importOriginal<typeof import('fs')>();
+ const pathMod = await import('path');
+ const mockHome = '/mock/home/user';
+ const MOCK_CWD1 = process.cwd();
+ const MOCK_CWD2 = pathMod.resolve(pathMod.sep, 'home', 'user', 'project');
+
+ const mockPaths = new Set([
+ MOCK_CWD1,
+ MOCK_CWD2,
+ pathMod.resolve(pathMod.sep, 'cli', 'path1'),
+ pathMod.resolve(pathMod.sep, 'settings', 'path1'),
+ pathMod.join(mockHome, 'settings', 'path2'),
+ pathMod.join(MOCK_CWD2, 'cli', 'path2'),
+ pathMod.join(MOCK_CWD2, 'settings', 'path3'),
+ ]);
+
+ return {
+ ...actualFs,
+ mkdirSync: vi.fn(),
+ writeFileSync: vi.fn(),
+ existsSync: vi.fn((p) => mockPaths.has(p.toString())),
+ statSync: vi.fn((p) => {
+ if (mockPaths.has(p.toString())) {
+ return { isDirectory: () => true } as unknown as import('fs').Stats;
+ }
+ return (actualFs as typeof import('fs')).statSync(p as unknown as string);
+ }),
+ realpathSync: vi.fn((p) => p),
+ };
+});
+
vi.mock('os', async (importOriginal) => {
const actualOs = await importOriginal<typeof os>();
return {
@@ -1441,35 +1472,6 @@ describe('loadCliConfig folderTrust', () => {
});
});
-vi.mock('fs', async () => {
- const actualFs = await vi.importActual<typeof fs>('fs');
- const MOCK_CWD1 = process.cwd();
- const MOCK_CWD2 = path.resolve(path.sep, 'home', 'user', 'project');
-
- const mockPaths = new Set([
- MOCK_CWD1,
- MOCK_CWD2,
- path.resolve(path.sep, 'cli', 'path1'),
- path.resolve(path.sep, 'settings', 'path1'),
- path.join(os.homedir(), 'settings', 'path2'),
- path.join(MOCK_CWD2, 'cli', 'path2'),
- path.join(MOCK_CWD2, 'settings', 'path3'),
- ]);
-
- return {
- ...actualFs,
- existsSync: vi.fn((p) => mockPaths.has(p.toString())),
- statSync: vi.fn((p) => {
- if (mockPaths.has(p.toString())) {
- return { isDirectory: () => true };
- }
- // Fallback for other paths if needed, though the test should be specific.
- return actualFs.statSync(p);
- }),
- realpathSync: vi.fn((p) => p),
- };
-});
-
describe('loadCliConfig with includeDirectories', () => {
const originalArgv = process.argv;
diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts
index 85852bd7..ece36916 100644
--- a/packages/cli/src/config/extension.test.ts
+++ b/packages/cli/src/config/extension.test.ts
@@ -10,7 +10,6 @@ import * as os from 'os';
import * as path from 'path';
import {
EXTENSIONS_CONFIG_FILENAME,
- EXTENSIONS_DIRECTORY_NAME,
annotateActiveExtensions,
loadExtensions,
} from './extension.js';
@@ -23,6 +22,8 @@ vi.mock('os', async (importOriginal) => {
};
});
+const EXTENSIONS_DIRECTORY_NAME = path.join('.gemini', 'extensions');
+
describe('loadExtensions', () => {
let tempWorkspaceDir: string;
let tempHomeDir: string;
diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts
index 1922f55a..46fd32fc 100644
--- a/packages/cli/src/config/extension.ts
+++ b/packages/cli/src/config/extension.ts
@@ -4,12 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { MCPServerConfig, GeminiCLIExtension } from '@google/gemini-cli-core';
+import {
+ MCPServerConfig,
+ GeminiCLIExtension,
+ Storage,
+} from '@google/gemini-cli-core';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
-export const EXTENSIONS_DIRECTORY_NAME = path.join('.gemini', 'extensions');
export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json';
export interface Extension {
@@ -43,7 +46,8 @@ export function loadExtensions(workspaceDir: string): Extension[] {
}
function loadExtensionsFromDir(dir: string): Extension[] {
- const extensionsDir = path.join(dir, EXTENSIONS_DIRECTORY_NAME);
+ const storage = new Storage(dir);
+ const extensionsDir = storage.getExtensionsDir();
if (!fs.existsSync(extensionsDir)) {
return [];
}
diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts
index 414caf11..3df98d95 100644
--- a/packages/cli/src/config/settings.ts
+++ b/packages/cli/src/config/settings.ts
@@ -11,6 +11,7 @@ import * as dotenv from 'dotenv';
import {
GEMINI_CONFIG_DIR as GEMINI_DIR,
getErrorMessage,
+ Storage,
} from '@google/gemini-cli-core';
import stripJsonComments from 'strip-json-comments';
import { DefaultLight } from '../ui/themes/default-light.js';
@@ -20,8 +21,9 @@ import { Settings, MemoryImportFormat } from './settingsSchema.js';
export type { Settings, MemoryImportFormat };
export const SETTINGS_DIRECTORY_NAME = '.gemini';
-export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME);
-export const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, 'settings.json');
+
+export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath();
+export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH);
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
export function getSystemSettingsPath(): string {
@@ -37,10 +39,6 @@ export function getSystemSettingsPath(): string {
}
}
-export function getWorkspaceSettingsPath(workspaceDir: string): string {
- return path.join(workspaceDir, SETTINGS_DIRECTORY_NAME, 'settings.json');
-}
-
export type { DnsResolutionOrder } from './settingsSchema.js';
export enum SettingScope {
@@ -269,7 +267,9 @@ export function loadEnvironment(settings?: Settings): void {
// If no settings provided, try to load workspace settings for exclusions
let resolvedSettings = settings;
if (!resolvedSettings) {
- const workspaceSettingsPath = getWorkspaceSettingsPath(process.cwd());
+ const workspaceSettingsPath = new Storage(
+ process.cwd(),
+ ).getWorkspaceSettingsPath();
try {
if (fs.existsSync(workspaceSettingsPath)) {
const workspaceContent = fs.readFileSync(
@@ -342,7 +342,9 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
// We expect homedir to always exist and be resolvable.
const realHomeDir = fs.realpathSync(resolvedHomeDir);
- const workspaceSettingsPath = getWorkspaceSettingsPath(workspaceDir);
+ const workspaceSettingsPath = new Storage(
+ workspaceDir,
+ ).getWorkspaceSettingsPath();
// Load system settings
try {
diff --git a/packages/cli/src/services/FileCommandLoader.test.ts b/packages/cli/src/services/FileCommandLoader.test.ts
index 42d93074..9960a632 100644
--- a/packages/cli/src/services/FileCommandLoader.test.ts
+++ b/packages/cli/src/services/FileCommandLoader.test.ts
@@ -5,11 +5,7 @@
*/
import * as path from 'node:path';
-import {
- Config,
- getProjectCommandsDir,
- getUserCommandsDir,
-} from '@google/gemini-cli-core';
+import { Config, Storage } from '@google/gemini-cli-core';
import mock from 'mock-fs';
import { FileCommandLoader } from './FileCommandLoader.js';
import { assert, vi } from 'vitest';
@@ -57,6 +53,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...original,
+ Storage: original.Storage,
isCommandAllowed: vi.fn(),
ShellExecutionService: {
execute: vi.fn(),
@@ -86,7 +83,7 @@ describe('FileCommandLoader', () => {
});
it('loads a single command from a file', async () => {
- const userCommandsDir = getUserCommandsDir();
+ const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'test.toml': 'prompt = "This is a test prompt"',
@@ -127,7 +124,7 @@ describe('FileCommandLoader', () => {
itif(process.platform !== 'win32')(
'loads commands from a symlinked directory',
async () => {
- const userCommandsDir = getUserCommandsDir();
+ const userCommandsDir = Storage.getUserCommandsDir();
const realCommandsDir = '/real/commands';
mock({
[realCommandsDir]: {
@@ -152,7 +149,7 @@ describe('FileCommandLoader', () => {
itif(process.platform !== 'win32')(
'loads commands from a symlinked subdirectory',
async () => {
- const userCommandsDir = getUserCommandsDir();
+ const userCommandsDir = Storage.getUserCommandsDir();
const realNamespacedDir = '/real/namespaced-commands';
mock({
[userCommandsDir]: {
@@ -176,7 +173,7 @@ describe('FileCommandLoader', () => {
);
it('loads multiple commands', async () => {
- const userCommandsDir = getUserCommandsDir();
+ const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'test1.toml': 'prompt = "Prompt 1"',
@@ -191,7 +188,7 @@ describe('FileCommandLoader', () => {
});
it('creates deeply nested namespaces correctly', async () => {
- const userCommandsDir = getUserCommandsDir();
+ const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
@@ -205,7 +202,7 @@ describe('FileCommandLoader', () => {
const mockConfig = {
getProjectRoot: vi.fn(() => '/path/to/project'),
getExtensions: vi.fn(() => []),
- } as unknown as Config;
+ } as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
@@ -213,7 +210,7 @@ describe('FileCommandLoader', () => {
});
it('creates namespaces from nested directories', async () => {
- const userCommandsDir = getUserCommandsDir();
+ const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
git: {
@@ -232,8 +229,10 @@ describe('FileCommandLoader', () => {
});
it('returns both user and project commands in order', async () => {
- const userCommandsDir = getUserCommandsDir();
- const projectCommandsDir = getProjectCommandsDir(process.cwd());
+ const userCommandsDir = Storage.getUserCommandsDir();
+ const projectCommandsDir = new Storage(
+ process.cwd(),
+ ).getProjectCommandsDir();
mock({
[userCommandsDir]: {
'test.toml': 'prompt = "User prompt"',
@@ -246,7 +245,7 @@ describe('FileCommandLoader', () => {
const mockConfig = {
getProjectRoot: vi.fn(() => process.cwd()),
getExtensions: vi.fn(() => []),
- } as unknown as Config;
+ } as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
@@ -284,7 +283,7 @@ describe('FileCommandLoader', () => {
});
it('ignores files with TOML syntax errors', async () => {
- const userCommandsDir = getUserCommandsDir();
+ const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'invalid.toml': 'this is not valid toml',
@@ -300,7 +299,7 @@ describe('FileCommandLoader', () => {
});
it('ignores files that are semantically invalid (missing prompt)', async () => {
- const userCommandsDir = getUserCommandsDir();
+ const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'no_prompt.toml': 'description = "This file is missing a prompt"',
@@ -316,7 +315,7 @@ describe('FileCommandLoader', () => {
});
it('handles filename edge cases correctly', async () => {
- const userCommandsDir = getUserCommandsDir();
+ const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'test.v1.toml': 'prompt = "Test prompt"',
@@ -338,7 +337,7 @@ describe('FileCommandLoader', () => {
});
it('uses a default description if not provided', async () => {
- const userCommandsDir = getUserCommandsDir();
+ const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'test.toml': 'prompt = "Test prompt"',
@@ -353,7 +352,7 @@ describe('FileCommandLoader', () => {
});
it('uses the provided description', async () => {
- const userCommandsDir = getUserCommandsDir();
+ const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'test.toml': 'prompt = "Test prompt"\ndescription = "My test command"',
@@ -368,7 +367,7 @@ describe('FileCommandLoader', () => {
});
it('should sanitize colons in filenames to prevent namespace conflicts', async () => {
- const userCommandsDir = getUserCommandsDir();
+ const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'legacy:command.toml': 'prompt = "This is a legacy command"',
@@ -388,7 +387,7 @@ describe('FileCommandLoader', () => {
describe('Processor Instantiation Logic', () => {
it('instantiates only DefaultArgumentProcessor if no {{args}} or !{} are present', async () => {
- const userCommandsDir = getUserCommandsDir();
+ const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'simple.toml': `prompt = "Just a regular prompt"`,
@@ -403,7 +402,7 @@ describe('FileCommandLoader', () => {
});
it('instantiates only ShellProcessor if {{args}} is present (but not !{})', async () => {
- const userCommandsDir = getUserCommandsDir();
+ const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'args.toml': `prompt = "Prompt with {{args}}"`,
@@ -418,7 +417,7 @@ describe('FileCommandLoader', () => {
});
it('instantiates ShellProcessor and DefaultArgumentProcessor if !{} is present (but not {{args}})', async () => {
- const userCommandsDir = getUserCommandsDir();
+ const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'shell.toml': `prompt = "Prompt with !{cmd}"`,
@@ -433,7 +432,7 @@ describe('FileCommandLoader', () => {
});
it('instantiates only ShellProcessor if both {{args}} and !{} are present', async () => {
- const userCommandsDir = getUserCommandsDir();
+ const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'both.toml': `prompt = "Prompt with {{args}} and !{cmd}"`,
@@ -450,8 +449,10 @@ describe('FileCommandLoader', () => {
describe('Extension Command Loading', () => {
it('loads commands from active extensions', async () => {
- const userCommandsDir = getUserCommandsDir();
- const projectCommandsDir = getProjectCommandsDir(process.cwd());
+ const userCommandsDir = Storage.getUserCommandsDir();
+ const projectCommandsDir = new Storage(
+ process.cwd(),
+ ).getProjectCommandsDir();
const extensionDir = path.join(
process.cwd(),
'.gemini/extensions/test-ext',
@@ -485,7 +486,7 @@ describe('FileCommandLoader', () => {
path: extensionDir,
},
]),
- } as unknown as Config;
+ } as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
@@ -499,8 +500,10 @@ describe('FileCommandLoader', () => {
});
it('extension commands have extensionName metadata for conflict resolution', async () => {
- const userCommandsDir = getUserCommandsDir();
- const projectCommandsDir = getProjectCommandsDir(process.cwd());
+ const userCommandsDir = Storage.getUserCommandsDir();
+ const projectCommandsDir = new Storage(
+ process.cwd(),
+ ).getProjectCommandsDir();
const extensionDir = path.join(
process.cwd(),
'.gemini/extensions/test-ext',
@@ -534,7 +537,7 @@ describe('FileCommandLoader', () => {
path: extensionDir,
},
]),
- } as unknown as Config;
+ } as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
@@ -641,7 +644,7 @@ describe('FileCommandLoader', () => {
path: extensionDir2,
},
]),
- } as unknown as Config;
+ } as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
@@ -677,7 +680,7 @@ describe('FileCommandLoader', () => {
path: extensionDir,
},
]),
- } as unknown as Config;
+ } as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(0);
@@ -709,7 +712,7 @@ describe('FileCommandLoader', () => {
getExtensions: vi.fn(() => [
{ name: 'a', version: '1.0.0', isActive: true, path: extensionDir },
]),
- } as unknown as Config;
+ } as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
@@ -742,7 +745,7 @@ describe('FileCommandLoader', () => {
describe('Argument Handling Integration (via ShellProcessor)', () => {
it('correctly processes a command with {{args}}', async () => {
- const userCommandsDir = getUserCommandsDir();
+ const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'shorthand.toml':
@@ -774,7 +777,7 @@ describe('FileCommandLoader', () => {
describe('Default Argument Processor Integration', () => {
it('correctly processes a command without {{args}}', async () => {
- const userCommandsDir = getUserCommandsDir();
+ const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'model_led.toml':
@@ -808,7 +811,7 @@ describe('FileCommandLoader', () => {
describe('Shell Processor Integration', () => {
it('instantiates ShellProcessor if {{args}} is present (even without shell trigger)', async () => {
- const userCommandsDir = getUserCommandsDir();
+ const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'args_only.toml': `prompt = "Hello {{args}}"`,
@@ -821,7 +824,7 @@ describe('FileCommandLoader', () => {
expect(ShellProcessor).toHaveBeenCalledWith('args_only');
});
it('instantiates ShellProcessor if the trigger is present', async () => {
- const userCommandsDir = getUserCommandsDir();
+ const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'shell.toml': `prompt = "Run this: ${SHELL_INJECTION_TRIGGER}echo hello}"`,
@@ -835,7 +838,7 @@ describe('FileCommandLoader', () => {
});
it('does not instantiate ShellProcessor if no triggers ({{args}} or !{}) are present', async () => {
- const userCommandsDir = getUserCommandsDir();
+ const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'regular.toml': `prompt = "Just a regular prompt"`,
@@ -849,7 +852,7 @@ describe('FileCommandLoader', () => {
});
it('returns a "submit_prompt" action if shell processing succeeds', async () => {
- const userCommandsDir = getUserCommandsDir();
+ const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'shell.toml': `prompt = "Run !{echo 'hello'}"`,
@@ -876,7 +879,7 @@ describe('FileCommandLoader', () => {
});
it('returns a "confirm_shell_commands" action if shell processing requires it', async () => {
- const userCommandsDir = getUserCommandsDir();
+ const userCommandsDir = Storage.getUserCommandsDir();
const rawInvocation = '/shell rm -rf /';
mock({
[userCommandsDir]: {
@@ -910,7 +913,7 @@ describe('FileCommandLoader', () => {
});
it('re-throws other errors from the processor', async () => {
- const userCommandsDir = getUserCommandsDir();
+ const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'shell.toml': `prompt = "Run !{something}"`,
@@ -935,7 +938,7 @@ describe('FileCommandLoader', () => {
).rejects.toThrow('Something else went wrong');
});
it('assembles the processor pipeline in the correct order (Shell -> Default)', async () => {
- const userCommandsDir = getUserCommandsDir();
+ const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
// This prompt uses !{} but NOT {{args}}, so both processors should be active.
diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts
index 2942a8b9..a405655a 100644
--- a/packages/cli/src/services/FileCommandLoader.ts
+++ b/packages/cli/src/services/FileCommandLoader.ts
@@ -9,11 +9,7 @@ import path from 'path';
import toml from '@iarna/toml';
import { glob } from 'glob';
import { z } from 'zod';
-import {
- Config,
- getProjectCommandsDir,
- getUserCommandsDir,
-} from '@google/gemini-cli-core';
+import { Config, Storage } from '@google/gemini-cli-core';
import { ICommandLoader } from './types.js';
import {
CommandContext,
@@ -130,11 +126,13 @@ export class FileCommandLoader implements ICommandLoader {
private getCommandDirectories(): CommandDirectory[] {
const dirs: CommandDirectory[] = [];
+ const storage = this.config?.storage ?? new Storage(this.projectRoot);
+
// 1. User commands
- dirs.push({ path: getUserCommandsDir() });
+ dirs.push({ path: Storage.getUserCommandsDir() });
// 2. Project commands (override user commands)
- dirs.push({ path: getProjectCommandsDir(this.projectRoot) });
+ dirs.push({ path: storage.getProjectCommandsDir() });
// 3. Extension commands (processed last to detect all conflicts)
if (this.config) {
diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx
index d71d8371..01c6581c 100644
--- a/packages/cli/src/ui/App.tsx
+++ b/packages/cli/src/ui/App.tsx
@@ -742,7 +742,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
}
}, [config, config.getGeminiMdFileCount]);
- const logger = useLogger();
+ const logger = useLogger(config.storage);
useEffect(() => {
const fetchUserMessages = async () => {
diff --git a/packages/cli/src/ui/commands/chatCommand.test.ts b/packages/cli/src/ui/commands/chatCommand.test.ts
index c7299883..88808c92 100644
--- a/packages/cli/src/ui/commands/chatCommand.test.ts
+++ b/packages/cli/src/ui/commands/chatCommand.test.ts
@@ -67,11 +67,14 @@ describe('chatCommand', () => {
mockContext = createMockCommandContext({
services: {
config: {
- getProjectTempDir: () => '/tmp/gemini',
+ getProjectRoot: () => '/project/root',
getGeminiClient: () =>
({
getChat: mockGetChat,
}) as unknown as GeminiClient,
+ storage: {
+ getProjectTempDir: () => '/project/root/.gemini/tmp/mockhash',
+ },
},
logger: {
saveCheckpoint: mockSaveCheckpoint,
diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts
index 1c9029a9..fdd174ea 100644
--- a/packages/cli/src/ui/commands/chatCommand.ts
+++ b/packages/cli/src/ui/commands/chatCommand.ts
@@ -28,7 +28,8 @@ const getSavedChatTags = async (
context: CommandContext,
mtSortDesc: boolean,
): Promise<ChatDetail[]> => {
- const geminiDir = context.services.config?.getProjectTempDir();
+ const cfg = context.services.config;
+ const geminiDir = cfg?.storage?.getProjectTempDir();
if (!geminiDir) {
return [];
}
diff --git a/packages/cli/src/ui/commands/ideCommand.test.ts b/packages/cli/src/ui/commands/ideCommand.test.ts
index f61df287..104eafb3 100644
--- a/packages/cli/src/ui/commands/ideCommand.test.ts
+++ b/packages/cli/src/ui/commands/ideCommand.test.ts
@@ -20,7 +20,14 @@ import * as core from '@google/gemini-cli-core';
vi.mock('child_process');
vi.mock('glob');
-vi.mock('@google/gemini-cli-core');
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const original = await importOriginal<typeof core>();
+ return {
+ ...original,
+ getOauthClient: vi.fn(original.getOauthClient),
+ getIdeInstaller: vi.fn(original.getIdeInstaller),
+ };
+});
describe('ideCommand', () => {
let mockContext: CommandContext;
diff --git a/packages/cli/src/ui/commands/restoreCommand.test.ts b/packages/cli/src/ui/commands/restoreCommand.test.ts
index 23f71499..b9ecc139 100644
--- a/packages/cli/src/ui/commands/restoreCommand.test.ts
+++ b/packages/cli/src/ui/commands/restoreCommand.test.ts
@@ -39,7 +39,10 @@ describe('restoreCommand', () => {
mockConfig = {
getCheckpointingEnabled: vi.fn().mockReturnValue(true),
- getProjectTempDir: vi.fn().mockReturnValue(geminiTempDir),
+ storage: {
+ getProjectTempCheckpointsDir: vi.fn().mockReturnValue(checkpointsDir),
+ getProjectTempDir: vi.fn().mockReturnValue(geminiTempDir),
+ },
getGeminiClient: vi.fn().mockReturnValue({
setHistory: mockSetHistory,
}),
@@ -77,7 +80,9 @@ describe('restoreCommand', () => {
describe('action', () => {
it('should return an error if temp dir is not found', async () => {
- vi.mocked(mockConfig.getProjectTempDir).mockReturnValue('');
+ vi.mocked(
+ mockConfig.storage.getProjectTempCheckpointsDir,
+ ).mockReturnValue('');
expect(
await restoreCommand(mockConfig)?.action?.(mockContext, ''),
@@ -219,7 +224,7 @@ describe('restoreCommand', () => {
describe('completion', () => {
it('should return an empty array if temp dir is not found', async () => {
- vi.mocked(mockConfig.getProjectTempDir).mockReturnValue('');
+ vi.mocked(mockConfig.storage.getProjectTempDir).mockReturnValue('');
const command = restoreCommand(mockConfig);
expect(await command?.completion?.(mockContext, '')).toEqual([]);
diff --git a/packages/cli/src/ui/commands/restoreCommand.ts b/packages/cli/src/ui/commands/restoreCommand.ts
index 84259288..f34cdf25 100644
--- a/packages/cli/src/ui/commands/restoreCommand.ts
+++ b/packages/cli/src/ui/commands/restoreCommand.ts
@@ -22,9 +22,7 @@ async function restoreAction(
const { config, git: gitService } = services;
const { addItem, loadHistory } = ui;
- const checkpointDir = config?.getProjectTempDir()
- ? path.join(config.getProjectTempDir(), 'checkpoints')
- : undefined;
+ const checkpointDir = config?.storage.getProjectTempCheckpointsDir();
if (!checkpointDir) {
return {
@@ -125,9 +123,7 @@ async function completion(
): Promise<string[]> {
const { services } = context;
const { config } = services;
- const checkpointDir = config?.getProjectTempDir()
- ? path.join(config.getProjectTempDir(), 'checkpoints')
- : undefined;
+ const checkpointDir = config?.storage.getProjectTempCheckpointsDir();
if (!checkpointDir) {
return [];
}
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 99a59c34..02c25bd8 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -81,7 +81,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const [cursorPosition, setCursorPosition] = useState<[number, number]>([
0, 0,
]);
- const shellHistory = useShellHistory(config.getProjectRoot());
+ const shellHistory = useShellHistory(config.getProjectRoot(), config.storage);
const historyData = shellHistory.history;
const completion = useCommandCompletion(
diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.test.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.test.ts
index 9c13c8ec..8a37dde0 100644
--- a/packages/cli/src/ui/hooks/shellCommandProcessor.test.ts
+++ b/packages/cli/src/ui/hooks/shellCommandProcessor.test.ts
@@ -17,15 +17,10 @@ import {
const mockIsBinary = vi.hoisted(() => vi.fn());
const mockShellExecutionService = vi.hoisted(() => vi.fn());
-vi.mock('@google/gemini-cli-core', async (importOriginal) => {
- const original =
- await importOriginal<typeof import('@google/gemini-cli-core')>();
- return {
- ...original,
- ShellExecutionService: { execute: mockShellExecutionService },
- isBinary: mockIsBinary,
- };
-});
+vi.mock('@google/gemini-cli-core', () => ({
+ ShellExecutionService: { execute: mockShellExecutionService },
+ isBinary: mockIsBinary,
+}));
vi.mock('fs');
vi.mock('os');
vi.mock('crypto');
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
index 4e70eab7..44a3c2fa 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
@@ -16,6 +16,7 @@ import {
makeSlashCommandEvent,
SlashCommandStatus,
ToolConfirmationOutcome,
+ Storage,
} from '@google/gemini-cli-core';
import { useSessionStats } from '../contexts/SessionContext.js';
import { runExitCleanup } from '../../utils/cleanup.js';
@@ -82,11 +83,14 @@ export const useSlashCommandProcessor = (
if (!config?.getProjectRoot()) {
return;
}
- return new GitService(config.getProjectRoot());
+ return new GitService(config.getProjectRoot(), config.storage);
}, [config]);
const logger = useMemo(() => {
- const l = new Logger(config?.getSessionId() || '');
+ const l = new Logger(
+ config?.getSessionId() || '',
+ config?.storage ?? new Storage(process.cwd()),
+ );
// The logger's initialize is async, but we can create the instance
// synchronously. Commands that use it will await its initialization.
return l;
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index 99b727b6..abfe28c7 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -105,13 +105,14 @@ export const useGeminiStream = (
useStateAndRef<HistoryItemWithoutId | null>(null);
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
const { startNewPrompt, getPromptCount } = useSessionStats();
- const logger = useLogger();
+ const storage = config.storage;
+ const logger = useLogger(storage);
const gitService = useMemo(() => {
if (!config.getProjectRoot()) {
return;
}
- return new GitService(config.getProjectRoot());
- }, [config]);
+ return new GitService(config.getProjectRoot(), storage);
+ }, [config, storage]);
const [toolCalls, scheduleToolCalls, markToolsAsSubmitted] =
useReactToolScheduler(
@@ -877,9 +878,7 @@ export const useGeminiStream = (
);
if (restorableToolCalls.length > 0) {
- const checkpointDir = config.getProjectTempDir()
- ? path.join(config.getProjectTempDir(), 'checkpoints')
- : undefined;
+ const checkpointDir = storage.getProjectTempCheckpointsDir();
if (!checkpointDir) {
return;
@@ -962,7 +961,15 @@ export const useGeminiStream = (
}
};
saveRestorableToolCalls();
- }, [toolCalls, config, onDebugMessage, gitService, history, geminiClient]);
+ }, [
+ toolCalls,
+ config,
+ onDebugMessage,
+ gitService,
+ history,
+ geminiClient,
+ storage,
+ ]);
return {
streamingState,
diff --git a/packages/cli/src/ui/hooks/useLogger.ts b/packages/cli/src/ui/hooks/useLogger.ts
index 879e9dd7..8833b642 100644
--- a/packages/cli/src/ui/hooks/useLogger.ts
+++ b/packages/cli/src/ui/hooks/useLogger.ts
@@ -5,16 +5,16 @@
*/
import { useState, useEffect } from 'react';
-import { sessionId, Logger } from '@google/gemini-cli-core';
+import { sessionId, Logger, Storage } from '@google/gemini-cli-core';
/**
* Hook to manage the logger instance.
*/
-export const useLogger = () => {
+export const useLogger = (storage: Storage) => {
const [logger, setLogger] = useState<Logger | null>(null);
useEffect(() => {
- const newLogger = new Logger(sessionId);
+ const newLogger = new Logger(sessionId, storage);
/**
* Start async initialization, no need to await. Using await slows down the
* time from launch to see the gemini-cli prompt and it's better to not save
@@ -26,7 +26,7 @@ export const useLogger = () => {
setLogger(newLogger);
})
.catch(() => {});
- }, []);
+ }, [storage]);
return logger;
};
diff --git a/packages/cli/src/ui/hooks/useShellHistory.test.ts b/packages/cli/src/ui/hooks/useShellHistory.test.ts
index 3e2c2dd8..f0d8586c 100644
--- a/packages/cli/src/ui/hooks/useShellHistory.test.ts
+++ b/packages/cli/src/ui/hooks/useShellHistory.test.ts
@@ -11,9 +11,41 @@ import * as path from 'path';
import * as os from 'os';
import * as crypto from 'crypto';
-vi.mock('fs/promises');
+vi.mock('fs/promises', () => ({
+ readFile: vi.fn(),
+ writeFile: vi.fn(),
+ mkdir: vi.fn(),
+}));
vi.mock('os');
vi.mock('crypto');
+vi.mock('fs', async (importOriginal) => {
+ const actualFs = await importOriginal<typeof import('fs')>();
+ return {
+ ...actualFs,
+ mkdirSync: vi.fn(),
+ };
+});
+vi.mock('@google/gemini-cli-core', () => {
+ class Storage {
+ getProjectTempDir(): string {
+ return path.join('/test/home/', '.gemini', 'tmp', 'mocked_hash');
+ }
+ getHistoryFilePath(): string {
+ return path.join(
+ '/test/home/',
+ '.gemini',
+ 'tmp',
+ 'mocked_hash',
+ 'shell_history',
+ );
+ }
+ }
+ return {
+ isNodeError: (err: unknown): err is NodeJS.ErrnoException =>
+ typeof err === 'object' && err !== null && 'code' in err,
+ Storage,
+ };
+});
const MOCKED_PROJECT_ROOT = '/test/project';
const MOCKED_HOME_DIR = '/test/home';
diff --git a/packages/cli/src/ui/hooks/useShellHistory.ts b/packages/cli/src/ui/hooks/useShellHistory.ts
index 2e18dfbd..a0812f5b 100644
--- a/packages/cli/src/ui/hooks/useShellHistory.ts
+++ b/packages/cli/src/ui/hooks/useShellHistory.ts
@@ -7,9 +7,8 @@
import { useState, useEffect, useCallback } from 'react';
import * as fs from 'fs/promises';
import * as path from 'path';
-import { isNodeError, getProjectTempDir } from '@google/gemini-cli-core';
+import { isNodeError, Storage } from '@google/gemini-cli-core';
-const HISTORY_FILE = 'shell_history';
const MAX_HISTORY_LENGTH = 100;
export interface UseShellHistoryReturn {
@@ -20,9 +19,12 @@ export interface UseShellHistoryReturn {
resetHistoryPosition: () => void;
}
-async function getHistoryFilePath(projectRoot: string): Promise<string> {
- const historyDir = getProjectTempDir(projectRoot);
- return path.join(historyDir, HISTORY_FILE);
+async function getHistoryFilePath(
+ projectRoot: string,
+ configStorage?: Storage,
+): Promise<string> {
+ const storage = configStorage ?? new Storage(projectRoot);
+ return storage.getHistoryFilePath();
}
// Handle multiline commands
@@ -67,20 +69,23 @@ async function writeHistoryFile(
}
}
-export function useShellHistory(projectRoot: string): UseShellHistoryReturn {
+export function useShellHistory(
+ projectRoot: string,
+ storage?: Storage,
+): UseShellHistoryReturn {
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const [historyFilePath, setHistoryFilePath] = useState<string | null>(null);
useEffect(() => {
async function loadHistory() {
- const filePath = await getHistoryFilePath(projectRoot);
+ const filePath = await getHistoryFilePath(projectRoot, storage);
setHistoryFilePath(filePath);
const loadedHistory = await readHistoryFile(filePath);
setHistory(loadedHistory.reverse()); // Newest first
}
loadHistory();
- }, [projectRoot]);
+ }, [projectRoot, storage]);
const addCommandToHistory = useCallback(
(command: string) => {
diff --git a/packages/cli/src/utils/cleanup.ts b/packages/cli/src/utils/cleanup.ts
index 1200b6da..33ca9ddb 100644
--- a/packages/cli/src/utils/cleanup.ts
+++ b/packages/cli/src/utils/cleanup.ts
@@ -6,7 +6,7 @@
import { promises as fs } from 'fs';
import { join } from 'path';
-import { getProjectTempDir } from '@google/gemini-cli-core';
+import { Storage } from '@google/gemini-cli-core';
const cleanupFunctions: Array<(() => void) | (() => Promise<void>)> = [];
@@ -26,7 +26,8 @@ export async function runExitCleanup() {
}
export async function cleanupCheckpoints() {
- const tempDir = getProjectTempDir(process.cwd());
+ const storage = new Storage(process.cwd());
+ const tempDir = storage.getProjectTempDir();
const checkpointsDir = join(tempDir, 'checkpoints');
try {
await fs.rm(checkpointsDir, { recursive: true, force: true });