From 4160d904da8328eb7168b5b652d4c0f17682546c Mon Sep 17 00:00:00 2001 From: matt korwel Date: Wed, 11 Jun 2025 13:34:35 -0700 Subject: [PATCH] Extensibility: Gemini.md files (#944) --- docs/extension.md | 2 +- packages/cli/src/config/config.test.ts | 41 +++++++++---- packages/cli/src/config/config.ts | 12 +++- packages/cli/src/config/extension.test.ts | 57 ++++++++++++++----- packages/cli/src/config/extension.ts | 16 ++++++ .../core/src/utils/memoryDiscovery.test.ts | 28 +++++++++ packages/core/src/utils/memoryDiscovery.ts | 10 ++++ 7 files changed, 139 insertions(+), 27 deletions(-) diff --git a/docs/extension.md b/docs/extension.md index e71b58a3..36068a0c 100644 --- a/docs/extension.md +++ b/docs/extension.md @@ -33,6 +33,6 @@ The `gemini-extension.json` file has the following structure: - `name`: The name of the extension. This is used to uniquely identify the extension. - `version`: The version of the extension. - `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like mcpServers configured in settings.json. If an extension and settings.json configure a mcp server with the same name, settings.json will take precedence. -- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the workspace. NOT YET SUPPORTED +- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the workspace. If this property is not used but a `Gemini.md` is present then that file will be loaded. When Gemini CLI starts, it will load all the extensions and merge their configurations. If there are any conflicts, the workspace configuration will take precedence. diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 269b4c81..b8c617bb 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -58,8 +58,11 @@ vi.mock('@gemini-cli/core', async () => { setUserMemory: vi.fn(), setGeminiMdFileCount: vi.fn(), })), - loadServerHierarchicalMemory: vi.fn(() => - Promise.resolve({ memoryContent: '', fileCount: 0 }), + loadServerHierarchicalMemory: vi.fn((cwd, debug, extensionPaths) => + Promise.resolve({ + memoryContent: extensionPaths?.join(',') || '', + fileCount: extensionPaths?.length || 0, + }), ), }; }); @@ -228,15 +231,31 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { vi.restoreAllMocks(); }); - it('should have a placeholder test to ensure test file validity', () => { - // This test suite is currently a placeholder. - // Tests for loadHierarchicalGeminiMemory were removed due to persistent - // and complex mocking issues with Node.js built-in modules (like 'os') - // in the Vitest environment. These issues prevented consistent and reliable - // testing of file system interactions dependent on os.homedir(). - // The core logic was implemented as per specification, but the tests - // could not be stabilized. - expect(true).toBe(true); + it('should pass extension context file paths to loadServerHierarchicalMemory', async () => { + process.argv = ['node', 'script.js']; + const settings: Settings = {}; + const extensions = [ + { + name: 'ext1', + version: '1.0.0', + contextFileName: '/path/to/ext1/gemini.md', + }, + { + name: 'ext2', + version: '1.0.0', + }, + { + name: 'ext3', + version: '1.0.0', + contextFileName: '/path/to/ext3/gemini.md', + }, + ]; + await loadCliConfig(settings, extensions, [], 'session-id'); + expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( + expect.any(String), + false, + ['/path/to/ext1/gemini.md', '/path/to/ext3/gemini.md'], + ); }); // NOTE TO FUTURE DEVELOPERS: diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 1c8ef625..3a602ef8 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -112,6 +112,7 @@ async function parseArguments(): Promise { export async function loadHierarchicalGeminiMemory( currentWorkingDirectory: string, debugMode: boolean, + extensionContextFilePaths: string[] = [], ): Promise<{ memoryContent: string; fileCount: number }> { if (debugMode) { logger.debug( @@ -120,7 +121,11 @@ export async function loadHierarchicalGeminiMemory( } // Directly call the server function. // The server function will use its own homedir() for the global path. - return loadServerHierarchicalMemory(currentWorkingDirectory, debugMode); + return loadServerHierarchicalMemory( + currentWorkingDirectory, + debugMode, + extensionContextFilePaths, + ); } export async function loadCliConfig( @@ -145,10 +150,15 @@ export async function loadCliConfig( setServerGeminiMdFilename(getCurrentGeminiMdFilename()); } + const extensionContextFilePaths = extensions + .map((e) => e.contextFileName) + .filter((p): p is string => !!p); + // Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory( process.cwd(), debugMode, + extensionContextFilePaths, ); const contentGeneratorConfig = await createContentGeneratorConfig(argv); diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 462024bf..6e0d1658 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -41,28 +41,47 @@ describe('loadExtensions', () => { fs.rmSync(tempHomeDir, { recursive: true, force: true }); }); - it('should deduplicate extensions, prioritizing the workspace directory', () => { - // Create extensions in the workspace + it('should load context file path when gemini.md is present', () => { const workspaceExtensionsDir = path.join( tempWorkspaceDir, EXTENSIONS_DIRECTORY_NAME, ); fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); - createExtension(workspaceExtensionsDir, 'ext1', '1.0.0'); + createExtension(workspaceExtensionsDir, 'ext1', '1.0.0', true); createExtension(workspaceExtensionsDir, 'ext2', '2.0.0'); - // Create extensions in the home directory - const homeExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME); - fs.mkdirSync(homeExtensionsDir, { recursive: true }); - createExtension(homeExtensionsDir, 'ext1', '1.1.0'); // Duplicate that should be ignored - createExtension(homeExtensionsDir, 'ext3', '3.0.0'); - const extensions = loadExtensions(tempWorkspaceDir); - expect(extensions).toHaveLength(3); - expect(extensions.find((e) => e.name === 'ext1')?.version).toBe('1.0.0'); // Workspace version should be kept - expect(extensions.find((e) => e.name === 'ext2')?.version).toBe('2.0.0'); - expect(extensions.find((e) => e.name === 'ext3')?.version).toBe('3.0.0'); + expect(extensions).toHaveLength(2); + const ext1 = extensions.find((e) => e.name === 'ext1'); + const ext2 = extensions.find((e) => e.name === 'ext2'); + expect(ext1?.contextFileName).toBe( + path.join(workspaceExtensionsDir, 'ext1', 'gemini.md'), + ); + expect(ext2?.contextFileName).toBeUndefined(); + }); + + it('should load context file path from the extension config', () => { + const workspaceExtensionsDir = path.join( + tempWorkspaceDir, + EXTENSIONS_DIRECTORY_NAME, + ); + fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); + createExtension( + workspaceExtensionsDir, + 'ext1', + '1.0.0', + false, + 'my-context.md', + ); + + const extensions = loadExtensions(tempWorkspaceDir); + + expect(extensions).toHaveLength(1); + const ext1 = extensions.find((e) => e.name === 'ext1'); + expect(ext1?.contextFileName).toBe( + path.join(workspaceExtensionsDir, 'ext1', 'my-context.md'), + ); }); }); @@ -70,11 +89,21 @@ function createExtension( extensionsDir: string, name: string, version: string, + addContextFile = false, + contextFileName?: string, ): void { const extDir = path.join(extensionsDir, name); fs.mkdirSync(extDir); fs.writeFileSync( path.join(extDir, EXTENSIONS_CONFIG_FILENAME), - JSON.stringify({ name, version }), + JSON.stringify({ name, version, contextFileName }), ); + + if (addContextFile) { + fs.writeFileSync(path.join(extDir, 'gemini.md'), 'context'); + } + + if (contextFileName) { + fs.writeFileSync(path.join(extDir, contextFileName), 'context'); + } } diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 641cfcb5..9dd33e1b 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -74,6 +74,22 @@ function loadExtensionsFromDir(dir: string): ExtensionConfig[] { ); continue; } + + if (extensionConfig.contextFileName) { + const contextFilePath = path.join( + extensionDir, + extensionConfig.contextFileName, + ); + if (fs.existsSync(contextFilePath)) { + extensionConfig.contextFileName = contextFilePath; + } + } else { + const contextFilePath = path.join(extensionDir, 'gemini.md'); + if (fs.existsSync(contextFilePath)) { + extensionConfig.contextFileName = contextFilePath; + } + } + extensions.push(extensionConfig); } catch (e) { console.error( diff --git a/packages/core/src/utils/memoryDiscovery.test.ts b/packages/core/src/utils/memoryDiscovery.test.ts index 5329a15b..9c78f625 100644 --- a/packages/core/src/utils/memoryDiscovery.test.ts +++ b/packages/core/src/utils/memoryDiscovery.test.ts @@ -564,4 +564,32 @@ describe('loadServerHierarchicalMemory', () => { ); consoleDebugSpy.mockRestore(); }); + + it('should load extension context file paths', async () => { + const extensionFilePath = '/test/extensions/ext1/gemini.md'; + mockFs.access.mockImplementation(async (p) => { + if (p === extensionFilePath) { + return undefined; + } + throw new Error('File not found'); + }); + mockFs.readFile.mockImplementation(async (p) => { + if (p === extensionFilePath) { + return 'Extension memory content'; + } + throw new Error('File not found'); + }); + + const { memoryContent, fileCount } = await loadServerHierarchicalMemory( + CWD, + false, + [extensionFilePath], + ); + + expect(memoryContent).toBe( + `--- Context from: ${path.relative(CWD, extensionFilePath)} ---\nExtension memory content\n--- End of Context from: ${path.relative(CWD, extensionFilePath)} ---`, + ); + expect(fileCount).toBe(1); + expect(mockFs.readFile).toHaveBeenCalledWith(extensionFilePath, 'utf-8'); + }); }); diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index 6e822145..07649415 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -81,6 +81,7 @@ async function getGeminiMdFilePathsInternal( currentWorkingDirectory: string, userHomePath: string, debugMode: boolean, + extensionContextFilePaths: string[] = [], ): Promise { const resolvedCwd = path.resolve(currentWorkingDirectory); const resolvedHome = path.resolve(userHomePath); @@ -195,6 +196,13 @@ async function getGeminiMdFilePathsInternal( } } + // Add extension context file paths + for (const extensionPath of extensionContextFilePaths) { + if (!paths.includes(extensionPath)) { + paths.push(extensionPath); + } + } + if (debugMode) logger.debug( `Final ordered ${getCurrentGeminiMdFilename()} paths to read: ${JSON.stringify(paths)}`, @@ -258,6 +266,7 @@ function concatenateInstructions( export async function loadServerHierarchicalMemory( currentWorkingDirectory: string, debugMode: boolean, + extensionContextFilePaths: string[] = [], ): Promise<{ memoryContent: string; fileCount: number }> { if (debugMode) logger.debug( @@ -270,6 +279,7 @@ export async function loadServerHierarchicalMemory( currentWorkingDirectory, userHomePath, debugMode, + extensionContextFilePaths, ); if (filePaths.length === 0) { if (debugMode) logger.debug('No GEMINI.md files found in hierarchy.');