Rationalize different Extension typings (#10435)

This commit is contained in:
Zack Birkenbuel
2025-10-08 07:31:41 -07:00
committed by GitHub
parent 5d09ab7eb3
commit 8980276b20
19 changed files with 300 additions and 256 deletions
+15 -14
View File
@@ -20,16 +20,16 @@ import {
GEMINI_CONFIG_DIR, GEMINI_CONFIG_DIR,
DEFAULT_GEMINI_EMBEDDING_MODEL, DEFAULT_GEMINI_EMBEDDING_MODEL,
DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL,
type GeminiCLIExtension,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { logger } from '../utils/logger.js'; import { logger } from '../utils/logger.js';
import type { Settings } from './settings.js'; import type { Settings } from './settings.js';
import type { Extension } from './extension.js';
import { type AgentSettings, CoderAgentEvent } from '../types.js'; import { type AgentSettings, CoderAgentEvent } from '../types.js';
export async function loadConfig( export async function loadConfig(
settings: Settings, settings: Settings,
extensions: Extension[], extensions: GeminiCLIExtension[],
taskId: string, taskId: string,
): Promise<Config> { ): Promise<Config> {
const mcpServers = mergeMcpServers(settings, extensions); const mcpServers = mergeMcpServers(settings, extensions);
@@ -118,20 +118,21 @@ export async function loadConfig(
return config; return config;
} }
export function mergeMcpServers(settings: Settings, extensions: Extension[]) { export function mergeMcpServers(
settings: Settings,
extensions: GeminiCLIExtension[],
) {
const mcpServers = { ...(settings.mcpServers || {}) }; const mcpServers = { ...(settings.mcpServers || {}) };
for (const extension of extensions) { for (const extension of extensions) {
Object.entries(extension.config.mcpServers || {}).forEach( Object.entries(extension.mcpServers || {}).forEach(([key, server]) => {
([key, server]) => { if (mcpServers[key]) {
if (mcpServers[key]) { console.warn(
console.warn( `Skipping extension MCP config for server with key "${key}" as it already exists.`,
`Skipping extension MCP config for server with key "${key}" as it already exists.`, );
); return;
return; }
} mcpServers[key] = server;
mcpServers[key] = server; });
},
);
} }
return mcpServers; return mcpServers;
} }
+49 -17
View File
@@ -6,7 +6,11 @@
// Copied exactly from packages/cli/src/config/extension.ts, last PR #1026 // Copied exactly from packages/cli/src/config/extension.ts, last PR #1026
import type { MCPServerConfig } from '@google/gemini-cli-core'; import type {
MCPServerConfig,
ExtensionInstallMetadata,
GeminiCLIExtension,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import * as os from 'node:os'; import * as os from 'node:os';
@@ -14,47 +18,51 @@ import { logger } from '../utils/logger.js';
export const EXTENSIONS_DIRECTORY_NAME = path.join('.gemini', 'extensions'); export const EXTENSIONS_DIRECTORY_NAME = path.join('.gemini', 'extensions');
export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json'; export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json';
export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json';
export interface Extension { /**
config: ExtensionConfig; * Extension definition as written to disk in gemini-extension.json files.
contextFiles: string[]; * This should *not* be referenced outside of the logic for reading files.
} * If information is required for manipulating extensions (load, unload, update)
* outside of the loading process that data needs to be stored on the
export interface ExtensionConfig { * GeminiCLIExtension class defined in Core.
*/
interface ExtensionConfig {
name: string; name: string;
version: string; version: string;
mcpServers?: Record<string, MCPServerConfig>; mcpServers?: Record<string, MCPServerConfig>;
contextFileName?: string | string[]; contextFileName?: string | string[];
excludeTools?: string[];
} }
export function loadExtensions(workspaceDir: string): Extension[] { export function loadExtensions(workspaceDir: string): GeminiCLIExtension[] {
const allExtensions = [ const allExtensions = [
...loadExtensionsFromDir(workspaceDir), ...loadExtensionsFromDir(workspaceDir),
...loadExtensionsFromDir(os.homedir()), ...loadExtensionsFromDir(os.homedir()),
]; ];
const uniqueExtensions: Extension[] = []; const uniqueExtensions: GeminiCLIExtension[] = [];
const seenNames = new Set<string>(); const seenNames = new Set<string>();
for (const extension of allExtensions) { for (const extension of allExtensions) {
if (!seenNames.has(extension.config.name)) { if (!seenNames.has(extension.name)) {
logger.info( logger.info(
`Loading extension: ${extension.config.name} (version: ${extension.config.version})`, `Loading extension: ${extension.name} (version: ${extension.version})`,
); );
uniqueExtensions.push(extension); uniqueExtensions.push(extension);
seenNames.add(extension.config.name); seenNames.add(extension.name);
} }
} }
return uniqueExtensions; return uniqueExtensions;
} }
function loadExtensionsFromDir(dir: string): Extension[] { function loadExtensionsFromDir(dir: string): GeminiCLIExtension[] {
const extensionsDir = path.join(dir, EXTENSIONS_DIRECTORY_NAME); const extensionsDir = path.join(dir, EXTENSIONS_DIRECTORY_NAME);
if (!fs.existsSync(extensionsDir)) { if (!fs.existsSync(extensionsDir)) {
return []; return [];
} }
const extensions: Extension[] = []; const extensions: GeminiCLIExtension[] = [];
for (const subdir of fs.readdirSync(extensionsDir)) { for (const subdir of fs.readdirSync(extensionsDir)) {
const extensionDir = path.join(extensionsDir, subdir); const extensionDir = path.join(extensionsDir, subdir);
@@ -66,7 +74,7 @@ function loadExtensionsFromDir(dir: string): Extension[] {
return extensions; return extensions;
} }
function loadExtension(extensionDir: string): Extension | null { function loadExtension(extensionDir: string): GeminiCLIExtension | null {
if (!fs.statSync(extensionDir).isDirectory()) { if (!fs.statSync(extensionDir).isDirectory()) {
logger.error( logger.error(
`Warning: unexpected file ${extensionDir} in extensions directory.`, `Warning: unexpected file ${extensionDir} in extensions directory.`,
@@ -92,14 +100,22 @@ function loadExtension(extensionDir: string): Extension | null {
return null; return null;
} }
const installMetadata = loadInstallMetadata(extensionDir);
const contextFiles = getContextFileNames(config) const contextFiles = getContextFileNames(config)
.map((contextFileName) => path.join(extensionDir, contextFileName)) .map((contextFileName) => path.join(extensionDir, contextFileName))
.filter((contextFilePath) => fs.existsSync(contextFilePath)); .filter((contextFilePath) => fs.existsSync(contextFilePath));
return { return {
config, name: config.name,
version: config.version,
path: extensionDir,
contextFiles, contextFiles,
}; installMetadata,
mcpServers: config.mcpServers,
excludeTools: config.excludeTools,
isActive: true, // Barring any other signals extensions should be considered Active.
} as GeminiCLIExtension;
} catch (e) { } catch (e) {
logger.error( logger.error(
`Warning: error parsing extension config in ${configFilePath}: ${e}`, `Warning: error parsing extension config in ${configFilePath}: ${e}`,
@@ -116,3 +132,19 @@ function getContextFileNames(config: ExtensionConfig): string[] {
} }
return config.contextFileName; return config.contextFileName;
} }
export function loadInstallMetadata(
extensionDir: string,
): ExtensionInstallMetadata | undefined {
const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME);
try {
const configContent = fs.readFileSync(metadataFilePath, 'utf-8');
const metadata = JSON.parse(configContent) as ExtensionInstallMetadata;
return metadata;
} catch (e) {
logger.warn(
`Failed to load or parse extension install metadata at ${metadataFilePath}: ${e}`,
);
return undefined;
}
}
+2 -4
View File
@@ -153,10 +153,8 @@ describe('mcp list command', () => {
mockedLoadExtensions.mockReturnValue([ mockedLoadExtensions.mockReturnValue([
{ {
config: { name: 'test-extension',
name: 'test-extension', mcpServers: { 'extension-server': { command: '/ext/server' } },
mcpServers: { 'extension-server': { command: '/ext/server' } },
},
}, },
]); ]);
+9 -11
View File
@@ -27,17 +27,15 @@ async function getMcpServersFromConfig(): Promise<
); );
const mcpServers = { ...(settings.merged.mcpServers || {}) }; const mcpServers = { ...(settings.merged.mcpServers || {}) };
for (const extension of extensions) { for (const extension of extensions) {
Object.entries(extension.config.mcpServers || {}).forEach( Object.entries(extension.mcpServers || {}).forEach(([key, server]) => {
([key, server]) => { if (mcpServers[key]) {
if (mcpServers[key]) { return;
return; }
} mcpServers[key] = {
mcpServers[key] = { ...server,
...server, extensionName: extension.name,
extensionName: extension.config.name, };
}; });
},
);
} }
return mcpServers; return mcpServers;
} }
+70 -76
View File
@@ -14,10 +14,11 @@ import {
DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_MODEL_AUTO,
OutputFormat, OutputFormat,
type GeminiCLIExtension,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments, type CliArgs } from './config.js'; import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
import type { Settings } from './settings.js'; import type { Settings } from './settings.js';
import { ExtensionStorage, type Extension } from './extension.js'; import { ExtensionStorage } from './extension.js';
import * as ServerConfig from '@google/gemini-cli-core'; import * as ServerConfig from '@google/gemini-cli-core';
import { isWorkspaceTrusted } from './trustedFolders.js'; import { isWorkspaceTrusted } from './trustedFolders.js';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
@@ -1098,33 +1099,30 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
it('should pass extension context file paths to loadServerHierarchicalMemory', async () => { it('should pass extension context file paths to loadServerHierarchicalMemory', async () => {
process.argv = ['node', 'script.js']; process.argv = ['node', 'script.js'];
const settings: Settings = {}; const settings: Settings = {};
const extensions: Extension[] = [ const extensions: GeminiCLIExtension[] = [
{ {
path: '/path/to/ext1', path: '/path/to/ext1',
config: { name: 'ext1',
name: 'ext1', version: '1.0.0',
version: '1.0.0',
},
contextFiles: ['/path/to/ext1/GEMINI.md'], contextFiles: ['/path/to/ext1/GEMINI.md'],
isActive: true,
}, },
{ {
path: '/path/to/ext2', path: '/path/to/ext2',
config: { name: 'ext2',
name: 'ext2', version: '1.0.0',
version: '1.0.0',
},
contextFiles: [], contextFiles: [],
isActive: true,
}, },
{ {
path: '/path/to/ext3', path: '/path/to/ext3',
config: { name: 'ext3',
name: 'ext3', version: '1.0.0',
version: '1.0.0',
},
contextFiles: [ contextFiles: [
'/path/to/ext3/context1.md', '/path/to/ext3/context1.md',
'/path/to/ext3/context2.md', '/path/to/ext3/context2.md',
], ],
isActive: true,
}, },
]; ];
const argv = await parseArguments({} as Settings); const argv = await parseArguments({} as Settings);
@@ -1195,19 +1193,18 @@ describe('mergeMcpServers', () => {
}, },
}, },
}; };
const extensions: Extension[] = [ const extensions: GeminiCLIExtension[] = [
{ {
path: '/path/to/ext1', path: '/path/to/ext1',
config: { name: 'ext1',
name: 'ext1', version: '1.0.0',
version: '1.0.0', mcpServers: {
mcpServers: { 'ext1-server': {
'ext1-server': { url: 'http://localhost:8081',
url: 'http://localhost:8081',
},
}, },
}, },
contextFiles: [], contextFiles: [],
isActive: true,
}, },
]; ];
const originalSettings = JSON.parse(JSON.stringify(settings)); const originalSettings = JSON.parse(JSON.stringify(settings));
@@ -1241,24 +1238,22 @@ describe('mergeExcludeTools', () => {
it('should merge excludeTools from settings and extensions', async () => { it('should merge excludeTools from settings and extensions', async () => {
const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } }; const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } };
const extensions: Extension[] = [ const extensions: GeminiCLIExtension[] = [
{ {
path: '/path/to/ext1', path: '/path/to/ext1',
config: { name: 'ext1',
name: 'ext1', version: '1.0.0',
version: '1.0.0', excludeTools: ['tool3', 'tool4'],
excludeTools: ['tool3', 'tool4'],
},
contextFiles: [], contextFiles: [],
isActive: true,
}, },
{ {
path: '/path/to/ext2', path: '/path/to/ext2',
config: { name: 'ext2',
name: 'ext2', version: '1.0.0',
version: '1.0.0', excludeTools: ['tool5'],
excludeTools: ['tool5'],
},
contextFiles: [], contextFiles: [],
isActive: true,
}, },
]; ];
process.argv = ['node', 'script.js']; process.argv = ['node', 'script.js'];
@@ -1281,15 +1276,14 @@ describe('mergeExcludeTools', () => {
it('should handle overlapping excludeTools between settings and extensions', async () => { it('should handle overlapping excludeTools between settings and extensions', async () => {
const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } }; const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } };
const extensions: Extension[] = [ const extensions: GeminiCLIExtension[] = [
{ {
path: '/path/to/ext1', path: '/path/to/ext1',
config: { name: 'ext1',
name: 'ext1', version: '1.0.0',
version: '1.0.0', excludeTools: ['tool2', 'tool3'],
excludeTools: ['tool2', 'tool3'],
},
contextFiles: [], contextFiles: [],
isActive: true,
}, },
]; ];
process.argv = ['node', 'script.js']; process.argv = ['node', 'script.js'];
@@ -1312,24 +1306,22 @@ describe('mergeExcludeTools', () => {
it('should handle overlapping excludeTools between extensions', async () => { it('should handle overlapping excludeTools between extensions', async () => {
const settings: Settings = { tools: { exclude: ['tool1'] } }; const settings: Settings = { tools: { exclude: ['tool1'] } };
const extensions: Extension[] = [ const extensions: GeminiCLIExtension[] = [
{ {
path: '/path/to/ext1', path: '/path/to/ext1',
config: { name: 'ext1',
name: 'ext1', version: '1.0.0',
version: '1.0.0', excludeTools: ['tool2', 'tool3'],
excludeTools: ['tool2', 'tool3'],
},
contextFiles: [], contextFiles: [],
isActive: true,
}, },
{ {
path: '/path/to/ext2', path: '/path/to/ext2',
config: { name: 'ext2',
name: 'ext2', version: '1.0.0',
version: '1.0.0', excludeTools: ['tool3', 'tool4'],
excludeTools: ['tool3', 'tool4'],
},
contextFiles: [], contextFiles: [],
isActive: true,
}, },
]; ];
process.argv = ['node', 'script.js']; process.argv = ['node', 'script.js'];
@@ -1353,7 +1345,7 @@ describe('mergeExcludeTools', () => {
it('should return an empty array when no excludeTools are specified and it is interactive', async () => { it('should return an empty array when no excludeTools are specified and it is interactive', async () => {
process.stdin.isTTY = true; process.stdin.isTTY = true;
const settings: Settings = {}; const settings: Settings = {};
const extensions: Extension[] = []; const extensions: GeminiCLIExtension[] = [];
process.argv = ['node', 'script.js']; process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings); const argv = await parseArguments({} as Settings);
const config = await loadCliConfig( const config = await loadCliConfig(
@@ -1372,7 +1364,7 @@ describe('mergeExcludeTools', () => {
it('should return default excludes when no excludeTools are specified and it is not interactive', async () => { it('should return default excludes when no excludeTools are specified and it is not interactive', async () => {
process.stdin.isTTY = false; process.stdin.isTTY = false;
const settings: Settings = {}; const settings: Settings = {};
const extensions: Extension[] = []; const extensions: GeminiCLIExtension[] = [];
process.argv = ['node', 'script.js', '-p', 'test']; process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments({} as Settings); const argv = await parseArguments({} as Settings);
const config = await loadCliConfig( const config = await loadCliConfig(
@@ -1392,7 +1384,7 @@ describe('mergeExcludeTools', () => {
process.argv = ['node', 'script.js']; process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings); const argv = await parseArguments({} as Settings);
const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } }; const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } };
const extensions: Extension[] = []; const extensions: GeminiCLIExtension[] = [];
const config = await loadCliConfig( const config = await loadCliConfig(
settings, settings,
extensions, extensions,
@@ -1411,15 +1403,14 @@ describe('mergeExcludeTools', () => {
it('should handle extensions with excludeTools but no settings', async () => { it('should handle extensions with excludeTools but no settings', async () => {
const settings: Settings = {}; const settings: Settings = {};
const extensions: Extension[] = [ const extensions: GeminiCLIExtension[] = [
{ {
path: '/path/to/ext', path: '/path/to/ext',
config: { name: 'ext1',
name: 'ext1', version: '1.0.0',
version: '1.0.0', excludeTools: ['tool1', 'tool2'],
excludeTools: ['tool1', 'tool2'],
},
contextFiles: [], contextFiles: [],
isActive: true,
}, },
]; ];
process.argv = ['node', 'script.js']; process.argv = ['node', 'script.js'];
@@ -1442,15 +1433,14 @@ describe('mergeExcludeTools', () => {
it('should not modify the original settings object', async () => { it('should not modify the original settings object', async () => {
const settings: Settings = { tools: { exclude: ['tool1'] } }; const settings: Settings = { tools: { exclude: ['tool1'] } };
const extensions: Extension[] = [ const extensions: GeminiCLIExtension[] = [
{ {
path: '/path/to/ext', path: '/path/to/ext',
config: { name: 'ext1',
name: 'ext1', version: '1.0.0',
version: '1.0.0', excludeTools: ['tool2'],
excludeTools: ['tool2'],
},
contextFiles: [], contextFiles: [],
isActive: true,
}, },
]; ];
const originalSettings = JSON.parse(JSON.stringify(settings)); const originalSettings = JSON.parse(JSON.stringify(settings));
@@ -1486,7 +1476,7 @@ describe('Approval mode tool exclusion logic', () => {
process.argv = ['node', 'script.js', '-p', 'test']; process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments({} as Settings); const argv = await parseArguments({} as Settings);
const settings: Settings = {}; const settings: Settings = {};
const extensions: Extension[] = []; const extensions: GeminiCLIExtension[] = [];
const config = await loadCliConfig( const config = await loadCliConfig(
settings, settings,
@@ -1516,7 +1506,7 @@ describe('Approval mode tool exclusion logic', () => {
]; ];
const argv = await parseArguments({} as Settings); const argv = await parseArguments({} as Settings);
const settings: Settings = {}; const settings: Settings = {};
const extensions: Extension[] = []; const extensions: GeminiCLIExtension[] = [];
const config = await loadCliConfig( const config = await loadCliConfig(
settings, settings,
@@ -1546,7 +1536,7 @@ describe('Approval mode tool exclusion logic', () => {
]; ];
const argv = await parseArguments({} as Settings); const argv = await parseArguments({} as Settings);
const settings: Settings = {}; const settings: Settings = {};
const extensions: Extension[] = []; const extensions: GeminiCLIExtension[] = [];
const config = await loadCliConfig( const config = await loadCliConfig(
settings, settings,
@@ -1576,7 +1566,7 @@ describe('Approval mode tool exclusion logic', () => {
]; ];
const argv = await parseArguments({} as Settings); const argv = await parseArguments({} as Settings);
const settings: Settings = {}; const settings: Settings = {};
const extensions: Extension[] = []; const extensions: GeminiCLIExtension[] = [];
const config = await loadCliConfig( const config = await loadCliConfig(
settings, settings,
@@ -1599,7 +1589,7 @@ describe('Approval mode tool exclusion logic', () => {
process.argv = ['node', 'script.js', '--yolo', '-p', 'test']; process.argv = ['node', 'script.js', '--yolo', '-p', 'test'];
const argv = await parseArguments({} as Settings); const argv = await parseArguments({} as Settings);
const settings: Settings = {}; const settings: Settings = {};
const extensions: Extension[] = []; const extensions: GeminiCLIExtension[] = [];
const config = await loadCliConfig( const config = await loadCliConfig(
settings, settings,
@@ -1633,7 +1623,7 @@ describe('Approval mode tool exclusion logic', () => {
process.argv = testCase.args; process.argv = testCase.args;
const argv = await parseArguments({} as Settings); const argv = await parseArguments({} as Settings);
const settings: Settings = {}; const settings: Settings = {};
const extensions: Extension[] = []; const extensions: GeminiCLIExtension[] = [];
const config = await loadCliConfig( const config = await loadCliConfig(
settings, settings,
@@ -1664,7 +1654,7 @@ describe('Approval mode tool exclusion logic', () => {
]; ];
const argv = await parseArguments({} as Settings); const argv = await parseArguments({} as Settings);
const settings: Settings = { tools: { exclude: ['custom_tool'] } }; const settings: Settings = { tools: { exclude: ['custom_tool'] } };
const extensions: Extension[] = []; const extensions: GeminiCLIExtension[] = [];
const config = await loadCliConfig( const config = await loadCliConfig(
settings, settings,
@@ -1694,7 +1684,7 @@ describe('Approval mode tool exclusion logic', () => {
}; };
const settings: Settings = {}; const settings: Settings = {};
const extensions: Extension[] = []; const extensions: GeminiCLIExtension[] = [];
await expect( await expect(
loadCliConfig( loadCliConfig(
settings, settings,
@@ -1976,16 +1966,20 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
}); });
describe('loadCliConfig extensions', () => { describe('loadCliConfig extensions', () => {
const mockExtensions: Extension[] = [ const mockExtensions: GeminiCLIExtension[] = [
{ {
path: '/path/to/ext1', path: '/path/to/ext1',
config: { name: 'ext1', version: '1.0.0' }, name: 'ext1',
version: '1.0.0',
contextFiles: ['/path/to/ext1.md'], contextFiles: ['/path/to/ext1.md'],
isActive: true,
}, },
{ {
path: '/path/to/ext2', path: '/path/to/ext2',
config: { name: 'ext2', version: '1.0.0' }, name: 'ext2',
version: '1.0.0',
contextFiles: ['/path/to/ext2.md'], contextFiles: ['/path/to/ext2.md'],
isActive: true,
}, },
]; ];
+17 -19
View File
@@ -15,6 +15,7 @@ import type {
FileFilteringOptions, FileFilteringOptions,
MCPServerConfig, MCPServerConfig,
OutputFormat, OutputFormat,
GeminiCLIExtension,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { extensionsCommand } from '../commands/extensions.js'; import { extensionsCommand } from '../commands/extensions.js';
import { import {
@@ -37,7 +38,6 @@ import {
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import type { Settings } from './settings.js'; import type { Settings } from './settings.js';
import type { Extension } from './extension.js';
import { annotateActiveExtensions } from './extension.js'; import { annotateActiveExtensions } from './extension.js';
import { getCliVersion } from '../utils/version.js'; import { getCliVersion } from '../utils/version.js';
import { loadSandboxConfig } from './sandboxConfig.js'; import { loadSandboxConfig } from './sandboxConfig.js';
@@ -472,7 +472,7 @@ export function isDebugMode(argv: CliArgs): boolean {
export async function loadCliConfig( export async function loadCliConfig(
settings: Settings, settings: Settings,
extensions: Extension[], extensions: GeminiCLIExtension[],
extensionEnablementManager: ExtensionEnablementManager, extensionEnablementManager: ExtensionEnablementManager,
sessionId: string, sessionId: string,
argv: CliArgs, argv: CliArgs,
@@ -787,30 +787,28 @@ function allowedMcpServers(
return mcpServers; return mcpServers;
} }
function mergeMcpServers(settings: Settings, extensions: Extension[]) { function mergeMcpServers(settings: Settings, extensions: GeminiCLIExtension[]) {
const mcpServers = { ...(settings.mcpServers || {}) }; const mcpServers = { ...(settings.mcpServers || {}) };
for (const extension of extensions) { for (const extension of extensions) {
Object.entries(extension.config.mcpServers || {}).forEach( Object.entries(extension.mcpServers || {}).forEach(([key, server]) => {
([key, server]) => { if (mcpServers[key]) {
if (mcpServers[key]) { logger.warn(
logger.warn( `Skipping extension MCP config for server with key "${key}" as it already exists.`,
`Skipping extension MCP config for server with key "${key}" as it already exists.`, );
); return;
return; }
} mcpServers[key] = {
mcpServers[key] = { ...server,
...server, extensionName: extension.name,
extensionName: extension.config.name, };
}; });
},
);
} }
return mcpServers; return mcpServers;
} }
function mergeExcludeTools( function mergeExcludeTools(
settings: Settings, settings: Settings,
extensions: Extension[], extensions: GeminiCLIExtension[],
extraExcludes?: string[] | undefined, extraExcludes?: string[] | undefined,
): string[] { ): string[] {
const allExcludeTools = new Set([ const allExcludeTools = new Set([
@@ -818,7 +816,7 @@ function mergeExcludeTools(
...(extraExcludes || []), ...(extraExcludes || []),
]); ]);
for (const extension of extensions) { for (const extension of extensions) {
for (const tool of extension.config.excludeTools || []) { for (const tool of extension.excludeTools || []) {
allExcludeTools.add(tool); allExcludeTools.add(tool);
} }
} }
+47 -34
View File
@@ -22,7 +22,6 @@ import {
performWorkspaceExtensionMigration, performWorkspaceExtensionMigration,
requestConsentNonInteractive, requestConsentNonInteractive,
uninstallExtension, uninstallExtension,
type Extension,
} from './extension.js'; } from './extension.js';
import { import {
GEMINI_DIR, GEMINI_DIR,
@@ -158,7 +157,7 @@ describe('extension tests', () => {
); );
expect(extensions).toHaveLength(1); expect(extensions).toHaveLength(1);
expect(extensions[0].path).toBe(extensionDir); expect(extensions[0].path).toBe(extensionDir);
expect(extensions[0].config.name).toBe('test-extension'); expect(extensions[0].name).toBe('test-extension');
}); });
it('should load context file path when GEMINI.md is present', () => { it('should load context file path when GEMINI.md is present', () => {
@@ -179,8 +178,8 @@ describe('extension tests', () => {
); );
expect(extensions).toHaveLength(2); expect(extensions).toHaveLength(2);
const ext1 = extensions.find((e) => e.config.name === 'ext1'); const ext1 = extensions.find((e) => e.name === 'ext1');
const ext2 = extensions.find((e) => e.config.name === 'ext2'); const ext2 = extensions.find((e) => e.name === 'ext2');
expect(ext1?.contextFiles).toEqual([ expect(ext1?.contextFiles).toEqual([
path.join(userExtensionsDir, 'ext1', 'GEMINI.md'), path.join(userExtensionsDir, 'ext1', 'GEMINI.md'),
]); ]);
@@ -201,7 +200,7 @@ describe('extension tests', () => {
); );
expect(extensions).toHaveLength(1); expect(extensions).toHaveLength(1);
const ext1 = extensions.find((e) => e.config.name === 'ext1'); const ext1 = extensions.find((e) => e.name === 'ext1');
expect(ext1?.contextFiles).toEqual([ expect(ext1?.contextFiles).toEqual([
path.join(userExtensionsDir, 'ext1', 'my-context-file.md'), path.join(userExtensionsDir, 'ext1', 'my-context-file.md'),
]); ]);
@@ -254,13 +253,12 @@ describe('extension tests', () => {
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
); );
expect(extensions).toHaveLength(1); expect(extensions).toHaveLength(1);
const loadedConfig = extensions[0].config;
const expectedCwd = path.join( const expectedCwd = path.join(
userExtensionsDir, userExtensionsDir,
'test-extension', 'test-extension',
'server', 'server',
); );
expect(loadedConfig.mcpServers?.['test-server'].cwd).toBe(expectedCwd); expect(extensions[0].mcpServers?.['test-server'].cwd).toBe(expectedCwd);
}); });
it('should load a linked extension correctly', async () => { it('should load a linked extension correctly', async () => {
@@ -287,7 +285,7 @@ describe('extension tests', () => {
expect(extensions).toHaveLength(1); expect(extensions).toHaveLength(1);
const linkedExt = extensions[0]; const linkedExt = extensions[0];
expect(linkedExt.config.name).toBe('my-linked-extension'); expect(linkedExt.name).toBe('my-linked-extension');
expect(linkedExt.path).toBe(sourceExtDir); expect(linkedExt.path).toBe(sourceExtDir);
expect(linkedExt.installMetadata).toEqual({ expect(linkedExt.installMetadata).toEqual({
@@ -340,10 +338,10 @@ describe('extension tests', () => {
expect(extensions).toHaveLength(1); expect(extensions).toHaveLength(1);
const extension = extensions[0]; const extension = extensions[0];
expect(extension.config.name).toBe('test-extension'); expect(extension.name).toBe('test-extension');
expect(extension.config.mcpServers).toBeDefined(); expect(extension.mcpServers).toBeDefined();
const serverConfig = extension.config.mcpServers?.['test-server']; const serverConfig = extension.mcpServers?.['test-server'];
expect(serverConfig).toBeDefined(); expect(serverConfig).toBeDefined();
expect(serverConfig?.env).toBeDefined(); expect(serverConfig?.env).toBeDefined();
expect(serverConfig?.env?.API_KEY).toBe('test-api-key-123'); expect(serverConfig?.env?.API_KEY).toBe('test-api-key-123');
@@ -393,7 +391,7 @@ describe('extension tests', () => {
expect(extensions).toHaveLength(1); expect(extensions).toHaveLength(1);
const extension = extensions[0]; const extension = extensions[0];
const serverConfig = extension.config.mcpServers!['test-server']; const serverConfig = extension.mcpServers!['test-server'];
expect(serverConfig.env).toBeDefined(); expect(serverConfig.env).toBeDefined();
expect(serverConfig.env!.MISSING_VAR).toBe('$UNDEFINED_ENV_VAR'); expect(serverConfig.env!.MISSING_VAR).toBe('$UNDEFINED_ENV_VAR');
expect(serverConfig.env!.MISSING_VAR_BRACES).toBe('${ALSO_UNDEFINED}'); expect(serverConfig.env!.MISSING_VAR_BRACES).toBe('${ALSO_UNDEFINED}');
@@ -422,7 +420,7 @@ describe('extension tests', () => {
); );
expect(extensions).toHaveLength(1); expect(extensions).toHaveLength(1);
expect(extensions[0].config.name).toBe('good-ext'); expect(extensions[0].name).toBe('good-ext');
expect(consoleSpy).toHaveBeenCalledOnce(); expect(consoleSpy).toHaveBeenCalledOnce();
expect(consoleSpy).toHaveBeenCalledWith( expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining( expect.stringContaining(
@@ -456,7 +454,7 @@ describe('extension tests', () => {
); );
expect(extensions).toHaveLength(1); expect(extensions).toHaveLength(1);
expect(extensions[0].config.name).toBe('good-ext'); expect(extensions[0].name).toBe('good-ext');
expect(consoleSpy).toHaveBeenCalledOnce(); expect(consoleSpy).toHaveBeenCalledOnce();
expect(consoleSpy).toHaveBeenCalledWith( expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining( expect.stringContaining(
@@ -485,8 +483,7 @@ describe('extension tests', () => {
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
); );
expect(extensions).toHaveLength(1); expect(extensions).toHaveLength(1);
const loadedConfig = extensions[0].config; expect(extensions[0].mcpServers?.['test-server'].trust).toBeUndefined();
expect(loadedConfig.mcpServers?.['test-server'].trust).toBeUndefined();
}); });
it('should throw an error for invalid extension names', () => { it('should throw an error for invalid extension names', () => {
@@ -513,21 +510,27 @@ describe('extension tests', () => {
}); });
describe('annotateActiveExtensions', () => { describe('annotateActiveExtensions', () => {
const extensions: Extension[] = [ const extensions: GeminiCLIExtension[] = [
{ {
path: '/path/to/ext1', path: '/path/to/ext1',
config: { name: 'ext1', version: '1.0.0' }, name: 'ext1',
version: '1.0.0',
contextFiles: [], contextFiles: [],
isActive: true,
}, },
{ {
path: '/path/to/ext2', path: '/path/to/ext2',
config: { name: 'ext2', version: '1.0.0' }, name: 'ext2',
version: '1.0.0',
contextFiles: [], contextFiles: [],
isActive: true,
}, },
{ {
path: '/path/to/ext3', path: '/path/to/ext3',
config: { name: 'ext3', version: '1.0.0' }, name: 'ext3',
version: '1.0.0',
contextFiles: [], contextFiles: [],
isActive: true,
}, },
]; ];
@@ -622,13 +625,15 @@ describe('extension tests', () => {
}); });
it('should be true if autoUpdate is true in install metadata', () => { it('should be true if autoUpdate is true in install metadata', () => {
const extensionsWithAutoUpdate: Extension[] = extensions.map((e) => ({ const extensionsWithAutoUpdate: GeminiCLIExtension[] = extensions.map(
...e, (e) => ({
installMetadata: { ...e,
...e.installMetadata!, installMetadata: {
autoUpdate: true, ...e.installMetadata!,
}, autoUpdate: true,
})); },
}),
);
const activeExtensions = annotateActiveExtensions( const activeExtensions = annotateActiveExtensions(
extensionsWithAutoUpdate, extensionsWithAutoUpdate,
tempHomeDir, tempHomeDir,
@@ -642,31 +647,37 @@ describe('extension tests', () => {
}); });
it('should respect the per-extension settings from install metadata', () => { it('should respect the per-extension settings from install metadata', () => {
const extensionsWithAutoUpdate: Extension[] = [ const extensionsWithAutoUpdate: GeminiCLIExtension[] = [
{ {
path: '/path/to/ext1', path: '/path/to/ext1',
config: { name: 'ext1', version: '1.0.0' }, name: 'ext1',
version: '1.0.0',
contextFiles: [], contextFiles: [],
installMetadata: { installMetadata: {
source: 'test', source: 'test',
type: 'local', type: 'local',
autoUpdate: true, autoUpdate: true,
}, },
isActive: true,
}, },
{ {
path: '/path/to/ext2', path: '/path/to/ext2',
config: { name: 'ext2', version: '1.0.0' }, name: 'ext2',
version: '1.0.0',
contextFiles: [], contextFiles: [],
installMetadata: { installMetadata: {
source: 'test', source: 'test',
type: 'local', type: 'local',
autoUpdate: false, autoUpdate: false,
}, },
isActive: true,
}, },
{ {
path: '/path/to/ext3', path: '/path/to/ext3',
config: { name: 'ext3', version: '1.0.0' }, name: 'ext3',
version: '1.0.0',
contextFiles: [], contextFiles: [],
isActive: true,
}, },
]; ];
const activeExtensions = annotateActiveExtensions( const activeExtensions = annotateActiveExtensions(
@@ -1229,7 +1240,7 @@ This extension will run the following MCP servers:
name: 'ext2', name: 'ext2',
version: '1.0.0', version: '1.0.0',
}); });
const extensionsToMigrate: Extension[] = [ const extensionsToMigrate: GeminiCLIExtension[] = [
loadExtension({ loadExtension({
extensionDir: ext1Path, extensionDir: ext1Path,
workspaceDir: tempWorkspaceDir, workspaceDir: tempWorkspaceDir,
@@ -1273,15 +1284,17 @@ This extension will run the following MCP servers:
version: '1.0.0', version: '1.0.0',
}); });
const extensions: Extension[] = [ const extensions: GeminiCLIExtension[] = [
loadExtension({ loadExtension({
extensionDir: ext1Path, extensionDir: ext1Path,
workspaceDir: tempWorkspaceDir, workspaceDir: tempWorkspaceDir,
})!, })!,
{ {
path: '/ext/path/1', path: '/ext/path/1',
config: { name: 'ext2', version: '1.0.0' }, name: 'ext2',
version: '1.0.0',
contextFiles: [], contextFiles: [],
isActive: true,
}, },
]; ];
+48 -50
View File
@@ -45,14 +45,14 @@ export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json'; export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json';
export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json'; export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json';
export interface Extension { /**
path: string; * Extension definition as written to disk in gemini-extension.json files.
config: ExtensionConfig; * This should *not* be referenced outside of the logic for reading files.
contextFiles: string[]; * If information is required for manipulating extensions (load, unload, update)
installMetadata?: ExtensionInstallMetadata | undefined; * outside of the loading process that data needs to be stored on the
} * GeminiCLIExtension class defined in Core.
*/
export interface ExtensionConfig { interface ExtensionConfig {
name: string; name: string;
version: string; version: string;
mcpServers?: Record<string, MCPServerConfig>; mcpServers?: Record<string, MCPServerConfig>;
@@ -96,7 +96,9 @@ export class ExtensionStorage {
} }
} }
export function getWorkspaceExtensions(workspaceDir: string): Extension[] { export function getWorkspaceExtensions(
workspaceDir: string,
): GeminiCLIExtension[] {
// If the workspace dir is the user extensions dir, there are no workspace extensions. // If the workspace dir is the user extensions dir, there are no workspace extensions.
if (path.resolve(workspaceDir) === path.resolve(os.homedir())) { if (path.resolve(workspaceDir) === path.resolve(os.homedir())) {
return []; return [];
@@ -112,7 +114,7 @@ export async function copyExtension(
} }
export async function performWorkspaceExtensionMigration( export async function performWorkspaceExtensionMigration(
extensions: Extension[], extensions: GeminiCLIExtension[],
requestConsent: (consent: string) => Promise<boolean>, requestConsent: (consent: string) => Promise<boolean>,
): Promise<string[]> { ): Promise<string[]> {
const failedInstallNames: string[] = []; const failedInstallNames: string[] = [];
@@ -125,7 +127,7 @@ export async function performWorkspaceExtensionMigration(
}; };
await installExtension(installMetadata, requestConsent); await installExtension(installMetadata, requestConsent);
} catch (_) { } catch (_) {
failedInstallNames.push(extension.config.name); failedInstallNames.push(extension.name);
} }
} }
return failedInstallNames; return failedInstallNames;
@@ -148,7 +150,7 @@ function getTelemetryConfig(cwd: string) {
export function loadExtensions( export function loadExtensions(
extensionEnablementManager: ExtensionEnablementManager, extensionEnablementManager: ExtensionEnablementManager,
workspaceDir: string = process.cwd(), workspaceDir: string = process.cwd(),
): Extension[] { ): GeminiCLIExtension[] {
const settings = loadSettings(workspaceDir).merged; const settings = loadSettings(workspaceDir).merged;
const allExtensions = [...loadUserExtensions()]; const allExtensions = [...loadUserExtensions()];
@@ -160,41 +162,41 @@ export function loadExtensions(
allExtensions.push(...getWorkspaceExtensions(workspaceDir)); allExtensions.push(...getWorkspaceExtensions(workspaceDir));
} }
const uniqueExtensions = new Map<string, Extension>(); const uniqueExtensions = new Map<string, GeminiCLIExtension>();
for (const extension of allExtensions) { for (const extension of allExtensions) {
if ( if (
!uniqueExtensions.has(extension.config.name) && !uniqueExtensions.has(extension.name) &&
extensionEnablementManager.isEnabled(extension.config.name, workspaceDir) extensionEnablementManager.isEnabled(extension.name, workspaceDir)
) { ) {
uniqueExtensions.set(extension.config.name, extension); uniqueExtensions.set(extension.name, extension);
} }
} }
return Array.from(uniqueExtensions.values()); return Array.from(uniqueExtensions.values());
} }
export function loadUserExtensions(): Extension[] { export function loadUserExtensions(): GeminiCLIExtension[] {
const userExtensions = loadExtensionsFromDir(os.homedir()); const userExtensions = loadExtensionsFromDir(os.homedir());
const uniqueExtensions = new Map<string, Extension>(); const uniqueExtensions = new Map<string, GeminiCLIExtension>();
for (const extension of userExtensions) { for (const extension of userExtensions) {
if (!uniqueExtensions.has(extension.config.name)) { if (!uniqueExtensions.has(extension.name)) {
uniqueExtensions.set(extension.config.name, extension); uniqueExtensions.set(extension.name, extension);
} }
} }
return Array.from(uniqueExtensions.values()); return Array.from(uniqueExtensions.values());
} }
export function loadExtensionsFromDir(dir: string): Extension[] { export function loadExtensionsFromDir(dir: string): GeminiCLIExtension[] {
const storage = new Storage(dir); const storage = new Storage(dir);
const extensionsDir = storage.getExtensionsDir(); const extensionsDir = storage.getExtensionsDir();
if (!fs.existsSync(extensionsDir)) { if (!fs.existsSync(extensionsDir)) {
return []; return [];
} }
const extensions: Extension[] = []; const extensions: GeminiCLIExtension[] = [];
for (const subdir of fs.readdirSync(extensionsDir)) { for (const subdir of fs.readdirSync(extensionsDir)) {
const extensionDir = path.join(extensionsDir, subdir); const extensionDir = path.join(extensionsDir, subdir);
@@ -206,7 +208,9 @@ export function loadExtensionsFromDir(dir: string): Extension[] {
return extensions; return extensions;
} }
export function loadExtension(context: LoadExtensionContext): Extension | null { export function loadExtension(
context: LoadExtensionContext,
): GeminiCLIExtension | null {
const { extensionDir, workspaceDir } = context; const { extensionDir, workspaceDir } = context;
if (!fs.statSync(extensionDir).isDirectory()) { if (!fs.statSync(extensionDir).isDirectory()) {
return null; return null;
@@ -243,10 +247,14 @@ export function loadExtension(context: LoadExtensionContext): Extension | null {
.filter((contextFilePath) => fs.existsSync(contextFilePath)); .filter((contextFilePath) => fs.existsSync(contextFilePath));
return { return {
name: config.name,
version: config.version,
path: effectiveExtensionPath, path: effectiveExtensionPath,
config,
contextFiles, contextFiles,
installMetadata, installMetadata,
mcpServers: config.mcpServers,
excludeTools: config.excludeTools,
isActive: true, // Barring any other signals extensions should be considered Active.
}; };
} catch (e) { } catch (e) {
console.error( console.error(
@@ -261,7 +269,7 @@ export function loadExtension(context: LoadExtensionContext): Extension | null {
export function loadExtensionByName( export function loadExtensionByName(
name: string, name: string,
workspaceDir: string = process.cwd(), workspaceDir: string = process.cwd(),
): Extension | null { ): GeminiCLIExtension | null {
const userExtensionsDir = ExtensionStorage.getUserExtensionsDir(); const userExtensionsDir = ExtensionStorage.getUserExtensionsDir();
if (!fs.existsSync(userExtensionsDir)) { if (!fs.existsSync(userExtensionsDir)) {
return null; return null;
@@ -273,10 +281,7 @@ export function loadExtensionByName(
continue; continue;
} }
const extension = loadExtension({ extensionDir, workspaceDir }); const extension = loadExtension({ extensionDir, workspaceDir });
if ( if (extension && extension.name.toLowerCase() === name.toLowerCase()) {
extension &&
extension.config.name.toLowerCase() === name.toLowerCase()
) {
return extension; return extension;
} }
} }
@@ -320,17 +325,14 @@ function getContextFileNames(config: ExtensionConfig): string[] {
* @param workspaceDir The current workspace directory. * @param workspaceDir The current workspace directory.
*/ */
export function annotateActiveExtensions( export function annotateActiveExtensions(
extensions: Extension[], extensions: GeminiCLIExtension[],
workspaceDir: string, workspaceDir: string,
manager: ExtensionEnablementManager, manager: ExtensionEnablementManager,
): GeminiCLIExtension[] { ): GeminiCLIExtension[] {
manager.validateExtensionOverrides(extensions); manager.validateExtensionOverrides(extensions);
return extensions.map((extension) => ({ return extensions.map((extension) => ({
name: extension.config.name, ...extension,
version: extension.config.version, isActive: manager.isEnabled(extension.name, workspaceDir),
isActive: manager.isEnabled(extension.config.name, workspaceDir),
path: extension.path,
installMetadata: extension.installMetadata,
})); }));
} }
@@ -489,7 +491,7 @@ export async function installExtension(
const installedExtensions = loadUserExtensions(); const installedExtensions = loadUserExtensions();
if ( if (
installedExtensions.some( installedExtensions.some(
(installed) => installed.config.name === newExtensionName, (installed) => installed.name === newExtensionName,
) )
) { ) {
throw new Error( throw new Error(
@@ -672,11 +674,10 @@ export async function uninstallExtension(
const installedExtensions = loadUserExtensions(); const installedExtensions = loadUserExtensions();
const extensionName = installedExtensions.find( const extensionName = installedExtensions.find(
(installed) => (installed) =>
installed.config.name.toLowerCase() === installed.name.toLowerCase() === extensionIdentifier.toLowerCase() ||
extensionIdentifier.toLowerCase() ||
installed.installMetadata?.source.toLowerCase() === installed.installMetadata?.source.toLowerCase() ===
extensionIdentifier.toLowerCase(), extensionIdentifier.toLowerCase(),
)?.config.name; )?.name;
if (!extensionName) { if (!extensionName) {
throw new Error(`Extension not found.`); throw new Error(`Extension not found.`);
} }
@@ -698,20 +699,17 @@ export async function uninstallExtension(
} }
export function toOutputString( export function toOutputString(
extension: Extension, extension: GeminiCLIExtension,
workspaceDir: string, workspaceDir: string,
): string { ): string {
const manager = new ExtensionEnablementManager( const manager = new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(), ExtensionStorage.getUserExtensionsDir(),
); );
const userEnabled = manager.isEnabled(extension.config.name, os.homedir()); const userEnabled = manager.isEnabled(extension.name, os.homedir());
const workspaceEnabled = manager.isEnabled( const workspaceEnabled = manager.isEnabled(extension.name, workspaceDir);
extension.config.name,
workspaceDir,
);
const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗'); const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗');
let output = `${status} ${extension.config.name} (${extension.config.version})`; let output = `${status} ${extension.name} (${extension.version})`;
output += `\n Path: ${extension.path}`; output += `\n Path: ${extension.path}`;
if (extension.installMetadata) { if (extension.installMetadata) {
output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`; output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`;
@@ -730,15 +728,15 @@ export function toOutputString(
output += `\n ${contextFile}`; output += `\n ${contextFile}`;
}); });
} }
if (extension.config.mcpServers) { if (extension.mcpServers) {
output += `\n MCP servers:`; output += `\n MCP servers:`;
Object.keys(extension.config.mcpServers).forEach((key) => { Object.keys(extension.mcpServers).forEach((key) => {
output += `\n ${key}`; output += `\n ${key}`;
}); });
} }
if (extension.config.excludeTools) { if (extension.excludeTools) {
output += `\n Excluded tools:`; output += `\n Excluded tools:`;
extension.config.excludeTools.forEach((tool) => { extension.excludeTools.forEach((tool) => {
output += `\n ${tool}`; output += `\n ${tool}`;
}); });
} }
@@ -9,7 +9,7 @@ import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { ExtensionEnablementManager, Override } from './extensionEnablement.js'; import { ExtensionEnablementManager, Override } from './extensionEnablement.js';
import type { Extension } from '../extension.js'; import type { GeminiCLIExtension } from '@google/gemini-cli-core';
// Helper to create a temporary directory for testing // Helper to create a temporary directory for testing
function createTestDir() { function createTestDir() {
@@ -286,9 +286,9 @@ describe('ExtensionEnablementManager', () => {
'ext-two', 'ext-two',
]); ]);
const extensions = [ const extensions = [
{ config: { name: 'ext-one' } }, { name: 'ext-one' },
{ config: { name: 'ext-two' } }, { name: 'ext-two' },
] as Extension[]; ] as GeminiCLIExtension[];
manager.validateExtensionOverrides(extensions); manager.validateExtensionOverrides(extensions);
expect(consoleErrorSpy).not.toHaveBeenCalled(); expect(consoleErrorSpy).not.toHaveBeenCalled();
}); });
@@ -300,9 +300,9 @@ describe('ExtensionEnablementManager', () => {
'ext-another-invalid', 'ext-another-invalid',
]); ]);
const extensions = [ const extensions = [
{ config: { name: 'ext-one' } }, { name: 'ext-one' },
{ config: { name: 'ext-two' } }, { name: 'ext-two' },
] as Extension[]; ] as GeminiCLIExtension[];
manager.validateExtensionOverrides(extensions); manager.validateExtensionOverrides(extensions);
expect(consoleErrorSpy).toHaveBeenCalledTimes(2); expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
expect(consoleErrorSpy).toHaveBeenCalledWith( expect(consoleErrorSpy).toHaveBeenCalledWith(
@@ -6,7 +6,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { type Extension } from '../extension.js'; import type { GeminiCLIExtension } from '@google/gemini-cli-core';
export interface ExtensionEnablementConfig { export interface ExtensionEnablementConfig {
overrides: string[]; overrides: string[];
@@ -119,13 +119,11 @@ export class ExtensionEnablementManager {
enabledExtensionNames?.map((name) => name.toLowerCase()) ?? []; enabledExtensionNames?.map((name) => name.toLowerCase()) ?? [];
} }
validateExtensionOverrides(extensions: Extension[]) { validateExtensionOverrides(extensions: GeminiCLIExtension[]) {
for (const name of this.enabledExtensionNamesOverride) { for (const name of this.enabledExtensionNamesOverride) {
if (name === 'none') continue; if (name === 'none') continue;
if ( if (
!extensions.some( !extensions.some((ext) => ext.name.toLowerCase() === name.toLowerCase())
(ext) => ext.config.name.toLowerCase() === name.toLowerCase(),
)
) { ) {
console.error(`Extension not found: ${name}`); console.error(`Extension not found: ${name}`);
} }
@@ -137,6 +137,7 @@ describe('git extension helpers', () => {
type: 'link', type: 'link',
source: '', source: '',
}, },
contextFiles: [],
}; };
const result = await checkForExtensionUpdate(extension); const result = await checkForExtensionUpdate(extension);
expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE); expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE);
@@ -152,6 +153,7 @@ describe('git extension helpers', () => {
type: 'git', type: 'git',
source: '', source: '',
}, },
contextFiles: [],
}; };
mockGit.getRemotes.mockResolvedValue([]); mockGit.getRemotes.mockResolvedValue([]);
const result = await checkForExtensionUpdate(extension); const result = await checkForExtensionUpdate(extension);
@@ -168,6 +170,7 @@ describe('git extension helpers', () => {
type: 'git', type: 'git',
source: 'my/ext', source: 'my/ext',
}, },
contextFiles: [],
}; };
mockGit.getRemotes.mockResolvedValue([ mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } }, { name: 'origin', refs: { fetch: 'http://my-repo.com' } },
@@ -189,6 +192,7 @@ describe('git extension helpers', () => {
type: 'git', type: 'git',
source: 'my/ext', source: 'my/ext',
}, },
contextFiles: [],
}; };
mockGit.getRemotes.mockResolvedValue([ mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } }, { name: 'origin', refs: { fetch: 'http://my-repo.com' } },
@@ -210,6 +214,7 @@ describe('git extension helpers', () => {
type: 'git', type: 'git',
source: 'my/ext', source: 'my/ext',
}, },
contextFiles: [],
}; };
mockGit.getRemotes.mockRejectedValue(new Error('git error')); mockGit.getRemotes.mockRejectedValue(new Error('git error'));
+1 -1
View File
@@ -134,7 +134,7 @@ export async function checkForExtensionUpdate(
); );
return ExtensionUpdateState.ERROR; return ExtensionUpdateState.ERROR;
} }
if (newExtension.config.version !== extension.version) { if (newExtension.version !== extension.version) {
return ExtensionUpdateState.UPDATE_AVAILABLE; return ExtensionUpdateState.UPDATE_AVAILABLE;
} }
return ExtensionUpdateState.UP_TO_DATE; return ExtensionUpdateState.UP_TO_DATE;
+1 -1
View File
@@ -90,7 +90,7 @@ export async function updateExtension(
}); });
throw new Error('Updated extension not found after installation.'); throw new Error('Updated extension not found after installation.');
} }
const updatedVersion = updatedExtension.config.version; const updatedVersion = updatedExtension.version;
dispatchExtensionStateUpdate({ dispatchExtensionStateUpdate({
type: 'SET_STATE', type: 'SET_STATE',
payload: { payload: {
+1 -1
View File
@@ -357,7 +357,7 @@ export async function main() {
if (config.getListExtensions()) { if (config.getListExtensions()) {
console.log('Installed extensions:'); console.log('Installed extensions:');
for (const extension of extensions) { for (const extension of extensions) {
console.log(`- ${extension.config.name}`); console.log(`- ${extension.name}`);
} }
process.exit(0); process.exit(0);
} }
@@ -5,16 +5,14 @@
*/ */
import { Box, Text, useInput } from 'ink'; import { Box, Text, useInput } from 'ink';
import { import type { GeminiCLIExtension } from '@google/gemini-cli-core';
type Extension, import { performWorkspaceExtensionMigration } from '../../config/extension.js';
performWorkspaceExtensionMigration,
} from '../../config/extension.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { theme } from '../semantic-colors.js'; import { theme } from '../semantic-colors.js';
import { useState } from 'react'; import { useState } from 'react';
export function WorkspaceMigrationDialog(props: { export function WorkspaceMigrationDialog(props: {
workspaceExtensions: Extension[]; workspaceExtensions: GeminiCLIExtension[];
onOpen: () => void; onOpen: () => void;
onClose: () => void; onClose: () => void;
}) { }) {
@@ -92,7 +90,7 @@ export function WorkspaceMigrationDialog(props: {
<Box flexDirection="column" marginTop={1} marginLeft={2}> <Box flexDirection="column" marginTop={1} marginLeft={2}>
{workspaceExtensions.map((extension) => ( {workspaceExtensions.map((extension) => (
<Text key={extension.config.name}>- {extension.config.name}</Text> <Text key={extension.name}>- {extension.name}</Text>
))} ))}
</Box> </Box>
<Box marginTop={1}> <Box marginTop={1}>
@@ -70,6 +70,7 @@ describe('useExtensionUpdates', () => {
source: 'https://some/repo', source: 'https://some/repo',
autoUpdate: false, autoUpdate: false,
}, },
contextFiles: [],
}, },
]; ];
const addItem = vi.fn(); const addItem = vi.fn();
@@ -262,6 +263,7 @@ describe('useExtensionUpdates', () => {
source: 'https://some/repo1', source: 'https://some/repo1',
autoUpdate: false, autoUpdate: false,
}, },
contextFiles: [],
}, },
{ {
name: 'test-extension-2', name: 'test-extension-2',
@@ -274,6 +276,7 @@ describe('useExtensionUpdates', () => {
source: 'https://some/repo2', source: 'https://some/repo2',
autoUpdate: false, autoUpdate: false,
}, },
contextFiles: [],
}, },
]; ];
const addItem = vi.fn(); const addItem = vi.fn();
@@ -5,19 +5,17 @@
*/ */
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { import type { GeminiCLIExtension } from '@google/gemini-cli-core';
type Extension, import { getWorkspaceExtensions } from '../../config/extension.js';
getWorkspaceExtensions,
} from '../../config/extension.js';
import { type LoadedSettings, SettingScope } from '../../config/settings.js'; import { type LoadedSettings, SettingScope } from '../../config/settings.js';
import process from 'node:process'; import process from 'node:process';
export function useWorkspaceMigration(settings: LoadedSettings) { export function useWorkspaceMigration(settings: LoadedSettings) {
const [showWorkspaceMigrationDialog, setShowWorkspaceMigrationDialog] = const [showWorkspaceMigrationDialog, setShowWorkspaceMigrationDialog] =
useState(false); useState(false);
const [workspaceExtensions, setWorkspaceExtensions] = useState<Extension[]>( const [workspaceExtensions, setWorkspaceExtensions] = useState<
[], GeminiCLIExtension[]
); >([]);
useEffect(() => { useEffect(() => {
// Default to true if not set. // Default to true if not set.
@@ -11,6 +11,7 @@ import type {
GeminiChat, GeminiChat,
ToolResult, ToolResult,
ToolCallConfirmationDetails, ToolCallConfirmationDetails,
GeminiCLIExtension,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { import {
AuthType, AuthType,
@@ -40,7 +41,7 @@ import * as path from 'node:path';
import { z } from 'zod'; import { z } from 'zod';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { ExtensionStorage, type Extension } from '../config/extension.js'; import { ExtensionStorage } from '../config/extension.js';
import type { CliArgs } from '../config/config.js'; import type { CliArgs } from '../config/config.js';
import { loadCliConfig } from '../config/config.js'; import { loadCliConfig } from '../config/config.js';
import { ExtensionEnablementManager } from '../config/extensions/extensionEnablement.js'; import { ExtensionEnablementManager } from '../config/extensions/extensionEnablement.js';
@@ -61,7 +62,7 @@ export function resolveModel(model: string, isInFallbackMode: boolean): string {
export async function runZedIntegration( export async function runZedIntegration(
config: Config, config: Config,
settings: LoadedSettings, settings: LoadedSettings,
extensions: Extension[], extensions: GeminiCLIExtension[],
argv: CliArgs, argv: CliArgs,
) { ) {
const stdout = Writable.toWeb(process.stdout) as WritableStream; const stdout = Writable.toWeb(process.stdout) as WritableStream;
@@ -88,7 +89,7 @@ class GeminiAgent {
constructor( constructor(
private config: Config, private config: Config,
private settings: LoadedSettings, private settings: LoadedSettings,
private extensions: Extension[], private extensions: GeminiCLIExtension[],
private argv: CliArgs, private argv: CliArgs,
private client: acp.Client, private client: acp.Client,
) {} ) {}
+9
View File
@@ -116,12 +116,21 @@ export interface OutputSettings {
format?: OutputFormat; format?: OutputFormat;
} }
/**
* All information required in CLI to handle an extension. Defined in Core so
* that the collection of loaded, active, and inactive extensions can be passed
* around on the config object though Core does not use this information
* directly.
*/
export interface GeminiCLIExtension { export interface GeminiCLIExtension {
name: string; name: string;
version: string; version: string;
isActive: boolean; isActive: boolean;
path: string; path: string;
installMetadata?: ExtensionInstallMetadata; installMetadata?: ExtensionInstallMetadata;
mcpServers?: Record<string, MCPServerConfig>;
contextFiles: string[];
excludeTools?: string[];
} }
export interface ExtensionInstallMetadata { export interface ExtensionInstallMetadata {