Fix default extension context filename and update docs (#1024)
This commit is contained in:
parent
1fa41af918
commit
54f0d9d0e5
|
@ -11,7 +11,9 @@ On startup, Gemini CLI looks for extensions in two locations:
|
|||
|
||||
It will load all extensions from both locations, but if an extension with the same name exists in both, the one in the workspace directory will take precedence.
|
||||
|
||||
Each extension is a directory that contains a `gemini-extension.json` file. This file contains the configuration for the extension.
|
||||
Each extension is a directory that contains a `gemini-extension.json` file. This file contains the configuration for the extension. For example:
|
||||
|
||||
`<workspace>/.gemini/extensions/my-extension/gemini-extension.json`
|
||||
|
||||
### `gemini-extension.json`
|
||||
|
||||
|
@ -30,9 +32,9 @@ 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. This should match the name of your extension directory.
|
||||
- `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. If this property is not used but a `Gemini.md` is present then that file will be loaded.
|
||||
- `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 in your extension directory, 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.
|
||||
|
|
|
@ -4,12 +4,11 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// packages/cli/src/config/config.test.ts
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as os from 'os';
|
||||
import { loadCliConfig } from './config.js';
|
||||
import { Settings } from './settings.js';
|
||||
import { Extension } from './extension.js';
|
||||
import * as ServerConfig from '@gemini-cli/core';
|
||||
|
||||
const MOCK_HOME_DIR = '/mock/home/user';
|
||||
|
@ -210,27 +209,41 @@ 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 = [
|
||||
const extensions: Extension[] = [
|
||||
{
|
||||
config: {
|
||||
name: 'ext1',
|
||||
version: '1.0.0',
|
||||
contextFileName: '/path/to/ext1/gemini.md',
|
||||
},
|
||||
contextFiles: ['/path/to/ext1/GEMINI.md'],
|
||||
},
|
||||
{
|
||||
config: {
|
||||
name: 'ext2',
|
||||
version: '1.0.0',
|
||||
},
|
||||
contextFiles: [],
|
||||
},
|
||||
{
|
||||
config: {
|
||||
name: 'ext3',
|
||||
version: '1.0.0',
|
||||
contextFileName: '/path/to/ext3/gemini.md',
|
||||
},
|
||||
contextFiles: [
|
||||
'/path/to/ext3/context1.md',
|
||||
'/path/to/ext3/context2.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'],
|
||||
[
|
||||
'/path/to/ext1/GEMINI.md',
|
||||
'/path/to/ext3/context1.md',
|
||||
'/path/to/ext3/context2.md',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ import {
|
|||
} from '@gemini-cli/core';
|
||||
import { Settings } from './settings.js';
|
||||
import { getEffectiveModel } from '../utils/modelCheck.js';
|
||||
import { ExtensionConfig } from './extension.js';
|
||||
import { Extension } from './extension.js';
|
||||
import * as dotenv from 'dotenv';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
@ -132,7 +132,7 @@ export async function loadHierarchicalGeminiMemory(
|
|||
|
||||
export async function loadCliConfig(
|
||||
settings: Settings,
|
||||
extensions: ExtensionConfig[],
|
||||
extensions: Extension[],
|
||||
geminiIgnorePatterns: string[],
|
||||
sessionId: string,
|
||||
): Promise<Config> {
|
||||
|
@ -152,9 +152,7 @@ export async function loadCliConfig(
|
|||
setServerGeminiMdFilename(getCurrentGeminiMdFilename());
|
||||
}
|
||||
|
||||
const extensionContextFilePaths = extensions
|
||||
.map((e) => e.contextFileName)
|
||||
.filter((p): p is string => !!p);
|
||||
const extensionContextFilePaths = extensions.flatMap((e) => e.contextFiles);
|
||||
|
||||
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
|
||||
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
|
||||
|
@ -206,10 +204,11 @@ export async function loadCliConfig(
|
|||
});
|
||||
}
|
||||
|
||||
function mergeMcpServers(settings: Settings, extensions: ExtensionConfig[]) {
|
||||
function mergeMcpServers(settings: Settings, extensions: Extension[]) {
|
||||
const mcpServers = settings.mcpServers || {};
|
||||
for (const extension of extensions) {
|
||||
Object.entries(extension.mcpServers || {}).forEach(([key, server]) => {
|
||||
Object.entries(extension.config.mcpServers || {}).forEach(
|
||||
([key, server]) => {
|
||||
if (mcpServers[key]) {
|
||||
logger.warn(
|
||||
`Skipping extension MCP config for server with key "${key}" as it already exists.`,
|
||||
|
@ -217,7 +216,8 @@ function mergeMcpServers(settings: Settings, extensions: ExtensionConfig[]) {
|
|||
return;
|
||||
}
|
||||
mcpServers[key] = server;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
return mcpServers;
|
||||
}
|
||||
|
|
|
@ -41,7 +41,7 @@ describe('loadExtensions', () => {
|
|||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should load context file path when gemini.md is present', () => {
|
||||
it('should load context file path when GEMINI.md is present', () => {
|
||||
const workspaceExtensionsDir = path.join(
|
||||
tempWorkspaceDir,
|
||||
EXTENSIONS_DIRECTORY_NAME,
|
||||
|
@ -53,12 +53,12 @@ describe('loadExtensions', () => {
|
|||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
|
||||
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();
|
||||
const ext1 = extensions.find((e) => e.config.name === 'ext1');
|
||||
const ext2 = extensions.find((e) => e.config.name === 'ext2');
|
||||
expect(ext1?.contextFiles).toEqual([
|
||||
path.join(workspaceExtensionsDir, 'ext1', 'GEMINI.md'),
|
||||
]);
|
||||
expect(ext2?.contextFiles).toEqual([]);
|
||||
});
|
||||
|
||||
it('should load context file path from the extension config', () => {
|
||||
|
@ -78,10 +78,10 @@ describe('loadExtensions', () => {
|
|||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
|
||||
expect(extensions).toHaveLength(1);
|
||||
const ext1 = extensions.find((e) => e.name === 'ext1');
|
||||
expect(ext1?.contextFileName).toBe(
|
||||
const ext1 = extensions.find((e) => e.config.name === 'ext1');
|
||||
expect(ext1?.contextFiles).toEqual([
|
||||
path.join(workspaceExtensionsDir, 'ext1', 'my-context.md'),
|
||||
);
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -100,7 +100,7 @@ function createExtension(
|
|||
);
|
||||
|
||||
if (addContextFile) {
|
||||
fs.writeFileSync(path.join(extDir, 'gemini.md'), 'context');
|
||||
fs.writeFileSync(path.join(extDir, 'GEMINI.md'), 'context');
|
||||
}
|
||||
|
||||
if (contextFileName) {
|
||||
|
|
|
@ -12,6 +12,11 @@ import * as os from 'os';
|
|||
export const EXTENSIONS_DIRECTORY_NAME = path.join('.gemini', 'extensions');
|
||||
export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json';
|
||||
|
||||
export interface Extension {
|
||||
config: ExtensionConfig;
|
||||
contextFiles: string[];
|
||||
}
|
||||
|
||||
export interface ExtensionConfig {
|
||||
name: string;
|
||||
version: string;
|
||||
|
@ -19,88 +24,92 @@ export interface ExtensionConfig {
|
|||
contextFileName?: string | string[];
|
||||
}
|
||||
|
||||
export function loadExtensions(workspaceDir: string): ExtensionConfig[] {
|
||||
export function loadExtensions(workspaceDir: string): Extension[] {
|
||||
const allExtensions = [
|
||||
...loadExtensionsFromDir(workspaceDir),
|
||||
...loadExtensionsFromDir(os.homedir()),
|
||||
];
|
||||
|
||||
const uniqueExtensions: ExtensionConfig[] = [];
|
||||
const uniqueExtensions: Extension[] = [];
|
||||
const seenNames = new Set<string>();
|
||||
for (const extension of allExtensions) {
|
||||
if (!seenNames.has(extension.name)) {
|
||||
if (!seenNames.has(extension.config.name)) {
|
||||
console.log(
|
||||
`Loading extension: ${extension.name} (version: ${extension.version})`,
|
||||
`Loading extension: ${extension.config.name} (version: ${extension.config.version})`,
|
||||
);
|
||||
uniqueExtensions.push(extension);
|
||||
seenNames.add(extension.name);
|
||||
seenNames.add(extension.config.name);
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueExtensions;
|
||||
}
|
||||
|
||||
function loadExtensionsFromDir(dir: string): ExtensionConfig[] {
|
||||
function loadExtensionsFromDir(dir: string): Extension[] {
|
||||
const extensionsDir = path.join(dir, EXTENSIONS_DIRECTORY_NAME);
|
||||
if (!fs.existsSync(extensionsDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const extensions: ExtensionConfig[] = [];
|
||||
const extensions: Extension[] = [];
|
||||
for (const subdir of fs.readdirSync(extensionsDir)) {
|
||||
const extensionDir = path.join(extensionsDir, subdir);
|
||||
|
||||
const extension = loadExtension(extensionDir);
|
||||
if (extension != null) {
|
||||
extensions.push(extension);
|
||||
}
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
|
||||
function loadExtension(extensionDir: string): Extension | null {
|
||||
if (!fs.statSync(extensionDir).isDirectory()) {
|
||||
console.error(
|
||||
`Warning: unexpected file ${extensionDir} in extensions directory.`,
|
||||
);
|
||||
continue;
|
||||
return null;
|
||||
}
|
||||
|
||||
const extensionPath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
if (!fs.existsSync(extensionPath)) {
|
||||
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
if (!fs.existsSync(configFilePath)) {
|
||||
console.error(
|
||||
`Warning: extension directory ${extensionDir} does not contain a config file ${extensionPath}.`,
|
||||
`Warning: extension directory ${extensionDir} does not contain a config file ${configFilePath}.`,
|
||||
);
|
||||
continue;
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileContent = fs.readFileSync(extensionPath, 'utf-8');
|
||||
const extensionConfig = JSON.parse(fileContent) as ExtensionConfig;
|
||||
if (!extensionConfig.name || !extensionConfig.version) {
|
||||
const configContent = fs.readFileSync(configFilePath, 'utf-8');
|
||||
const config = JSON.parse(configContent) as ExtensionConfig;
|
||||
if (!config.name || !config.version) {
|
||||
console.error(
|
||||
`Invalid extension config in ${extensionPath}: missing name or version.`,
|
||||
`Invalid extension config in ${configFilePath}: missing name or version.`,
|
||||
);
|
||||
continue;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (extensionConfig.contextFileName) {
|
||||
const contextFileNames = Array.isArray(extensionConfig.contextFileName)
|
||||
? extensionConfig.contextFileName
|
||||
: [extensionConfig.contextFileName];
|
||||
const resolvedPaths = contextFileNames
|
||||
.map((fileName) => path.join(extensionDir, fileName))
|
||||
.filter((filePath) => fs.existsSync(filePath));
|
||||
if (resolvedPaths.length > 0) {
|
||||
extensionConfig.contextFileName =
|
||||
resolvedPaths.length === 1 ? resolvedPaths[0] : resolvedPaths;
|
||||
}
|
||||
} else {
|
||||
const contextFilePath = path.join(extensionDir, 'gemini.md');
|
||||
if (fs.existsSync(contextFilePath)) {
|
||||
extensionConfig.contextFileName = contextFilePath;
|
||||
}
|
||||
}
|
||||
const contextFiles = getContextFileNames(config)
|
||||
.map((contextFileName) => path.join(extensionDir, contextFileName))
|
||||
.filter((contextFilePath) => fs.existsSync(contextFilePath));
|
||||
|
||||
extensions.push(extensionConfig);
|
||||
return {
|
||||
config,
|
||||
contextFiles,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to load extension config from ${extensionPath}:`,
|
||||
e,
|
||||
`Warning: error parsing extension config in ${configFilePath}: ${e}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return extensions;
|
||||
function getContextFileNames(config: ExtensionConfig): string[] {
|
||||
if (!config.contextFileName) {
|
||||
return ['GEMINI.md', 'gemini.md', 'Gemini.md'];
|
||||
} else if (!Array.isArray(config.contextFileName)) {
|
||||
return [config.contextFileName];
|
||||
}
|
||||
return config.contextFileName;
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ import { themeManager } from './ui/themes/theme-manager.js';
|
|||
import { getStartupWarnings } from './utils/startupWarnings.js';
|
||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||
import { loadGeminiIgnorePatterns } from './utils/loadIgnorePatterns.js';
|
||||
import { loadExtensions, ExtensionConfig } from './config/extension.js';
|
||||
import { loadExtensions, Extension } from './config/extension.js';
|
||||
import { cleanupCheckpoints } from './utils/cleanup.js';
|
||||
import {
|
||||
ApprovalMode,
|
||||
|
@ -164,7 +164,7 @@ process.on('unhandledRejection', (reason, _promise) => {
|
|||
|
||||
async function loadNonInteractiveConfig(
|
||||
config: Config,
|
||||
extensions: ExtensionConfig[],
|
||||
extensions: Extension[],
|
||||
settings: LoadedSettings,
|
||||
) {
|
||||
if (config.getApprovalMode() === ApprovalMode.YOLO) {
|
||||
|
|
|
@ -566,7 +566,7 @@ describe('loadServerHierarchicalMemory', () => {
|
|||
});
|
||||
|
||||
it('should load extension context file paths', async () => {
|
||||
const extensionFilePath = '/test/extensions/ext1/gemini.md';
|
||||
const extensionFilePath = '/test/extensions/ext1/GEMINI.md';
|
||||
mockFs.access.mockImplementation(async (p) => {
|
||||
if (p === extensionFilePath) {
|
||||
return undefined;
|
||||
|
|
Loading…
Reference in New Issue