diff --git a/packages/cli/src/commands/hooks/migrate.ts b/packages/cli/src/commands/hooks/migrate.ts index 36a1344f74..008997d4fe 100644 --- a/packages/cli/src/commands/hooks/migrate.ts +++ b/packages/cli/src/commands/hooks/migrate.ts @@ -230,8 +230,7 @@ export async function handleMigrateFromClaude() { const settings = loadSettings(workingDir); // Merge migrated hooks with existing hooks - const existingHooks = - (settings.merged.hooks as Record) || {}; + const existingHooks = settings.merged.hooks as Record; const mergedHooks = { ...existingHooks, ...migratedHooks }; // Update settings (setValue automatically saves) diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts index 7d78d48233..fed9fb6a5c 100644 --- a/packages/cli/src/commands/mcp/list.test.ts +++ b/packages/cli/src/commands/mcp/list.test.ts @@ -6,15 +6,20 @@ import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; import { listMcpServers } from './list.js'; -import { loadSettings } from '../../config/settings.js'; +import { loadSettings, mergeSettings } from '../../config/settings.js'; import { createTransport, debugLogger } from '@google/gemini-cli-core'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { ExtensionStorage } from '../../config/extensions/storage.js'; import { ExtensionManager } from '../../config/extension-manager.js'; -vi.mock('../../config/settings.js', () => ({ - loadSettings: vi.fn(), -})); +vi.mock('../../config/settings.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadSettings: vi.fn(), + }; +}); vi.mock('../../config/extensions/storage.js', () => ({ ExtensionStorage: { getUserExtensionsDir: vi.fn(), @@ -32,11 +37,16 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { 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', - })), + Storage: Object.assign( + vi.fn().mockImplementation((_cwd: string) => ({ + getGlobalSettingsPath: () => '/tmp/gemini/settings.json', + getWorkspaceSettingsPath: () => '/tmp/gemini/workspace-settings.json', + getProjectTempDir: () => '/test/home/.gemini/tmp/mocked_hash', + })), + { + getGlobalSettingsPath: () => '/tmp/gemini/settings.json', + }, + ), GEMINI_DIR: '.gemini', getErrorMessage: (e: unknown) => e instanceof Error ? e.message : String(e), @@ -96,7 +106,10 @@ describe('mcp list command', () => { }); it('should display message when no servers configured', async () => { - mockedLoadSettings.mockReturnValue({ merged: { mcpServers: {} } }); + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + mockedLoadSettings.mockReturnValue({ + merged: { ...defaultMergedSettings, mcpServers: {} }, + }); await listMcpServers(); @@ -104,8 +117,10 @@ describe('mcp list command', () => { }); it('should display different server types with connected status', async () => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockedLoadSettings.mockReturnValue({ merged: { + ...defaultMergedSettings, mcpServers: { 'stdio-server': { command: '/path/to/server', args: ['arg1'] }, 'sse-server': { url: 'https://example.com/sse' }, @@ -138,8 +153,10 @@ describe('mcp list command', () => { }); it('should display disconnected status when connection fails', async () => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockedLoadSettings.mockReturnValue({ merged: { + ...defaultMergedSettings, mcpServers: { 'test-server': { command: '/test/server' }, }, @@ -158,9 +175,13 @@ describe('mcp list command', () => { }); it('should merge extension servers with config servers', async () => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockedLoadSettings.mockReturnValue({ merged: { - mcpServers: { 'config-server': { command: '/config/server' } }, + ...defaultMergedSettings, + mcpServers: { + 'config-server': { command: '/config/server' }, + }, }, }); diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts index b41baec960..27a25fec4a 100644 --- a/packages/cli/src/commands/mcp/list.ts +++ b/packages/cli/src/commands/mcp/list.ts @@ -35,7 +35,7 @@ async function getMcpServersFromConfig(): Promise< requestSetting: promptForSetting, }); const extensions = await extensionManager.loadExtensions(); - const mcpServers = { ...(settings.merged.mcpServers || {}) }; + const mcpServers = { ...settings.merged.mcpServers }; for (const extension of extensions) { Object.entries(extension.mcpServers || {}).forEach(([key, server]) => { if (mcpServers[key]) { @@ -63,8 +63,7 @@ async function testMCPConnection( const sanitizationConfig = { enableEnvironmentVariableRedaction: true, allowedEnvironmentVariables: [], - blockedEnvironmentVariables: - settings.merged.advanced?.excludedEnvVars || [], + blockedEnvironmentVariables: settings.merged.advanced.excludedEnvVars, }; let transport; diff --git a/packages/cli/src/config/config.integration.test.ts b/packages/cli/src/config/config.integration.test.ts index 49afb1ae5b..6797be4447 100644 --- a/packages/cli/src/config/config.integration.test.ts +++ b/packages/cli/src/config/config.integration.test.ts @@ -22,8 +22,9 @@ import { Config, DEFAULT_FILE_FILTERING_OPTIONS, } from '@google/gemini-cli-core'; -import type { Settings } from './settingsSchema.js'; +import { createTestMergedSettings } from './settings.js'; import { http, HttpResponse } from 'msw'; + import { setupServer } from 'msw/node'; export const server = setupServer(); @@ -212,7 +213,7 @@ describe('Configuration Integration Tests', () => { const originalArgv = process.argv; try { process.argv = argv; - const parsedArgs = await parseArguments({} as Settings); + const parsedArgs = await parseArguments(createTestMergedSettings()); expect(parsedArgs.approvalMode).toBe(expected.approvalMode); expect(parsedArgs.prompt).toBe(expected.prompt); expect(parsedArgs.yolo).toBe(expected.yolo); @@ -235,7 +236,9 @@ describe('Configuration Integration Tests', () => { const originalArgv = process.argv; try { process.argv = argv; - await expect(parseArguments({} as Settings)).rejects.toThrow(); + await expect( + parseArguments(createTestMergedSettings()), + ).rejects.toThrow(); } finally { process.argv = originalArgv; } diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 78a4847fd2..3dd1a6e155 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -19,8 +19,9 @@ import { ApprovalMode, } from '@google/gemini-cli-core'; import { loadCliConfig, parseArguments, type CliArgs } from './config.js'; -import type { Settings } from './settings.js'; +import { type Settings, createTestMergedSettings } from './settings.js'; import * as ServerConfig from '@google/gemini-cli-core'; + import { isWorkspaceTrusted } from './trustedFolders.js'; import { ExtensionManager } from './extension-manager.js'; import { RESUME_LATEST } from '../utils/sessionUtils.js'; @@ -189,7 +190,7 @@ describe('parseArguments', () => { .spyOn(console, 'error') .mockImplementation(() => {}); - await expect(parseArguments({} as Settings)).rejects.toThrow( + await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( 'process.exit called', ); @@ -222,7 +223,7 @@ describe('parseArguments', () => { }, ])('$description', async ({ argv, expected }) => { process.argv = argv; - const parsedArgs = await parseArguments({} as Settings); + const parsedArgs = await parseArguments(createTestMergedSettings()); expect(parsedArgs.prompt).toBe(expected.prompt); expect(parsedArgs.promptInteractive).toBe(expected.promptInteractive); }); @@ -344,7 +345,7 @@ describe('parseArguments', () => { '$description', async ({ argv, expectedQuery, expectedModel, debug }) => { process.argv = argv; - const parsedArgs = await parseArguments({} as Settings); + const parsedArgs = await parseArguments(createTestMergedSettings()); expect(parsedArgs.query).toBe(expectedQuery); expect(parsedArgs.prompt).toBe(expectedQuery); expect(parsedArgs.promptInteractive).toBeUndefined(); @@ -380,7 +381,7 @@ describe('parseArguments', () => { .spyOn(console, 'error') .mockImplementation(() => {}); - await expect(parseArguments({} as Settings)).rejects.toThrow( + await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( 'process.exit called', ); @@ -408,7 +409,7 @@ describe('parseArguments', () => { }, ])('$description', async ({ argv, expected }) => { process.argv = argv; - const parsedArgs = await parseArguments({} as Settings); + const parsedArgs = await parseArguments(createTestMergedSettings()); expect(parsedArgs.approvalMode).toBe(expected.approvalMode); expect(parsedArgs.yolo).toBe(expected.yolo); }); @@ -427,7 +428,7 @@ describe('parseArguments', () => { .spyOn(debugLogger, 'error') .mockImplementation(() => {}); - await expect(parseArguments({} as Settings)).rejects.toThrow( + await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( 'process.exit called', ); @@ -447,7 +448,7 @@ describe('parseArguments', () => { process.argv = ['node', 'script.js', '--resume', 'session-id']; try { - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.resume).toBe('session-id'); } finally { process.stdin.isTTY = originalIsTTY; @@ -460,7 +461,7 @@ describe('parseArguments', () => { process.argv = ['node', 'script.js', '--resume']; try { - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.resume).toBe(RESUME_LATEST); expect(argv.resume).toBe('latest'); } finally { @@ -475,7 +476,7 @@ describe('parseArguments', () => { '--allowed-tools', 'read_file,ShellTool(git status)', ]; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.allowedTools).toEqual(['read_file', 'ShellTool(git status)']); }); @@ -486,13 +487,13 @@ describe('parseArguments', () => { '--allowed-mcp-server-names', 'server1,server2', ]; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.allowedMcpServerNames).toEqual(['server1', 'server2']); }); it('should support comma-separated values for --extensions', async () => { process.argv = ['node', 'script.js', '--extensions', 'ext1,ext2']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.extensions).toEqual(['ext1', 'ext2']); }); @@ -504,7 +505,7 @@ describe('parseArguments', () => { 'test-model-string', 'my-positional-arg', ]; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.model).toBe('test-model-string'); expect(argv.query).toBe('my-positional-arg'); }); @@ -521,7 +522,7 @@ describe('parseArguments', () => { '--allowed-tools=ShellTool(wc)', 'Use whoami to write a poem in file poem.md about my username in pig latin and use wc to tell me how many lines are in the poem you wrote.', ]; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.extensions).toEqual(['none']); expect(argv.approvalMode).toBe('auto_edit'); expect(argv.allowedTools).toEqual([ @@ -576,8 +577,8 @@ describe('loadCliConfig', () => { it(`should leave proxy to empty by default`, async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getProxy()).toBeFalsy(); }); @@ -617,8 +618,8 @@ describe('loadCliConfig', () => { it(`should set proxy to ${expected} according to environment variable [${input.env_name}]`, async () => { vi.stubEnv(input.env_name, input.proxy_url); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getProxy()).toBe(expected); }); @@ -627,8 +628,8 @@ describe('loadCliConfig', () => { it('should use default fileFilter options when unconfigured', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getFileFilteringRespectGitIgnore()).toBe( DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore, @@ -653,7 +654,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { it('should pass extension context file paths to loadServerHierarchicalMemory', async () => { process.argv = ['node', 'script.js']; - const settings: Settings = {}; + const settings = createTestMergedSettings(); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', @@ -683,7 +684,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { isActive: true, }, ]); - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); await loadCliConfig(settings, 'session-id', argv); expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( expect.any(String), @@ -693,24 +694,24 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { expect.any(ExtensionManager), true, 'tree', - { - respectGitIgnore: false, + expect.objectContaining({ + respectGitIgnore: true, respectGeminiIgnore: true, - }, - undefined, // maxDirs + }), + 200, // maxDirs ); }); }); describe('mergeMcpServers', () => { it('should not modify the original settings object', async () => { - const settings: Settings = { + const settings = createTestMergedSettings({ mcpServers: { 'test-server': { url: 'http://localhost:8080', }, }, - }; + }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { @@ -730,7 +731,7 @@ describe('mergeMcpServers', () => { ]); const originalSettings = JSON.parse(JSON.stringify(settings)); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); await loadCliConfig(settings, 'test-session', argv); expect(settings).toEqual(originalSettings); }); @@ -755,7 +756,9 @@ describe('mergeExcludeTools', () => { }); it('should merge excludeTools from settings and extensions', async () => { - const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } }; + const settings = createTestMergedSettings({ + tools: { exclude: ['tool1', 'tool2'] }, + }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', @@ -777,7 +780,7 @@ describe('mergeExcludeTools', () => { }, ]); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( settings, @@ -791,7 +794,9 @@ describe('mergeExcludeTools', () => { }); it('should handle overlapping excludeTools between settings and extensions', async () => { - const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } }; + const settings = createTestMergedSettings({ + tools: { exclude: ['tool1', 'tool2'] }, + }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', @@ -804,7 +809,7 @@ describe('mergeExcludeTools', () => { }, ]); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual( new Set(['tool1', 'tool2', 'tool3']), @@ -813,7 +818,9 @@ describe('mergeExcludeTools', () => { }); it('should handle overlapping excludeTools between extensions', async () => { - const settings: Settings = { tools: { exclude: ['tool1'] } }; + const settings = createTestMergedSettings({ + tools: { exclude: ['tool1'] }, + }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', @@ -835,7 +842,7 @@ describe('mergeExcludeTools', () => { }, ]); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual( new Set(['tool1', 'tool2', 'tool3', 'tool4']), @@ -845,26 +852,28 @@ describe('mergeExcludeTools', () => { it('should return an empty array when no excludeTools are specified and it is interactive', async () => { process.stdin.isTTY = true; - const settings: Settings = {}; + const settings = createTestMergedSettings(); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual(new Set([])); }); it('should return default excludes when no excludeTools are specified and it is not interactive', async () => { process.stdin.isTTY = false; - const settings: Settings = {}; + const settings = createTestMergedSettings(); process.argv = ['node', 'script.js', '-p', 'test']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual(defaultExcludes); }); it('should handle settings with excludeTools but no extensions', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + tools: { exclude: ['tool1', 'tool2'] }, + }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual(new Set(['tool1', 'tool2'])); @@ -872,7 +881,7 @@ describe('mergeExcludeTools', () => { }); it('should handle extensions with excludeTools but no settings', async () => { - const settings: Settings = {}; + const settings = createTestMergedSettings(); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext', @@ -885,14 +894,16 @@ describe('mergeExcludeTools', () => { }, ]); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual(new Set(['tool1', 'tool2'])); expect(config.getExcludeTools()).toHaveLength(2); }); it('should not modify the original settings object', async () => { - const settings: Settings = { tools: { exclude: ['tool1'] } }; + const settings = createTestMergedSettings({ + tools: { exclude: ['tool1'] }, + }); vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext', @@ -906,7 +917,7 @@ describe('mergeExcludeTools', () => { ]); const originalSettings = JSON.parse(JSON.stringify(settings)); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); await loadCliConfig(settings, 'test-session', argv); expect(settings).toEqual(originalSettings); }); @@ -930,8 +941,8 @@ describe('Approval mode tool exclusion logic', () => { it('should exclude all interactive tools in non-interactive mode with default approval mode', async () => { process.argv = ['node', 'script.js', '-p', 'test']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); @@ -949,8 +960,8 @@ describe('Approval mode tool exclusion logic', () => { '-p', 'test', ]; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); @@ -969,8 +980,8 @@ describe('Approval mode tool exclusion logic', () => { '-p', 'test', ]; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); @@ -989,8 +1000,8 @@ describe('Approval mode tool exclusion logic', () => { '-p', 'test', ]; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); @@ -1002,8 +1013,8 @@ describe('Approval mode tool exclusion logic', () => { it('should exclude no interactive tools in non-interactive mode with legacy yolo flag', async () => { process.argv = ['node', 'script.js', '--yolo', '-p', 'test']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); @@ -1026,8 +1037,8 @@ describe('Approval mode tool exclusion logic', () => { for (const testCase of testCases) { process.argv = testCase.args; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); @@ -1047,8 +1058,10 @@ describe('Approval mode tool exclusion logic', () => { '-p', 'test', ]; - const argv = await parseArguments({} as Settings); - const settings: Settings = { tools: { exclude: ['custom_tool'] } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + tools: { exclude: ['custom_tool'] }, + }); const config = await loadCliConfig(settings, 'test-session', argv); @@ -1061,12 +1074,12 @@ describe('Approval mode tool exclusion logic', () => { it('should throw an error if YOLO mode is attempted when disableYoloMode is true', async () => { process.argv = ['node', 'script.js', '--yolo']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ security: { disableYoloMode: true, }, - }; + }); await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( 'Cannot start in YOLO mode since it is disabled by your admin', @@ -1082,7 +1095,7 @@ describe('Approval mode tool exclusion logic', () => { yolo: false, }; - const settings: Settings = {}; + const settings = createTestMergedSettings(); await expect( loadCliConfig(settings, 'test-session', invalidArgv as CliArgs), ).rejects.toThrow( @@ -1104,17 +1117,17 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { vi.restoreAllMocks(); }); - const baseSettings: Settings = { + const baseSettings = createTestMergedSettings({ mcpServers: { server1: { url: 'http://localhost:8080' }, server2: { url: 'http://localhost:8081' }, server3: { url: 'http://localhost:8082' }, }, - }; + }); it('should allow all MCP servers if the flag is not provided', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getMcpServers()).toEqual(baseSettings.mcpServers); }); @@ -1126,7 +1139,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { '--allowed-mcp-server-names', 'server1', ]; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server1']); }); @@ -1140,7 +1153,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { '--allowed-mcp-server-names', 'server3', ]; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server1', 'server3']); }); @@ -1154,50 +1167,50 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { '--allowed-mcp-server-names', 'server4', ]; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server1', 'server4']); }); it('should allow no MCP servers if the flag is provided but empty', async () => { process.argv = ['node', 'script.js', '--allowed-mcp-server-names', '']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['']); }); it('should read allowMCPServers from settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ...baseSettings, mcp: { allowed: ['server1', 'server2'] }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server1', 'server2']); }); it('should read excludeMCPServers from settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ...baseSettings, mcp: { excluded: ['server1', 'server2'] }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getBlockedMcpServers()).toEqual(['server1', 'server2']); }); it('should override allowMCPServers with excludeMCPServers if overlapping', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ...baseSettings, mcp: { excluded: ['server1'], allowed: ['server1', 'server2'], }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server1', 'server2']); expect(config.getBlockedMcpServers()).toEqual(['server1']); @@ -1210,14 +1223,14 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { '--allowed-mcp-server-names', 'server1', ]; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ...baseSettings, mcp: { excluded: ['server1'], allowed: ['server2'], }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server1']); }); @@ -1231,14 +1244,14 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { '--allowed-mcp-server-names', 'server3', ]; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ...baseSettings, mcp: { allowed: ['server1', 'server2'], // Should be ignored excluded: ['server3'], // Should be ignored }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getAllowedMcpServers()).toEqual(['server2', 'server3']); expect(config.getBlockedMcpServers()).toEqual([]); @@ -1256,13 +1269,13 @@ describe('loadCliConfig model selection', () => { it('selects a model from settings.json if provided', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( - { + createTestMergedSettings({ model: { name: 'gemini-2.5-pro', }, - }, + }), 'test-session', argv, ); @@ -1272,11 +1285,11 @@ describe('loadCliConfig model selection', () => { it('uses the default gemini model if nothing is set', async () => { process.argv = ['node', 'script.js']; // No model set. - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( - { + createTestMergedSettings({ // No model set. - }, + }), 'test-session', argv, ); @@ -1286,13 +1299,13 @@ describe('loadCliConfig model selection', () => { it('always prefers model from argv', async () => { process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash-preview']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( - { + createTestMergedSettings({ model: { name: 'gemini-2.5-pro', }, - }, + }), 'test-session', argv, ); @@ -1302,11 +1315,11 @@ describe('loadCliConfig model selection', () => { it('selects the model from argv if provided', async () => { process.argv = ['node', 'script.js', '--model', 'gemini-2.5-flash-preview']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( - { + createTestMergedSettings({ // No model provided via settings. - }, + }), 'test-session', argv, ); @@ -1316,11 +1329,11 @@ describe('loadCliConfig model selection', () => { it('selects the default auto model if provided via auto alias', async () => { process.argv = ['node', 'script.js', '--model', 'auto']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( - { + createTestMergedSettings({ // No model provided via settings. - }, + }), 'test-session', argv, ); @@ -1344,36 +1357,36 @@ describe('loadCliConfig folderTrust', () => { it('should be false when folderTrust is false', async () => { process.argv = ['node', 'script.js']; - const settings: Settings = { + const settings = createTestMergedSettings({ security: { folderTrust: { enabled: false, }, }, - }; - const argv = await parseArguments({} as Settings); + }); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getFolderTrust()).toBe(false); }); it('should be true when folderTrust is true', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ security: { folderTrust: { enabled: true, }, }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getFolderTrust()).toBe(true); }); it('should be false by default', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getFolderTrust()).toBe(false); }); @@ -1404,8 +1417,8 @@ describe('loadCliConfig with includeDirectories', () => { '--include-directories', `${path.resolve(path.sep, 'cli', 'path1')},${path.join(mockCwd, 'cli', 'path2')}`, ]; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ context: { includeDirectories: [ path.resolve(path.sep, 'settings', 'path1'), @@ -1413,7 +1426,7 @@ describe('loadCliConfig with includeDirectories', () => { path.join(mockCwd, 'settings', 'path3'), ], }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); const expected = [ mockCwd, @@ -1449,22 +1462,22 @@ describe('loadCliConfig compressionThreshold', () => { it('should pass settings to the core config', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ model: { compressionThreshold: 0.5, }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(await config.getCompressionThreshold()).toBe(0.5); }); - it('should have undefined compressionThreshold if not in settings', async () => { + it('should have default compressionThreshold if not in settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); - expect(await config.getCompressionThreshold()).toBeUndefined(); + expect(await config.getCompressionThreshold()).toBe(0.5); }); }); @@ -1483,24 +1496,24 @@ describe('loadCliConfig useRipgrep', () => { it('should be true by default when useRipgrep is not set in settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getUseRipgrep()).toBe(true); }); it('should be false when useRipgrep is set to false in settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { tools: { useRipgrep: false } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ tools: { useRipgrep: false } }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getUseRipgrep()).toBe(false); }); it('should be true when useRipgrep is explicitly set to true in settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { tools: { useRipgrep: true } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ tools: { useRipgrep: true } }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getUseRipgrep()).toBe(true); }); @@ -1521,38 +1534,38 @@ describe('screenReader configuration', () => { it('should use screenReader value from settings if CLI flag is not present (settings true)', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ui: { accessibility: { screenReader: true } }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getScreenReader()).toBe(true); }); it('should use screenReader value from settings if CLI flag is not present (settings false)', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ui: { accessibility: { screenReader: false } }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getScreenReader()).toBe(false); }); it('should prioritize --screen-reader CLI flag (true) over settings (false)', async () => { process.argv = ['node', 'script.js', '--screen-reader']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ui: { accessibility: { screenReader: false } }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getScreenReader()).toBe(true); }); it('should be false by default when no flag or setting is present', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = {}; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getScreenReader()).toBe(false); }); @@ -1582,8 +1595,12 @@ describe('loadCliConfig tool exclusions', () => { it('should not exclude interactive tools in interactive mode without YOLO', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).not.toContain('run_shell_command'); expect(config.getExcludeTools()).not.toContain('replace'); expect(config.getExcludeTools()).not.toContain('write_file'); @@ -1592,8 +1609,12 @@ describe('loadCliConfig tool exclusions', () => { it('should not exclude interactive tools in interactive mode with YOLO', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '--yolo']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).not.toContain('run_shell_command'); expect(config.getExcludeTools()).not.toContain('replace'); expect(config.getExcludeTools()).not.toContain('write_file'); @@ -1602,8 +1623,12 @@ describe('loadCliConfig tool exclusions', () => { it('should exclude interactive tools in non-interactive mode without YOLO', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js', '-p', 'test']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).toContain('run_shell_command'); expect(config.getExcludeTools()).toContain('replace'); expect(config.getExcludeTools()).toContain('write_file'); @@ -1612,8 +1637,12 @@ describe('loadCliConfig tool exclusions', () => { it('should not exclude interactive tools in non-interactive mode with YOLO', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js', '-p', 'test', '--yolo']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).not.toContain('run_shell_command'); expect(config.getExcludeTools()).not.toContain('replace'); expect(config.getExcludeTools()).not.toContain('write_file'); @@ -1629,16 +1658,24 @@ describe('loadCliConfig tool exclusions', () => { '--allowed-tools', 'ShellTool', ]; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).not.toContain(SHELL_TOOL_NAME); }); it('should exclude web-fetch in non-interactive mode when not allowed', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js', '-p', 'test']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).toContain(WEB_FETCH_TOOL_NAME); }); @@ -1652,8 +1689,12 @@ describe('loadCliConfig tool exclusions', () => { '--allowed-tools', WEB_FETCH_TOOL_NAME, ]; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).not.toContain(WEB_FETCH_TOOL_NAME); }); @@ -1667,8 +1708,12 @@ describe('loadCliConfig tool exclusions', () => { '--allowed-tools', 'run_shell_command', ]; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).not.toContain(SHELL_TOOL_NAME); }); @@ -1682,8 +1727,12 @@ describe('loadCliConfig tool exclusions', () => { '--allowed-tools', 'ShellTool(wc)', ]; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getExcludeTools()).not.toContain(SHELL_TOOL_NAME); }); }); @@ -1708,40 +1757,60 @@ describe('loadCliConfig interactive', () => { it('should be interactive if isTTY and no prompt', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(true); }); it('should be interactive if prompt-interactive is set', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js', '--prompt-interactive', 'test']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(true); }); it('should not be interactive if not isTTY and no prompt', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(false); }); it('should not be interactive if prompt is set', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '--prompt', 'test']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(false); }); it('should not be interactive if positional prompt words are provided with other flags', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '--model', 'gemini-2.5-pro', 'Hello']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(false); }); @@ -1755,8 +1824,12 @@ describe('loadCliConfig interactive', () => { '--yolo', 'Hello world', ]; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(false); // Verify the question is preserved for one-shot execution expect(argv.prompt).toBe('Hello world'); @@ -1766,8 +1839,12 @@ describe('loadCliConfig interactive', () => { it('should not be interactive if positional prompt words are provided with extensions flag', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '-e', 'none', 'hello']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(false); expect(argv.query).toBe('hello'); expect(argv.extensions).toEqual(['none']); @@ -1776,8 +1853,12 @@ describe('loadCliConfig interactive', () => { it('should handle multiple positional words correctly', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', 'hello world how are you']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(false); expect(argv.query).toBe('hello world how are you'); expect(argv.prompt).toBe('hello world how are you'); @@ -1797,8 +1878,12 @@ describe('loadCliConfig interactive', () => { 'sort', 'array', ]; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(false); expect(argv.query).toBe('write a function to sort array'); expect(argv.model).toBe('gemini-2.5-pro'); @@ -1807,8 +1892,12 @@ describe('loadCliConfig interactive', () => { it('should handle empty positional arguments', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(true); expect(argv.query).toBeUndefined(); }); @@ -1826,8 +1915,12 @@ describe('loadCliConfig interactive', () => { 'are', 'you', ]; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(false); expect(argv.query).toBe('hello world how are you'); expect(argv.extensions).toEqual(['none']); @@ -1836,8 +1929,12 @@ describe('loadCliConfig interactive', () => { it('should be interactive if no positional prompt words are provided with flags', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '--model', 'gemini-2.5-pro']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(true); }); }); @@ -1865,43 +1962,67 @@ describe('loadCliConfig approval mode', () => { it('should default to DEFAULT approval mode when no flags are set', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should set YOLO approval mode when --yolo flag is used', async () => { process.argv = ['node', 'script.js', '--yolo']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); it('should set YOLO approval mode when -y flag is used', async () => { process.argv = ['node', 'script.js', '-y']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); it('should set DEFAULT approval mode when --approval-mode=default', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'default']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should set AUTO_EDIT approval mode when --approval-mode=auto_edit', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.AUTO_EDIT); }); it('should set YOLO approval mode when --approval-mode=yolo', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'yolo']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); @@ -1909,17 +2030,25 @@ describe('loadCliConfig approval mode', () => { // Note: This test documents the intended behavior, but in practice the validation // prevents both flags from being used together process.argv = ['node', 'script.js', '--approval-mode', 'default']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); // Manually set yolo to true to simulate what would happen if validation didn't prevent it argv.yolo = true; - const config = await loadCliConfig({}, 'test-session', argv); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should fall back to --yolo behavior when --approval-mode is not set', async () => { process.argv = ['node', 'script.js', '--yolo']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); @@ -1934,29 +2063,45 @@ describe('loadCliConfig approval mode', () => { it('should override --approval-mode=yolo to DEFAULT', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'yolo']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should override --approval-mode=auto_edit to DEFAULT', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should override --yolo flag to DEFAULT', async () => { process.argv = ['node', 'script.js', '--yolo']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should remain DEFAULT when --approval-mode=default', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'default']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); }); @@ -2032,11 +2177,11 @@ describe('loadCliConfig fileFiltering', () => { it.each(testCases)( 'should pass $property from settings to config when $value', async ({ property, getter, value }) => { - const settings: Settings = { + const settings = createTestMergedSettings({ context: { fileFiltering: { [property]: value }, }, - }; + }); const argv = await parseArguments(settings); const config = await loadCliConfig(settings, 'test-session', argv); expect(getter(config)).toBe(value); @@ -2055,16 +2200,20 @@ describe('Output format', () => { it('should default to TEXT', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getOutputFormat()).toBe(OutputFormat.TEXT); }); it('should use the format from settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( - { output: { format: OutputFormat.JSON } }, + createTestMergedSettings({ output: { format: OutputFormat.JSON } }), 'test-session', argv, ); @@ -2073,9 +2222,9 @@ describe('Output format', () => { it('should prioritize the format from argv', async () => { process.argv = ['node', 'script.js', '--output-format', 'json']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( - { output: { format: OutputFormat.JSON } }, + createTestMergedSettings({ output: { format: OutputFormat.JSON } }), 'test-session', argv, ); @@ -2084,8 +2233,12 @@ describe('Output format', () => { it('should accept stream-json as a valid output format', async () => { process.argv = ['node', 'script.js', '--output-format', 'stream-json']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getOutputFormat()).toBe(OutputFormat.STREAM_JSON); }); @@ -2103,7 +2256,7 @@ describe('Output format', () => { .spyOn(debugLogger, 'error') .mockImplementation(() => {}); - await expect(parseArguments({} as Settings)).rejects.toThrow( + await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( 'process.exit called', ); expect(debugErrorSpy).toHaveBeenCalledWith( @@ -2145,7 +2298,7 @@ describe('parseArguments with positional prompt', () => { .spyOn(debugLogger, 'error') .mockImplementation(() => {}); - await expect(parseArguments({} as Settings)).rejects.toThrow( + await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( 'process.exit called', ); @@ -2162,7 +2315,7 @@ describe('parseArguments with positional prompt', () => { it('should correctly parse a positional prompt to query field', async () => { process.argv = ['node', 'script.js', 'positional', 'prompt']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.query).toBe('positional prompt'); // Since no explicit prompt flags are set and query doesn't start with @, should map to prompt (one-shot) expect(argv.prompt).toBe('positional prompt'); @@ -2175,13 +2328,13 @@ describe('parseArguments with positional prompt', () => { // This test verifies that the positional 'query' argument is properly configured // with the description: "Positional prompt. Defaults to one-shot; use -i/--prompt-interactive for interactive." process.argv = ['node', 'script.js', 'test', 'query']; - const argv = await yargsInstance.parseArguments({} as Settings); + const argv = await yargsInstance.parseArguments(createTestMergedSettings()); expect(argv.query).toBe('test query'); }); it('should correctly parse a prompt from the --prompt flag', async () => { process.argv = ['node', 'script.js', '--prompt', 'test prompt']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); expect(argv.prompt).toBe('test prompt'); }); }); @@ -2197,8 +2350,10 @@ describe('Telemetry configuration via environment variables', () => { it('should prioritize GEMINI_TELEMETRY_ENABLED over settings', async () => { vi.stubEnv('GEMINI_TELEMETRY_ENABLED', 'true'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { telemetry: { enabled: false } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + telemetry: { enabled: false }, + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryEnabled()).toBe(true); }); @@ -2206,10 +2361,10 @@ describe('Telemetry configuration via environment variables', () => { it('should prioritize GEMINI_TELEMETRY_TARGET over settings', async () => { vi.stubEnv('GEMINI_TELEMETRY_TARGET', 'gcp'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ telemetry: { target: ServerConfig.TelemetryTarget.LOCAL }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryTarget()).toBe('gcp'); }); @@ -2217,10 +2372,10 @@ describe('Telemetry configuration via environment variables', () => { it('should throw when GEMINI_TELEMETRY_TARGET is invalid', async () => { vi.stubEnv('GEMINI_TELEMETRY_TARGET', 'bogus'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ telemetry: { target: ServerConfig.TelemetryTarget.GCP }, - }; + }); await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( /Invalid telemetry configuration: .*Invalid telemetry target/i, ); @@ -2231,10 +2386,10 @@ describe('Telemetry configuration via environment variables', () => { vi.stubEnv('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://default.env.com'); vi.stubEnv('GEMINI_TELEMETRY_OTLP_ENDPOINT', 'http://gemini.env.com'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ telemetry: { otlpEndpoint: 'http://settings.com' }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryOtlpEndpoint()).toBe('http://gemini.env.com'); }); @@ -2242,8 +2397,10 @@ describe('Telemetry configuration via environment variables', () => { it('should prioritize GEMINI_TELEMETRY_OTLP_PROTOCOL over settings', async () => { vi.stubEnv('GEMINI_TELEMETRY_OTLP_PROTOCOL', 'http'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { telemetry: { otlpProtocol: 'grpc' } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + telemetry: { otlpProtocol: 'grpc' }, + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryOtlpProtocol()).toBe('http'); }); @@ -2251,8 +2408,10 @@ describe('Telemetry configuration via environment variables', () => { it('should prioritize GEMINI_TELEMETRY_LOG_PROMPTS over settings', async () => { vi.stubEnv('GEMINI_TELEMETRY_LOG_PROMPTS', 'false'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { telemetry: { logPrompts: true } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + telemetry: { logPrompts: true }, + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryLogPromptsEnabled()).toBe(false); }); @@ -2260,10 +2419,10 @@ describe('Telemetry configuration via environment variables', () => { it('should prioritize GEMINI_TELEMETRY_OUTFILE over settings', async () => { vi.stubEnv('GEMINI_TELEMETRY_OUTFILE', '/gemini/env/telemetry.log'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ telemetry: { outfile: '/settings/telemetry.log' }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryOutfile()).toBe('/gemini/env/telemetry.log'); }); @@ -2271,8 +2430,10 @@ describe('Telemetry configuration via environment variables', () => { it('should prioritize GEMINI_TELEMETRY_USE_COLLECTOR over settings', async () => { vi.stubEnv('GEMINI_TELEMETRY_USE_COLLECTOR', 'true'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { telemetry: { useCollector: false } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + telemetry: { useCollector: false }, + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryUseCollector()).toBe(true); }); @@ -2280,8 +2441,8 @@ describe('Telemetry configuration via environment variables', () => { it('should use settings value when GEMINI_TELEMETRY_ENABLED is not set', async () => { vi.stubEnv('GEMINI_TELEMETRY_ENABLED', undefined); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { telemetry: { enabled: true } }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ telemetry: { enabled: true } }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryEnabled()).toBe(true); }); @@ -2289,10 +2450,10 @@ describe('Telemetry configuration via environment variables', () => { it('should use settings value when GEMINI_TELEMETRY_TARGET is not set', async () => { vi.stubEnv('GEMINI_TELEMETRY_TARGET', undefined); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ telemetry: { target: ServerConfig.TelemetryTarget.LOCAL }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryTarget()).toBe('local'); }); @@ -2300,17 +2461,21 @@ describe('Telemetry configuration via environment variables', () => { it("should treat GEMINI_TELEMETRY_ENABLED='1' as true", async () => { vi.stubEnv('GEMINI_TELEMETRY_ENABLED', '1'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getTelemetryEnabled()).toBe(true); }); it("should treat GEMINI_TELEMETRY_ENABLED='0' as false", async () => { vi.stubEnv('GEMINI_TELEMETRY_ENABLED', '0'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( - { telemetry: { enabled: true } }, + createTestMergedSettings({ telemetry: { enabled: true } }), 'test-session', argv, ); @@ -2320,17 +2485,21 @@ describe('Telemetry configuration via environment variables', () => { it("should treat GEMINI_TELEMETRY_LOG_PROMPTS='1' as true", async () => { vi.stubEnv('GEMINI_TELEMETRY_LOG_PROMPTS', '1'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.getTelemetryLogPromptsEnabled()).toBe(true); }); it("should treat GEMINI_TELEMETRY_LOG_PROMPTS='false' as false", async () => { vi.stubEnv('GEMINI_TELEMETRY_LOG_PROMPTS', 'false'); process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); + const argv = await parseArguments(createTestMergedSettings()); const config = await loadCliConfig( - { telemetry: { logPrompts: true } }, + createTestMergedSettings({ telemetry: { logPrompts: true } }), 'test-session', argv, ); @@ -2355,8 +2524,12 @@ describe('PolicyEngine nonInteractive wiring', () => { it('should set nonInteractive to true in one-shot mode', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', 'echo hello']; // Positional query makes it one-shot - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(false); expect( (config.getPolicyEngine() as unknown as { nonInteractive: boolean }) @@ -2367,8 +2540,12 @@ describe('PolicyEngine nonInteractive wiring', () => { it('should set nonInteractive to false in interactive mode', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, 'test-session', argv); + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); expect(config.isInteractive()).toBe(true); expect( (config.getPolicyEngine() as unknown as { nonInteractive: boolean }) @@ -2392,8 +2569,10 @@ describe('Policy Engine Integration in loadCliConfig', () => { it('should pass merged allowed tools from CLI and settings to createPolicyEngineConfig', async () => { process.argv = ['node', 'script.js', '--allowed-tools', 'cli-tool']; - const settings: Settings = { tools: { allowed: ['settings-tool'] } }; - const argv = await parseArguments({} as Settings); + const settings = createTestMergedSettings({ + tools: { allowed: ['settings-tool'] }, + }); + const argv = await parseArguments(createTestMergedSettings()); await loadCliConfig(settings, 'test-session', argv); @@ -2410,8 +2589,10 @@ describe('Policy Engine Integration in loadCliConfig', () => { it('should pass merged exclude tools from CLI logic and settings to createPolicyEngineConfig', async () => { process.stdin.isTTY = false; // Non-interactive to trigger default excludes process.argv = ['node', 'script.js', '-p', 'test']; - const settings: Settings = { tools: { exclude: ['settings-exclude'] } }; - const argv = await parseArguments({} as Settings); + const settings = createTestMergedSettings({ + tools: { exclude: ['settings-exclude'] }, + }); + const argv = await parseArguments(createTestMergedSettings()); await loadCliConfig(settings, 'test-session', argv); @@ -2446,20 +2627,20 @@ describe('loadCliConfig disableYoloMode', () => { it('should allow auto_edit mode even if yolo mode is disabled', async () => { process.argv = ['node', 'script.js', '--approval-mode=auto_edit']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ security: { disableYoloMode: true }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getApprovalMode()).toBe(ApprovalMode.AUTO_EDIT); }); it('should throw if YOLO mode is attempted when disableYoloMode is true', async () => { process.argv = ['node', 'script.js', '--yolo']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ security: { disableYoloMode: true }, - }; + }); await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( 'Cannot start in YOLO mode since it is disabled by your admin', ); @@ -2485,12 +2666,12 @@ describe('loadCliConfig secureModeEnabled', () => { it('should throw an error if YOLO mode is attempted when secureModeEnabled is true', async () => { process.argv = ['node', 'script.js', '--yolo']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ admin: { secureModeEnabled: true, }, - }; + }); await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( 'Cannot start in YOLO mode since it is disabled by your admin', @@ -2499,12 +2680,12 @@ describe('loadCliConfig secureModeEnabled', () => { it('should throw an error if approval-mode=yolo is attempted when secureModeEnabled is true', async () => { process.argv = ['node', 'script.js', '--approval-mode=yolo']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ admin: { secureModeEnabled: true, }, - }; + }); await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( 'Cannot start in YOLO mode since it is disabled by your admin', @@ -2513,12 +2694,12 @@ describe('loadCliConfig secureModeEnabled', () => { it('should set disableYoloMode to true when secureModeEnabled is true', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ admin: { secureModeEnabled: true, }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.isYoloModeDisabled()).toBe(true); }); @@ -2548,8 +2729,8 @@ describe('loadCliConfig mcpEnabled', () => { it('should enable MCP by default', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { ...mcpSettings }; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ...mcpSettings }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getMcpEnabled()).toBe(true); expect(config.getMcpServerCommand()).toBe('mcp-server'); @@ -2560,15 +2741,15 @@ describe('loadCliConfig mcpEnabled', () => { it('should disable MCP when mcpEnabled is false', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ...mcpSettings, admin: { mcp: { enabled: false, }, }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getMcpEnabled()).toBe(false); expect(config.getMcpServerCommand()).toBeUndefined(); @@ -2579,15 +2760,15 @@ describe('loadCliConfig mcpEnabled', () => { it('should enable MCP when mcpEnabled is true', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments({} as Settings); - const settings: Settings = { + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ ...mcpSettings, admin: { mcp: { enabled: true, }, }, - }; + }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getMcpEnabled()).toBe(true); expect(config.getMcpServerCommand()).toBe('mcp-server'); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 341226fed2..7dfa125bcb 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -38,8 +38,12 @@ import { type OutputFormat, GEMINI_MODEL_ALIAS_AUTO, } from '@google/gemini-cli-core'; -import type { Settings } from './settings.js'; -import { saveModelChange, loadSettings } from './settings.js'; +import { + type Settings, + type MergedSettings, + saveModelChange, + loadSettings, +} from './settings.js'; import { loadSandboxConfig } from './sandboxConfig.js'; import { resolvePath } from '../utils/resolvePath.js'; @@ -54,7 +58,6 @@ import { requestConsentNonInteractive } from './extensions/consent.js'; import { promptForSetting } from './extensions/extensionSettings.js'; import type { EventEmitter } from 'node:stream'; import { runExitCleanup } from '../utils/cleanup.js'; -import { getEnableHooks, getEnableHooksUI } from './settingsSchema.js'; export interface CliArgs { query: string | undefined; @@ -82,7 +85,9 @@ export interface CliArgs { recordResponses: string | undefined; } -export async function parseArguments(settings: Settings): Promise { +export async function parseArguments( + settings: MergedSettings, +): Promise { const rawArgv = hideBin(process.argv); const yargsInstance = yargs(rawArgv) .locale('en') @@ -280,16 +285,16 @@ export async function parseArguments(settings: Settings): Promise { return true; }); - if (settings?.experimental?.extensionManagement ?? true) { + if (settings.experimental.extensionManagement) { yargsInstance.command(extensionsCommand); } - if (settings?.experimental?.skills ?? false) { + if (settings.experimental.skills) { yargsInstance.command(skillsCommand); } // Register hooks command if hooks are enabled - if (getEnableHooksUI(settings)) { + if (settings.tools.enableHooks) { yargsInstance.command(hooksCommand); } @@ -392,7 +397,7 @@ export interface LoadCliConfigOptions { } export async function loadCliConfig( - settings: Settings, + settings: MergedSettings, sessionId: string, argv: CliArgs, options: LoadCliConfigOptions = {}, @@ -590,10 +595,7 @@ export async function loadCliConfig( } } - const excludeTools = mergeExcludeTools( - settings, - extraExcludes.length > 0 ? extraExcludes : undefined, - ); + const excludeTools = mergeExcludeTools(settings, extraExcludes); // Create a settings object that includes CLI overrides for policy generation const effectiveSettings: Settings = { @@ -742,15 +744,17 @@ export async function loadCliConfig( disableLLMCorrection: settings.tools?.disableLLMCorrection, modelConfigServiceConfig: settings.modelConfigs, // TODO: loading of hooks based on workspace trust - enableHooks: getEnableHooks(settings), - enableHooksUI: getEnableHooksUI(settings), + enableHooks: + (settings.tools?.enableHooks ?? true) && + (settings.hooks?.enabled ?? false), + enableHooksUI: settings.tools?.enableHooks ?? true, hooks: settings.hooks || {}, projectHooks: projectHooks || {}, onModelChange: (model: string) => saveModelChange(loadedSettings, model), onReload: async () => { const refreshedSettings = loadSettings(cwd); return { - disabledSkills: refreshedSettings.merged.skills?.disabled, + disabledSkills: refreshedSettings.merged.skills.disabled, agents: refreshedSettings.merged.agents, }; }, @@ -758,12 +762,12 @@ export async function loadCliConfig( } function mergeExcludeTools( - settings: Settings, - extraExcludes?: string[] | undefined, + settings: MergedSettings, + extraExcludes: string[] = [], ): string[] { const allExcludeTools = new Set([ - ...(settings.tools?.exclude || []), - ...(extraExcludes || []), + ...(settings.tools.exclude || []), + ...extraExcludes, ]); - return [...allExcludeTools]; + return Array.from(allExcludeTools); } diff --git a/packages/cli/src/config/extension-manager-agents.test.ts b/packages/cli/src/config/extension-manager-agents.test.ts index 7ae845875f..19ef150d22 100644 --- a/packages/cli/src/config/extension-manager-agents.test.ts +++ b/packages/cli/src/config/extension-manager-agents.test.ts @@ -10,7 +10,7 @@ import * as path from 'node:path'; import * as os from 'node:os'; import { ExtensionManager } from './extension-manager.js'; import { debugLogger } from '@google/gemini-cli-core'; -import { type Settings } from './settings.js'; +import { createTestMergedSettings } from './settings.js'; import { createExtension } from '../test-utils/createExtension.js'; import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js'; @@ -52,10 +52,9 @@ describe('ExtensionManager agents loading', () => { fs.mkdirSync(extensionsDir, { recursive: true }); extensionManager = new ExtensionManager({ - settings: { + settings: createTestMergedSettings({ telemetry: { enabled: false }, - trustedFolders: [tempDir], - } as unknown as Settings, + }), requestConsent: vi.fn().mockResolvedValue(true), requestSetting: vi.fn(), workspaceDir: tempDir, diff --git a/packages/cli/src/config/extension-manager-scope.test.ts b/packages/cli/src/config/extension-manager-scope.test.ts index 6d3e51b4d8..5079075366 100644 --- a/packages/cli/src/config/extension-manager-scope.test.ts +++ b/packages/cli/src/config/extension-manager-scope.test.ts @@ -9,7 +9,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { ExtensionManager } from './extension-manager.js'; -import type { Settings } from './settings.js'; +import { createTestMergedSettings } from './settings.js'; import { loadAgentsFromDirectory, loadSkillsFromDir, @@ -105,14 +105,10 @@ describe('ExtensionManager Settings Scope', () => { workspaceDir: tempWorkspace, requestConsent: async () => true, requestSetting: async () => '', - settings: { - telemetry: { - enabled: false, - }, - experimental: { - extensionConfig: true, - }, - } as Settings, + settings: createTestMergedSettings({ + telemetry: { enabled: false }, + experimental: { extensionConfig: true }, + }), }); const extensions = await extensionManager.loadExtensions(); @@ -147,14 +143,10 @@ describe('ExtensionManager Settings Scope', () => { workspaceDir: tempWorkspace, requestConsent: async () => true, requestSetting: async () => '', - settings: { - telemetry: { - enabled: false, - }, - experimental: { - extensionConfig: true, - }, - } as Settings, + settings: createTestMergedSettings({ + telemetry: { enabled: false }, + experimental: { extensionConfig: true }, + }), }); const extensions = await extensionManager.loadExtensions(); @@ -187,14 +179,10 @@ describe('ExtensionManager Settings Scope', () => { workspaceDir: tempWorkspace, requestConsent: async () => true, requestSetting: async () => '', - settings: { - telemetry: { - enabled: false, - }, - experimental: { - extensionConfig: true, - }, - } as Settings, + settings: createTestMergedSettings({ + telemetry: { enabled: false }, + experimental: { extensionConfig: true }, + }), }); const extensions = await extensionManager.loadExtensions(); diff --git a/packages/cli/src/config/extension-manager-skills.test.ts b/packages/cli/src/config/extension-manager-skills.test.ts index ecc0dfa3c0..a76d88482d 100644 --- a/packages/cli/src/config/extension-manager-skills.test.ts +++ b/packages/cli/src/config/extension-manager-skills.test.ts @@ -10,7 +10,7 @@ import * as path from 'node:path'; import * as os from 'node:os'; import { ExtensionManager } from './extension-manager.js'; import { debugLogger, coreEvents } from '@google/gemini-cli-core'; -import { type Settings } from './settings.js'; +import { createTestMergedSettings } from './settings.js'; import { createExtension } from '../test-utils/createExtension.js'; import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js'; @@ -58,10 +58,9 @@ describe('ExtensionManager skills validation', () => { fs.mkdirSync(extensionsDir, { recursive: true }); extensionManager = new ExtensionManager({ - settings: { + settings: createTestMergedSettings({ telemetry: { enabled: false }, - trustedFolders: [tempDir], - } as unknown as Settings, + }), requestConsent: vi.fn().mockResolvedValue(true), requestSetting: vi.fn(), workspaceDir: tempDir, @@ -134,10 +133,9 @@ describe('ExtensionManager skills validation', () => { // 3. Create a fresh ExtensionManager to force loading from disk const newExtensionManager = new ExtensionManager({ - settings: { + settings: createTestMergedSettings({ telemetry: { enabled: false }, - trustedFolders: [tempDir], - } as unknown as Settings, + }), requestConsent: vi.fn().mockResolvedValue(true), requestSetting: vi.fn(), workspaceDir: tempDir, diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 75416f1909..45ca5a0d8a 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -9,7 +9,7 @@ import * as path from 'node:path'; import { stat } from 'node:fs/promises'; import chalk from 'chalk'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; -import { type Settings, SettingScope } from './settings.js'; +import { type MergedSettings, SettingScope } from './settings.js'; import { createHash, randomUUID } from 'node:crypto'; import { loadInstallMetadata, type ExtensionConfig } from './extension.js'; import { @@ -68,11 +68,10 @@ import { ExtensionSettingScope, } from './extensions/extensionSettings.js'; import type { EventEmitter } from 'node:stream'; -import { getEnableHooks } from './settingsSchema.js'; interface ExtensionManagerParams { enabledExtensionOverrides?: string[]; - settings: Settings; + settings: MergedSettings; requestConsent: (consent: string) => Promise; requestSetting: ((setting: ExtensionSetting) => Promise) | null; workspaceDir: string; @@ -86,7 +85,7 @@ interface ExtensionManagerParams { */ export class ExtensionManager extends ExtensionLoader { private extensionEnablementManager: ExtensionEnablementManager; - private settings: Settings; + private settings: MergedSettings; private requestConsent: (consent: string) => Promise; private requestSetting: | ((setting: ExtensionSetting) => Promise) @@ -143,7 +142,7 @@ export class ExtensionManager extends ExtensionLoader { if ( (installMetadata.type === 'git' || installMetadata.type === 'github-release') && - this.settings.security?.blockGitExtensions + this.settings.security.blockGitExtensions ) { throw new Error( 'Installing extensions from remote sources is disallowed by your current settings.', @@ -287,10 +286,7 @@ Would you like to attempt to install via "git clone" instead?`, } await fs.promises.mkdir(destinationPath, { recursive: true }); - if ( - this.requestSetting && - (this.settings.experimental?.extensionConfig ?? false) - ) { + if (this.requestSetting && this.settings.experimental.extensionConfig) { if (isUpdate) { await maybePromptForSettings( newExtensionConfig, @@ -308,14 +304,13 @@ Would you like to attempt to install via "git clone" instead?`, } } - const missingSettings = - (this.settings.experimental?.extensionConfig ?? false) - ? await getMissingSettings( - newExtensionConfig, - extensionId, - this.workspaceDir, - ) - : []; + const missingSettings = this.settings.experimental.extensionConfig + ? await getMissingSettings( + newExtensionConfig, + extensionId, + this.workspaceDir, + ) + : []; if (missingSettings.length > 0) { const message = `Extension "${newExtensionConfig.name}" has missing settings: ${missingSettings .map((s) => s.name) @@ -478,7 +473,7 @@ Would you like to attempt to install via "git clone" instead?`, throw new Error('Extensions already loaded, only load extensions once.'); } - if (this.settings.admin?.extensions?.enabled === false) { + if (this.settings.admin.extensions.enabled === false) { this.loadedExtensions = []; return this.loadedExtensions; } @@ -511,7 +506,7 @@ Would you like to attempt to install via "git clone" instead?`, if ( (installMetadata?.type === 'git' || installMetadata?.type === 'github-release') && - this.settings.security?.blockGitExtensions + this.settings.security.blockGitExtensions ) { return null; } @@ -535,7 +530,7 @@ Would you like to attempt to install via "git clone" instead?`, let userSettings: Record = {}; let workspaceSettings: Record = {}; - if (this.settings.experimental?.extensionConfig ?? false) { + if (this.settings.experimental.extensionConfig) { userSettings = await getScopedEnvContents( config, extensionId, @@ -553,10 +548,7 @@ Would you like to attempt to install via "git clone" instead?`, config = resolveEnvVarsInObject(config, customEnv); const resolvedSettings: ResolvedExtensionSetting[] = []; - if ( - config.settings && - (this.settings.experimental?.extensionConfig ?? false) - ) { + if (config.settings && this.settings.experimental.extensionConfig) { for (const setting of config.settings) { const value = customEnv[setting.envVar]; let scope: 'user' | 'workspace' | undefined; @@ -600,7 +592,7 @@ Would you like to attempt to install via "git clone" instead?`, } if (config.mcpServers) { - if (this.settings.admin?.mcp?.enabled === false) { + if (this.settings.admin.mcp.enabled === false) { config.mcpServers = undefined; } else { config.mcpServers = Object.fromEntries( @@ -619,7 +611,7 @@ Would you like to attempt to install via "git clone" instead?`, .filter((contextFilePath) => fs.existsSync(contextFilePath)); let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined; - if (getEnableHooks(this.settings)) { + if (this.settings.tools.enableHooks && this.settings.hooks.enabled) { hooks = await this.loadExtensionHooks(effectiveExtensionPath, { extensionPath: effectiveExtensionPath, workspacePath: this.workspaceDir, diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index a3230058f7..55f44a6c20 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -26,7 +26,11 @@ import { loadAgentsFromDirectory, loadSkillsFromDir, } from '@google/gemini-cli-core'; -import { loadSettings, SettingScope } from './settings.js'; +import { + loadSettings, + createTestMergedSettings, + SettingScope, +} from './settings.js'; import { isWorkspaceTrusted, resetTrustedFoldersForTesting, @@ -201,7 +205,7 @@ describe('extension tests', () => { }); vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); const settings = loadSettings(tempWorkspaceDir).merged; - (settings.experimental ??= {}).extensionConfig = true; + settings.experimental.extensionConfig = true; extensionManager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, @@ -628,11 +632,9 @@ describe('extension tests', () => { }, }); - const blockGitExtensionsSetting = { - security: { - blockGitExtensions: true, - }, - }; + const blockGitExtensionsSetting = createTestMergedSettings({ + security: { blockGitExtensions: true }, + }); extensionManager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, @@ -652,7 +654,6 @@ describe('extension tests', () => { version: '1.0.0', }); const loadedSettings = loadSettings(tempWorkspaceDir).merged; - (loadedSettings.admin ??= {}).extensions ??= {}; loadedSettings.admin.extensions.enabled = false; extensionManager = new ExtensionManager({ @@ -676,7 +677,6 @@ describe('extension tests', () => { }, }); const loadedSettings = loadSettings(tempWorkspaceDir).merged; - (loadedSettings.admin ??= {}).mcp ??= {}; loadedSettings.admin.mcp.enabled = false; extensionManager = new ExtensionManager({ @@ -701,7 +701,6 @@ describe('extension tests', () => { }, }); const loadedSettings = loadSettings(tempWorkspaceDir).merged; - (loadedSettings.admin ??= {}).mcp ??= {}; loadedSettings.admin.mcp.enabled = true; extensionManager = new ExtensionManager({ @@ -837,7 +836,6 @@ describe('extension tests', () => { ); const settings = loadSettings(tempWorkspaceDir).merged; - if (!settings.hooks) settings.hooks = {}; settings.hooks.enabled = true; extensionManager = new ExtensionManager({ @@ -873,7 +871,6 @@ describe('extension tests', () => { ); const settings = loadSettings(tempWorkspaceDir).merged; - if (!settings.hooks) settings.hooks = {}; settings.hooks.enabled = false; extensionManager = new ExtensionManager({ @@ -1098,11 +1095,9 @@ describe('extension tests', () => { it('should not install a github extension if blockGitExtensions is set', async () => { const gitUrl = 'https://somehost.com/somerepo.git'; - const blockGitExtensionsSetting = { - security: { - blockGitExtensions: true, - }, - }; + const blockGitExtensionsSetting = createTestMergedSettings({ + security: { blockGitExtensions: true }, + }); extensionManager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, diff --git a/packages/cli/src/config/extensions/extensionUpdates.test.ts b/packages/cli/src/config/extensions/extensionUpdates.test.ts index a9240a1676..43b19d1228 100644 --- a/packages/cli/src/config/extensions/extensionUpdates.test.ts +++ b/packages/cli/src/config/extensions/extensionUpdates.test.ts @@ -11,7 +11,6 @@ import * as fs from 'node:fs'; import { getMissingSettings } from './extensionSettings.js'; import type { ExtensionConfig } from '../extension.js'; import { ExtensionStorage } from './storage.js'; -import type { Settings } from '../settings.js'; import { KeychainTokenStorage, debugLogger, @@ -21,6 +20,7 @@ import { } from '@google/gemini-cli-core'; import { EXTENSION_SETTINGS_FILENAME } from './variables.js'; import { ExtensionManager } from '../extension-manager.js'; +import { createTestMergedSettings } from '../settings.js'; vi.mock('node:fs', async (importOriginal) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -247,12 +247,10 @@ describe('extensionUpdates', () => { const manager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, - settings: { - telemetry: { - enabled: false, - }, + settings: createTestMergedSettings({ + telemetry: { enabled: false }, experimental: { extensionConfig: true }, - } as unknown as Settings, + }), requestConsent: vi.fn().mockResolvedValue(true), requestSetting: null, // Simulate non-interactive }); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 07cd457785..a7bbd76ca6 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -24,12 +24,24 @@ import { DefaultDark } from '../ui/themes/default.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { type Settings, + type MergedSettings, type MemoryImportFormat, type MergeStrategy, type SettingsSchema, type SettingDefinition, getSettingsSchema, } from './settingsSchema.js'; + +export { + type Settings, + type MergedSettings, + type MemoryImportFormat, + type MergeStrategy, + type SettingsSchema, + type SettingDefinition, + getSettingsSchema, +}; + import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { customDeepMerge } from '../utils/deepMerge.js'; import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js'; @@ -59,8 +71,6 @@ function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined { return current?.mergeStrategy; } -export type { Settings, MemoryImportFormat }; - 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']; @@ -201,10 +211,7 @@ export function getDefaultsFromSchema( for (const key in schema) { const definition = schema[key]; if (definition.properties) { - const childDefaults = getDefaultsFromSchema(definition.properties); - if (Object.keys(childDefaults).length > 0) { - defaults[key] = childDefaults; - } + defaults[key] = getDefaultsFromSchema(definition.properties); } else if (definition.default !== undefined) { defaults[key] = definition.default; } @@ -212,13 +219,13 @@ export function getDefaultsFromSchema( return defaults as Settings; } -function mergeSettings( +export function mergeSettings( system: Settings, systemDefaults: Settings, user: Settings, workspace: Settings, isTrusted: boolean, -): Settings { +): MergedSettings { const safeWorkspace = isTrusted ? workspace : ({} as Settings); const schemaDefaults = getDefaultsFromSchema(); @@ -236,7 +243,24 @@ function mergeSettings( user, safeWorkspace, system, - ) as Settings; + ) as MergedSettings; +} + +/** + * Creates a fully populated MergedSettings object for testing purposes. + * It merges the provided overrides with the default settings from the schema. + * + * @param overrides Partial settings to override the defaults. + * @returns A complete MergedSettings object. + */ +export function createTestMergedSettings( + overrides: Partial = {}, +): MergedSettings { + return customDeepMerge( + getMergeStrategyForPath, + getDefaultsFromSchema(), + overrides, + ) as MergedSettings; } export class LoadedSettings { @@ -264,14 +288,14 @@ export class LoadedSettings { readonly isTrusted: boolean; readonly errors: SettingsError[]; - private _merged: Settings; + private _merged: MergedSettings; private _remoteAdminSettings: Partial | undefined; - get merged(): Settings { + get merged(): MergedSettings { return this._merged; } - private computeMergedSettings(): Settings { + private computeMergedSettings(): MergedSettings { const merged = mergeSettings( this.system.settings, this.systemDefaults.settings, @@ -293,7 +317,7 @@ export class LoadedSettings { (path: string[]) => getMergeStrategyForPath(['admin', ...path]), adminDefaults, this._remoteAdminSettings?.admin ?? {}, - ) as Settings['admin']; + ) as MergedSettings['admin']; } return merged; } diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index b7d4cbb296..0af37b7c1f 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -14,6 +14,7 @@ import type { BugCommandSettings, TelemetrySettings, AuthType, + AgentOverride, } from '@google/gemini-cli-core'; import { DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, @@ -799,7 +800,7 @@ const SETTINGS_SCHEMA = { label: 'Agent Overrides', category: 'Advanced', requiresRestart: true, - default: {}, + default: {} as Record, description: 'Override settings for specific agents, e.g. to disable the agent, set a custom model config, or run config.', showInDialog: false, @@ -2262,12 +2263,17 @@ type InferSettings = { : T[K]['default']; }; +type InferMergedSettings = { + -readonly [K in keyof T]-?: T[K] extends { properties: SettingsSchema } + ? InferMergedSettings + : T[K]['type'] extends 'enum' + ? T[K]['options'] extends readonly SettingEnumOption[] + ? T[K]['options'][number]['value'] + : T[K]['default'] + : T[K]['default'] extends boolean + ? boolean + : T[K]['default']; +}; + export type Settings = InferSettings; - -export function getEnableHooksUI(settings: Settings): boolean { - return settings.tools?.enableHooks ?? true; -} - -export function getEnableHooks(settings: Settings): boolean { - return getEnableHooksUI(settings) && (settings.hooks?.enabled ?? false); -} +export type MergedSettings = InferMergedSettings; diff --git a/packages/cli/src/core/initializer.test.ts b/packages/cli/src/core/initializer.test.ts index 61a4b00422..57f1c41551 100644 --- a/packages/cli/src/core/initializer.test.ts +++ b/packages/cli/src/core/initializer.test.ts @@ -127,7 +127,7 @@ describe('initializer', () => { }); it('should handle undefined auth type', async () => { - mockSettings.merged.security!.auth!.selectedType = undefined; + mockSettings.merged.security.auth.selectedType = undefined; const result = await initializeApp( mockConfig as unknown as Config, mockSettings, diff --git a/packages/cli/src/core/initializer.ts b/packages/cli/src/core/initializer.ts index 0ba76a989f..e99efd90f6 100644 --- a/packages/cli/src/core/initializer.ts +++ b/packages/cli/src/core/initializer.ts @@ -39,13 +39,13 @@ export async function initializeApp( const authHandle = startupProfiler.start('authenticate'); const authError = await performInitialAuth( config, - settings.merged.security?.auth?.selectedType, + settings.merged.security.auth.selectedType, ); authHandle?.end(); const themeError = validateTheme(settings); const shouldOpenAuthDialog = - settings.merged.security?.auth?.selectedType === undefined || !!authError; + settings.merged.security.auth.selectedType === undefined || !!authError; logCliConfiguration( config, diff --git a/packages/cli/src/core/theme.test.ts b/packages/cli/src/core/theme.test.ts index fb57d2cde3..eb87a9ee10 100644 --- a/packages/cli/src/core/theme.test.ts +++ b/packages/cli/src/core/theme.test.ts @@ -46,7 +46,7 @@ describe('theme', () => { }); it('should return null if theme is undefined', () => { - mockSettings.merged.ui!.theme = undefined; + mockSettings.merged.ui.theme = undefined; const result = validateTheme(mockSettings); expect(result).toBeNull(); expect(themeManager.findThemeByName).not.toHaveBeenCalled(); diff --git a/packages/cli/src/core/theme.ts b/packages/cli/src/core/theme.ts index ed2805a5ab..f0f58fdbba 100644 --- a/packages/cli/src/core/theme.ts +++ b/packages/cli/src/core/theme.ts @@ -13,7 +13,7 @@ import { type LoadedSettings } from '../config/settings.js'; * @returns An error message if the theme is not found, otherwise null. */ export function validateTheme(settings: LoadedSettings): string | null { - const effectiveTheme = settings.merged.ui?.theme; + const effectiveTheme = settings.merged.ui.theme; if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) { return `Theme "${effectiveTheme}" not found.`; } diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 9619035b0d..896f89e3c8 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -23,8 +23,30 @@ import { import os from 'node:os'; import v8 from 'node:v8'; import { type CliArgs } from './config/config.js'; -import { type LoadedSettings } from './config/settings.js'; +import { + type LoadedSettings, + type Settings, + createTestMergedSettings, +} from './config/settings.js'; import { appEvents, AppEvent } from './utils/events.js'; + +function createMockSettings( + overrides: Record = {}, +): LoadedSettings { + const merged = createTestMergedSettings( + (overrides['merged'] as Partial) || {}, + ); + + return { + system: { settings: {} }, + systemDefaults: { settings: {} }, + user: { settings: {} }, + workspace: { settings: {} }, + errors: [], + ...overrides, + merged, + } as unknown as LoadedSettings; +} import { type Config, type ResumedSessionData, @@ -108,26 +130,19 @@ class MockProcessExitError extends Error { } // Mock dependencies -vi.mock('./config/settings.js', () => ({ - loadSettings: vi.fn().mockReturnValue({ - merged: { - advanced: {}, - security: { auth: {} }, - ui: {}, - }, - workspace: { settings: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - errors: [], - }), - migrateDeprecatedSettings: vi.fn(), - SettingScope: { - User: 'user', - Workspace: 'workspace', - System: 'system', - SystemDefaults: 'system-defaults', - }, -})); +vi.mock('./config/settings.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadSettings: vi.fn().mockImplementation(() => ({ + merged: actual.getDefaultsFromSchema(), + workspace: { settings: {} }, + errors: [], + })), + saveModelChange: vi.fn(), + getDefaultsFromSchema: actual.getDefaultsFromSchema, + }; +}); vi.mock('./ui/utils/terminalCapabilityManager.js', () => ({ terminalCapabilityManager: { @@ -443,17 +458,15 @@ describe('gemini.tsx main function kitty protocol', () => { getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'), }, } as unknown as Config); - vi.mocked(loadSettings).mockReturnValue({ - errors: [], - merged: { - advanced: {}, - security: { auth: {} }, - ui: {}, - }, - workspace: { settings: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - } as never); + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { + advanced: {}, + security: { auth: {} }, + ui: {}, + }, + }), + ); vi.mocked(parseArguments).mockResolvedValue({ model: undefined, sandbox: undefined, @@ -505,17 +518,18 @@ describe('gemini.tsx main function kitty protocol', () => { throw new MockProcessExitError(code); }); - vi.mocked(loadSettings).mockReturnValue({ - merged: { - advanced: {}, - security: { auth: {} }, - ui: {}, - }, - workspace: { settings: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - errors: [], - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { + advanced: {}, + security: { auth: {} }, + ui: {}, + }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); vi.mocked(parseArguments).mockResolvedValue({ promptInteractive: false, @@ -594,17 +608,18 @@ describe('gemini.tsx main function kitty protocol', () => { promptInteractive: false, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any - vi.mocked(loadSettings).mockReturnValue({ - merged: { - advanced: {}, - security: { auth: {} }, - ui: {}, - }, - workspace: { settings: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - errors: [], - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { + advanced: {}, + security: { auth: {} }, + ui: {}, + }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); const mockConfig = { isInteractive: () => false, @@ -665,17 +680,18 @@ describe('gemini.tsx main function kitty protocol', () => { throw new MockProcessExitError(code); }); - vi.mocked(loadSettings).mockReturnValue({ - merged: { - advanced: {}, - security: { auth: {} }, - ui: { theme: 'non-existent-theme' }, - }, - workspace: { settings: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - errors: [], - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { + advanced: {}, + security: { auth: {} }, + ui: { theme: 'non-existent-theme' }, + }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); vi.mocked(parseArguments).mockResolvedValue({ promptInteractive: false, @@ -753,13 +769,14 @@ describe('gemini.tsx main function kitty protocol', () => { }); const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); - vi.mocked(loadSettings).mockReturnValue({ - merged: { advanced: {}, security: { auth: {} }, ui: { theme: 'test' } }, - workspace: { settings: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - errors: [], - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { advanced: {}, security: { auth: {} }, ui: { theme: 'test' } }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); vi.mocked(parseArguments).mockResolvedValue({ promptInteractive: false, @@ -839,13 +856,14 @@ describe('gemini.tsx main function kitty protocol', () => { throw new MockProcessExitError(code); }); - vi.mocked(loadSettings).mockReturnValue({ - merged: { advanced: {}, security: { auth: {} }, ui: {} }, - workspace: { settings: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - errors: [], - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { advanced: {}, security: { auth: {} }, ui: {} }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); vi.mocked(parseArguments).mockResolvedValue({ promptInteractive: false, @@ -918,13 +936,14 @@ describe('gemini.tsx main function kitty protocol', () => { throw new MockProcessExitError(code); }); - vi.mocked(loadSettings).mockReturnValue({ - merged: { advanced: {}, security: { auth: {} }, ui: {} }, - workspace: { settings: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - errors: [], - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { advanced: {}, security: { auth: {} }, ui: {} }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); vi.mocked(parseArguments).mockResolvedValue({ promptInteractive: false, @@ -1034,10 +1053,11 @@ describe('gemini.tsx main function exit codes', () => { ); const { loadSettings } = await import('./config/settings.js'); vi.mocked(loadCliConfig).mockResolvedValue({} as Config); - vi.mocked(loadSettings).mockReturnValue({ - merged: { security: { auth: {} }, ui: {} }, - errors: [], - } as never); + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { security: { auth: {} }, ui: {} }, + }), + ); vi.mocked(parseArguments).mockResolvedValue({ promptInteractive: true, } as unknown as CliArgs); @@ -1066,14 +1086,13 @@ describe('gemini.tsx main function exit codes', () => { vi.mocked(loadCliConfig).mockResolvedValue({ refreshAuth: vi.fn().mockRejectedValue(new Error('Auth failed')), } as unknown as Config); - vi.mocked(loadSettings).mockReturnValue({ - merged: { - security: { auth: { selectedType: 'google', useExternal: false } }, - ui: {}, - }, - workspace: { settings: {} }, - errors: [], - } as never); + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { + security: { auth: { selectedType: 'google', useExternal: false } }, + }, + }), + ); vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs); vi.mock('./config/auth.js', () => ({ validateAuthMethod: vi.fn().mockReturnValue(null), @@ -1131,11 +1150,11 @@ describe('gemini.tsx main function exit codes', () => { getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'), }, } as unknown as Config); - vi.mocked(loadSettings).mockReturnValue({ - merged: { security: { auth: {} }, ui: {} }, - workspace: { settings: {} }, - errors: [], - } as never); + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { security: { auth: {} }, ui: {} }, + }), + ); vi.mocked(parseArguments).mockResolvedValue({ resume: 'invalid-session', } as unknown as CliArgs); @@ -1200,11 +1219,11 @@ describe('gemini.tsx main function exit codes', () => { }, getRemoteAdminSettings: () => undefined, } as unknown as Config); - vi.mocked(loadSettings).mockReturnValue({ - merged: { security: { auth: {} }, ui: {} }, - workspace: { settings: {} }, - errors: [], - } as never); + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { security: { auth: {} }, ui: {} }, + }), + ); vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs); Object.defineProperty(process.stdin, 'isTTY', { value: true, // Simulate TTY so it doesn't try to read stdin diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 3f808c20b7..36411feae5 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -213,12 +213,12 @@ export async function startInteractiveUI( @@ -263,8 +263,7 @@ export async function startInteractiveUI( patchConsole: false, alternateBuffer: useAlternateBuffer, incrementalRendering: - settings.merged.ui?.incrementalRendering !== false && - useAlternateBuffer, + settings.merged.ui.incrementalRendering !== false && useAlternateBuffer, }, ); @@ -336,13 +335,13 @@ export async function main() { registerCleanup(consolePatcher.cleanup); dns.setDefaultResultOrder( - validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder), + validateDnsResolutionOrder(settings.merged.advanced.dnsResolutionOrder), ); // Set a default auth type if one isn't set or is set to a legacy type if ( - !settings.merged.security?.auth?.selectedType || - settings.merged.security?.auth?.selectedType === AuthType.LEGACY_CLOUD_SHELL + !settings.merged.security.auth.selectedType || + settings.merged.security.auth.selectedType === AuthType.LEGACY_CLOUD_SHELL ) { if ( process.env['CLOUD_SHELL'] === 'true' || @@ -364,8 +363,8 @@ export async function main() { // the sandbox because the sandbox will interfere with the Oauth2 web // redirect. if ( - settings.merged.security?.auth?.selectedType && - !settings.merged.security?.auth?.useExternal + settings.merged.security.auth.selectedType && + !settings.merged.security.auth.useExternal ) { try { if (partialConfig.isInteractive()) { @@ -381,8 +380,8 @@ export async function main() { ); } else { const authType = await validateNonInteractiveAuth( - settings.merged.security?.auth?.selectedType, - settings.merged.security?.auth?.useExternal, + settings.merged.security.auth.selectedType, + settings.merged.security.auth.useExternal, partialConfig, settings, ); @@ -403,7 +402,7 @@ export async function main() { // hop into sandbox if we are outside and sandboxing is enabled if (!process.env['SANDBOX']) { - const memoryArgs = settings.merged.advanced?.autoConfigureMemory + const memoryArgs = settings.merged.advanced.autoConfigureMemory ? getNodeMemoryArgs(isDebugMode) : []; const sandboxConfig = await loadSandboxConfig(settings.merged, argv); @@ -506,7 +505,7 @@ export async function main() { // Handle --list-sessions flag if (config.getListSessions()) { // Attempt auth for summary generation (gracefully skips if not configured) - const authType = settings.merged.security?.auth?.selectedType; + const authType = settings.merged.security.auth.selectedType; if (authType) { try { await config.refreshAuth(authType); @@ -566,7 +565,7 @@ export async function main() { initAppHandle?.end(); if ( - settings.merged.security?.auth?.selectedType === + settings.merged.security.auth.selectedType === AuthType.LOGIN_WITH_GOOGLE && config.isBrowserLaunchSuppressed() ) { @@ -678,8 +677,8 @@ export async function main() { ); const authType = await validateNonInteractiveAuth( - settings.merged.security?.auth?.selectedType, - settings.merged.security?.auth?.useExternal, + settings.merged.security.auth.selectedType, + settings.merged.security.auth.useExternal, config, settings, ); @@ -705,14 +704,14 @@ export async function main() { } function setWindowTitle(title: string, settings: LoadedSettings) { - if (!settings.merged.ui?.hideWindowTitle) { + if (!settings.merged.ui.hideWindowTitle) { // Initial state before React loop starts const windowTitle = computeTerminalTitle({ streamingState: StreamingState.Idle, isConfirming: false, folderName: title, - showThoughts: !!settings.merged.ui?.showStatusInTitle, - useDynamicTitle: settings.merged.ui?.dynamicWindowTitle ?? true, + showThoughts: !!settings.merged.ui.showStatusInTitle, + useDynamicTitle: settings.merged.ui.dynamicWindowTitle, }); writeToStdout(`\x1b]0;${windowTitle}\x07`); diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 37a0edcb19..63328b2a21 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -7,6 +7,7 @@ import { vi } from 'vitest'; import type { CommandContext } from '../ui/commands/types.js'; import type { LoadedSettings } from '../config/settings.js'; +import { mergeSettings } from '../config/settings.js'; import type { GitService } from '@google/gemini-cli-core'; import type { SessionStatsState } from '../ui/contexts/SessionContext.js'; @@ -27,6 +28,8 @@ type DeepPartial = T extends object export const createMockCommandContext = ( overrides: DeepPartial = {}, ): CommandContext => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + const defaultMocks: CommandContext = { invocation: { raw: '', @@ -35,7 +38,11 @@ export const createMockCommandContext = ( }, services: { config: null, - settings: { merged: {} } as LoadedSettings, + settings: { + merged: defaultMergedSettings, + setValue: vi.fn(), + forScope: vi.fn().mockReturnValue({ settings: {} }), + } as unknown as LoadedSettings, git: undefined as GitService | undefined, logger: { log: vi.fn(), diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 1a8851685a..0f806702ea 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -136,7 +136,7 @@ describe('App', () => { pendingHistoryItems: [{ type: 'user', text: 'pending item' }], } as UIState; - mockLoadedSettings.merged.ui = { useAlternateBuffer: true }; + mockLoadedSettings.merged.ui.useAlternateBuffer = true; const { lastFrame } = renderWithProviders(, quittingUIState); @@ -144,7 +144,7 @@ describe('App', () => { expect(lastFrame()).toContain('Quitting...'); // Reset settings - mockLoadedSettings.merged.ui = { useAlternateBuffer: false }; + mockLoadedSettings.merged.ui.useAlternateBuffer = false; }); it('should render dialog manager when dialogs are visible', () => { diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 3dbf61c965..74ad2f35b1 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -82,7 +82,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }; }); import ansiEscapes from 'ansi-escapes'; -import type { LoadedSettings } from '../config/settings.js'; +import { type LoadedSettings, mergeSettings } from '../config/settings.js'; import type { InitializationResult } from '../core/initializer.js'; import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; import { UIStateContext, type UIState } from './contexts/UIStateContext.js'; @@ -380,14 +380,17 @@ describe('AppContainer State Management', () => { ); // Mock LoadedSettings + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockSettings = { merged: { + ...defaultMergedSettings, hideBanner: false, hideFooter: false, hideTips: false, showMemoryUsage: false, theme: 'default', ui: { + ...defaultMergedSettings.ui, showStatusInTitle: false, hideWindowTitle: false, }, @@ -507,8 +510,10 @@ describe('AppContainer State Management', () => { describe('Settings Integration', () => { it('handles settings with all display options disabled', async () => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const settingsAllHidden = { merged: { + ...defaultMergedSettings, hideBanner: true, hideFooter: true, hideTips: true, @@ -526,8 +531,10 @@ describe('AppContainer State Management', () => { }); it('handles settings with memory usage enabled', async () => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const settingsWithMemory = { merged: { + ...defaultMergedSettings, hideBanner: false, hideFooter: false, hideTips: false, @@ -574,7 +581,7 @@ describe('AppContainer State Management', () => { it('handles undefined settings gracefully', async () => { const undefinedSettings = { - merged: {}, + merged: mergeSettings({}, {}, {}, {}, true), } as LoadedSettings; let unmount: () => void; @@ -991,12 +998,13 @@ describe('AppContainer State Management', () => { it('should update terminal title with Working… when showStatusInTitle is false', () => { // Arrange: Set up mock settings with showStatusInTitle disabled + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const mockSettingsWithShowStatusFalse = { ...mockSettings, merged: { - ...mockSettings.merged, + ...defaultMergedSettings, ui: { - ...mockSettings.merged.ui, + ...defaultMergedSettings.ui, showStatusInTitle: false, hideWindowTitle: false, }, @@ -1073,12 +1081,13 @@ describe('AppContainer State Management', () => { it('should not update terminal title when hideWindowTitle is true', () => { // Arrange: Set up mock settings with hideWindowTitle enabled + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const mockSettingsWithHideTitleTrue = { ...mockSettings, merged: { - ...mockSettings.merged, + ...defaultMergedSettings, ui: { - ...mockSettings.merged.ui, + ...defaultMergedSettings.ui, showStatusInTitle: true, hideWindowTitle: true, }, @@ -1101,12 +1110,13 @@ describe('AppContainer State Management', () => { it('should update terminal title with thought subject when in active state', () => { // Arrange: Set up mock settings with showStatusInTitle enabled + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const mockSettingsWithTitleEnabled = { ...mockSettings, merged: { - ...mockSettings.merged, + ...defaultMergedSettings, ui: { - ...mockSettings.merged.ui, + ...defaultMergedSettings.ui, showStatusInTitle: true, hideWindowTitle: false, }, @@ -1143,12 +1153,13 @@ describe('AppContainer State Management', () => { it('should update terminal title with default text when in Idle state and no thought subject', () => { // Arrange: Set up mock settings with showStatusInTitle enabled + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const mockSettingsWithTitleEnabled = { ...mockSettings, merged: { - ...mockSettings.merged, + ...defaultMergedSettings, ui: { - ...mockSettings.merged.ui, + ...defaultMergedSettings.ui, showStatusInTitle: true, hideWindowTitle: false, }, @@ -1184,12 +1195,13 @@ describe('AppContainer State Management', () => { it('should update terminal title when in WaitingForConfirmation state with thought subject', async () => { // Arrange: Set up mock settings with showStatusInTitle enabled + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const mockSettingsWithTitleEnabled = { ...mockSettings, merged: { - ...mockSettings.merged, + ...defaultMergedSettings, ui: { - ...mockSettings.merged.ui, + ...defaultMergedSettings.ui, showStatusInTitle: true, hideWindowTitle: false, }, @@ -1392,12 +1404,13 @@ describe('AppContainer State Management', () => { it('should pad title to exactly 80 characters', () => { // Arrange: Set up mock settings with showStatusInTitle enabled + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const mockSettingsWithTitleEnabled = { ...mockSettings, merged: { - ...mockSettings.merged, + ...defaultMergedSettings, ui: { - ...mockSettings.merged.ui, + ...defaultMergedSettings.ui, showStatusInTitle: true, hideWindowTitle: false, }, @@ -1435,12 +1448,13 @@ describe('AppContainer State Management', () => { it('should use correct ANSI escape code format', () => { // Arrange: Set up mock settings with showStatusInTitle enabled + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const mockSettingsWithTitleEnabled = { ...mockSettings, merged: { - ...mockSettings.merged, + ...defaultMergedSettings, ui: { - ...mockSettings.merged.ui, + ...defaultMergedSettings.ui, showStatusInTitle: true, hideWindowTitle: false, }, @@ -1802,12 +1816,13 @@ describe('AppContainer State Management', () => { const setupCopyModeTest = async (isAlternateMode = false) => { // Update settings for this test run + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const testSettings = { ...mockSettings, merged: { - ...mockSettings.merged, + ...defaultMergedSettings, ui: { - ...mockSettings.merged.ui, + ...defaultMergedSettings.ui, useAlternateBuffer: isAlternateMode, }, }, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 10f5a54a1c..46dd1a69c2 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -392,8 +392,8 @@ export const AppContainer = (props: AppContainerProps) => { }, []); const getPreferredEditor = useCallback( - () => settings.merged.general?.preferredEditor as EditorType, - [settings.merged.general?.preferredEditor], + () => settings.merged.general.preferredEditor as EditorType, + [settings.merged.general.preferredEditor], ); const buffer = useTextBuffer({ @@ -443,7 +443,7 @@ export const AppContainer = (props: AppContainerProps) => { useEffect(() => { if ( - !(settings.merged.ui?.hideBanner || config.getScreenReader()) && + !(settings.merged.ui.hideBanner || config.getScreenReader()) && bannerVisible && bannerText ) { @@ -603,17 +603,17 @@ Logging in with Google... Restarting Gemini CLI to continue. // Check for enforced auth type mismatch useEffect(() => { if ( - settings.merged.security?.auth?.enforcedType && - settings.merged.security?.auth.selectedType && - settings.merged.security?.auth.enforcedType !== - settings.merged.security?.auth.selectedType + settings.merged.security.auth.enforcedType && + settings.merged.security.auth.selectedType && + settings.merged.security.auth.enforcedType !== + settings.merged.security.auth.selectedType ) { onAuthError( - `Authentication is enforced to be ${settings.merged.security?.auth.enforcedType}, but you are currently using ${settings.merged.security?.auth.selectedType}.`, + `Authentication is enforced to be ${settings.merged.security.auth.enforcedType}, but you are currently using ${settings.merged.security.auth.selectedType}.`, ); } else if ( - settings.merged.security?.auth?.selectedType && - !settings.merged.security?.auth?.useExternal + settings.merged.security.auth.selectedType && + !settings.merged.security.auth.useExternal ) { // We skip validation for Gemini API key here because it might be stored // in the keychain, which we can't check synchronously. @@ -630,9 +630,9 @@ Logging in with Google... Restarting Gemini CLI to continue. } } }, [ - settings.merged.security?.auth?.selectedType, - settings.merged.security?.auth?.enforcedType, - settings.merged.security?.auth?.useExternal, + settings.merged.security.auth.selectedType, + settings.merged.security.auth.enforcedType, + settings.merged.security.auth.useExternal, onAuthError, ]); @@ -951,8 +951,8 @@ Logging in with Google... Restarting Gemini CLI to continue. Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING), 1, ), - pager: settings.merged.tools?.shell?.pager, - showColor: settings.merged.tools?.shell?.showColor, + pager: settings.merged.tools.shell.pager, + showColor: settings.merged.tools.shell.showColor, sanitizationConfig: config.sanitizationConfig, }); @@ -960,13 +960,13 @@ Logging in with Google... Restarting Gemini CLI to continue. // Context file names computation const contextFileNames = useMemo(() => { - const fromSettings = settings.merged.context?.fileName; + const fromSettings = settings.merged.context.fileName; return fromSettings ? Array.isArray(fromSettings) ? fromSettings : [fromSettings] : getAllGeminiMdFilenames(); - }, [settings.merged.context?.fileName]); + }, [settings.merged.context.fileName]); // Initial prompt handling const initialPrompt = useMemo(() => config.getQuestion(), [config]); const initialPromptSubmitted = useRef(false); @@ -1040,7 +1040,7 @@ Logging in with Google... Restarting Gemini CLI to continue. const shouldShowIdePrompt = Boolean( currentIDE && !config.getIdeMode() && - !settings.merged.ide?.hasSeenNudge && + !settings.merged.ide.hasSeenNudge && !idePromptAnswered, ); @@ -1221,7 +1221,7 @@ Logging in with Google... Restarting Gemini CLI to continue. const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator( streamingState, - settings.merged.ui?.customWittyPhrases, + settings.merged.ui.customWittyPhrases, !!activePtyId && !embeddedShellFocused, lastOutputTime, retryStatus, @@ -1237,7 +1237,7 @@ Logging in with Google... Restarting Gemini CLI to continue. } // Debug log keystrokes if enabled - if (settings.merged.general?.debugKeystrokeLogging) { + if (settings.merged.general.debugKeystrokeLogging) { debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key)); } @@ -1337,7 +1337,7 @@ Logging in with Google... Restarting Gemini CLI to continue. cancelOngoingRequest, activePtyId, embeddedShellFocused, - settings.merged.general?.debugKeystrokeLogging, + settings.merged.general.debugKeystrokeLogging, refreshStatic, setCopyModeEnabled, copyModeEnabled, @@ -1351,7 +1351,7 @@ Logging in with Google... Restarting Gemini CLI to continue. // Update terminal title with Gemini CLI status and thoughts useEffect(() => { // Respect hideWindowTitle settings - if (settings.merged.ui?.hideWindowTitle) return; + if (settings.merged.ui.hideWindowTitle) return; const paddedTitle = computeTerminalTitle({ streamingState, @@ -1361,8 +1361,8 @@ Logging in with Google... Restarting Gemini CLI to continue. !!confirmationRequest || showShellActionRequired, folderName: basename(config.getTargetDir()), - showThoughts: !!settings.merged.ui?.showStatusInTitle, - useDynamicTitle: settings.merged.ui?.dynamicWindowTitle ?? true, + showThoughts: !!settings.merged.ui.showStatusInTitle, + useDynamicTitle: settings.merged.ui.dynamicWindowTitle, }); // Only update the title if it's different from the last value we set @@ -1377,9 +1377,9 @@ Logging in with Google... Restarting Gemini CLI to continue. shellConfirmationRequest, confirmationRequest, showShellActionRequired, - settings.merged.ui?.showStatusInTitle, - settings.merged.ui?.dynamicWindowTitle, - settings.merged.ui?.hideWindowTitle, + settings.merged.ui.showStatusInTitle, + settings.merged.ui.dynamicWindowTitle, + settings.merged.ui.hideWindowTitle, config, stdout, ]); diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index 66be01856d..6757979c42 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -152,7 +152,7 @@ describe('AuthDialog', () => { }); it('filters auth types when enforcedType is set', () => { - props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI; + props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; renderWithProviders(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; expect(items).toHaveLength(1); @@ -160,7 +160,7 @@ describe('AuthDialog', () => { }); it('sets initial index to 0 when enforcedType is set', () => { - props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI; + props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; renderWithProviders(); const { initialIndex } = mockedRadioButtonSelect.mock.calls[0][0]; expect(initialIndex).toBe(0); @@ -170,7 +170,7 @@ describe('AuthDialog', () => { it.each([ { setup: () => { - props.settings.merged.security!.auth!.selectedType = + props.settings.merged.security.auth.selectedType = AuthType.USE_VERTEX_AI; }, expected: AuthType.USE_VERTEX_AI, @@ -290,7 +290,7 @@ describe('AuthDialog', () => { mockedValidateAuthMethod.mockReturnValue(null); process.env['GEMINI_API_KEY'] = 'test-key-from-env'; // Simulate that the user has already authenticated once - props.settings.merged.security!.auth!.selectedType = + props.settings.merged.security.auth.selectedType = AuthType.LOGIN_WITH_GOOGLE; renderWithProviders(); @@ -349,7 +349,7 @@ describe('AuthDialog', () => { { desc: 'calls onAuthError on escape if no auth method is set', setup: () => { - props.settings.merged.security!.auth!.selectedType = undefined; + props.settings.merged.security.auth.selectedType = undefined; }, expectations: (p: typeof props) => { expect(p.onAuthError).toHaveBeenCalledWith( @@ -360,7 +360,7 @@ describe('AuthDialog', () => { { desc: 'calls setAuthState(Unauthenticated) on escape if auth method is set', setup: () => { - props.settings.merged.security!.auth!.selectedType = + props.settings.merged.security.auth.selectedType = AuthType.USE_GEMINI; }, expectations: (p: typeof props) => { @@ -392,7 +392,7 @@ describe('AuthDialog', () => { }); it('renders correctly with enforced auth type', () => { - props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI; + props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; const { lastFrame } = renderWithProviders(); expect(lastFrame()).toMatchSnapshot(); }); diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index 558927dcf2..0799b38b70 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -78,9 +78,9 @@ export function AuthDialog({ }, ]; - if (settings.merged.security?.auth?.enforcedType) { + if (settings.merged.security.auth.enforcedType) { items = items.filter( - (item) => item.value === settings.merged.security?.auth?.enforcedType, + (item) => item.value === settings.merged.security.auth.enforcedType, ); } @@ -94,7 +94,7 @@ export function AuthDialog({ } let initialAuthIndex = items.findIndex((item) => { - if (settings.merged.security?.auth?.selectedType) { + if (settings.merged.security.auth.selectedType) { return item.value === settings.merged.security.auth.selectedType; } @@ -108,7 +108,7 @@ export function AuthDialog({ return item.value === AuthType.LOGIN_WITH_GOOGLE; }); - if (settings.merged.security?.auth?.enforcedType) { + if (settings.merged.security.auth.enforcedType) { initialAuthIndex = 0; } @@ -171,7 +171,7 @@ export function AuthDialog({ if (authError) { return; } - if (settings.merged.security?.auth?.selectedType === undefined) { + if (settings.merged.security.auth.selectedType === undefined) { // Prevent exiting if no auth method is set onAuthError( 'You must select an auth method to proceed. Press Ctrl+C twice to exit.', diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 004e362d10..7b37e2d421 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -20,11 +20,11 @@ export function validateAuthMethodWithSettings( authType: AuthType, settings: LoadedSettings, ): string | null { - const enforcedType = settings.merged.security?.auth?.enforcedType; + const enforcedType = settings.merged.security.auth.enforcedType; if (enforcedType && enforcedType !== authType) { return `Authentication is enforced to be ${enforcedType}, but you are currently using ${authType}.`; } - if (settings.merged.security?.auth?.useExternal) { + 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. @@ -80,7 +80,7 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => { return; } - const authType = settings.merged.security?.auth?.selectedType; + const authType = settings.merged.security.auth.selectedType; if (!authType) { if (process.env['GEMINI_API_KEY']) { onAuthError( diff --git a/packages/cli/src/ui/commands/aboutCommand.ts b/packages/cli/src/ui/commands/aboutCommand.ts index 46589a0c99..3def750895 100644 --- a/packages/cli/src/ui/commands/aboutCommand.ts +++ b/packages/cli/src/ui/commands/aboutCommand.ts @@ -33,7 +33,7 @@ export const aboutCommand: SlashCommand = { const modelVersion = context.services.config?.getModel() || 'Unknown'; const cliVersion = await getVersion(); const selectedAuthType = - context.services.settings.merged.security?.auth?.selectedType || ''; + context.services.settings.merged.security.auth.selectedType || ''; const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || ''; const ideClient = await getIdeClientName(context); diff --git a/packages/cli/src/ui/commands/agentsCommand.test.ts b/packages/cli/src/ui/commands/agentsCommand.test.ts index f126ddd8ee..e8d2568f60 100644 --- a/packages/cli/src/ui/commands/agentsCommand.test.ts +++ b/packages/cli/src/ui/commands/agentsCommand.test.ts @@ -7,7 +7,7 @@ import { vi, describe, it, expect, beforeEach } from 'vitest'; import { agentsCommand } from './agentsCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; -import type { Config, AgentOverride } from '@google/gemini-cli-core'; +import type { Config } from '@google/gemini-cli-core'; import type { LoadedSettings } from '../../config/settings.js'; import { MessageType } from '../types.js'; import { enableAgent, disableAgent } from '../../utils/agentSettings.js'; @@ -148,12 +148,9 @@ describe('agentsCommand', () => { reload: reloadSpy, }); // Add agent to disabled overrides so validation passes - ( - mockContext.services.settings.merged.agents!.overrides as Record< - string, - AgentOverride - > - )['test-agent'] = { disabled: true }; + mockContext.services.settings.merged.agents.overrides['test-agent'] = { + disabled: true, + }; vi.mocked(enableAgent).mockReturnValue({ status: 'success', @@ -266,12 +263,9 @@ describe('agentsCommand', () => { it('should show info message if agent is already disabled', async () => { mockConfig.getAgentRegistry().getAllAgentNames.mockReturnValue([]); - ( - mockContext.services.settings.merged.agents!.overrides as Record< - string, - AgentOverride - > - )['test-agent'] = { disabled: true }; + mockContext.services.settings.merged.agents.overrides['test-agent'] = { + disabled: true, + }; const disableCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'disable', diff --git a/packages/cli/src/ui/commands/agentsCommand.ts b/packages/cli/src/ui/commands/agentsCommand.ts index 1c03524332..345d66bb24 100644 --- a/packages/cli/src/ui/commands/agentsCommand.ts +++ b/packages/cli/src/ui/commands/agentsCommand.ts @@ -12,7 +12,6 @@ import type { import { CommandKind } from './types.js'; import { MessageType, type HistoryItemAgentsList } from '../types.js'; import { SettingScope } from '../../config/settings.js'; -import type { AgentOverride } from '@google/gemini-cli-core'; import { disableAgent, enableAgent } from '../../utils/agentSettings.js'; import { renderAgentActionFeedback } from '../../utils/agentUtils.js'; @@ -84,10 +83,7 @@ async function enableAction( } const allAgents = agentRegistry.getAllAgentNames(); - const overrides = (settings.merged.agents?.overrides ?? {}) as Record< - string, - AgentOverride - >; + const overrides = settings.merged.agents.overrides; const disabledAgents = Object.keys(overrides).filter( (name) => overrides[name]?.disabled === true, ); @@ -157,10 +153,7 @@ async function disableAction( } const allAgents = agentRegistry.getAllAgentNames(); - const overrides = (settings.merged.agents?.overrides ?? {}) as Record< - string, - AgentOverride - >; + const overrides = settings.merged.agents.overrides; const disabledAgents = Object.keys(overrides).filter( (name) => overrides[name]?.disabled === true, ); @@ -211,10 +204,7 @@ function completeAgentsToEnable(context: CommandContext, partialArg: string) { const { config, settings } = context.services; if (!config) return []; - const overrides = (settings.merged.agents?.overrides ?? {}) as Record< - string, - AgentOverride - >; + const overrides = settings.merged.agents.overrides; const disabledAgents = Object.entries(overrides) .filter(([_, override]) => override?.disabled === true) .map(([name]) => name); diff --git a/packages/cli/src/ui/commands/hooksCommand.test.ts b/packages/cli/src/ui/commands/hooksCommand.test.ts index 4f9499c0aa..2d5588dee8 100644 --- a/packages/cli/src/ui/commands/hooksCommand.test.ts +++ b/packages/cli/src/ui/commands/hooksCommand.test.ts @@ -271,9 +271,10 @@ describe('hooksCommand', () => { it('should enable a hook and update settings', async () => { // Update the context's settings with disabled hooks - mockContext.services.settings.merged.hooks = { - disabled: ['test-hook', 'other-hook'], - }; + mockContext.services.settings.merged.hooks.disabled = [ + 'test-hook', + 'other-hook', + ]; const enableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'enable', @@ -401,9 +402,7 @@ describe('hooksCommand', () => { }); it('should disable a hook and update settings', async () => { - mockContext.services.settings.merged.hooks = { - disabled: [], - }; + mockContext.services.settings.merged.hooks.disabled = []; const disableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'disable', @@ -432,9 +431,7 @@ describe('hooksCommand', () => { it('should return info when hook is already disabled', async () => { // Update the context's settings with the hook already disabled - mockContext.services.settings.merged.hooks = { - disabled: ['test-hook'], - }; + mockContext.services.settings.merged.hooks.disabled = ['test-hook']; const disableCmd = hooksCommand.subCommands!.find( (cmd) => cmd.name === 'disable', @@ -455,9 +452,7 @@ describe('hooksCommand', () => { }); it('should handle error when disabling hook fails', async () => { - mockContext.services.settings.merged.hooks = { - disabled: [], - }; + mockContext.services.settings.merged.hooks.disabled = []; mockSettings.setValue.mockImplementationOnce(() => { throw new Error('Failed to save settings'); }); diff --git a/packages/cli/src/ui/commands/hooksCommand.ts b/packages/cli/src/ui/commands/hooksCommand.ts index 050bf3045e..e8afca5613 100644 --- a/packages/cli/src/ui/commands/hooksCommand.ts +++ b/packages/cli/src/ui/commands/hooksCommand.ts @@ -76,8 +76,7 @@ async function enableAction( // Get current disabled hooks from settings const settings = context.services.settings; - const disabledHooks = settings.merged.hooks?.disabled || ([] as string[]); - + const disabledHooks = settings.merged.hooks.disabled; // Remove from disabled list if present const newDisabledHooks = disabledHooks.filter( (name: string) => name !== hookName, @@ -143,8 +142,7 @@ async function disableAction( // Get current disabled hooks from settings const settings = context.services.settings; - const disabledHooks = settings.merged.hooks?.disabled || ([] as string[]); - + const disabledHooks = settings.merged.hooks.disabled; // Add to disabled list if not already present if (!disabledHooks.includes(hookName)) { const newDisabledHooks = [...disabledHooks, hookName]; diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx index c404c0e9f9..a70a7b20d8 100644 --- a/packages/cli/src/ui/components/AppHeader.tsx +++ b/packages/cli/src/ui/components/AppHeader.tsx @@ -26,7 +26,7 @@ export const AppHeader = ({ version }: AppHeaderProps) => { return ( - {!(settings.merged.ui?.hideBanner || config.getScreenReader()) && ( + {!(settings.merged.ui.hideBanner || config.getScreenReader()) && ( <>
{bannerVisible && bannerText && ( @@ -38,7 +38,7 @@ export const AppHeader = ({ version }: AppHeaderProps) => { )} )} - {!(settings.merged.ui?.hideTips || config.getScreenReader()) && ( + {!(settings.merged.ui.hideTips || config.getScreenReader()) && ( )} diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index c39d7c5ece..73e68684a5 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -24,6 +24,7 @@ vi.mock('../contexts/VimModeContext.js', () => ({ })); import { ApprovalMode } from '@google/gemini-cli-core'; import { StreamingState } from '../types.js'; +import { mergeSettings } from '../../config/settings.js'; // Mock child components vi.mock('./LoadingIndicator.js', () => ({ @@ -163,13 +164,20 @@ const createMockConfig = (overrides = {}) => ({ ...overrides, }); -const createMockSettings = (merged = {}) => ({ - merged: { - hideFooter: false, - showMemoryUsage: false, - ...merged, - }, -}); +const createMockSettings = (merged = {}) => { + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + return { + merged: { + ...defaultMergedSettings, + ui: { + ...defaultMergedSettings.ui, + hideFooter: false, + showMemoryUsage: false, + ...merged, + }, + }, + }; +}; /* eslint-disable @typescript-eslint/no-explicit-any */ const renderComposer = ( diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index d48cced332..b7db494409 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -82,9 +82,7 @@ export const Composer = () => { { /> )} - {!settings.merged.ui?.hideFooter && !isScreenReaderEnabled &&