diff --git a/packages/a2a-server/src/agent/executor.ts b/packages/a2a-server/src/agent/executor.ts index a5360a94b7..1e3afd6e81 100644 --- a/packages/a2a-server/src/agent/executor.ts +++ b/packages/a2a-server/src/agent/executor.ts @@ -17,7 +17,10 @@ import type { ServerGeminiToolCallRequestEvent, Config, } from '@google/gemini-cli-core'; -import { GeminiEventType } from '@google/gemini-cli-core'; +import { + GeminiEventType, + SimpleExtensionLoader, +} from '@google/gemini-cli-core'; import { v4 as uuidv4 } from 'uuid'; import { logger } from '../utils/logger.js'; @@ -96,7 +99,11 @@ export class CoderAgentExecutor implements AgentExecutor { loadEnvironment(); // Will override any global env with workspace envs const settings = loadSettings(workspaceRoot); const extensions = loadExtensions(workspaceRoot); - return await loadConfig(settings, extensions, taskId); + return await loadConfig( + settings, + new SimpleExtensionLoader(extensions), + taskId, + ); } /** diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index c75c902ca5..5492bb9b0a 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -21,6 +21,7 @@ import { DEFAULT_GEMINI_EMBEDDING_MODEL, DEFAULT_GEMINI_MODEL, type GeminiCLIExtension, + type ExtensionLoader, debugLogger, } from '@google/gemini-cli-core'; @@ -30,10 +31,10 @@ import { type AgentSettings, CoderAgentEvent } from '../types.js'; export async function loadConfig( settings: Settings, - extensions: GeminiCLIExtension[], + extensionLoader: ExtensionLoader, taskId: string, ): Promise { - const mcpServers = mergeMcpServers(settings, extensions); + const mcpServers = mergeMcpServers(settings, extensionLoader.getExtensions()); const workspaceDir = process.cwd(); const adcFilePath = process.env['GOOGLE_APPLICATION_CREDENTIALS']; @@ -71,7 +72,7 @@ export async function loadConfig( }, ideMode: false, folderTrust: settings.folderTrust === true, - extensions, + extensionLoader, }; const fileService = new FileDiscoveryService(workspaceDir); @@ -80,7 +81,7 @@ export async function loadConfig( [workspaceDir], false, fileService, - extensions, + extensionLoader, settings.folderTrust === true, ); configParams.userMemory = memoryContent; diff --git a/packages/a2a-server/src/http/app.ts b/packages/a2a-server/src/http/app.ts index e7b45d347c..89bfa2cf25 100644 --- a/packages/a2a-server/src/http/app.ts +++ b/packages/a2a-server/src/http/app.ts @@ -20,6 +20,7 @@ import { loadConfig, loadEnvironment, setTargetDir } from '../config/config.js'; import { loadSettings } from '../config/settings.js'; import { loadExtensions } from '../config/extension.js'; import { commandRegistry } from '../commands/command-registry.js'; +import { SimpleExtensionLoader } from '@google/gemini-cli-core'; const coderAgentCard: AgentCard = { name: 'Gemini SDLC Agent', @@ -70,7 +71,11 @@ export async function createApp() { loadEnvironment(); const settings = loadSettings(workspaceRoot); const extensions = loadExtensions(workspaceRoot); - const config = await loadConfig(settings, extensions, 'a2a-server'); + const config = await loadConfig( + settings, + new SimpleExtensionLoader(extensions), + 'a2a-server', + ); // loadEnvironment() is called within getConfig now const bucketName = process.env['GCS_BUCKET_NAME']; diff --git a/packages/cli/src/commands/extensions/disable.ts b/packages/cli/src/commands/extensions/disable.ts index 184d11a410..40bed33f83 100644 --- a/packages/cli/src/commands/extensions/disable.ts +++ b/packages/cli/src/commands/extensions/disable.ts @@ -23,8 +23,9 @@ export function handleDisable(args: DisableArgs) { workspaceDir, requestConsent: requestConsentNonInteractive, requestSetting: promptForSetting, - loadedSettings: loadSettings(workspaceDir), + settings: loadSettings(workspaceDir).merged, }); + extensionManager.loadExtensions(); try { if (args.scope?.toLowerCase() === 'workspace') { diff --git a/packages/cli/src/commands/extensions/enable.ts b/packages/cli/src/commands/extensions/enable.ts index 43523af372..468353f6a1 100644 --- a/packages/cli/src/commands/extensions/enable.ts +++ b/packages/cli/src/commands/extensions/enable.ts @@ -26,8 +26,10 @@ export function handleEnable(args: EnableArgs) { workspaceDir: workingDir, requestConsent: requestConsentNonInteractive, requestSetting: promptForSetting, - loadedSettings: loadSettings(workingDir), + settings: loadSettings(workingDir).merged, }); + extensionManager.loadExtensions(); + try { if (args.scope?.toLowerCase() === 'workspace') { extensionManager.enableExtension(args.name, SettingScope.Workspace); diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts index 7348bf89ec..1e5ff94eb6 100644 --- a/packages/cli/src/commands/extensions/install.test.ts +++ b/packages/cli/src/commands/extensions/install.test.ts @@ -23,6 +23,7 @@ vi.mock('../../config/extension-manager.ts', async (importOriginal) => { ...actual, ExtensionManager: vi.fn().mockImplementation(() => ({ installOrUpdateExtension: mockInstallOrUpdateExtension, + loadExtensions: vi.fn(), })), }; }); diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index 13c59a1855..95d2e17b7a 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -74,8 +74,9 @@ export async function handleInstall(args: InstallArgs) { workspaceDir, requestConsent, requestSetting: promptForSetting, - loadedSettings: loadSettings(workspaceDir), + settings: loadSettings(workspaceDir).merged, }); + extensionManager.loadExtensions(); const name = await extensionManager.installOrUpdateExtension(installMetadata); debugLogger.log(`Extension "${name}" installed successfully and enabled.`); diff --git a/packages/cli/src/commands/extensions/link.ts b/packages/cli/src/commands/extensions/link.ts index 9f0693cd7e..69c18d8bbe 100644 --- a/packages/cli/src/commands/extensions/link.ts +++ b/packages/cli/src/commands/extensions/link.ts @@ -31,8 +31,9 @@ export async function handleLink(args: InstallArgs) { workspaceDir, requestConsent: requestConsentNonInteractive, requestSetting: promptForSetting, - loadedSettings: loadSettings(workspaceDir), + settings: loadSettings(workspaceDir).merged, }); + extensionManager.loadExtensions(); const extensionName = await extensionManager.installOrUpdateExtension(installMetadata); debugLogger.log( diff --git a/packages/cli/src/commands/extensions/list.ts b/packages/cli/src/commands/extensions/list.ts index 432299c902..a0b31e45f3 100644 --- a/packages/cli/src/commands/extensions/list.ts +++ b/packages/cli/src/commands/extensions/list.ts @@ -19,7 +19,7 @@ export async function handleList() { workspaceDir, requestConsent: requestConsentNonInteractive, requestSetting: promptForSetting, - loadedSettings: loadSettings(workspaceDir), + settings: loadSettings(workspaceDir).merged, }); const extensions = extensionManager.loadExtensions(); if (extensions.length === 0) { diff --git a/packages/cli/src/commands/extensions/uninstall.ts b/packages/cli/src/commands/extensions/uninstall.ts index 59dc8c828f..91242fe3a1 100644 --- a/packages/cli/src/commands/extensions/uninstall.ts +++ b/packages/cli/src/commands/extensions/uninstall.ts @@ -23,8 +23,9 @@ export async function handleUninstall(args: UninstallArgs) { workspaceDir, requestConsent: requestConsentNonInteractive, requestSetting: promptForSetting, - loadedSettings: loadSettings(workspaceDir), + settings: loadSettings(workspaceDir).merged, }); + extensionManager.loadExtensions(); await extensionManager.uninstallExtension(args.name, false); debugLogger.log(`Extension "${args.name}" successfully uninstalled.`); } catch (error) { diff --git a/packages/cli/src/commands/extensions/update.ts b/packages/cli/src/commands/extensions/update.ts index 5523149f18..b5c1620810 100644 --- a/packages/cli/src/commands/extensions/update.ts +++ b/packages/cli/src/commands/extensions/update.ts @@ -34,7 +34,7 @@ export async function handleUpdate(args: UpdateArgs) { workspaceDir, requestConsent: requestConsentNonInteractive, requestSetting: promptForSetting, - loadedSettings: loadSettings(workspaceDir), + settings: loadSettings(workspaceDir).merged, }); const extensions = extensionManager.loadExtensions(); diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts index 3253641894..9e41964d17 100644 --- a/packages/cli/src/commands/mcp/list.ts +++ b/packages/cli/src/commands/mcp/list.ts @@ -28,7 +28,7 @@ async function getMcpServersFromConfig(): Promise< > { const settings = loadSettings(); const extensionManager = new ExtensionManager({ - loadedSettings: settings, + settings: settings.merged, workspaceDir: process.cwd(), requestConsent: requestConsentNonInteractive, requestSetting: promptForSetting, diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index ebf448930f..c6e8a71458 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -12,15 +12,16 @@ import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL_AUTO, OutputFormat, - type GeminiCLIExtension, SHELL_TOOL_NAME, WRITE_FILE_TOOL_NAME, EDIT_TOOL_NAME, + type ExtensionLoader, } from '@google/gemini-cli-core'; import { loadCliConfig, parseArguments, type CliArgs } from './config.js'; import type { Settings } from './settings.js'; import * as ServerConfig from '@google/gemini-cli-core'; import { isWorkspaceTrusted } from './trustedFolders.js'; +import { ExtensionManager } from './extension-manager.js'; vi.mock('./trustedFolders.js', () => ({ isWorkspaceTrusted: vi @@ -97,11 +98,22 @@ vi.mock('@google/gemini-cli-core', async () => { }, loadEnvironment: vi.fn(), loadServerHierarchicalMemory: vi.fn( - (cwd, dirs, debug, fileService, extensionPaths, _maxDirs) => - Promise.resolve({ - memoryContent: extensionPaths?.join(',') || '', + ( + cwd, + dirs, + debug, + fileService, + extensionLoader: ExtensionLoader, + _maxDirs, + ) => { + const extensionPaths = extensionLoader + .getExtensions() + .flatMap((e) => e.contextFiles); + return Promise.resolve({ + memoryContent: extensionPaths.join(',') || '', fileCount: extensionPaths?.length || 0, - }), + }); + }, ), DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: { respectGitIgnore: false, @@ -114,6 +126,8 @@ vi.mock('@google/gemini-cli-core', async () => { }; }); +vi.mock('./extension-manager.js'); + // Global setup to ensure clean environment for all tests in this file const originalArgv = process.argv; const originalGeminiModel = process.env['GEMINI_MODEL']; @@ -509,6 +523,7 @@ describe('loadCliConfig', () => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { @@ -546,7 +561,7 @@ describe('loadCliConfig', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getProxy()).toBeFalsy(); }); @@ -587,7 +602,7 @@ describe('loadCliConfig', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getProxy()).toBe(expected); }); }); @@ -597,7 +612,7 @@ describe('loadCliConfig', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getFileFilteringRespectGitIgnore()).toBe( DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore, ); @@ -621,7 +636,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 extensions: GeminiCLIExtension[] = [ + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', name: 'ext1', @@ -649,15 +664,15 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { ], isActive: true, }, - ]; + ]); const argv = await parseArguments({} as Settings); - await loadCliConfig(settings, extensions, 'session-id', argv); + await loadCliConfig(settings, 'session-id', argv); expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( expect.any(String), [], false, expect.any(Object), - extensions, + expect.any(ExtensionManager), true, 'tree', { @@ -711,7 +726,8 @@ describe('mergeMcpServers', () => { }, }, }; - const extensions: GeminiCLIExtension[] = [ + + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', name: 'ext1', @@ -726,11 +742,11 @@ describe('mergeMcpServers', () => { contextFiles: [], isActive: true, }, - ]; + ]); const originalSettings = JSON.parse(JSON.stringify(settings)); process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - await loadCliConfig(settings, extensions, 'test-session', argv); + await loadCliConfig(settings, 'test-session', argv); expect(settings).toEqual(originalSettings); }); }); @@ -744,6 +760,7 @@ describe('mergeExcludeTools', () => { const originalIsTTY = process.stdin.isTTY; beforeEach(() => { + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); process.stdin.isTTY = true; }); @@ -753,7 +770,7 @@ describe('mergeExcludeTools', () => { it('should merge excludeTools from settings and extensions', async () => { const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } }; - const extensions: GeminiCLIExtension[] = [ + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', name: 'ext1', @@ -772,12 +789,12 @@ describe('mergeExcludeTools', () => { contextFiles: [], isActive: true, }, - ]; + ]); process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const config = await loadCliConfig( settings, - extensions, + 'test-session', argv, ); @@ -789,7 +806,7 @@ describe('mergeExcludeTools', () => { it('should handle overlapping excludeTools between settings and extensions', async () => { const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } }; - const extensions: GeminiCLIExtension[] = [ + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', name: 'ext1', @@ -799,15 +816,10 @@ describe('mergeExcludeTools', () => { contextFiles: [], isActive: true, }, - ]; + ]); process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig( - settings, - extensions, - 'test-session', - argv, - ); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual( expect.arrayContaining(['tool1', 'tool2', 'tool3']), ); @@ -816,7 +828,7 @@ describe('mergeExcludeTools', () => { it('should handle overlapping excludeTools between extensions', async () => { const settings: Settings = { tools: { exclude: ['tool1'] } }; - const extensions: GeminiCLIExtension[] = [ + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext1', name: 'ext1', @@ -835,15 +847,10 @@ describe('mergeExcludeTools', () => { contextFiles: [], isActive: true, }, - ]; + ]); process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig( - settings, - extensions, - 'test-session', - argv, - ); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual( expect.arrayContaining(['tool1', 'tool2', 'tool3', 'tool4']), ); @@ -853,30 +860,18 @@ 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 extensions: GeminiCLIExtension[] = []; process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig( - settings, - extensions, - 'test-session', - argv, - ); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual([]); }); it('should return default excludes when no excludeTools are specified and it is not interactive', async () => { process.stdin.isTTY = false; const settings: Settings = {}; - const extensions: GeminiCLIExtension[] = []; process.argv = ['node', 'script.js', '-p', 'test']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig( - settings, - extensions, - 'test-session', - argv, - ); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual(defaultExcludes); }); @@ -884,13 +879,8 @@ describe('mergeExcludeTools', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } }; - const extensions: GeminiCLIExtension[] = []; - const config = await loadCliConfig( - settings, - extensions, - 'test-session', - argv, - ); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual( expect.arrayContaining(['tool1', 'tool2']), ); @@ -899,7 +889,7 @@ describe('mergeExcludeTools', () => { it('should handle extensions with excludeTools but no settings', async () => { const settings: Settings = {}; - const extensions: GeminiCLIExtension[] = [ + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext', name: 'ext1', @@ -909,15 +899,10 @@ describe('mergeExcludeTools', () => { contextFiles: [], isActive: true, }, - ]; + ]); process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig( - settings, - extensions, - 'test-session', - argv, - ); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getExcludeTools()).toEqual( expect.arrayContaining(['tool1', 'tool2']), ); @@ -926,7 +911,7 @@ describe('mergeExcludeTools', () => { it('should not modify the original settings object', async () => { const settings: Settings = { tools: { exclude: ['tool1'] } }; - const extensions: GeminiCLIExtension[] = [ + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ { path: '/path/to/ext', name: 'ext1', @@ -936,11 +921,11 @@ describe('mergeExcludeTools', () => { contextFiles: [], isActive: true, }, - ]; + ]); const originalSettings = JSON.parse(JSON.stringify(settings)); process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - await loadCliConfig(settings, extensions, 'test-session', argv); + await loadCliConfig(settings, 'test-session', argv); expect(settings).toEqual(originalSettings); }); }); @@ -954,6 +939,7 @@ describe('Approval mode tool exclusion logic', () => { isTrusted: true, source: undefined, }); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { @@ -964,14 +950,7 @@ describe('Approval mode tool exclusion logic', () => { process.argv = ['node', 'script.js', '-p', 'test']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const extensions: GeminiCLIExtension[] = []; - - const config = await loadCliConfig( - settings, - extensions, - 'test-session', - argv, - ); + const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); expect(excludedTools).toContain(SHELL_TOOL_NAME); @@ -990,14 +969,8 @@ describe('Approval mode tool exclusion logic', () => { ]; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const extensions: GeminiCLIExtension[] = []; - const config = await loadCliConfig( - settings, - extensions, - 'test-session', - argv, - ); + const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); expect(excludedTools).toContain(SHELL_TOOL_NAME); @@ -1016,14 +989,8 @@ describe('Approval mode tool exclusion logic', () => { ]; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const extensions: GeminiCLIExtension[] = []; - const config = await loadCliConfig( - settings, - extensions, - 'test-session', - argv, - ); + const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); expect(excludedTools).toContain(SHELL_TOOL_NAME); @@ -1042,14 +1009,8 @@ describe('Approval mode tool exclusion logic', () => { ]; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const extensions: GeminiCLIExtension[] = []; - const config = await loadCliConfig( - settings, - extensions, - 'test-session', - argv, - ); + const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); expect(excludedTools).not.toContain(SHELL_TOOL_NAME); @@ -1061,14 +1022,8 @@ describe('Approval mode tool exclusion logic', () => { process.argv = ['node', 'script.js', '--yolo', '-p', 'test']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const extensions: GeminiCLIExtension[] = []; - const config = await loadCliConfig( - settings, - extensions, - 'test-session', - argv, - ); + const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); expect(excludedTools).not.toContain(SHELL_TOOL_NAME); @@ -1091,14 +1046,8 @@ describe('Approval mode tool exclusion logic', () => { process.argv = testCase.args; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const extensions: GeminiCLIExtension[] = []; - const config = await loadCliConfig( - settings, - extensions, - 'test-session', - argv, - ); + const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); expect(excludedTools).not.toContain(SHELL_TOOL_NAME); @@ -1118,14 +1067,8 @@ describe('Approval mode tool exclusion logic', () => { ]; const argv = await parseArguments({} as Settings); const settings: Settings = { tools: { exclude: ['custom_tool'] } }; - const extensions: GeminiCLIExtension[] = []; - const config = await loadCliConfig( - settings, - extensions, - 'test-session', - argv, - ); + const config = await loadCliConfig(settings, 'test-session', argv); const excludedTools = config.getExcludeTools(); expect(excludedTools).toContain('custom_tool'); // From settings @@ -1142,11 +1085,8 @@ describe('Approval mode tool exclusion logic', () => { disableYoloMode: true, }, }; - const extensions: GeminiCLIExtension[] = []; - await expect( - loadCliConfig(settings, extensions, 'test-session', argv), - ).rejects.toThrow( + await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( 'Cannot start in YOLO mode when it is disabled by settings', ); }); @@ -1161,14 +1101,8 @@ describe('Approval mode tool exclusion logic', () => { }; const settings: Settings = {}; - const extensions: GeminiCLIExtension[] = []; await expect( - loadCliConfig( - settings, - extensions, - 'test-session', - invalidArgv as CliArgs, - ), + loadCliConfig(settings, 'test-session', invalidArgv as CliArgs), ).rejects.toThrow( 'Invalid approval mode: invalid_mode. Valid values are: yolo, auto_edit, default', ); @@ -1180,6 +1114,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { @@ -1198,7 +1133,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { 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 config = await loadCliConfig(baseSettings, [], 'test-session', argv); + const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getMcpServers()).toEqual(baseSettings.mcpServers); }); @@ -1210,7 +1145,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { 'server1', ]; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig(baseSettings, [], 'test-session', argv); + const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getMcpServers()).toEqual({ server1: { url: 'http://localhost:8080' }, }); @@ -1226,7 +1161,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { 'server3', ]; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig(baseSettings, [], 'test-session', argv); + const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getMcpServers()).toEqual({ server1: { url: 'http://localhost:8080' }, server3: { url: 'http://localhost:8082' }, @@ -1243,7 +1178,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { 'server4', ]; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig(baseSettings, [], 'test-session', argv); + const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getMcpServers()).toEqual({ server1: { url: 'http://localhost:8080' }, }); @@ -1252,7 +1187,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { 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 config = await loadCliConfig(baseSettings, [], 'test-session', argv); + const config = await loadCliConfig(baseSettings, 'test-session', argv); expect(config.getMcpServers()).toEqual({}); }); @@ -1263,7 +1198,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { ...baseSettings, mcp: { allowed: ['server1', 'server2'] }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getMcpServers()).toEqual({ server1: { url: 'http://localhost:8080' }, server2: { url: 'http://localhost:8081' }, @@ -1277,7 +1212,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { ...baseSettings, mcp: { excluded: ['server1', 'server2'] }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getMcpServers()).toEqual({ server3: { url: 'http://localhost:8082' }, }); @@ -1293,7 +1228,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { allowed: ['server1', 'server2'], }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getMcpServers()).toEqual({ server2: { url: 'http://localhost:8081' }, }); @@ -1314,7 +1249,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { allowed: ['server2'], }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getMcpServers()).toEqual({ server1: { url: 'http://localhost:8080' }, }); @@ -1337,7 +1272,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { excluded: ['server3'], // Should be ignored }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getMcpServers()).toEqual({ server2: { url: 'http://localhost:8081' }, server3: { url: 'http://localhost:8082' }, @@ -1346,6 +1281,14 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { }); describe('loadCliConfig model selection', () => { + beforeEach(() => { + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + it('selects a model from settings.json if provided', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); @@ -1355,7 +1298,6 @@ describe('loadCliConfig model selection', () => { name: 'gemini-2.5-pro', }, }, - [], 'test-session', argv, ); @@ -1370,7 +1312,6 @@ describe('loadCliConfig model selection', () => { { // No model set. }, - [], 'test-session', argv, ); @@ -1387,7 +1328,6 @@ describe('loadCliConfig model selection', () => { name: 'gemini-2.5-pro', }, }, - [], 'test-session', argv, ); @@ -1402,7 +1342,6 @@ describe('loadCliConfig model selection', () => { { // No model provided via settings. }, - [], 'test-session', argv, ); @@ -1412,6 +1351,14 @@ describe('loadCliConfig model selection', () => { }); describe('loadCliConfig model selection with model router', () => { + beforeEach(() => { + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + it('should use auto model when useModelRouter is true and no model is provided', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); @@ -1421,7 +1368,6 @@ describe('loadCliConfig model selection with model router', () => { useModelRouter: true, }, }, - [], 'test-session', argv, ); @@ -1438,7 +1384,6 @@ describe('loadCliConfig model selection with model router', () => { useModelRouter: false, }, }, - [], 'test-session', argv, ); @@ -1455,7 +1400,6 @@ describe('loadCliConfig model selection with model router', () => { useModelRouter: true, }, }, - [], 'test-session', argv, ); @@ -1475,7 +1419,6 @@ describe('loadCliConfig model selection with model router', () => { name: 'gemini-from-settings', }, }, - [], 'test-session', argv, ); @@ -1493,7 +1436,6 @@ describe('loadCliConfig model selection with model router', () => { useModelRouter: true, }, }, - [], 'test-session', argv, ); @@ -1507,6 +1449,7 @@ describe('loadCliConfig folderTrust', () => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { @@ -1524,7 +1467,7 @@ describe('loadCliConfig folderTrust', () => { }, }; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getFolderTrust()).toBe(false); }); @@ -1538,7 +1481,7 @@ describe('loadCliConfig folderTrust', () => { }, }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getFolderTrust()).toBe(true); }); @@ -1546,7 +1489,7 @@ describe('loadCliConfig folderTrust', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getFolderTrust()).toBe(false); }); }); @@ -1559,6 +1502,7 @@ describe('loadCliConfig with includeDirectories', () => { vi.spyOn(process, 'cwd').mockReturnValue( path.resolve(path.sep, 'home', 'user', 'project'), ); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { @@ -1583,7 +1527,7 @@ describe('loadCliConfig with includeDirectories', () => { ], }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); const expected = [ mockCwd, path.resolve(path.sep, 'cli', 'path1'), @@ -1606,6 +1550,7 @@ describe('loadCliConfig chatCompression', () => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { @@ -1623,7 +1568,7 @@ describe('loadCliConfig chatCompression', () => { }, }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getChatCompression()).toEqual({ contextPercentageThreshold: 0.5, }); @@ -1633,7 +1578,7 @@ describe('loadCliConfig chatCompression', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getChatCompression()).toBeUndefined(); }); }); @@ -1643,6 +1588,7 @@ describe('loadCliConfig useRipgrep', () => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { @@ -1654,7 +1600,7 @@ describe('loadCliConfig useRipgrep', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getUseRipgrep()).toBe(true); }); @@ -1662,7 +1608,7 @@ describe('loadCliConfig useRipgrep', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { tools: { useRipgrep: false } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getUseRipgrep()).toBe(false); }); @@ -1670,7 +1616,7 @@ describe('loadCliConfig useRipgrep', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { tools: { useRipgrep: true } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getUseRipgrep()).toBe(true); }); @@ -1679,7 +1625,7 @@ describe('loadCliConfig useRipgrep', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getUseModelRouter()).toBe(true); }); @@ -1687,7 +1633,7 @@ describe('loadCliConfig useRipgrep', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { experimental: { useModelRouter: true } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getUseModelRouter()).toBe(true); }); @@ -1695,7 +1641,7 @@ describe('loadCliConfig useRipgrep', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { experimental: { useModelRouter: false } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getUseModelRouter()).toBe(false); }); }); @@ -1706,6 +1652,7 @@ describe('screenReader configuration', () => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { @@ -1719,7 +1666,7 @@ describe('screenReader configuration', () => { const settings: Settings = { ui: { accessibility: { screenReader: true } }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getScreenReader()).toBe(true); }); @@ -1729,7 +1676,7 @@ describe('screenReader configuration', () => { const settings: Settings = { ui: { accessibility: { screenReader: false } }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getScreenReader()).toBe(false); }); @@ -1739,7 +1686,7 @@ describe('screenReader configuration', () => { const settings: Settings = { ui: { accessibility: { screenReader: false } }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getScreenReader()).toBe(true); }); @@ -1747,7 +1694,7 @@ describe('screenReader configuration', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getScreenReader()).toBe(false); }); }); @@ -1764,6 +1711,7 @@ describe('loadCliConfig tool exclusions', () => { isTrusted: true, source: undefined, }); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { @@ -1776,7 +1724,7 @@ describe('loadCliConfig tool exclusions', () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getExcludeTools()).not.toContain('run_shell_command'); expect(config.getExcludeTools()).not.toContain('replace'); expect(config.getExcludeTools()).not.toContain('write_file'); @@ -1786,7 +1734,7 @@ describe('loadCliConfig tool exclusions', () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '--yolo']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getExcludeTools()).not.toContain('run_shell_command'); expect(config.getExcludeTools()).not.toContain('replace'); expect(config.getExcludeTools()).not.toContain('write_file'); @@ -1796,7 +1744,7 @@ describe('loadCliConfig tool exclusions', () => { 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 config = await loadCliConfig({}, 'test-session', argv); expect(config.getExcludeTools()).toContain('run_shell_command'); expect(config.getExcludeTools()).toContain('replace'); expect(config.getExcludeTools()).toContain('write_file'); @@ -1806,7 +1754,7 @@ describe('loadCliConfig tool exclusions', () => { 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 config = await loadCliConfig({}, 'test-session', argv); expect(config.getExcludeTools()).not.toContain('run_shell_command'); expect(config.getExcludeTools()).not.toContain('replace'); expect(config.getExcludeTools()).not.toContain('write_file'); @@ -1823,7 +1771,7 @@ describe('loadCliConfig tool exclusions', () => { 'ShellTool', ]; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getExcludeTools()).not.toContain(SHELL_TOOL_NAME); }); @@ -1838,7 +1786,7 @@ describe('loadCliConfig tool exclusions', () => { 'run_shell_command', ]; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getExcludeTools()).not.toContain(SHELL_TOOL_NAME); }); @@ -1853,7 +1801,7 @@ describe('loadCliConfig tool exclusions', () => { 'ShellTool(wc)', ]; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.getExcludeTools()).not.toContain(SHELL_TOOL_NAME); }); }); @@ -1866,6 +1814,7 @@ describe('loadCliConfig interactive', () => { vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); process.stdin.isTTY = true; + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { @@ -1878,7 +1827,7 @@ describe('loadCliConfig interactive', () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.isInteractive()).toBe(true); }); @@ -1886,7 +1835,7 @@ describe('loadCliConfig interactive', () => { 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 config = await loadCliConfig({}, 'test-session', argv); expect(config.isInteractive()).toBe(true); }); @@ -1894,7 +1843,7 @@ describe('loadCliConfig interactive', () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.isInteractive()).toBe(false); }); @@ -1902,7 +1851,7 @@ describe('loadCliConfig interactive', () => { 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 config = await loadCliConfig({}, 'test-session', argv); expect(config.isInteractive()).toBe(false); }); @@ -1910,7 +1859,7 @@ describe('loadCliConfig interactive', () => { 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 config = await loadCliConfig({}, 'test-session', argv); expect(config.isInteractive()).toBe(false); }); @@ -1925,7 +1874,7 @@ describe('loadCliConfig interactive', () => { 'Hello world', ]; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.isInteractive()).toBe(false); // Verify the question is preserved for one-shot execution expect(argv.prompt).toBe('Hello world'); @@ -1936,7 +1885,7 @@ describe('loadCliConfig interactive', () => { 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 config = await loadCliConfig({}, 'test-session', argv); expect(config.isInteractive()).toBe(false); expect(argv.query).toBe('hello'); expect(argv.extensions).toEqual(['none']); @@ -1946,7 +1895,7 @@ describe('loadCliConfig interactive', () => { 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 config = await loadCliConfig({}, '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'); @@ -1967,7 +1916,7 @@ describe('loadCliConfig interactive', () => { 'array', ]; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, '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'); @@ -1977,7 +1926,7 @@ describe('loadCliConfig interactive', () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '']; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.isInteractive()).toBe(true); expect(argv.query).toBeUndefined(); }); @@ -1996,7 +1945,7 @@ describe('loadCliConfig interactive', () => { 'you', ]; const argv = await parseArguments({} as Settings); - const config = await loadCliConfig({}, [], 'test-session', argv); + const config = await loadCliConfig({}, 'test-session', argv); expect(config.isInteractive()).toBe(false); expect(argv.query).toBe('hello world how are you'); expect(argv.extensions).toEqual(['none']); @@ -2006,7 +1955,7 @@ describe('loadCliConfig interactive', () => { 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 config = await loadCliConfig({}, 'test-session', argv); expect(config.isInteractive()).toBe(true); }); }); @@ -2023,6 +1972,7 @@ describe('loadCliConfig approval mode', () => { isTrusted: true, source: undefined, }); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { @@ -2034,42 +1984,42 @@ 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 config = await loadCliConfig({}, '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 config = await loadCliConfig({}, '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 config = await loadCliConfig({}, '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 config = await loadCliConfig({}, '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 config = await loadCliConfig({}, '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 config = await loadCliConfig({}, 'test-session', argv); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); @@ -2080,14 +2030,14 @@ describe('loadCliConfig approval mode', () => { const argv = await parseArguments({} as Settings); // 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({}, '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 config = await loadCliConfig({}, 'test-session', argv); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); @@ -2103,28 +2053,28 @@ 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 config = await loadCliConfig({}, '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 config = await loadCliConfig({}, '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 config = await loadCliConfig({}, '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 config = await loadCliConfig({}, 'test-session', argv); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); }); @@ -2138,6 +2088,7 @@ describe('loadCliConfig fileFiltering', () => { vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); process.argv = ['node', 'script.js']; // Reset argv for each test + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); }); afterEach(() => { @@ -2205,17 +2156,25 @@ describe('loadCliConfig fileFiltering', () => { }, }; const argv = await parseArguments(settings); - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(getter(config)).toBe(value); }, ); }); describe('Output format', () => { + beforeEach(() => { + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + 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 config = await loadCliConfig({}, 'test-session', argv); expect(config.getOutputFormat()).toBe(OutputFormat.TEXT); }); @@ -2224,7 +2183,6 @@ describe('Output format', () => { const argv = await parseArguments({} as Settings); const config = await loadCliConfig( { output: { format: OutputFormat.JSON } }, - [], 'test-session', argv, ); @@ -2236,7 +2194,6 @@ describe('Output format', () => { const argv = await parseArguments({} as Settings); const config = await loadCliConfig( { output: { format: OutputFormat.JSON } }, - [], 'test-session', argv, ); @@ -2246,7 +2203,7 @@ 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 config = await loadCliConfig({}, 'test-session', argv); expect(config.getOutputFormat()).toBe(OutputFormat.STREAM_JSON); }); @@ -2335,12 +2292,19 @@ describe('parseArguments with positional prompt', () => { }); describe('Telemetry configuration via environment variables', () => { + beforeEach(() => { + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + afterEach(() => { + vi.resetAllMocks(); + }); + 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 config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryEnabled()).toBe(true); }); @@ -2351,7 +2315,7 @@ describe('Telemetry configuration via environment variables', () => { const settings: Settings = { telemetry: { target: ServerConfig.TelemetryTarget.LOCAL }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryTarget()).toBe('gcp'); }); @@ -2362,9 +2326,7 @@ describe('Telemetry configuration via environment variables', () => { const settings: Settings = { telemetry: { target: ServerConfig.TelemetryTarget.GCP }, }; - await expect( - loadCliConfig(settings, [], 'test-session', argv), - ).rejects.toThrow( + await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( /Invalid telemetry configuration: .*Invalid telemetry target/i, ); vi.unstubAllEnvs(); @@ -2378,7 +2340,7 @@ describe('Telemetry configuration via environment variables', () => { const settings: Settings = { telemetry: { otlpEndpoint: 'http://settings.com' }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryOtlpEndpoint()).toBe('http://gemini.env.com'); }); @@ -2387,7 +2349,7 @@ describe('Telemetry configuration via environment variables', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { otlpProtocol: 'grpc' } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryOtlpProtocol()).toBe('http'); }); @@ -2396,7 +2358,7 @@ describe('Telemetry configuration via environment variables', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { logPrompts: true } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryLogPromptsEnabled()).toBe(false); }); @@ -2407,7 +2369,7 @@ describe('Telemetry configuration via environment variables', () => { const settings: Settings = { telemetry: { outfile: '/settings/telemetry.log' }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryOutfile()).toBe('/gemini/env/telemetry.log'); }); @@ -2416,7 +2378,7 @@ describe('Telemetry configuration via environment variables', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { useCollector: false } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryUseCollector()).toBe(true); }); @@ -2425,7 +2387,7 @@ describe('Telemetry configuration via environment variables', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { enabled: true } }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryEnabled()).toBe(true); }); @@ -2436,7 +2398,7 @@ describe('Telemetry configuration via environment variables', () => { const settings: Settings = { telemetry: { target: ServerConfig.TelemetryTarget.LOCAL }, }; - const config = await loadCliConfig(settings, [], 'test-session', argv); + const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryTarget()).toBe('local'); }); @@ -2444,7 +2406,7 @@ describe('Telemetry configuration via environment variables', () => { 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 config = await loadCliConfig({}, 'test-session', argv); expect(config.getTelemetryEnabled()).toBe(true); }); @@ -2454,7 +2416,6 @@ describe('Telemetry configuration via environment variables', () => { const argv = await parseArguments({} as Settings); const config = await loadCliConfig( { telemetry: { enabled: true } }, - [], 'test-session', argv, ); @@ -2465,7 +2426,7 @@ describe('Telemetry configuration via environment variables', () => { 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 config = await loadCliConfig({}, 'test-session', argv); expect(config.getTelemetryLogPromptsEnabled()).toBe(true); }); @@ -2475,7 +2436,6 @@ describe('Telemetry configuration via environment variables', () => { const argv = await parseArguments({} as Settings); const config = await loadCliConfig( { telemetry: { logPrompts: true } }, - [], 'test-session', argv, ); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 7617770b79..76ee5e8a21 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -48,6 +48,10 @@ import { appEvents } from '../utils/events.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { createPolicyEngineConfig } from './policy.js'; +import { ExtensionManager } from './extension-manager.js'; +import type { ExtensionLoader } from '@google/gemini-cli-core/src/utils/extensionLoader.js'; +import { requestConsentNonInteractive } from './extensions/consent.js'; +import { promptForSetting } from './extensions/extensionSettings.js'; export interface CliArgs { query: string | undefined; @@ -293,7 +297,7 @@ export async function loadHierarchicalGeminiMemory( debugMode: boolean, fileService: FileDiscoveryService, settings: Settings, - extensions: GeminiCLIExtension[], + extensionLoader: ExtensionLoader, folderTrust: boolean, memoryImportFormat: 'flat' | 'tree' = 'tree', fileFilteringOptions?: FileFilteringOptions, @@ -319,7 +323,7 @@ export async function loadHierarchicalGeminiMemory( includeDirectoriesToReadGemini, debugMode, fileService, - extensions, + extensionLoader, folderTrust, memoryImportFormat, fileFilteringOptions, @@ -368,7 +372,6 @@ export function isDebugMode(argv: CliArgs): boolean { export async function loadCliConfig( settings: Settings, - allExtensions: GeminiCLIExtension[], sessionId: string, argv: CliArgs, cwd: string = process.cwd(), @@ -413,6 +416,15 @@ export async function loadCliConfig( .map(resolvePath) .concat((argv.includeDirectories || []).map(resolvePath)); + const extensionManager = new ExtensionManager({ + settings, + requestConsent: requestConsentNonInteractive, + requestSetting: promptForSetting, + workspaceDir: cwd, + enabledExtensionOverrides: argv.extensions, + }); + extensionManager.loadExtensions(); + // Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version const { memoryContent, fileCount, filePaths } = await loadHierarchicalGeminiMemory( @@ -423,13 +435,13 @@ export async function loadCliConfig( debugMode, fileService, settings, - allExtensions, + extensionManager, trustedFolder, memoryImportFormat, memoryFileFiltering, ); - let mcpServers = mergeMcpServers(settings, allExtensions); + let mcpServers = mergeMcpServers(settings, extensionManager.getExtensions()); const question = argv.promptInteractive || argv.prompt || ''; // Determine approval mode with backward compatibility @@ -540,7 +552,7 @@ export async function loadCliConfig( const excludeTools = mergeExcludeTools( settings, - allExtensions, + extensionManager.getExtensions(), extraExcludes.length > 0 ? extraExcludes : undefined, ); const blockedMcpServers: Array<{ name: string; extensionName: string }> = []; @@ -636,7 +648,7 @@ export async function loadCliConfig( experimentalZedIntegration: argv.experimentalAcp || false, listExtensions: argv.listExtensions || false, enabledExtensions: argv.extensions, - extensions: allExtensions, + extensionLoader: extensionManager, blockedMcpServers, noBrowser: !!process.env['NO_BROWSER'], summarizeToolOutput: settings.model?.summarizeToolOutput, diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 9fb8263758..d25591fd48 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 * as os from 'node:os'; import chalk from 'chalk'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; -import { type LoadedSettings, SettingScope } from './settings.js'; +import { type Settings, SettingScope } from './settings.js'; import { createHash, randomUUID } from 'node:crypto'; import { loadInstallMetadata, type ExtensionConfig } from './extension.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; @@ -50,33 +50,45 @@ import { maybePromptForSettings, type ExtensionSetting, } from './extensions/extensionSettings.js'; +import type { + ExtensionEvents, + ExtensionLoader, +} from '@google/gemini-cli-core/src/utils/extensionLoader.js'; +import { EventEmitter } from 'node:events'; interface ExtensionManagerParams { enabledExtensionOverrides?: string[]; - loadedSettings: LoadedSettings; + settings: Settings; requestConsent: (consent: string) => Promise; requestSetting: ((setting: ExtensionSetting) => Promise) | null; workspaceDir: string; } -export class ExtensionManager { +/** + * Actual implementation of an ExtensionLoader. + * + * You must call `loadExtensions` prior to calling other methods on this class. + */ +export class ExtensionManager implements ExtensionLoader { private extensionEnablementManager: ExtensionEnablementManager; - private loadedSettings: LoadedSettings; + private settings: Settings; private requestConsent: (consent: string) => Promise; private requestSetting: | ((setting: ExtensionSetting) => Promise) - | null; + | undefined; private telemetryConfig: Config; private workspaceDir: string; + private loadedExtensions: GeminiCLIExtension[] | undefined; + private eventEmitter: EventEmitter; constructor(options: ExtensionManagerParams) { this.workspaceDir = options.workspaceDir; this.extensionEnablementManager = new ExtensionEnablementManager( options.enabledExtensionOverrides, ); - this.loadedSettings = options.loadedSettings; + this.settings = options.settings; this.telemetryConfig = new Config({ - telemetry: options.loadedSettings.merged.telemetry, + telemetry: options.settings.telemetry, interactive: false, sessionId: randomUUID(), targetDir: options.workspaceDir, @@ -85,19 +97,45 @@ export class ExtensionManager { debugMode: false, }); this.requestConsent = options.requestConsent; - this.requestSetting = options.requestSetting; + this.requestSetting = options.requestSetting ?? undefined; + this.eventEmitter = new EventEmitter(); + } + + setRequestConsent( + requestConsent: (consent: string) => Promise, + ): void { + this.requestConsent = requestConsent; + } + + setRequestSetting( + requestSetting?: (setting: ExtensionSetting) => Promise, + ): void { + this.requestSetting = requestSetting; + } + + getExtensions(): GeminiCLIExtension[] { + if (!this.loadedExtensions) { + throw new Error( + 'Extensions not yet loaded, must call `loadExtensions` first', + ); + } + return this.loadedExtensions!; + } + + extensionEvents(): EventEmitter { + return this.eventEmitter; } async installOrUpdateExtension( installMetadata: ExtensionInstallMetadata, previousExtensionConfig?: ExtensionConfig, - ): Promise { + ): Promise { const isUpdate = !!previousExtensionConfig; let newExtensionConfig: ExtensionConfig | null = null; let localSourcePath: string | undefined; + let extension: GeminiCLIExtension; try { - const settings = this.loadedSettings.merged; - if (!isWorkspaceTrusted(settings).isTrusted) { + if (!isWorkspaceTrusted(this.settings).isTrusted) { throw new Error( `Could not install extension from untrusted folder at ${installMetadata.source}`, ); @@ -187,17 +225,17 @@ export class ExtensionManager { } const newExtensionName = newExtensionConfig.name; - if (!isUpdate) { - const installedExtensions = this.loadExtensions(); - if ( - installedExtensions.some( - (installed) => installed.name === newExtensionName, - ) - ) { - throw new Error( - `Extension "${newExtensionName}" is already installed. Please uninstall it first.`, - ); - } + const previous = this.getExtensions().find( + (installed) => installed.name === newExtensionName, + ); + if (isUpdate && !previous) { + throw new Error( + `Extension "${newExtensionName}" was not already installed, cannot update it.`, + ); + } else if (!isUpdate && previous) { + throw new Error( + `Extension "${newExtensionName}" is already installed. Please uninstall it first.`, + ); } await maybeRequestConsentOrFail( @@ -245,39 +283,43 @@ export class ExtensionManager { INSTALL_METADATA_FILENAME, ); await fs.promises.writeFile(metadataPath, metadataString); + + // TODO: Gracefully handle this call failing, we should back up the old + // extension prior to overwriting it and then restore it. + extension = this.loadExtension(destinationPath)!; + if (isUpdate) { + logExtensionUpdateEvent( + this.telemetryConfig, + new ExtensionUpdateEvent( + hashValue(newExtensionConfig.name), + getExtensionId(newExtensionConfig, installMetadata), + newExtensionConfig.version, + previousExtensionConfig.version, + installMetadata.type, + 'success', + ), + ); + this.eventEmitter.emit('extensionUpdated', { extension }); + } else { + logExtensionInstallEvent( + this.telemetryConfig, + new ExtensionInstallEvent( + hashValue(newExtensionConfig.name), + getExtensionId(newExtensionConfig, installMetadata), + newExtensionConfig.version, + installMetadata.type, + 'success', + ), + ); + this.eventEmitter.emit('extensionInstalled', { extension }); + this.enableExtension(newExtensionConfig.name, SettingScope.User); + } } finally { if (tempDir) { await fs.promises.rm(tempDir, { recursive: true, force: true }); } } - - if (isUpdate) { - logExtensionUpdateEvent( - this.telemetryConfig, - new ExtensionUpdateEvent( - hashValue(newExtensionConfig.name), - getExtensionId(newExtensionConfig, installMetadata), - newExtensionConfig.version, - previousExtensionConfig.version, - installMetadata.type, - 'success', - ), - ); - } else { - logExtensionInstallEvent( - this.telemetryConfig, - new ExtensionInstallEvent( - hashValue(newExtensionConfig.name), - getExtensionId(newExtensionConfig, installMetadata), - newExtensionConfig.version, - installMetadata.type, - 'success', - ), - ); - this.enableExtension(newExtensionConfig.name, SettingScope.User); - } - - return newExtensionConfig!.name; + return extension; } catch (error) { // Attempt to load config from the source path even if installation fails // to get the name and version for logging. @@ -324,7 +366,7 @@ export class ExtensionManager { extensionIdentifier: string, isUpdate: boolean, ): Promise { - const installedExtensions = this.loadExtensions(); + const installedExtensions = this.getExtensions(); const extension = installedExtensions.find( (installed) => installed.name.toLowerCase() === extensionIdentifier.toLowerCase() || @@ -334,6 +376,7 @@ export class ExtensionManager { if (!extension) { throw new Error(`Extension not found.`); } + this.unloadExtension(extension); const storage = new ExtensionStorage(extension.name); await fs.promises.rm(storage.getExtensionDir(), { @@ -355,36 +398,28 @@ export class ExtensionManager { 'success', ), ); + this.eventEmitter.emit('extensionUninstalled', { extension }); } loadExtensions(): GeminiCLIExtension[] { - const extensionsDir = ExtensionStorage.getUserExtensionsDir(); - if (!fs.existsSync(extensionsDir)) { - return []; + if (this.loadedExtensions) { + throw new Error('Extensions already loaded, only load extensions once.'); + } + const extensionsDir = ExtensionStorage.getUserExtensionsDir(); + this.loadedExtensions = []; + if (!fs.existsSync(extensionsDir)) { + return this.loadedExtensions; } - - const extensions: GeminiCLIExtension[] = []; for (const subdir of fs.readdirSync(extensionsDir)) { const extensionDir = path.join(extensionsDir, subdir); - const extension = this.loadExtension(extensionDir); - if (extension != null) { - extensions.push(extension); - } + this.loadExtension(extensionDir); } - - const uniqueExtensions = new Map(); - - for (const extension of extensions) { - if (!uniqueExtensions.has(extension.name)) { - uniqueExtensions.set(extension.name, extension); - } - } - - return Array.from(uniqueExtensions.values()); + return this.loadedExtensions; } - loadExtension(extensionDir: string): GeminiCLIExtension | null { + private loadExtension(extensionDir: string): GeminiCLIExtension | null { + this.loadedExtensions ??= []; if (!fs.statSync(extensionDir).isDirectory()) { return null; } @@ -398,6 +433,13 @@ export class ExtensionManager { try { let config = this.loadExtensionConfig(effectiveExtensionPath); + if ( + this.getExtensions().find((extension) => extension.name === config.name) + ) { + throw new Error( + `Extension with name ${config.name} already was loaded.`, + ); + } const customEnv = getEnvContents(new ExtensionStorage(config.name)); config = resolveEnvVarsInObject(config, customEnv); @@ -417,7 +459,7 @@ export class ExtensionManager { ) .filter((contextFilePath) => fs.existsSync(contextFilePath)); - return { + const extension = { name: config.name, version: config.version, path: effectiveExtensionPath, @@ -431,6 +473,9 @@ export class ExtensionManager { ), id: getExtensionId(config, installMetadata), }; + this.eventEmitter.emit('extensionLoaded', { extension }); + this.getExtensions().push(extension); + return extension; } catch (e) { debugLogger.error( `Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage( @@ -441,24 +486,11 @@ export class ExtensionManager { } } - loadExtensionByName(name: string): GeminiCLIExtension | null { - const userExtensionsDir = ExtensionStorage.getUserExtensionsDir(); - if (!fs.existsSync(userExtensionsDir)) { - return null; - } - - for (const subdir of fs.readdirSync(userExtensionsDir)) { - const extensionDir = path.join(userExtensionsDir, subdir); - if (!fs.statSync(extensionDir).isDirectory()) { - continue; - } - const extension = this.loadExtension(extensionDir); - if (extension && extension.name.toLowerCase() === name.toLowerCase()) { - return extension; - } - } - - return null; + private unloadExtension(extension: GeminiCLIExtension) { + this.loadedExtensions = this.getExtensions().filter( + (entry) => extension !== entry, + ); + this.eventEmitter.emit('extensionUnloaded', { extension }); } loadExtensionConfig(extensionDir: string): ExtensionConfig { @@ -548,7 +580,9 @@ export class ExtensionManager { ) { throw new Error('System and SystemDefaults scopes are not supported.'); } - const extension = this.loadExtensionByName(name); + const extension = this.getExtensions().find( + (extension) => extension.name === name, + ); if (!extension) { throw new Error(`Extension with name ${name} does not exist.`); } @@ -560,6 +594,8 @@ export class ExtensionManager { this.telemetryConfig, new ExtensionDisableEvent(hashValue(name), extension.id, scope), ); + extension.isActive = false; + this.eventEmitter.emit('extensionDisabled', { extension }); } enableExtension(name: string, scope: SettingScope) { @@ -569,7 +605,9 @@ export class ExtensionManager { ) { throw new Error('System and SystemDefaults scopes are not supported.'); } - const extension = this.loadExtensionByName(name); + const extension = this.getExtensions().find( + (extension) => extension.name === name, + ); if (!extension) { throw new Error(`Extension with name ${name} does not exist.`); } @@ -580,6 +618,8 @@ export class ExtensionManager { this.telemetryConfig, new ExtensionEnableEvent(hashValue(name), extension.id, scope), ); + extension.isActive = true; + this.eventEmitter.emit('extensionEnabled', { extension }); } } diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index f701e3cb3e..3ce23405f3 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -140,7 +140,7 @@ describe('extension tests', () => { workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, - loadedSettings: loadSettings(tempWorkspaceDir), + settings: loadSettings(tempWorkspaceDir).merged, }); }); @@ -220,11 +220,12 @@ describe('extension tests', () => { name: 'enabled-extension', version: '2.0.0', }); + extensionManager.loadExtensions(); extensionManager.disableExtension( 'disabled-extension', SettingScope.User, ); - const extensions = extensionManager.loadExtensions(); + const extensions = extensionManager.getExtensions(); expect(extensions).toHaveLength(2); expect(extensions[0].name).toBe('disabled-extension'); expect(extensions[0].isActive).toBe(false); @@ -265,13 +266,14 @@ describe('extension tests', () => { }); fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context'); - const extensionName = await extensionManager.installOrUpdateExtension({ + extensionManager.loadExtensions(); + const extension = await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'link', }); - expect(extensionName).toEqual('my-linked-extension'); - const extensions = extensionManager.loadExtensions(); + expect(extension.name).toEqual('my-linked-extension'); + const extensions = extensionManager.getExtensions(); expect(extensions).toHaveLength(1); const linkedExt = extensions[0]; @@ -301,12 +303,13 @@ describe('extension tests', () => { }, }); + extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'link', }); - const extensions = extensionManager.loadExtensions(); + const extensions = extensionManager.getExtensions(); expect(extensions).toHaveLength(1); expect(extensions[0].mcpServers?.['test-server'].cwd).toBe( path.join(sourceExtDir, 'server'), @@ -525,15 +528,17 @@ describe('extension tests', () => { const consoleSpy = vi .spyOn(console, 'error') .mockImplementation(() => {}); - const badExtDir = createExtension({ + createExtension({ extensionsDir: userExtensionsDir, name: 'bad_name', version: '1.0.0', }); - const extension = extensionManager.loadExtension(badExtDir); + const extension = extensionManager + .loadExtensions() + .find((e) => e.name === 'bad_name'); - expect(extension).toBeNull(); + expect(extension).toBeUndefined(); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('Invalid extension name: "bad_name"'), ); @@ -542,7 +547,7 @@ describe('extension tests', () => { describe('id generation', () => { it('should generate id from source for non-github git urls', () => { - const extensionDir = createExtension({ + createExtension({ extensionsDir: userExtensionsDir, name: 'my-ext', version: '1.0.0', @@ -552,12 +557,14 @@ describe('extension tests', () => { }, }); - const extension = extensionManager.loadExtension(extensionDir); + const extension = extensionManager + .loadExtensions() + .find((e) => e.name === 'my-ext'); expect(extension?.id).toBe(hashValue('http://somehost.com/foo/bar')); }); it('should generate id from owner/repo for github http urls', () => { - const extensionDir = createExtension({ + createExtension({ extensionsDir: userExtensionsDir, name: 'my-ext', version: '1.0.0', @@ -567,12 +574,14 @@ describe('extension tests', () => { }, }); - const extension = extensionManager.loadExtension(extensionDir); + const extension = extensionManager + .loadExtensions() + .find((e) => e.name === 'my-ext'); expect(extension?.id).toBe(hashValue('https://github.com/foo/bar')); }); it('should generate id from owner/repo for github ssh urls', () => { - const extensionDir = createExtension({ + createExtension({ extensionsDir: userExtensionsDir, name: 'my-ext', version: '1.0.0', @@ -582,12 +591,14 @@ describe('extension tests', () => { }, }); - const extension = extensionManager.loadExtension(extensionDir); + const extension = extensionManager + .loadExtensions() + .find((e) => e.name === 'my-ext'); expect(extension?.id).toBe(hashValue('https://github.com/foo/bar')); }); it('should generate id from source for github-release extension', () => { - const extensionDir = createExtension({ + createExtension({ extensionsDir: userExtensionsDir, name: 'my-ext', version: '1.0.0', @@ -597,12 +608,14 @@ describe('extension tests', () => { }, }); - const extension = extensionManager.loadExtension(extensionDir); + const extension = extensionManager + .loadExtensions() + .find((e) => e.name === 'my-ext'); expect(extension?.id).toBe(hashValue('https://github.com/foo/bar')); }); it('should generate id from the original source for local extension', () => { - const extensionDir = createExtension({ + createExtension({ extensionsDir: userExtensionsDir, name: 'local-ext-name', version: '1.0.0', @@ -612,7 +625,9 @@ describe('extension tests', () => { }, }); - const extension = extensionManager.loadExtension(extensionDir); + const extension = extensionManager + .loadExtensions() + .find((e) => e.name === 'local-ext-name'); expect(extension?.id).toBe(hashValue('/some/path')); }); @@ -623,25 +638,28 @@ describe('extension tests', () => { name: 'link-ext-name', version: '1.0.0', }); - const extensionName = await extensionManager.installOrUpdateExtension({ + extensionManager.loadExtensions(); + await extensionManager.installOrUpdateExtension({ type: 'link', source: actualExtensionDir, }); - const extension = extensionManager.loadExtension( - new ExtensionStorage(extensionName).getExtensionDir(), - ); + const extension = extensionManager + .getExtensions() + .find((e) => e.name === 'link-ext-name'); expect(extension?.id).toBe(hashValue(actualExtensionDir)); }); it('should generate id from name for extension with no install metadata', () => { - const extensionDir = createExtension({ + createExtension({ extensionsDir: userExtensionsDir, name: 'no-meta-name', version: '1.0.0', }); - const extension = extensionManager.loadExtension(extensionDir); + const extension = extensionManager + .loadExtensions() + .find((e) => e.name === 'no-meta-name'); expect(extension?.id).toBe(hashValue('no-meta-name')); }); }); @@ -657,6 +675,7 @@ describe('extension tests', () => { const targetExtDir = path.join(userExtensionsDir, 'my-local-extension'); const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); + extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', @@ -678,6 +697,7 @@ describe('extension tests', () => { name: 'my-local-extension', version: '1.0.0', }); + extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', @@ -771,6 +791,7 @@ describe('extension tests', () => { type: 'github-release', }); + extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: gitUrl, type: 'git', @@ -795,6 +816,7 @@ describe('extension tests', () => { const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME); + extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'link', @@ -824,6 +846,7 @@ describe('extension tests', () => { name: 'my-local-extension', version: '1.1.0', }); + extensionManager.loadExtensions(); if (isUpdate) { await extensionManager.installOrUpdateExtension({ source: sourceExtDir, @@ -897,12 +920,15 @@ describe('extension tests', () => { }, }); + extensionManager.loadExtensions(); await expect( extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', }), - ).resolves.toBe('my-local-extension'); + ).resolves.toMatchObject({ + name: 'my-local-extension', + }); expect(mockRequestConsent).toHaveBeenCalledWith( `Installing extension "my-local-extension". @@ -926,12 +952,13 @@ This extension will run the following MCP servers: }, }); + extensionManager.loadExtensions(); await expect( extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', }), - ).resolves.toBe('my-local-extension'); + ).resolves.toMatchObject({ name: 'my-local-extension' }); }); it('should cancel installation if user declines prompt for local extension with mcp servers', async () => { @@ -947,6 +974,7 @@ This extension will run the following MCP servers: }, }); mockRequestConsent.mockResolvedValue(false); + extensionManager.loadExtensions(); await expect( extensionManager.installOrUpdateExtension({ source: sourceExtDir, @@ -964,6 +992,7 @@ This extension will run the following MCP servers: const targetExtDir = path.join(userExtensionsDir, 'my-local-extension'); const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); + extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', @@ -994,6 +1023,7 @@ This extension will run the following MCP servers: }, }); + extensionManager.loadExtensions(); // Install it with hard coded consent first. await extensionManager.installOrUpdateExtension({ source: sourceExtDir, @@ -1008,7 +1038,7 @@ This extension will run the following MCP servers: // Provide its own existing config as the previous config. await extensionManager.loadExtensionConfig(sourceExtDir), ), - ).resolves.toBe('my-local-extension'); + ).resolves.toMatchObject({ name: 'my-local-extension' }); // Still only called once expect(mockRequestConsent).toHaveBeenCalledOnce(); @@ -1028,6 +1058,7 @@ This extension will run the following MCP servers: ], }); + extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', @@ -1054,9 +1085,10 @@ This extension will run the following MCP servers: workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, requestSetting: null, - loadedSettings: loadSettings(tempWorkspaceDir), + settings: loadSettings(tempWorkspaceDir).merged, }); + extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local', @@ -1079,6 +1111,7 @@ This extension will run the following MCP servers: }); mockPromptForSettings.mockResolvedValueOnce('old-api-key'); + extensionManager.loadExtensions(); // Install it so it exists in the userExtensionsDir await extensionManager.installOrUpdateExtension({ source: oldSourceExtDir, @@ -1148,6 +1181,7 @@ This extension will run the following MCP servers: }, ], }); + extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: oldSourceExtDir, type: 'local', @@ -1239,6 +1273,7 @@ This extension will run the following MCP servers: join(tempDir, extensionName), ); + extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: gitUrl, type: 'github-release', @@ -1263,6 +1298,7 @@ This extension will run the following MCP servers: type: 'github-release', }); + extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension( { source: gitUrl, type: 'github-release' }, // Use github-release to force consent ); @@ -1293,6 +1329,7 @@ This extension will run the following MCP servers: }); mockRequestConsent.mockResolvedValue(false); + extensionManager.loadExtensions(); await expect( extensionManager.installOrUpdateExtension({ source: gitUrl, @@ -1317,6 +1354,7 @@ This extension will run the following MCP servers: type: 'github-release', }); + extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension({ source: gitUrl, type: 'git', @@ -1347,6 +1385,7 @@ This extension will run the following MCP servers: type: 'github-release', }); + extensionManager.loadExtensions(); await extensionManager.installOrUpdateExtension( { source: gitUrl, type: 'github-release' }, // Note the type ); @@ -1369,6 +1408,7 @@ This extension will run the following MCP servers: version: '1.0.0', }); + extensionManager.loadExtensions(); await extensionManager.uninstallExtension('my-local-extension', false); expect(fs.existsSync(sourceExtDir)).toBe(false); @@ -1386,14 +1426,16 @@ This extension will run the following MCP servers: version: '1.0.0', }); + extensionManager.loadExtensions(); await extensionManager.uninstallExtension('my-local-extension', false); expect(fs.existsSync(sourceExtDir)).toBe(false); - expect(extensionManager.loadExtensions()).toHaveLength(1); + expect(extensionManager.getExtensions()).toHaveLength(1); expect(fs.existsSync(otherExtDir)).toBe(true); }); it('should throw an error if the extension does not exist', async () => { + extensionManager.loadExtensions(); await expect( extensionManager.uninstallExtension('nonexistent-extension', false), ).rejects.toThrow('Extension not found.'); @@ -1411,6 +1453,7 @@ This extension will run the following MCP servers: }, }); + extensionManager.loadExtensions(); await extensionManager.uninstallExtension( 'my-local-extension', isUpdate, @@ -1438,6 +1481,7 @@ This extension will run the following MCP servers: const enablementManager = new ExtensionEnablementManager(); enablementManager.enable('test-extension', true, '/some/scope'); + extensionManager.loadExtensions(); await extensionManager.uninstallExtension('test-extension', isUpdate); const config = enablementManager.readConfig()['test-extension']; @@ -1462,6 +1506,7 @@ This extension will run the following MCP servers: }, }); + extensionManager.loadExtensions(); await extensionManager.uninstallExtension(gitUrl, false); expect(fs.existsSync(sourceExtDir)).toBe(false); @@ -1481,6 +1526,7 @@ This extension will run the following MCP servers: // No installMetadata provided }); + extensionManager.loadExtensions(); await expect( extensionManager.uninstallExtension( 'https://github.com/google/no-metadata-extension', @@ -1498,6 +1544,7 @@ This extension will run the following MCP servers: version: '1.0.0', }); + extensionManager.loadExtensions(); extensionManager.disableExtension('my-extension', SettingScope.User); expect( isEnabled({ @@ -1514,6 +1561,7 @@ This extension will run the following MCP servers: version: '1.0.0', }); + extensionManager.loadExtensions(); extensionManager.disableExtension('my-extension', SettingScope.Workspace); expect( isEnabled({ @@ -1536,6 +1584,7 @@ This extension will run the following MCP servers: version: '1.0.0', }); + extensionManager.loadExtensions(); extensionManager.disableExtension('my-extension', SettingScope.User); extensionManager.disableExtension('my-extension', SettingScope.User); expect( @@ -1563,6 +1612,7 @@ This extension will run the following MCP servers: }, }); + extensionManager.loadExtensions(); extensionManager.disableExtension('ext1', SettingScope.Workspace); expect(mockLogExtensionDisable).toHaveBeenCalled(); @@ -1580,7 +1630,7 @@ This extension will run the following MCP servers: }); const getActiveExtensions = (): GeminiCLIExtension[] => { - const extensions = extensionManager.loadExtensions(); + const extensions = extensionManager.getExtensions(); return extensions.filter((e) => e.isActive); }; @@ -1590,6 +1640,7 @@ This extension will run the following MCP servers: name: 'ext1', version: '1.0.0', }); + extensionManager.loadExtensions(); extensionManager.disableExtension('ext1', SettingScope.User); let activeExtensions = getActiveExtensions(); expect(activeExtensions).toHaveLength(0); @@ -1606,6 +1657,7 @@ This extension will run the following MCP servers: name: 'ext1', version: '1.0.0', }); + extensionManager.loadExtensions(); extensionManager.disableExtension('ext1', SettingScope.Workspace); let activeExtensions = getActiveExtensions(); expect(activeExtensions).toHaveLength(0); @@ -1626,6 +1678,7 @@ This extension will run the following MCP servers: type: 'local', }, }); + extensionManager.loadExtensions(); extensionManager.disableExtension('ext1', SettingScope.Workspace); extensionManager.enableExtension('ext1', SettingScope.Workspace); diff --git a/packages/cli/src/config/extensions/github.test.ts b/packages/cli/src/config/extensions/github.test.ts index 57eaa3e32e..06a43cb93e 100644 --- a/packages/cli/src/config/extensions/github.test.ts +++ b/packages/cli/src/config/extensions/github.test.ts @@ -170,7 +170,7 @@ describe('git extension helpers', () => { workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, - loadedSettings: loadSettings(tempWorkspaceDir), + settings: loadSettings(tempWorkspaceDir).merged, }); }); diff --git a/packages/cli/src/config/extensions/github.ts b/packages/cli/src/config/extensions/github.ts index 5e5e5cde7d..f2b1973064 100644 --- a/packages/cli/src/config/extensions/github.ts +++ b/packages/cli/src/config/extensions/github.ts @@ -157,14 +157,16 @@ export async function checkForExtensionUpdate( ): Promise { const installMetadata = extension.installMetadata; if (installMetadata?.type === 'local') { - const newExtension = extensionManager.loadExtension(installMetadata.source); - if (!newExtension) { + const latestConfig = extensionManager.loadExtensionConfig( + installMetadata.source, + ); + if (!latestConfig) { debugLogger.error( `Failed to check for update for local extension "${extension.name}". Could not load extension from source path: ${installMetadata.source}`, ); return ExtensionUpdateState.ERROR; } - if (newExtension.version !== extension.version) { + if (latestConfig.version !== extension.version) { return ExtensionUpdateState.UPDATE_AVAILABLE; } return ExtensionUpdateState.UP_TO_DATE; diff --git a/packages/cli/src/config/extensions/update.test.ts b/packages/cli/src/config/extensions/update.test.ts index 66bf99fabc..8c02168164 100644 --- a/packages/cli/src/config/extensions/update.test.ts +++ b/packages/cli/src/config/extensions/update.test.ts @@ -109,7 +109,7 @@ describe('update tests', () => { workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, requestSetting: mockPromptForSettings, - loadedSettings: loadSettings(tempWorkspaceDir), + settings: loadSettings(tempWorkspaceDir).merged, }); }); @@ -145,7 +145,9 @@ describe('update tests', () => { ); }); mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); - const extension = extensionManager.loadExtension(targetExtDir)!; + const extension = extensionManager + .loadExtensions() + .find((e) => e.name === extensionName)!; const updateInfo = await updateExtension( extension, extensionManager, @@ -170,7 +172,7 @@ describe('update tests', () => { it('should call setExtensionUpdateState with UPDATING and then UPDATED_NEEDS_RESTART on success', async () => { const extensionName = 'test-extension'; - const extensionDir = createExtension({ + createExtension({ extensionsDir: userExtensionsDir, name: extensionName, version: '1.0.0', @@ -192,7 +194,10 @@ describe('update tests', () => { mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); const dispatch = vi.fn(); - const extension = extensionManager.loadExtension(extensionDir)!; + + const extension = extensionManager + .loadExtensions() + .find((e) => e.name === extensionName)!; await updateExtension( extension, extensionManager, @@ -218,7 +223,7 @@ describe('update tests', () => { it('should call setExtensionUpdateState with ERROR on failure', async () => { const extensionName = 'test-extension'; - const extensionDir = createExtension({ + createExtension({ extensionsDir: userExtensionsDir, name: extensionName, version: '1.0.0', @@ -232,7 +237,9 @@ describe('update tests', () => { mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); const dispatch = vi.fn(); - const extension = extensionManager.loadExtension(extensionDir)!; + const extension = extensionManager + .loadExtensions() + .find((e) => e.name === extensionName)!; await expect( updateExtension( extension, @@ -261,7 +268,7 @@ describe('update tests', () => { describe('checkForAllExtensionUpdates', () => { it('should return UpdateAvailable for a git extension with updates', async () => { - const extensionDir = createExtension({ + createExtension({ extensionsDir: userExtensionsDir, name: 'test-extension', version: '1.0.0', @@ -270,7 +277,6 @@ describe('update tests', () => { type: 'git', }, }); - const extension = extensionManager.loadExtension(extensionDir)!; mockGit.getRemotes.mockResolvedValue([ { name: 'origin', refs: { fetch: 'https://some.git/repo' } }, @@ -280,7 +286,7 @@ describe('update tests', () => { const dispatch = vi.fn(); await checkForAllExtensionUpdates( - [extension], + extensionManager.loadExtensions(), extensionManager, dispatch, ); @@ -294,7 +300,7 @@ describe('update tests', () => { }); it('should return UpToDate for a git extension with no updates', async () => { - const extensionDir = createExtension({ + createExtension({ extensionsDir: userExtensionsDir, name: 'test-extension', version: '1.0.0', @@ -303,7 +309,6 @@ describe('update tests', () => { type: 'git', }, }); - const extension = extensionManager.loadExtension(extensionDir)!; mockGit.getRemotes.mockResolvedValue([ { name: 'origin', refs: { fetch: 'https://some.git/repo' } }, @@ -313,7 +318,7 @@ describe('update tests', () => { const dispatch = vi.fn(); await checkForAllExtensionUpdates( - [extension], + extensionManager.loadExtensions(), extensionManager, dispatch, ); @@ -334,16 +339,15 @@ describe('update tests', () => { version: '1.0.0', }); - const installedExtensionDir = createExtension({ + createExtension({ extensionsDir: userExtensionsDir, name: 'local-extension', version: '1.0.0', installMetadata: { source: sourceExtensionDir, type: 'local' }, }); - const extension = extensionManager.loadExtension(installedExtensionDir)!; const dispatch = vi.fn(); await checkForAllExtensionUpdates( - [extension], + extensionManager.loadExtensions(), extensionManager, dispatch, ); @@ -360,20 +364,19 @@ describe('update tests', () => { const localExtensionSourcePath = path.join(tempHomeDir, 'local-source'); const sourceExtensionDir = createExtension({ extensionsDir: localExtensionSourcePath, - name: 'my-local-ext', + name: 'local-extension', version: '1.1.0', }); - const installedExtensionDir = createExtension({ + createExtension({ extensionsDir: userExtensionsDir, name: 'local-extension', version: '1.0.0', installMetadata: { source: sourceExtensionDir, type: 'local' }, }); - const extension = extensionManager.loadExtension(installedExtensionDir)!; const dispatch = vi.fn(); await checkForAllExtensionUpdates( - [extension], + extensionManager.loadExtensions(), extensionManager, dispatch, ); @@ -387,7 +390,7 @@ describe('update tests', () => { }); it('should return Error when git check fails', async () => { - const extensionDir = createExtension({ + createExtension({ extensionsDir: userExtensionsDir, name: 'error-extension', version: '1.0.0', @@ -396,13 +399,12 @@ describe('update tests', () => { type: 'git', }, }); - const extension = extensionManager.loadExtension(extensionDir)!; mockGit.getRemotes.mockRejectedValue(new Error('Git error')); const dispatch = vi.fn(); await checkForAllExtensionUpdates( - [extension], + extensionManager.loadExtensions(), extensionManager, dispatch, ); diff --git a/packages/cli/src/config/extensions/update.ts b/packages/cli/src/config/extensions/update.ts index 141ace88d8..40f1330bc7 100644 --- a/packages/cli/src/config/extensions/update.ts +++ b/packages/cli/src/config/extensions/update.ts @@ -61,20 +61,20 @@ export async function updateExtension( const previousExtensionConfig = await extensionManager.loadExtensionConfig( extension.path, ); - await extensionManager.installOrUpdateExtension( - installMetadata, - previousExtensionConfig, - ); - const updatedExtensionStorage = new ExtensionStorage(extension.name); - const updatedExtension = extensionManager.loadExtension( - updatedExtensionStorage.getExtensionDir(), - ); - if (!updatedExtension) { + let updatedExtension: GeminiCLIExtension; + try { + updatedExtension = await extensionManager.installOrUpdateExtension( + installMetadata, + previousExtensionConfig, + ); + } catch (e) { dispatchExtensionStateUpdate({ type: 'SET_STATE', payload: { name: extension.name, state: ExtensionUpdateState.ERROR }, }); - throw new Error('Updated extension not found after installation.'); + throw new Error( + `Updated extension not found after installation, got error:\n${e}`, + ); } const updatedVersion = updatedExtension.version; dispatchExtensionStateUpdate({ diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index a0e3b5196e..78e85041f2 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -2433,7 +2433,7 @@ describe('Settings Loading and Merging', () => { const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); const setValueSpy = vi.spyOn(loadedSettings, 'setValue'); const extensionManager = new ExtensionManager({ - loadedSettings, + settings: loadedSettings.merged, workspaceDir: MOCK_WORKSPACE_DIR, requestConsent: vi.fn(), requestSetting: vi.fn(), @@ -2506,7 +2506,7 @@ describe('Settings Loading and Merging', () => { const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); const setValueSpy = vi.spyOn(loadedSettings, 'setValue'); const extensionManager = new ExtensionManager({ - loadedSettings, + settings: loadedSettings.merged, workspaceDir: MOCK_WORKSPACE_DIR, requestConsent: vi.fn(), requestSetting: vi.fn(), diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 05388524f3..8aa68e72c2 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -67,8 +67,8 @@ import { } from './utils/relaunch.js'; import { loadSandboxConfig } from './config/sandboxConfig.js'; import { ExtensionManager } from './config/extension-manager.js'; -import { requestConsentNonInteractive } from './config/extensions/consent.js'; import { createPolicyUpdater } from './config/policy.js'; +import { requestConsentNonInteractive } from './config/extensions/consent.js'; export function validateDnsResolutionOrder( order: string | undefined, @@ -230,7 +230,7 @@ export async function main() { // Temporary extension manager only used during this non-interactive UI phase. new ExtensionManager({ workspaceDir: process.cwd(), - loadedSettings: settings, + settings: settings.merged, enabledExtensionOverrides: [], requestConsent: requestConsentNonInteractive, requestSetting: null, @@ -299,7 +299,6 @@ export async function main() { if (sandboxConfig) { const partialConfig = await loadCliConfig( settings.merged, - [], sessionId, argv, ); @@ -370,23 +369,7 @@ export async function main() { // to run Gemini CLI. It is now safe to perform expensive initialization that // may have side effects. { - // Eventually, `extensions` should move off of `config` entirely and into - // the UI state instead. - const extensionManager = new ExtensionManager({ - loadedSettings: settings, - workspaceDir: process.cwd(), - // At this stage, we still don't have an interactive UI. - requestConsent: requestConsentNonInteractive, - requestSetting: null, - enabledExtensionOverrides: argv.extensions, - }); - const extensions = extensionManager.loadExtensions(); - const config = await loadCliConfig( - settings.merged, - extensions, - sessionId, - argv, - ); + const config = await loadCliConfig(settings.merged, sessionId, argv); const policyEngine = config.getPolicyEngine(); const messageBus = config.getMessageBus(); @@ -397,7 +380,7 @@ export async function main() { if (config.getListExtensions()) { debugLogger.log('Installed extensions:'); - for (const extension of extensions) { + for (const extension of config.getExtensions()) { debugLogger.log(`- ${extension.name}`); } process.exit(0); @@ -434,7 +417,7 @@ export async function main() { } if (config.getExperimentalZedIntegration()) { - return runZedIntegration(config, settings, extensions, argv); + return runZedIntegration(config, settings, argv); } let input = config.getQuestion(); diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 5864437880..0337a6bc1a 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -12,6 +12,7 @@ import { beforeEach, afterEach, type Mock, + type MockedObject, } from 'vitest'; import { render, cleanup } from 'ink-testing-library'; import { AppContainer } from './AppContainer.js'; @@ -131,11 +132,13 @@ import { useKeypress, type Key } from './hooks/useKeypress.js'; import { measureElement } from 'ink'; import { useTerminalSize } from './hooks/useTerminalSize.js'; import { ShellExecutionService } from '@google/gemini-cli-core'; +import { type ExtensionManager } from '../config/extension-manager.js'; describe('AppContainer State Management', () => { let mockConfig: Config; let mockSettings: LoadedSettings; let mockInitResult: InitializationResult; + let mockExtensionManager: MockedObject; // Create typed mocks for all hooks const mockedUseQuotaAndFallback = useQuotaAndFallback as Mock; @@ -282,6 +285,15 @@ describe('AppContainer State Management', () => { // Mock config's getTargetDir to return consistent workspace directory vi.spyOn(mockConfig, 'getTargetDir').mockReturnValue('/test/workspace'); + mockExtensionManager = vi.mockObject({ + getExtensions: vi.fn().mockReturnValue([]), + setRequestConsent: vi.fn(), + setRequestSetting: vi.fn(), + } as unknown as ExtensionManager); + vi.spyOn(mockConfig, 'getExtensionLoader').mockReturnValue( + mockExtensionManager, + ); + // Mock LoadedSettings mockSettings = { merged: { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index a6ff6c0eeb..a81ad63280 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -98,7 +98,7 @@ import { useExtensionUpdates, } from './hooks/useExtensionUpdates.js'; import { ShellFocusContext } from './contexts/ShellFocusContext.js'; -import { ExtensionManager } from '../config/extension-manager.js'; +import { type ExtensionManager } from '../config/extension-manager.js'; import { requestConsentInteractive } from '../config/extensions/consent.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; @@ -168,21 +168,12 @@ export const AppContainer = (props: AppContainerProps) => { null, ); - const extensions = config.getExtensions(); - const [extensionManager] = useState( - new ExtensionManager({ - enabledExtensionOverrides: config.getEnabledExtensions(), - workspaceDir: config.getWorkingDir(), - requestConsent: (description) => - requestConsentInteractive( - description, - addConfirmUpdateExtensionRequest, - ), - // TODO: Support requesting settings in the interactive CLI - requestSetting: null, - loadedSettings: settings, - }), + const extensionManager = config.getExtensionLoader() as ExtensionManager; + // We are in the interactive CLI, update how we request consent and settings. + extensionManager.setRequestConsent((description) => + requestConsentInteractive(description, addConfirmUpdateExtensionRequest), ); + extensionManager.setRequestSetting(); const { addConfirmUpdateExtensionRequest, confirmUpdateExtensionRequests } = useConfirmUpdateRequests(); @@ -190,7 +181,7 @@ export const AppContainer = (props: AppContainerProps) => { extensionsUpdateState, extensionsUpdateStateInternal, dispatchExtensionStateUpdate, - } = useExtensionUpdates(extensions, extensionManager, historyManager.addItem); + } = useExtensionUpdates(extensionManager, historyManager.addItem); const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false); const openPermissionsDialog = useCallback( @@ -548,7 +539,7 @@ Logging in with Google... Please restart Gemini CLI to continue. config.getDebugMode(), config.getFileService(), settings.merged, - config.getExtensions(), + config.getExtensionLoader(), config.isTrustedFolder(), settings.merged.context?.importFormat || 'tree', // Use setting or default to 'tree' config.getFileFilteringOptions(), diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index b174b1d8d5..ee078356c5 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -103,7 +103,7 @@ export const directoryCommand: SlashCommand = { ], config.getDebugMode(), config.getFileService(), - config.getExtensions(), + config.getExtensionLoader(), config.getFolderTrust(), context.services.settings.merged.context?.importFormat || 'tree', // Use setting or default to 'tree' diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index b1f65a8a5f..523e0be0f1 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -13,6 +13,7 @@ import { MessageType } from '../types.js'; import type { LoadedSettings } from '../../config/settings.js'; import { getErrorMessage, + SimpleExtensionLoader, type FileDiscoveryService, } from '@google/gemini-cli-core'; import type { LoadServerHierarchicalMemoryResponse } from '@google/gemini-cli-core/index.js'; @@ -72,6 +73,7 @@ describe('memoryCommand', () => { config: { getUserMemory: mockGetUserMemory, getGeminiMdFileCount: mockGetGeminiMdFileCount, + getExtensionLoader: () => new SimpleExtensionLoader([]), }, }, }); @@ -176,6 +178,7 @@ describe('memoryCommand', () => { getWorkingDir: () => '/test/dir', getDebugMode: () => false, getFileService: () => ({}) as FileDiscoveryService, + getExtensionLoader: () => new SimpleExtensionLoader([]), getExtensions: () => [], shouldLoadMemoryFromIncludeDirectories: () => false, getWorkspaceContext: () => ({ diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index 988c611291..ffe04fbe08 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -91,7 +91,7 @@ export const memoryCommand: SlashCommand = { config.getDebugMode(), config.getFileService(), settings.merged, - config.getExtensions(), + config.getExtensionLoader(), config.isTrustedFolder(), settings.merged.context?.importFormat || 'tree', config.getFileFilteringOptions(), diff --git a/packages/cli/src/ui/hooks/useExtensionUpdates.test.tsx b/packages/cli/src/ui/hooks/useExtensionUpdates.test.tsx index 7d17a57611..be1a415538 100644 --- a/packages/cli/src/ui/hooks/useExtensionUpdates.test.tsx +++ b/packages/cli/src/ui/hooks/useExtensionUpdates.test.tsx @@ -10,7 +10,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { createExtension } from '../../test-utils/createExtension.js'; import { useExtensionUpdates } from './useExtensionUpdates.js'; -import { GEMINI_DIR, type GeminiCLIExtension } from '@google/gemini-cli-core'; +import { GEMINI_DIR } from '@google/gemini-cli-core'; import { render } from 'ink-testing-library'; import { MessageType } from '../types.js'; import { @@ -57,7 +57,7 @@ describe('useExtensionUpdates', () => { workspaceDir: tempHomeDir, requestConsent: vi.fn(), requestSetting: vi.fn(), - loadedSettings: loadSettings(), + settings: loadSettings().merged, }); }); @@ -66,11 +66,10 @@ describe('useExtensionUpdates', () => { }); it('should check for updates and log a message if an update is available', async () => { - const extensions = [ + vi.spyOn(extensionManager, 'getExtensions').mockReturnValue([ { name: 'test-extension', id: 'test-extension-id', - type: 'git', version: '1.0.0', path: '/some/path', isActive: true, @@ -81,7 +80,7 @@ describe('useExtensionUpdates', () => { }, contextFiles: [], }, - ]; + ]); const addItem = vi.fn(); vi.mocked(checkForAllExtensionUpdates).mockImplementation( @@ -97,11 +96,7 @@ describe('useExtensionUpdates', () => { ); function TestComponent() { - useExtensionUpdates( - extensions as GeminiCLIExtension[], - extensionManager, - addItem, - ); + useExtensionUpdates(extensionManager, addItem); return null; } @@ -119,7 +114,7 @@ describe('useExtensionUpdates', () => { }); it('should check for updates and automatically update if autoUpdate is true', async () => { - const extensionDir = createExtension({ + createExtension({ extensionsDir: userExtensionsDir, name: 'test-extension', version: '1.0.0', @@ -129,7 +124,6 @@ describe('useExtensionUpdates', () => { autoUpdate: true, }, }); - const extension = extensionManager.loadExtension(extensionDir)!; const addItem = vi.fn(); @@ -151,8 +145,9 @@ describe('useExtensionUpdates', () => { name: '', }); + extensionManager.loadExtensions(); function TestComponent() { - useExtensionUpdates([extension], extensionManager, addItem); + useExtensionUpdates(extensionManager, addItem); return null; } @@ -173,7 +168,7 @@ describe('useExtensionUpdates', () => { }); it('should batch update notifications for multiple extensions', async () => { - const extensionDir1 = createExtension({ + createExtension({ extensionsDir: userExtensionsDir, name: 'test-extension-1', version: '1.0.0', @@ -183,7 +178,7 @@ describe('useExtensionUpdates', () => { autoUpdate: true, }, }); - const extensionDir2 = createExtension({ + createExtension({ extensionsDir: userExtensionsDir, name: 'test-extension-2', version: '2.0.0', @@ -194,10 +189,7 @@ describe('useExtensionUpdates', () => { }, }); - const extensions = [ - extensionManager.loadExtension(extensionDir1)!, - extensionManager.loadExtension(extensionDir2)!, - ]; + extensionManager.loadExtensions(); const addItem = vi.fn(); @@ -233,7 +225,7 @@ describe('useExtensionUpdates', () => { }); function TestComponent() { - useExtensionUpdates(extensions, extensionManager, addItem); + useExtensionUpdates(extensionManager, addItem); return null; } @@ -262,11 +254,10 @@ describe('useExtensionUpdates', () => { }); it('should batch update notifications for multiple extensions with autoUpdate: false', async () => { - const extensions = [ + vi.spyOn(extensionManager, 'getExtensions').mockReturnValue([ { name: 'test-extension-1', id: 'test-extension-1-id', - type: 'git', version: '1.0.0', path: '/some/path1', isActive: true, @@ -281,7 +272,6 @@ describe('useExtensionUpdates', () => { name: 'test-extension-2', id: 'test-extension-2-id', - type: 'git', version: '2.0.0', path: '/some/path2', isActive: true, @@ -292,7 +282,7 @@ describe('useExtensionUpdates', () => { }, contextFiles: [], }, - ]; + ]); const addItem = vi.fn(); vi.mocked(checkForAllExtensionUpdates).mockImplementation( @@ -318,11 +308,7 @@ describe('useExtensionUpdates', () => { ); function TestComponent() { - useExtensionUpdates( - extensions as GeminiCLIExtension[], - extensionManager, - addItem, - ); + useExtensionUpdates(extensionManager, addItem); return null; } diff --git a/packages/cli/src/ui/hooks/useExtensionUpdates.ts b/packages/cli/src/ui/hooks/useExtensionUpdates.ts index 3bad4f771b..43dc5f2e20 100644 --- a/packages/cli/src/ui/hooks/useExtensionUpdates.ts +++ b/packages/cli/src/ui/hooks/useExtensionUpdates.ts @@ -78,7 +78,6 @@ export const useConfirmUpdateRequests = () => { }; export const useExtensionUpdates = ( - extensions: GeminiCLIExtension[], extensionManager: ExtensionManager, addItem: UseHistoryManagerReturn['addItem'], ) => { @@ -86,6 +85,7 @@ export const useExtensionUpdates = ( extensionUpdatesReducer, initialExtensionUpdatesState, ); + const extensions = extensionManager.getExtensions(); useEffect(() => { const extensionsToCheck = extensions.filter((extension) => { diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts index c320bbe3a9..50a20a3a05 100644 --- a/packages/cli/src/zed-integration/zedIntegration.ts +++ b/packages/cli/src/zed-integration/zedIntegration.ts @@ -11,7 +11,6 @@ import type { GeminiChat, ToolResult, ToolCallConfirmationDetails, - GeminiCLIExtension, FilterFilesOptions, } from '@google/gemini-cli-core'; import { @@ -63,7 +62,6 @@ export function resolveModel(model: string, isInFallbackMode: boolean): string { export async function runZedIntegration( config: Config, settings: LoadedSettings, - extensions: GeminiCLIExtension[], argv: CliArgs, ) { const stdout = Writable.toWeb(process.stdout) as WritableStream; @@ -76,8 +74,7 @@ export async function runZedIntegration( console.debug = console.error; new acp.AgentSideConnection( - (client: acp.Client) => - new GeminiAgent(config, settings, extensions, argv, client), + (client: acp.Client) => new GeminiAgent(config, settings, argv, client), stdout, stdin, ); @@ -90,7 +87,6 @@ class GeminiAgent { constructor( private config: Config, private settings: LoadedSettings, - private extensions: GeminiCLIExtension[], private argv: CliArgs, private client: acp.Client, ) {} @@ -204,13 +200,7 @@ class GeminiAgent { const settings = { ...this.settings.merged, mcpServers: mergedMcpServers }; - const config = await loadCliConfig( - settings, - this.extensions, - sessionId, - this.argv, - cwd, - ); + const config = await loadCliConfig(settings, sessionId, this.argv, cwd); await config.initialize(); return config; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 37f5f85641..398914ae5a 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -154,6 +154,10 @@ import { DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, } from './constants.js'; import { debugLogger } from '../utils/debugLogger.js'; +import { + type ExtensionLoader, + SimpleExtensionLoader, +} from '../utils/extensionLoader.js'; export type { FileFilteringOptions }; export { @@ -248,7 +252,7 @@ export interface ConfigParameters { maxSessionTurns?: number; experimentalZedIntegration?: boolean; listExtensions?: boolean; - extensions?: GeminiCLIExtension[]; + extensionLoader?: ExtensionLoader; enabledExtensions?: string[]; blockedMcpServers?: Array<{ name: string; extensionName: string }>; noBrowser?: boolean; @@ -337,7 +341,7 @@ export class Config { private inFallbackMode = false; private readonly maxSessionTurns: number; private readonly listExtensions: boolean; - private readonly _extensions: GeminiCLIExtension[]; + private readonly _extensionLoader: ExtensionLoader; private readonly _enabledExtensions: string[]; private readonly _blockedMcpServers: Array<{ name: string; @@ -440,7 +444,8 @@ export class Config { this.experimentalZedIntegration = params.experimentalZedIntegration ?? false; this.listExtensions = params.listExtensions ?? false; - this._extensions = params.extensions ?? []; + this._extensionLoader = + params.extensionLoader ?? new SimpleExtensionLoader([]); this._enabledExtensions = params.enabledExtensions ?? []; this._blockedMcpServers = params.blockedMcpServers ?? []; this.noBrowser = params.noBrowser ?? false; @@ -885,7 +890,11 @@ export class Config { } getExtensions(): GeminiCLIExtension[] { - return this._extensions; + return this._extensionLoader.getExtensions(); + } + + getExtensionLoader(): ExtensionLoader { + return this._extensionLoader; } // The list of explicitly enabled extensions, if any were given, may contain diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bc2eab2147..8754c23bfe 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -66,6 +66,7 @@ export * from './utils/promptIdContext.js'; export * from './utils/thoughtUtils.js'; export * from './utils/debugLogger.js'; export * from './utils/events.js'; +export * from './utils/extensionLoader.js'; // Export services export * from './services/fileDiscoveryService.js'; diff --git a/packages/core/src/utils/extensionLoader.ts b/packages/core/src/utils/extensionLoader.ts new file mode 100644 index 0000000000..d42fcf6084 --- /dev/null +++ b/packages/core/src/utils/extensionLoader.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EventEmitter } from 'node:events'; +import type { GeminiCLIExtension } from '../config/config.js'; + +export interface ExtensionLoader { + getExtensions(): GeminiCLIExtension[]; + + extensionEvents(): EventEmitter; +} + +export interface ExtensionEvents { + extensionEnabled: ExtensionEnableEvent[]; + extensionDisabled: ExtensionDisableEvent[]; + extensionLoaded: ExtensionLoadEvent[]; + extensionUnloaded: ExtensionUnloadEvent[]; + extensionInstalled: ExtensionInstallEvent[]; + extensionUninstalled: ExtensionUninstallEvent[]; + extensionUpdated: ExtensionUpdateEvent[]; +} + +interface BaseExtensionEvent { + extension: GeminiCLIExtension; +} +export type ExtensionDisableEvent = BaseExtensionEvent; +export type ExtensionEnableEvent = BaseExtensionEvent; +export type ExtensionInstallEvent = BaseExtensionEvent; +export type ExtensionLoadEvent = BaseExtensionEvent; +export type ExtensionUnloadEvent = BaseExtensionEvent; +export type ExtensionUninstallEvent = BaseExtensionEvent; +export type ExtensionUpdateEvent = BaseExtensionEvent; + +export class SimpleExtensionLoader implements ExtensionLoader { + private _eventEmitter = new EventEmitter(); + constructor(private readonly extensions: GeminiCLIExtension[]) {} + + extensionEvents(): EventEmitter { + return this._eventEmitter; + } + + getExtensions(): GeminiCLIExtension[] { + return this.extensions; + } +} diff --git a/packages/core/src/utils/memoryDiscovery.test.ts b/packages/core/src/utils/memoryDiscovery.test.ts index 6d7d4da971..5f8c4b534c 100644 --- a/packages/core/src/utils/memoryDiscovery.test.ts +++ b/packages/core/src/utils/memoryDiscovery.test.ts @@ -16,6 +16,7 @@ import { import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { GEMINI_DIR } from './paths.js'; import type { GeminiCLIExtension } from '../config/config.js'; +import { SimpleExtensionLoader } from './extensionLoader.js'; vi.mock('os', async (importOriginal) => { const actualOs = await importOriginal(); @@ -88,7 +89,7 @@ describe('loadServerHierarchicalMemory', () => { [], false, new FileDiscoveryService(projectRoot), - [], // extensions + new SimpleExtensionLoader([]), false, // untrusted ); @@ -117,7 +118,7 @@ describe('loadServerHierarchicalMemory', () => { [], false, new FileDiscoveryService(projectRoot), - [], // extensions + new SimpleExtensionLoader([]), false, // untrusted ); @@ -133,7 +134,7 @@ describe('loadServerHierarchicalMemory', () => { [], false, new FileDiscoveryService(projectRoot), - [], // extensions + new SimpleExtensionLoader([]), DEFAULT_FOLDER_TRUST, ); @@ -155,7 +156,7 @@ describe('loadServerHierarchicalMemory', () => { [], false, new FileDiscoveryService(projectRoot), - [], // extensions + new SimpleExtensionLoader([]), DEFAULT_FOLDER_TRUST, ); @@ -182,7 +183,7 @@ default context content [], false, new FileDiscoveryService(projectRoot), - [], // extensions + new SimpleExtensionLoader([]), DEFAULT_FOLDER_TRUST, ); @@ -213,7 +214,7 @@ custom context content [], false, new FileDiscoveryService(projectRoot), - [], // extensions + new SimpleExtensionLoader([]), DEFAULT_FOLDER_TRUST, ); @@ -248,7 +249,7 @@ cwd context content [], false, new FileDiscoveryService(projectRoot), - [], // extensions + new SimpleExtensionLoader([]), DEFAULT_FOLDER_TRUST, ); @@ -280,7 +281,7 @@ Subdir custom memory [], false, new FileDiscoveryService(projectRoot), - [], // extensions + new SimpleExtensionLoader([]), DEFAULT_FOLDER_TRUST, ); @@ -312,7 +313,7 @@ Src directory memory [], false, new FileDiscoveryService(projectRoot), - [], // extensions + new SimpleExtensionLoader([]), DEFAULT_FOLDER_TRUST, ); @@ -356,7 +357,7 @@ Subdir memory [], false, new FileDiscoveryService(projectRoot), - [], // extensions + new SimpleExtensionLoader([]), DEFAULT_FOLDER_TRUST, ); @@ -409,7 +410,7 @@ Subdir memory [], false, new FileDiscoveryService(projectRoot), - [], // extensions + new SimpleExtensionLoader([]), DEFAULT_FOLDER_TRUST, 'tree', { @@ -445,7 +446,7 @@ My code memory [], true, new FileDiscoveryService(projectRoot), - [], // extensions + new SimpleExtensionLoader([]), DEFAULT_FOLDER_TRUST, 'tree', // importFormat { @@ -467,7 +468,7 @@ My code memory [], false, new FileDiscoveryService(projectRoot), - [], // extensions + new SimpleExtensionLoader([]), DEFAULT_FOLDER_TRUST, ); @@ -489,12 +490,12 @@ My code memory [], false, new FileDiscoveryService(projectRoot), - [ + new SimpleExtensionLoader([ { contextFiles: [extensionFilePath], isActive: true, } as GeminiCLIExtension, - ], // extensions + ]), DEFAULT_FOLDER_TRUST, ); @@ -521,7 +522,7 @@ Extension memory content [includedDir], false, new FileDiscoveryService(projectRoot), - [], // extensions + new SimpleExtensionLoader([]), DEFAULT_FOLDER_TRUST, ); @@ -556,7 +557,7 @@ included directory memory createdFiles.map((f) => path.dirname(f)), false, new FileDiscoveryService(projectRoot), - [], // extensions + new SimpleExtensionLoader([]), DEFAULT_FOLDER_TRUST, ); @@ -591,7 +592,7 @@ included directory memory [childDir, parentDir], // Deliberately include duplicates false, new FileDiscoveryService(projectRoot), - [], // extensions + new SimpleExtensionLoader([]), DEFAULT_FOLDER_TRUST, ); diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index 8d77f0bad4..212dd7f935 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -15,7 +15,7 @@ import { processImports } from './memoryImportProcessor.js'; import type { FileFilteringOptions } from '../config/constants.js'; import { DEFAULT_MEMORY_FILE_FILTERING_OPTIONS } from '../config/constants.js'; import { GEMINI_DIR } from './paths.js'; -import type { GeminiCLIExtension } from '../config/config.js'; +import type { ExtensionLoader } from './extensionLoader.js'; import { debugLogger } from './debugLogger.js'; // Simple console logger, similar to the one previously in CLI's config.ts @@ -338,7 +338,7 @@ export async function loadServerHierarchicalMemory( includeDirectoriesToReadGemini: readonly string[], debugMode: boolean, fileService: FileDiscoveryService, - extensions: GeminiCLIExtension[], + extensionLoader: ExtensionLoader, folderTrust: boolean, importFormat: 'flat' | 'tree' = 'tree', fileFilteringOptions?: FileFilteringOptions, @@ -365,7 +365,8 @@ export async function loadServerHierarchicalMemory( // Add extension file paths separately since they may be conditionally enabled. filePaths.push( - ...extensions + ...extensionLoader + .getExtensions() .filter((ext) => ext.isActive) .flatMap((ext) => ext.contextFiles), );