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
+2 -4
View File
@@ -153,10 +153,8 @@ describe('mcp list command', () => {
mockedLoadExtensions.mockReturnValue([
{
config: {
name: 'test-extension',
mcpServers: { 'extension-server': { command: '/ext/server' } },
},
name: 'test-extension',
mcpServers: { 'extension-server': { command: '/ext/server' } },
},
]);
+9 -11
View File
@@ -27,17 +27,15 @@ async function getMcpServersFromConfig(): Promise<
);
const mcpServers = { ...(settings.merged.mcpServers || {}) };
for (const extension of extensions) {
Object.entries(extension.config.mcpServers || {}).forEach(
([key, server]) => {
if (mcpServers[key]) {
return;
}
mcpServers[key] = {
...server,
extensionName: extension.config.name,
};
},
);
Object.entries(extension.mcpServers || {}).forEach(([key, server]) => {
if (mcpServers[key]) {
return;
}
mcpServers[key] = {
...server,
extensionName: extension.name,
};
});
}
return mcpServers;
}
+70 -76
View File
@@ -14,10 +14,11 @@ import {
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
OutputFormat,
type GeminiCLIExtension,
} from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments, type CliArgs } from './config.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 { isWorkspaceTrusted } from './trustedFolders.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 () => {
process.argv = ['node', 'script.js'];
const settings: Settings = {};
const extensions: Extension[] = [
const extensions: GeminiCLIExtension[] = [
{
path: '/path/to/ext1',
config: {
name: 'ext1',
version: '1.0.0',
},
name: 'ext1',
version: '1.0.0',
contextFiles: ['/path/to/ext1/GEMINI.md'],
isActive: true,
},
{
path: '/path/to/ext2',
config: {
name: 'ext2',
version: '1.0.0',
},
name: 'ext2',
version: '1.0.0',
contextFiles: [],
isActive: true,
},
{
path: '/path/to/ext3',
config: {
name: 'ext3',
version: '1.0.0',
},
name: 'ext3',
version: '1.0.0',
contextFiles: [
'/path/to/ext3/context1.md',
'/path/to/ext3/context2.md',
],
isActive: true,
},
];
const argv = await parseArguments({} as Settings);
@@ -1195,19 +1193,18 @@ describe('mergeMcpServers', () => {
},
},
};
const extensions: Extension[] = [
const extensions: GeminiCLIExtension[] = [
{
path: '/path/to/ext1',
config: {
name: 'ext1',
version: '1.0.0',
mcpServers: {
'ext1-server': {
url: 'http://localhost:8081',
},
name: 'ext1',
version: '1.0.0',
mcpServers: {
'ext1-server': {
url: 'http://localhost:8081',
},
},
contextFiles: [],
isActive: true,
},
];
const originalSettings = JSON.parse(JSON.stringify(settings));
@@ -1241,24 +1238,22 @@ describe('mergeExcludeTools', () => {
it('should merge excludeTools from settings and extensions', async () => {
const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } };
const extensions: Extension[] = [
const extensions: GeminiCLIExtension[] = [
{
path: '/path/to/ext1',
config: {
name: 'ext1',
version: '1.0.0',
excludeTools: ['tool3', 'tool4'],
},
name: 'ext1',
version: '1.0.0',
excludeTools: ['tool3', 'tool4'],
contextFiles: [],
isActive: true,
},
{
path: '/path/to/ext2',
config: {
name: 'ext2',
version: '1.0.0',
excludeTools: ['tool5'],
},
name: 'ext2',
version: '1.0.0',
excludeTools: ['tool5'],
contextFiles: [],
isActive: true,
},
];
process.argv = ['node', 'script.js'];
@@ -1281,15 +1276,14 @@ describe('mergeExcludeTools', () => {
it('should handle overlapping excludeTools between settings and extensions', async () => {
const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } };
const extensions: Extension[] = [
const extensions: GeminiCLIExtension[] = [
{
path: '/path/to/ext1',
config: {
name: 'ext1',
version: '1.0.0',
excludeTools: ['tool2', 'tool3'],
},
name: 'ext1',
version: '1.0.0',
excludeTools: ['tool2', 'tool3'],
contextFiles: [],
isActive: true,
},
];
process.argv = ['node', 'script.js'];
@@ -1312,24 +1306,22 @@ describe('mergeExcludeTools', () => {
it('should handle overlapping excludeTools between extensions', async () => {
const settings: Settings = { tools: { exclude: ['tool1'] } };
const extensions: Extension[] = [
const extensions: GeminiCLIExtension[] = [
{
path: '/path/to/ext1',
config: {
name: 'ext1',
version: '1.0.0',
excludeTools: ['tool2', 'tool3'],
},
name: 'ext1',
version: '1.0.0',
excludeTools: ['tool2', 'tool3'],
contextFiles: [],
isActive: true,
},
{
path: '/path/to/ext2',
config: {
name: 'ext2',
version: '1.0.0',
excludeTools: ['tool3', 'tool4'],
},
name: 'ext2',
version: '1.0.0',
excludeTools: ['tool3', 'tool4'],
contextFiles: [],
isActive: true,
},
];
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 () => {
process.stdin.isTTY = true;
const settings: Settings = {};
const extensions: Extension[] = [];
const extensions: GeminiCLIExtension[] = [];
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
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 () => {
process.stdin.isTTY = false;
const settings: Settings = {};
const extensions: Extension[] = [];
const extensions: GeminiCLIExtension[] = [];
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(
@@ -1392,7 +1384,7 @@ describe('mergeExcludeTools', () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } };
const extensions: Extension[] = [];
const extensions: GeminiCLIExtension[] = [];
const config = await loadCliConfig(
settings,
extensions,
@@ -1411,15 +1403,14 @@ describe('mergeExcludeTools', () => {
it('should handle extensions with excludeTools but no settings', async () => {
const settings: Settings = {};
const extensions: Extension[] = [
const extensions: GeminiCLIExtension[] = [
{
path: '/path/to/ext',
config: {
name: 'ext1',
version: '1.0.0',
excludeTools: ['tool1', 'tool2'],
},
name: 'ext1',
version: '1.0.0',
excludeTools: ['tool1', 'tool2'],
contextFiles: [],
isActive: true,
},
];
process.argv = ['node', 'script.js'];
@@ -1442,15 +1433,14 @@ describe('mergeExcludeTools', () => {
it('should not modify the original settings object', async () => {
const settings: Settings = { tools: { exclude: ['tool1'] } };
const extensions: Extension[] = [
const extensions: GeminiCLIExtension[] = [
{
path: '/path/to/ext',
config: {
name: 'ext1',
version: '1.0.0',
excludeTools: ['tool2'],
},
name: 'ext1',
version: '1.0.0',
excludeTools: ['tool2'],
contextFiles: [],
isActive: true,
},
];
const originalSettings = JSON.parse(JSON.stringify(settings));
@@ -1486,7 +1476,7 @@ describe('Approval mode tool exclusion logic', () => {
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {};
const extensions: Extension[] = [];
const extensions: GeminiCLIExtension[] = [];
const config = await loadCliConfig(
settings,
@@ -1516,7 +1506,7 @@ describe('Approval mode tool exclusion logic', () => {
];
const argv = await parseArguments({} as Settings);
const settings: Settings = {};
const extensions: Extension[] = [];
const extensions: GeminiCLIExtension[] = [];
const config = await loadCliConfig(
settings,
@@ -1546,7 +1536,7 @@ describe('Approval mode tool exclusion logic', () => {
];
const argv = await parseArguments({} as Settings);
const settings: Settings = {};
const extensions: Extension[] = [];
const extensions: GeminiCLIExtension[] = [];
const config = await loadCliConfig(
settings,
@@ -1576,7 +1566,7 @@ describe('Approval mode tool exclusion logic', () => {
];
const argv = await parseArguments({} as Settings);
const settings: Settings = {};
const extensions: Extension[] = [];
const extensions: GeminiCLIExtension[] = [];
const config = await loadCliConfig(
settings,
@@ -1599,7 +1589,7 @@ describe('Approval mode tool exclusion logic', () => {
process.argv = ['node', 'script.js', '--yolo', '-p', 'test'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {};
const extensions: Extension[] = [];
const extensions: GeminiCLIExtension[] = [];
const config = await loadCliConfig(
settings,
@@ -1633,7 +1623,7 @@ describe('Approval mode tool exclusion logic', () => {
process.argv = testCase.args;
const argv = await parseArguments({} as Settings);
const settings: Settings = {};
const extensions: Extension[] = [];
const extensions: GeminiCLIExtension[] = [];
const config = await loadCliConfig(
settings,
@@ -1664,7 +1654,7 @@ describe('Approval mode tool exclusion logic', () => {
];
const argv = await parseArguments({} as Settings);
const settings: Settings = { tools: { exclude: ['custom_tool'] } };
const extensions: Extension[] = [];
const extensions: GeminiCLIExtension[] = [];
const config = await loadCliConfig(
settings,
@@ -1694,7 +1684,7 @@ describe('Approval mode tool exclusion logic', () => {
};
const settings: Settings = {};
const extensions: Extension[] = [];
const extensions: GeminiCLIExtension[] = [];
await expect(
loadCliConfig(
settings,
@@ -1976,16 +1966,20 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
});
describe('loadCliConfig extensions', () => {
const mockExtensions: Extension[] = [
const mockExtensions: GeminiCLIExtension[] = [
{
path: '/path/to/ext1',
config: { name: 'ext1', version: '1.0.0' },
name: 'ext1',
version: '1.0.0',
contextFiles: ['/path/to/ext1.md'],
isActive: true,
},
{
path: '/path/to/ext2',
config: { name: 'ext2', version: '1.0.0' },
name: 'ext2',
version: '1.0.0',
contextFiles: ['/path/to/ext2.md'],
isActive: true,
},
];
+17 -19
View File
@@ -15,6 +15,7 @@ import type {
FileFilteringOptions,
MCPServerConfig,
OutputFormat,
GeminiCLIExtension,
} from '@google/gemini-cli-core';
import { extensionsCommand } from '../commands/extensions.js';
import {
@@ -37,7 +38,6 @@ import {
} from '@google/gemini-cli-core';
import type { Settings } from './settings.js';
import type { Extension } from './extension.js';
import { annotateActiveExtensions } from './extension.js';
import { getCliVersion } from '../utils/version.js';
import { loadSandboxConfig } from './sandboxConfig.js';
@@ -472,7 +472,7 @@ export function isDebugMode(argv: CliArgs): boolean {
export async function loadCliConfig(
settings: Settings,
extensions: Extension[],
extensions: GeminiCLIExtension[],
extensionEnablementManager: ExtensionEnablementManager,
sessionId: string,
argv: CliArgs,
@@ -787,30 +787,28 @@ function allowedMcpServers(
return mcpServers;
}
function mergeMcpServers(settings: Settings, extensions: Extension[]) {
function mergeMcpServers(settings: Settings, extensions: GeminiCLIExtension[]) {
const mcpServers = { ...(settings.mcpServers || {}) };
for (const extension of extensions) {
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.`,
);
return;
}
mcpServers[key] = {
...server,
extensionName: extension.config.name,
};
},
);
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,
extensionName: extension.name,
};
});
}
return mcpServers;
}
function mergeExcludeTools(
settings: Settings,
extensions: Extension[],
extensions: GeminiCLIExtension[],
extraExcludes?: string[] | undefined,
): string[] {
const allExcludeTools = new Set([
@@ -818,7 +816,7 @@ function mergeExcludeTools(
...(extraExcludes || []),
]);
for (const extension of extensions) {
for (const tool of extension.config.excludeTools || []) {
for (const tool of extension.excludeTools || []) {
allExcludeTools.add(tool);
}
}
+47 -34
View File
@@ -22,7 +22,6 @@ import {
performWorkspaceExtensionMigration,
requestConsentNonInteractive,
uninstallExtension,
type Extension,
} from './extension.js';
import {
GEMINI_DIR,
@@ -158,7 +157,7 @@ describe('extension tests', () => {
);
expect(extensions).toHaveLength(1);
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', () => {
@@ -179,8 +178,8 @@ describe('extension tests', () => {
);
expect(extensions).toHaveLength(2);
const ext1 = extensions.find((e) => e.config.name === 'ext1');
const ext2 = extensions.find((e) => e.config.name === 'ext2');
const ext1 = extensions.find((e) => e.name === 'ext1');
const ext2 = extensions.find((e) => e.name === 'ext2');
expect(ext1?.contextFiles).toEqual([
path.join(userExtensionsDir, 'ext1', 'GEMINI.md'),
]);
@@ -201,7 +200,7 @@ describe('extension tests', () => {
);
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([
path.join(userExtensionsDir, 'ext1', 'my-context-file.md'),
]);
@@ -254,13 +253,12 @@ describe('extension tests', () => {
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
);
expect(extensions).toHaveLength(1);
const loadedConfig = extensions[0].config;
const expectedCwd = path.join(
userExtensionsDir,
'test-extension',
'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 () => {
@@ -287,7 +285,7 @@ describe('extension tests', () => {
expect(extensions).toHaveLength(1);
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.installMetadata).toEqual({
@@ -340,10 +338,10 @@ describe('extension tests', () => {
expect(extensions).toHaveLength(1);
const extension = extensions[0];
expect(extension.config.name).toBe('test-extension');
expect(extension.config.mcpServers).toBeDefined();
expect(extension.name).toBe('test-extension');
expect(extension.mcpServers).toBeDefined();
const serverConfig = extension.config.mcpServers?.['test-server'];
const serverConfig = extension.mcpServers?.['test-server'];
expect(serverConfig).toBeDefined();
expect(serverConfig?.env).toBeDefined();
expect(serverConfig?.env?.API_KEY).toBe('test-api-key-123');
@@ -393,7 +391,7 @@ describe('extension tests', () => {
expect(extensions).toHaveLength(1);
const extension = extensions[0];
const serverConfig = extension.config.mcpServers!['test-server'];
const serverConfig = extension.mcpServers!['test-server'];
expect(serverConfig.env).toBeDefined();
expect(serverConfig.env!.MISSING_VAR).toBe('$UNDEFINED_ENV_VAR');
expect(serverConfig.env!.MISSING_VAR_BRACES).toBe('${ALSO_UNDEFINED}');
@@ -422,7 +420,7 @@ describe('extension tests', () => {
);
expect(extensions).toHaveLength(1);
expect(extensions[0].config.name).toBe('good-ext');
expect(extensions[0].name).toBe('good-ext');
expect(consoleSpy).toHaveBeenCalledOnce();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
@@ -456,7 +454,7 @@ describe('extension tests', () => {
);
expect(extensions).toHaveLength(1);
expect(extensions[0].config.name).toBe('good-ext');
expect(extensions[0].name).toBe('good-ext');
expect(consoleSpy).toHaveBeenCalledOnce();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
@@ -485,8 +483,7 @@ describe('extension tests', () => {
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
);
expect(extensions).toHaveLength(1);
const loadedConfig = extensions[0].config;
expect(loadedConfig.mcpServers?.['test-server'].trust).toBeUndefined();
expect(extensions[0].mcpServers?.['test-server'].trust).toBeUndefined();
});
it('should throw an error for invalid extension names', () => {
@@ -513,21 +510,27 @@ describe('extension tests', () => {
});
describe('annotateActiveExtensions', () => {
const extensions: Extension[] = [
const extensions: GeminiCLIExtension[] = [
{
path: '/path/to/ext1',
config: { name: 'ext1', version: '1.0.0' },
name: 'ext1',
version: '1.0.0',
contextFiles: [],
isActive: true,
},
{
path: '/path/to/ext2',
config: { name: 'ext2', version: '1.0.0' },
name: 'ext2',
version: '1.0.0',
contextFiles: [],
isActive: true,
},
{
path: '/path/to/ext3',
config: { name: 'ext3', version: '1.0.0' },
name: 'ext3',
version: '1.0.0',
contextFiles: [],
isActive: true,
},
];
@@ -622,13 +625,15 @@ describe('extension tests', () => {
});
it('should be true if autoUpdate is true in install metadata', () => {
const extensionsWithAutoUpdate: Extension[] = extensions.map((e) => ({
...e,
installMetadata: {
...e.installMetadata!,
autoUpdate: true,
},
}));
const extensionsWithAutoUpdate: GeminiCLIExtension[] = extensions.map(
(e) => ({
...e,
installMetadata: {
...e.installMetadata!,
autoUpdate: true,
},
}),
);
const activeExtensions = annotateActiveExtensions(
extensionsWithAutoUpdate,
tempHomeDir,
@@ -642,31 +647,37 @@ describe('extension tests', () => {
});
it('should respect the per-extension settings from install metadata', () => {
const extensionsWithAutoUpdate: Extension[] = [
const extensionsWithAutoUpdate: GeminiCLIExtension[] = [
{
path: '/path/to/ext1',
config: { name: 'ext1', version: '1.0.0' },
name: 'ext1',
version: '1.0.0',
contextFiles: [],
installMetadata: {
source: 'test',
type: 'local',
autoUpdate: true,
},
isActive: true,
},
{
path: '/path/to/ext2',
config: { name: 'ext2', version: '1.0.0' },
name: 'ext2',
version: '1.0.0',
contextFiles: [],
installMetadata: {
source: 'test',
type: 'local',
autoUpdate: false,
},
isActive: true,
},
{
path: '/path/to/ext3',
config: { name: 'ext3', version: '1.0.0' },
name: 'ext3',
version: '1.0.0',
contextFiles: [],
isActive: true,
},
];
const activeExtensions = annotateActiveExtensions(
@@ -1229,7 +1240,7 @@ This extension will run the following MCP servers:
name: 'ext2',
version: '1.0.0',
});
const extensionsToMigrate: Extension[] = [
const extensionsToMigrate: GeminiCLIExtension[] = [
loadExtension({
extensionDir: ext1Path,
workspaceDir: tempWorkspaceDir,
@@ -1273,15 +1284,17 @@ This extension will run the following MCP servers:
version: '1.0.0',
});
const extensions: Extension[] = [
const extensions: GeminiCLIExtension[] = [
loadExtension({
extensionDir: ext1Path,
workspaceDir: tempWorkspaceDir,
})!,
{
path: '/ext/path/1',
config: { name: 'ext2', version: '1.0.0' },
name: 'ext2',
version: '1.0.0',
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 INSTALL_METADATA_FILENAME = '.gemini-extension-install.json';
export interface Extension {
path: string;
config: ExtensionConfig;
contextFiles: string[];
installMetadata?: ExtensionInstallMetadata | undefined;
}
export interface ExtensionConfig {
/**
* Extension definition as written to disk in gemini-extension.json files.
* 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
* GeminiCLIExtension class defined in Core.
*/
interface ExtensionConfig {
name: string;
version: string;
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 (path.resolve(workspaceDir) === path.resolve(os.homedir())) {
return [];
@@ -112,7 +114,7 @@ export async function copyExtension(
}
export async function performWorkspaceExtensionMigration(
extensions: Extension[],
extensions: GeminiCLIExtension[],
requestConsent: (consent: string) => Promise<boolean>,
): Promise<string[]> {
const failedInstallNames: string[] = [];
@@ -125,7 +127,7 @@ export async function performWorkspaceExtensionMigration(
};
await installExtension(installMetadata, requestConsent);
} catch (_) {
failedInstallNames.push(extension.config.name);
failedInstallNames.push(extension.name);
}
}
return failedInstallNames;
@@ -148,7 +150,7 @@ function getTelemetryConfig(cwd: string) {
export function loadExtensions(
extensionEnablementManager: ExtensionEnablementManager,
workspaceDir: string = process.cwd(),
): Extension[] {
): GeminiCLIExtension[] {
const settings = loadSettings(workspaceDir).merged;
const allExtensions = [...loadUserExtensions()];
@@ -160,41 +162,41 @@ export function loadExtensions(
allExtensions.push(...getWorkspaceExtensions(workspaceDir));
}
const uniqueExtensions = new Map<string, Extension>();
const uniqueExtensions = new Map<string, GeminiCLIExtension>();
for (const extension of allExtensions) {
if (
!uniqueExtensions.has(extension.config.name) &&
extensionEnablementManager.isEnabled(extension.config.name, workspaceDir)
!uniqueExtensions.has(extension.name) &&
extensionEnablementManager.isEnabled(extension.name, workspaceDir)
) {
uniqueExtensions.set(extension.config.name, extension);
uniqueExtensions.set(extension.name, extension);
}
}
return Array.from(uniqueExtensions.values());
}
export function loadUserExtensions(): Extension[] {
export function loadUserExtensions(): GeminiCLIExtension[] {
const userExtensions = loadExtensionsFromDir(os.homedir());
const uniqueExtensions = new Map<string, Extension>();
const uniqueExtensions = new Map<string, GeminiCLIExtension>();
for (const extension of userExtensions) {
if (!uniqueExtensions.has(extension.config.name)) {
uniqueExtensions.set(extension.config.name, extension);
if (!uniqueExtensions.has(extension.name)) {
uniqueExtensions.set(extension.name, extension);
}
}
return Array.from(uniqueExtensions.values());
}
export function loadExtensionsFromDir(dir: string): Extension[] {
export function loadExtensionsFromDir(dir: string): GeminiCLIExtension[] {
const storage = new Storage(dir);
const extensionsDir = storage.getExtensionsDir();
if (!fs.existsSync(extensionsDir)) {
return [];
}
const extensions: Extension[] = [];
const extensions: GeminiCLIExtension[] = [];
for (const subdir of fs.readdirSync(extensionsDir)) {
const extensionDir = path.join(extensionsDir, subdir);
@@ -206,7 +208,9 @@ export function loadExtensionsFromDir(dir: string): Extension[] {
return extensions;
}
export function loadExtension(context: LoadExtensionContext): Extension | null {
export function loadExtension(
context: LoadExtensionContext,
): GeminiCLIExtension | null {
const { extensionDir, workspaceDir } = context;
if (!fs.statSync(extensionDir).isDirectory()) {
return null;
@@ -243,10 +247,14 @@ export function loadExtension(context: LoadExtensionContext): Extension | null {
.filter((contextFilePath) => fs.existsSync(contextFilePath));
return {
name: config.name,
version: config.version,
path: effectiveExtensionPath,
config,
contextFiles,
installMetadata,
mcpServers: config.mcpServers,
excludeTools: config.excludeTools,
isActive: true, // Barring any other signals extensions should be considered Active.
};
} catch (e) {
console.error(
@@ -261,7 +269,7 @@ export function loadExtension(context: LoadExtensionContext): Extension | null {
export function loadExtensionByName(
name: string,
workspaceDir: string = process.cwd(),
): Extension | null {
): GeminiCLIExtension | null {
const userExtensionsDir = ExtensionStorage.getUserExtensionsDir();
if (!fs.existsSync(userExtensionsDir)) {
return null;
@@ -273,10 +281,7 @@ export function loadExtensionByName(
continue;
}
const extension = loadExtension({ extensionDir, workspaceDir });
if (
extension &&
extension.config.name.toLowerCase() === name.toLowerCase()
) {
if (extension && extension.name.toLowerCase() === name.toLowerCase()) {
return extension;
}
}
@@ -320,17 +325,14 @@ function getContextFileNames(config: ExtensionConfig): string[] {
* @param workspaceDir The current workspace directory.
*/
export function annotateActiveExtensions(
extensions: Extension[],
extensions: GeminiCLIExtension[],
workspaceDir: string,
manager: ExtensionEnablementManager,
): GeminiCLIExtension[] {
manager.validateExtensionOverrides(extensions);
return extensions.map((extension) => ({
name: extension.config.name,
version: extension.config.version,
isActive: manager.isEnabled(extension.config.name, workspaceDir),
path: extension.path,
installMetadata: extension.installMetadata,
...extension,
isActive: manager.isEnabled(extension.name, workspaceDir),
}));
}
@@ -489,7 +491,7 @@ export async function installExtension(
const installedExtensions = loadUserExtensions();
if (
installedExtensions.some(
(installed) => installed.config.name === newExtensionName,
(installed) => installed.name === newExtensionName,
)
) {
throw new Error(
@@ -672,11 +674,10 @@ export async function uninstallExtension(
const installedExtensions = loadUserExtensions();
const extensionName = installedExtensions.find(
(installed) =>
installed.config.name.toLowerCase() ===
extensionIdentifier.toLowerCase() ||
installed.name.toLowerCase() === extensionIdentifier.toLowerCase() ||
installed.installMetadata?.source.toLowerCase() ===
extensionIdentifier.toLowerCase(),
)?.config.name;
)?.name;
if (!extensionName) {
throw new Error(`Extension not found.`);
}
@@ -698,20 +699,17 @@ export async function uninstallExtension(
}
export function toOutputString(
extension: Extension,
extension: GeminiCLIExtension,
workspaceDir: string,
): string {
const manager = new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
);
const userEnabled = manager.isEnabled(extension.config.name, os.homedir());
const workspaceEnabled = manager.isEnabled(
extension.config.name,
workspaceDir,
);
const userEnabled = manager.isEnabled(extension.name, os.homedir());
const workspaceEnabled = manager.isEnabled(extension.name, workspaceDir);
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}`;
if (extension.installMetadata) {
output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`;
@@ -730,15 +728,15 @@ export function toOutputString(
output += `\n ${contextFile}`;
});
}
if (extension.config.mcpServers) {
if (extension.mcpServers) {
output += `\n MCP servers:`;
Object.keys(extension.config.mcpServers).forEach((key) => {
Object.keys(extension.mcpServers).forEach((key) => {
output += `\n ${key}`;
});
}
if (extension.config.excludeTools) {
if (extension.excludeTools) {
output += `\n Excluded tools:`;
extension.config.excludeTools.forEach((tool) => {
extension.excludeTools.forEach((tool) => {
output += `\n ${tool}`;
});
}
@@ -9,7 +9,7 @@ import fs from 'node:fs';
import os from 'node:os';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
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
function createTestDir() {
@@ -286,9 +286,9 @@ describe('ExtensionEnablementManager', () => {
'ext-two',
]);
const extensions = [
{ config: { name: 'ext-one' } },
{ config: { name: 'ext-two' } },
] as Extension[];
{ name: 'ext-one' },
{ name: 'ext-two' },
] as GeminiCLIExtension[];
manager.validateExtensionOverrides(extensions);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
@@ -300,9 +300,9 @@ describe('ExtensionEnablementManager', () => {
'ext-another-invalid',
]);
const extensions = [
{ config: { name: 'ext-one' } },
{ config: { name: 'ext-two' } },
] as Extension[];
{ name: 'ext-one' },
{ name: 'ext-two' },
] as GeminiCLIExtension[];
manager.validateExtensionOverrides(extensions);
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
expect(consoleErrorSpy).toHaveBeenCalledWith(
@@ -6,7 +6,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { type Extension } from '../extension.js';
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
export interface ExtensionEnablementConfig {
overrides: string[];
@@ -119,13 +119,11 @@ export class ExtensionEnablementManager {
enabledExtensionNames?.map((name) => name.toLowerCase()) ?? [];
}
validateExtensionOverrides(extensions: Extension[]) {
validateExtensionOverrides(extensions: GeminiCLIExtension[]) {
for (const name of this.enabledExtensionNamesOverride) {
if (name === 'none') continue;
if (
!extensions.some(
(ext) => ext.config.name.toLowerCase() === name.toLowerCase(),
)
!extensions.some((ext) => ext.name.toLowerCase() === name.toLowerCase())
) {
console.error(`Extension not found: ${name}`);
}
@@ -137,6 +137,7 @@ describe('git extension helpers', () => {
type: 'link',
source: '',
},
contextFiles: [],
};
const result = await checkForExtensionUpdate(extension);
expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE);
@@ -152,6 +153,7 @@ describe('git extension helpers', () => {
type: 'git',
source: '',
},
contextFiles: [],
};
mockGit.getRemotes.mockResolvedValue([]);
const result = await checkForExtensionUpdate(extension);
@@ -168,6 +170,7 @@ describe('git extension helpers', () => {
type: 'git',
source: 'my/ext',
},
contextFiles: [],
};
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
@@ -189,6 +192,7 @@ describe('git extension helpers', () => {
type: 'git',
source: 'my/ext',
},
contextFiles: [],
};
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
@@ -210,6 +214,7 @@ describe('git extension helpers', () => {
type: 'git',
source: 'my/ext',
},
contextFiles: [],
};
mockGit.getRemotes.mockRejectedValue(new Error('git error'));
+1 -1
View File
@@ -134,7 +134,7 @@ export async function checkForExtensionUpdate(
);
return ExtensionUpdateState.ERROR;
}
if (newExtension.config.version !== extension.version) {
if (newExtension.version !== extension.version) {
return ExtensionUpdateState.UPDATE_AVAILABLE;
}
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.');
}
const updatedVersion = updatedExtension.config.version;
const updatedVersion = updatedExtension.version;
dispatchExtensionStateUpdate({
type: 'SET_STATE',
payload: {
+1 -1
View File
@@ -357,7 +357,7 @@ export async function main() {
if (config.getListExtensions()) {
console.log('Installed extensions:');
for (const extension of extensions) {
console.log(`- ${extension.config.name}`);
console.log(`- ${extension.name}`);
}
process.exit(0);
}
@@ -5,16 +5,14 @@
*/
import { Box, Text, useInput } from 'ink';
import {
type Extension,
performWorkspaceExtensionMigration,
} from '../../config/extension.js';
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
import { performWorkspaceExtensionMigration } from '../../config/extension.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { theme } from '../semantic-colors.js';
import { useState } from 'react';
export function WorkspaceMigrationDialog(props: {
workspaceExtensions: Extension[];
workspaceExtensions: GeminiCLIExtension[];
onOpen: () => void;
onClose: () => void;
}) {
@@ -92,7 +90,7 @@ export function WorkspaceMigrationDialog(props: {
<Box flexDirection="column" marginTop={1} marginLeft={2}>
{workspaceExtensions.map((extension) => (
<Text key={extension.config.name}>- {extension.config.name}</Text>
<Text key={extension.name}>- {extension.name}</Text>
))}
</Box>
<Box marginTop={1}>
@@ -70,6 +70,7 @@ describe('useExtensionUpdates', () => {
source: 'https://some/repo',
autoUpdate: false,
},
contextFiles: [],
},
];
const addItem = vi.fn();
@@ -262,6 +263,7 @@ describe('useExtensionUpdates', () => {
source: 'https://some/repo1',
autoUpdate: false,
},
contextFiles: [],
},
{
name: 'test-extension-2',
@@ -274,6 +276,7 @@ describe('useExtensionUpdates', () => {
source: 'https://some/repo2',
autoUpdate: false,
},
contextFiles: [],
},
];
const addItem = vi.fn();
@@ -5,19 +5,17 @@
*/
import { useState, useEffect } from 'react';
import {
type Extension,
getWorkspaceExtensions,
} from '../../config/extension.js';
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
import { getWorkspaceExtensions } from '../../config/extension.js';
import { type LoadedSettings, SettingScope } from '../../config/settings.js';
import process from 'node:process';
export function useWorkspaceMigration(settings: LoadedSettings) {
const [showWorkspaceMigrationDialog, setShowWorkspaceMigrationDialog] =
useState(false);
const [workspaceExtensions, setWorkspaceExtensions] = useState<Extension[]>(
[],
);
const [workspaceExtensions, setWorkspaceExtensions] = useState<
GeminiCLIExtension[]
>([]);
useEffect(() => {
// Default to true if not set.
@@ -11,6 +11,7 @@ import type {
GeminiChat,
ToolResult,
ToolCallConfirmationDetails,
GeminiCLIExtension,
} from '@google/gemini-cli-core';
import {
AuthType,
@@ -40,7 +41,7 @@ import * as path from 'node:path';
import { z } from 'zod';
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 { loadCliConfig } from '../config/config.js';
import { ExtensionEnablementManager } from '../config/extensions/extensionEnablement.js';
@@ -61,7 +62,7 @@ export function resolveModel(model: string, isInFallbackMode: boolean): string {
export async function runZedIntegration(
config: Config,
settings: LoadedSettings,
extensions: Extension[],
extensions: GeminiCLIExtension[],
argv: CliArgs,
) {
const stdout = Writable.toWeb(process.stdout) as WritableStream;
@@ -88,7 +89,7 @@ class GeminiAgent {
constructor(
private config: Config,
private settings: LoadedSettings,
private extensions: Extension[],
private extensions: GeminiCLIExtension[],
private argv: CliArgs,
private client: acp.Client,
) {}