diff options
Diffstat (limited to 'packages/cli/src')
| -rw-r--r-- | packages/cli/src/config/config.ts | 22 | ||||
| -rw-r--r-- | packages/cli/src/config/extension.test.ts | 80 | ||||
| -rw-r--r-- | packages/cli/src/config/extension.ts | 87 | ||||
| -rw-r--r-- | packages/cli/src/gemini.tsx | 16 |
4 files changed, 202 insertions, 3 deletions
diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 69257c78..87beb067 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -18,6 +18,7 @@ import { } from '@gemini-cli/core'; import { Settings } from './settings.js'; import { getEffectiveModel } from '../utils/modelCheck.js'; +import { ExtensionConfig } from './extension.js'; // Simple console logger for now - replace with actual logger if available const logger = { @@ -117,6 +118,7 @@ export async function loadHierarchicalGeminiMemory( export async function loadCliConfig( settings: Settings, + extensions: ExtensionConfig[], geminiIgnorePatterns: string[], ): Promise<Config> { loadEnvironment(); @@ -143,6 +145,8 @@ export async function loadCliConfig( const contentGeneratorConfig = await createContentGeneratorConfig(argv); + const mcpServers = mergeMcpServers(settings, extensions); + let sandbox = argv.sandbox ?? settings.sandbox; if (argv.yolo) { sandbox = false; @@ -160,7 +164,7 @@ export async function loadCliConfig( toolDiscoveryCommand: settings.toolDiscoveryCommand, toolCallCommand: settings.toolCallCommand, mcpServerCommand: settings.mcpServerCommand, - mcpServers: settings.mcpServers, + mcpServers, userMemory: memoryContent, geminiMdFileCount: fileCount, approvalMode: argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT, @@ -180,6 +184,22 @@ export async function loadCliConfig( }); } +function mergeMcpServers(settings: Settings, extensions: ExtensionConfig[]) { + const mcpServers = settings.mcpServers || {}; + for (const extension of extensions) { + Object.entries(extension.mcpServers || {}).forEach(([key, server]) => { + if (mcpServers[key]) { + logger.warn( + `Skipping extension MCP config for server with key "${key}" as it already exists.`, + ); + return; + } + mcpServers[key] = server; + }); + } + return mcpServers; +} + async function createContentGeneratorConfig( argv: CliArgs, ): Promise<ContentGeneratorConfig> { diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts new file mode 100644 index 00000000..462024bf --- /dev/null +++ b/packages/cli/src/config/extension.test.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + EXTENSIONS_CONFIG_FILENAME, + EXTENSIONS_DIRECTORY_NAME, + loadExtensions, +} from './extension.js'; + +vi.mock('os', async (importOriginal) => { + const os = await importOriginal<typeof import('os')>(); + return { + ...os, + homedir: vi.fn(), + }; +}); + +describe('loadExtensions', () => { + let tempWorkspaceDir: string; + let tempHomeDir: string; + + beforeEach(() => { + tempWorkspaceDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-test-workspace-'), + ); + tempHomeDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-test-home-'), + ); + vi.mocked(os.homedir).mockReturnValue(tempHomeDir); + }); + + afterEach(() => { + fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); + fs.rmSync(tempHomeDir, { recursive: true, force: true }); + }); + + it('should deduplicate extensions, prioritizing the workspace directory', () => { + // Create extensions in the workspace + const workspaceExtensionsDir = path.join( + tempWorkspaceDir, + EXTENSIONS_DIRECTORY_NAME, + ); + fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); + createExtension(workspaceExtensionsDir, 'ext1', '1.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); + + 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'); + }); +}); + +function createExtension( + extensionsDir: string, + name: string, + version: string, +): void { + const extDir = path.join(extensionsDir, name); + fs.mkdirSync(extDir); + fs.writeFileSync( + path.join(extDir, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name, version }), + ); +} diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts new file mode 100644 index 00000000..641cfcb5 --- /dev/null +++ b/packages/cli/src/config/extension.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MCPServerConfig } from '@gemini-cli/core'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +export const EXTENSIONS_DIRECTORY_NAME = path.join('.gemini', 'extensions'); +export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json'; + +export interface ExtensionConfig { + name: string; + version: string; + mcpServers?: Record<string, MCPServerConfig>; + contextFileName?: string; +} + +export function loadExtensions(workspaceDir: string): ExtensionConfig[] { + const allExtensions = [ + ...loadExtensionsFromDir(workspaceDir), + ...loadExtensionsFromDir(os.homedir()), + ]; + + const uniqueExtensions: ExtensionConfig[] = []; + const seenNames = new Set<string>(); + for (const extension of allExtensions) { + if (!seenNames.has(extension.name)) { + console.log( + `Loading extension: ${extension.name} (version: ${extension.version})`, + ); + uniqueExtensions.push(extension); + seenNames.add(extension.name); + } + } + + return uniqueExtensions; +} + +function loadExtensionsFromDir(dir: string): ExtensionConfig[] { + const extensionsDir = path.join(dir, EXTENSIONS_DIRECTORY_NAME); + if (!fs.existsSync(extensionsDir)) { + return []; + } + + const extensions: ExtensionConfig[] = []; + for (const subdir of fs.readdirSync(extensionsDir)) { + const extensionDir = path.join(extensionsDir, subdir); + + if (!fs.statSync(extensionDir).isDirectory()) { + console.error( + `Warning: unexpected file ${extensionDir} in extensions directory.`, + ); + continue; + } + + const extensionPath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME); + if (!fs.existsSync(extensionPath)) { + console.error( + `Warning: extension directory ${extensionDir} does not contain a config file ${extensionPath}.`, + ); + continue; + } + + try { + const fileContent = fs.readFileSync(extensionPath, 'utf-8'); + const extensionConfig = JSON.parse(fileContent) as ExtensionConfig; + if (!extensionConfig.name || !extensionConfig.version) { + console.error( + `Invalid extension config in ${extensionPath}: missing name or version.`, + ); + continue; + } + extensions.push(extensionConfig); + } catch (e) { + console.error( + `Failed to load extension config from ${extensionPath}:`, + e, + ); + } + } + + return extensions; +} diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index ebb2a552..27547454 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -17,6 +17,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 { ApprovalMode, Config, @@ -74,7 +75,12 @@ export async function main() { process.exit(1); } - const config = await loadCliConfig(settings.merged, geminiIgnorePatterns); + const extensions = loadExtensions(workspaceRoot); + const config = await loadCliConfig( + settings.merged, + extensions, + geminiIgnorePatterns, + ); // Initialize centralized FileDiscoveryService await config.getFileService(); @@ -124,7 +130,11 @@ export async function main() { } // Non-interactive mode handled by runNonInteractive - const nonInteractiveConfig = await loadNonInteractiveConfig(config, settings); + const nonInteractiveConfig = await loadNonInteractiveConfig( + config, + extensions, + settings, + ); await runNonInteractive(nonInteractiveConfig, input); process.exit(0); @@ -157,6 +167,7 @@ process.on('unhandledRejection', (reason, _promise) => { async function loadNonInteractiveConfig( config: Config, + extensions: ExtensionConfig[], settings: LoadedSettings, ) { if (config.getApprovalMode() === ApprovalMode.YOLO) { @@ -190,6 +201,7 @@ async function loadNonInteractiveConfig( }; return await loadCliConfig( nonInteractiveSettings, + extensions, config.getGeminiIgnorePatterns(), ); } |
