diff --git a/docs/extensions/index.md b/docs/extensions/index.md index 2ea11998f5a..d4048248ae0 100644 --- a/docs/extensions/index.md +++ b/docs/extensions/index.md @@ -152,7 +152,10 @@ The file has the following structure: } }, "contextFileName": "GEMINI.md", - "excludeTools": ["run_shell_command"] + "excludeTools": ["run_shell_command"], + "context": { + "includeDirectories": ["/path/to/include"] + } } ``` @@ -181,6 +184,10 @@ The file has the following structure: `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command. Note that this differs from the MCP server `excludeTools` functionality, which can be listed in the MCP server config. +- `context.includeDirectories`: An array of directories to include in the + context. These directories will be added to the CLI's `includeDirectories` + setting when the extension is active. Relative paths are resolved relative to + the extension's directory. When Gemini CLI starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index bfb6967beff..913cbccd014 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -14,6 +14,7 @@ import { WRITE_FILE_TOOL_NAME, EDIT_TOOL_NAME, WEB_FETCH_TOOL_NAME, + type GeminiCLIExtension, type ExtensionLoader, debugLogger, } from '@google/gemini-cli-core'; @@ -88,8 +89,12 @@ vi.mock('@google/gemini-cli-core', async () => { const actualServer = await vi.importActual( '@google/gemini-cli-core', ); + const actualConfig = await vi.importActual< + typeof import('../../../core/src/config/config.js') + >('../../../core/src/config/config.js'); return { ...actualServer, + Config: actualConfig.Config, IdeClient: { getInstance: vi.fn().mockResolvedValue({ getConnectionStatus: vi.fn(), @@ -97,6 +102,13 @@ vi.mock('@google/gemini-cli-core', async () => { shutdown: vi.fn(), }), }, + EDITOR_DISPLAY_NAMES: { + vim: 'Vim', + nano: 'Nano', + }, + checkHasEditorType: vi.fn().mockReturnValue(true), + allowEditorTypeInSandbox: vi.fn().mockReturnValue(true), + logExtensionEnable: vi.fn(), loadEnvironment: vi.fn(), loadServerHierarchicalMemory: vi.fn( ( @@ -1445,6 +1457,30 @@ describe('loadCliConfig with includeDirectories', () => { expected.length - 1, ); }); + + it('should include directories from active extensions', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments({} as Settings); + const settings: Settings = {}; + + const mockExtension = { + name: 'test-extension', + version: '1.0.0', + isActive: true, + path: '/path/to/extension', + id: 'test-extension-id', + contextFiles: [], + includeDirectories: ['/extension/dir'], + } as unknown as GeminiCLIExtension; + + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([ + mockExtension, + ]); + + const config = await loadCliConfig(settings, 'test-session', argv); + const pendingDirs = config.getPendingIncludeDirectories(); + expect(pendingDirs).toContain(path.resolve('/extension/dir')); + }); }); describe('loadCliConfig compressionThreshold', () => { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 3a85a0760a5..3f63c490c43 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -414,10 +414,6 @@ export async function loadCliConfig( ...settings.context?.fileFiltering, }; - const includeDirectories = (settings.context?.includeDirectories || []) - .map(resolvePath) - .concat((argv.includeDirectories || []).map(resolvePath)); - const extensionManager = new ExtensionManager({ settings, requestConsent: requestConsentNonInteractive, @@ -428,6 +424,17 @@ export async function loadCliConfig( }); await extensionManager.loadExtensions(); + const includeDirectories = (settings.context?.includeDirectories || []) + .map(resolvePath) + .concat((argv.includeDirectories || []).map(resolvePath)) + .concat( + extensionManager + .getExtensions() + .filter((e) => e.isActive && e.includeDirectories) + .flatMap((e) => e.includeDirectories!) + .map(resolvePath), + ); + // Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version const { memoryContent, fileCount, filePaths } = await loadServerHierarchicalMemory( diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index e6467d9b966..505ea7dfdd4 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -514,6 +514,11 @@ export class ExtensionManager extends ExtensionLoader { this.workspaceDir, ), id: getExtensionId(config, installMetadata), + includeDirectories: config.context?.includeDirectories?.map((dir) => + path.isAbsolute(dir) || dir.startsWith('~') + ? dir + : path.resolve(effectiveExtensionPath, dir), + ), }; this.loadedExtensions = [...this.loadedExtensions, extension]; diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index a30065f612b..fc4ad260264 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -310,6 +310,65 @@ describe('extension tests', () => { expect(extensions[0].mcpServers?.['test-server'].cwd).toBe(expectedCwd); }); + it('should load includeDirectories from extension config', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + includeDirectories: [ + '/absolute/path', + '~/home/path', + './relative/path', + '${extensionPath}/hydrated/path', + ], + }); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(1); + const includeDirs = extensions[0].includeDirectories; + expect(includeDirs).toBeDefined(); + expect(includeDirs).toContain('/absolute/path'); + // ~ is not expanded by extension loader, but by config loader. + // However, relative paths should be resolved relative to extension dir? + // Relative paths should be resolved relative to the extension directory + const expectedRelativePath = path.join( + userExtensionsDir, + 'test-extension', + 'relative', + 'path', + ); + expect(includeDirs).toContain(expectedRelativePath); + + // Variable substitution happens in loadExtensionConfig + const expectedHydratedPath = path.join( + userExtensionsDir, + 'test-extension', + 'hydrated', + 'path', + ); + expect(includeDirs).toContain(expectedHydratedPath); + }); + + it('should not include directories from inactive extensions', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'inactive-extension', + version: '1.0.0', + includeDirectories: ['/inactive/path'], + }); + + // Mock isEnabled to return false for this extension + vi.spyOn( + extensionManager['extensionEnablementManager'], + 'isEnabled', + ).mockReturnValue(false); + + const extensions = await extensionManager.loadExtensions(); + expect(extensions).toHaveLength(1); + expect(extensions[0].isActive).toBe(false); + // The extension object still has the property, but config.ts filters by isActive + }); + it('should load a linked extension correctly', async () => { const sourceExtDir = createExtension({ extensionsDir: tempWorkspaceDir, diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index bafaba59a8e..b219f6b67e8 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -26,6 +26,9 @@ export interface ExtensionConfig { mcpServers?: Record; contextFileName?: string | string[]; excludeTools?: string[]; + context?: { + includeDirectories?: string[]; + }; settings?: ExtensionSetting[]; } diff --git a/packages/cli/src/config/extensions/consent.test.ts b/packages/cli/src/config/extensions/consent.test.ts index 9101c2a1563..ddc4f497072 100644 --- a/packages/cli/src/config/extensions/consent.test.ts +++ b/packages/cli/src/config/extensions/consent.test.ts @@ -188,6 +188,26 @@ describe('consent', () => { await maybeRequestConsentOrFail(newConfig, requestConsent, prevConfig); expect(requestConsent).toHaveBeenCalledTimes(1); }); + + it('should request consent if includeDirectories changes', async () => { + const prevConfig: ExtensionConfig = { ...baseConfig }; + const newConfig: ExtensionConfig = { + ...baseConfig, + context: { + includeDirectories: ['/path/to/include'], + }, + }; + const requestConsent = vi.fn().mockResolvedValue(true); + await maybeRequestConsentOrFail(newConfig, requestConsent, prevConfig); + + const expectedConsentString = [ + 'Installing extension "test-ext".', + INSTALL_WARNING_MESSAGE, + 'This extension will add the following directories to your context (while active): /path/to/include', + ].join('\n'); + + expect(requestConsent).toHaveBeenCalledWith(expectedConsentString); + }); }); }); }); diff --git a/packages/cli/src/config/extensions/consent.ts b/packages/cli/src/config/extensions/consent.ts index ee040cbdee7..d1c9a45c82c 100644 --- a/packages/cli/src/config/extensions/consent.ts +++ b/packages/cli/src/config/extensions/consent.ts @@ -130,6 +130,11 @@ function extensionConsentString(extensionConfig: ExtensionConfig): string { `This extension will exclude the following core tools: ${sanitizedConfig.excludeTools}`, ); } + if (sanitizedConfig.context?.includeDirectories) { + output.push( + `This extension will add the following directories to your context (while active): ${sanitizedConfig.context.includeDirectories.join(', ')}`, + ); + } return output.join('\n'); } diff --git a/packages/cli/src/test-utils/createExtension.ts b/packages/cli/src/test-utils/createExtension.ts index f7ad425f064..3574b814a3c 100644 --- a/packages/cli/src/test-utils/createExtension.ts +++ b/packages/cli/src/test-utils/createExtension.ts @@ -25,12 +25,20 @@ export function createExtension({ mcpServers = {} as Record, installMetadata = undefined as ExtensionInstallMetadata | undefined, settings = undefined as ExtensionSetting[] | undefined, + includeDirectories = undefined as string[] | undefined, } = {}): string { const extDir = path.join(extensionsDir, name); fs.mkdirSync(extDir, { recursive: true }); fs.writeFileSync( path.join(extDir, EXTENSIONS_CONFIG_FILENAME), - JSON.stringify({ name, version, contextFileName, mcpServers, settings }), + JSON.stringify({ + name, + version, + contextFileName, + mcpServers, + settings, + context: includeDirectories ? { includeDirectories } : undefined, + }), ); if (addContextFile) { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 4693810f0f2..41a4fbd5ed6 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -143,6 +143,7 @@ export interface GeminiCLIExtension { excludeTools?: string[]; id: string; hooks?: { [K in HookEventName]?: HookDefinition[] }; + includeDirectories?: string[]; } export interface ExtensionInstallMetadata {