Extensibility: Gemini.md files (#944)
This commit is contained in:
parent
24c61147b8
commit
4160d904da
|
@ -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.
|
- `name`: The name of the extension. This is used to uniquely identify the extension.
|
||||||
- `version`: The version of 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.
|
- `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.
|
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.
|
||||||
|
|
|
@ -58,8 +58,11 @@ vi.mock('@gemini-cli/core', async () => {
|
||||||
setUserMemory: vi.fn(),
|
setUserMemory: vi.fn(),
|
||||||
setGeminiMdFileCount: vi.fn(),
|
setGeminiMdFileCount: vi.fn(),
|
||||||
})),
|
})),
|
||||||
loadServerHierarchicalMemory: vi.fn(() =>
|
loadServerHierarchicalMemory: vi.fn((cwd, debug, extensionPaths) =>
|
||||||
Promise.resolve({ memoryContent: '', fileCount: 0 }),
|
Promise.resolve({
|
||||||
|
memoryContent: extensionPaths?.join(',') || '',
|
||||||
|
fileCount: extensionPaths?.length || 0,
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
@ -228,15 +231,31 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have a placeholder test to ensure test file validity', () => {
|
it('should pass extension context file paths to loadServerHierarchicalMemory', async () => {
|
||||||
// This test suite is currently a placeholder.
|
process.argv = ['node', 'script.js'];
|
||||||
// Tests for loadHierarchicalGeminiMemory were removed due to persistent
|
const settings: Settings = {};
|
||||||
// and complex mocking issues with Node.js built-in modules (like 'os')
|
const extensions = [
|
||||||
// in the Vitest environment. These issues prevented consistent and reliable
|
{
|
||||||
// testing of file system interactions dependent on os.homedir().
|
name: 'ext1',
|
||||||
// The core logic was implemented as per specification, but the tests
|
version: '1.0.0',
|
||||||
// could not be stabilized.
|
contextFileName: '/path/to/ext1/gemini.md',
|
||||||
expect(true).toBe(true);
|
},
|
||||||
|
{
|
||||||
|
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:
|
// NOTE TO FUTURE DEVELOPERS:
|
||||||
|
|
|
@ -112,6 +112,7 @@ async function parseArguments(): Promise<CliArgs> {
|
||||||
export async function loadHierarchicalGeminiMemory(
|
export async function loadHierarchicalGeminiMemory(
|
||||||
currentWorkingDirectory: string,
|
currentWorkingDirectory: string,
|
||||||
debugMode: boolean,
|
debugMode: boolean,
|
||||||
|
extensionContextFilePaths: string[] = [],
|
||||||
): Promise<{ memoryContent: string; fileCount: number }> {
|
): Promise<{ memoryContent: string; fileCount: number }> {
|
||||||
if (debugMode) {
|
if (debugMode) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
@ -120,7 +121,11 @@ export async function loadHierarchicalGeminiMemory(
|
||||||
}
|
}
|
||||||
// Directly call the server function.
|
// Directly call the server function.
|
||||||
// The server function will use its own homedir() for the global path.
|
// 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(
|
export async function loadCliConfig(
|
||||||
|
@ -145,10 +150,15 @@ export async function loadCliConfig(
|
||||||
setServerGeminiMdFilename(getCurrentGeminiMdFilename());
|
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
|
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
|
||||||
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
|
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
debugMode,
|
debugMode,
|
||||||
|
extensionContextFilePaths,
|
||||||
);
|
);
|
||||||
|
|
||||||
const contentGeneratorConfig = await createContentGeneratorConfig(argv);
|
const contentGeneratorConfig = await createContentGeneratorConfig(argv);
|
||||||
|
|
|
@ -41,28 +41,47 @@ describe('loadExtensions', () => {
|
||||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should deduplicate extensions, prioritizing the workspace directory', () => {
|
it('should load context file path when gemini.md is present', () => {
|
||||||
// Create extensions in the workspace
|
|
||||||
const workspaceExtensionsDir = path.join(
|
const workspaceExtensionsDir = path.join(
|
||||||
tempWorkspaceDir,
|
tempWorkspaceDir,
|
||||||
EXTENSIONS_DIRECTORY_NAME,
|
EXTENSIONS_DIRECTORY_NAME,
|
||||||
);
|
);
|
||||||
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
|
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
|
||||||
createExtension(workspaceExtensionsDir, 'ext1', '1.0.0');
|
createExtension(workspaceExtensionsDir, 'ext1', '1.0.0', true);
|
||||||
createExtension(workspaceExtensionsDir, 'ext2', '2.0.0');
|
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);
|
const extensions = loadExtensions(tempWorkspaceDir);
|
||||||
|
|
||||||
expect(extensions).toHaveLength(3);
|
expect(extensions).toHaveLength(2);
|
||||||
expect(extensions.find((e) => e.name === 'ext1')?.version).toBe('1.0.0'); // Workspace version should be kept
|
const ext1 = extensions.find((e) => e.name === 'ext1');
|
||||||
expect(extensions.find((e) => e.name === 'ext2')?.version).toBe('2.0.0');
|
const ext2 = extensions.find((e) => e.name === 'ext2');
|
||||||
expect(extensions.find((e) => e.name === 'ext3')?.version).toBe('3.0.0');
|
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,
|
extensionsDir: string,
|
||||||
name: string,
|
name: string,
|
||||||
version: string,
|
version: string,
|
||||||
|
addContextFile = false,
|
||||||
|
contextFileName?: string,
|
||||||
): void {
|
): void {
|
||||||
const extDir = path.join(extensionsDir, name);
|
const extDir = path.join(extensionsDir, name);
|
||||||
fs.mkdirSync(extDir);
|
fs.mkdirSync(extDir);
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(extDir, EXTENSIONS_CONFIG_FILENAME),
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -74,6 +74,22 @@ function loadExtensionsFromDir(dir: string): ExtensionConfig[] {
|
||||||
);
|
);
|
||||||
continue;
|
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);
|
extensions.push(extensionConfig);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
|
|
|
@ -564,4 +564,32 @@ describe('loadServerHierarchicalMemory', () => {
|
||||||
);
|
);
|
||||||
consoleDebugSpy.mockRestore();
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -81,6 +81,7 @@ async function getGeminiMdFilePathsInternal(
|
||||||
currentWorkingDirectory: string,
|
currentWorkingDirectory: string,
|
||||||
userHomePath: string,
|
userHomePath: string,
|
||||||
debugMode: boolean,
|
debugMode: boolean,
|
||||||
|
extensionContextFilePaths: string[] = [],
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const resolvedCwd = path.resolve(currentWorkingDirectory);
|
const resolvedCwd = path.resolve(currentWorkingDirectory);
|
||||||
const resolvedHome = path.resolve(userHomePath);
|
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)
|
if (debugMode)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Final ordered ${getCurrentGeminiMdFilename()} paths to read: ${JSON.stringify(paths)}`,
|
`Final ordered ${getCurrentGeminiMdFilename()} paths to read: ${JSON.stringify(paths)}`,
|
||||||
|
@ -258,6 +266,7 @@ function concatenateInstructions(
|
||||||
export async function loadServerHierarchicalMemory(
|
export async function loadServerHierarchicalMemory(
|
||||||
currentWorkingDirectory: string,
|
currentWorkingDirectory: string,
|
||||||
debugMode: boolean,
|
debugMode: boolean,
|
||||||
|
extensionContextFilePaths: string[] = [],
|
||||||
): Promise<{ memoryContent: string; fileCount: number }> {
|
): Promise<{ memoryContent: string; fileCount: number }> {
|
||||||
if (debugMode)
|
if (debugMode)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
@ -270,6 +279,7 @@ export async function loadServerHierarchicalMemory(
|
||||||
currentWorkingDirectory,
|
currentWorkingDirectory,
|
||||||
userHomePath,
|
userHomePath,
|
||||||
debugMode,
|
debugMode,
|
||||||
|
extensionContextFilePaths,
|
||||||
);
|
);
|
||||||
if (filePaths.length === 0) {
|
if (filePaths.length === 0) {
|
||||||
if (debugMode) logger.debug('No GEMINI.md files found in hierarchy.');
|
if (debugMode) logger.debug('No GEMINI.md files found in hierarchy.');
|
||||||
|
|
Loading…
Reference in New Issue