Pass whole extensions rather than just context files (#10910)

Co-authored-by: Jake Macdonald <jakemac@google.com>
This commit is contained in:
Zack Birkenbuel 2025-10-20 16:15:23 -07:00 committed by GitHub
parent 995ae717cc
commit cc7e1472f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 487 additions and 1193 deletions

View file

@ -73,13 +73,12 @@ export async function loadConfig(
};
const fileService = new FileDiscoveryService(workspaceDir);
const extensionContextFilePaths = extensions.flatMap((e) => e.contextFiles);
const { memoryContent, fileCount } = await loadServerHierarchicalMemory(
workspaceDir,
[workspaceDir],
false,
fileService,
extensionContextFilePaths,
extensions,
settings.folderTrust === true,
);
configParams.userMemory = memoryContent;

View file

@ -8,6 +8,7 @@ import { type CommandModule } from 'yargs';
import { disableExtension } from '../../config/extension.js';
import { SettingScope } from '../../config/settings.js';
import { getErrorMessage } from '../../utils/errors.js';
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
import { debugLogger } from '@google/gemini-cli-core';
interface DisableArgs {
@ -16,11 +17,20 @@ interface DisableArgs {
}
export function handleDisable(args: DisableArgs) {
const extensionEnablementManager = new ExtensionEnablementManager();
try {
if (args.scope?.toLowerCase() === 'workspace') {
disableExtension(args.name, SettingScope.Workspace);
disableExtension(
args.name,
SettingScope.Workspace,
extensionEnablementManager,
);
} else {
disableExtension(args.name, SettingScope.User);
disableExtension(
args.name,
SettingScope.User,
extensionEnablementManager,
);
}
debugLogger.log(
`Extension "${args.name}" successfully disabled for scope "${args.scope}".`,
@ -41,7 +51,7 @@ export const disableCommand: CommandModule = {
type: 'string',
})
.option('scope', {
describe: 'The scope to disable the extenison in.',
describe: 'The scope to disable the extension in.',
type: 'string',
default: SettingScope.User,
})

View file

@ -8,6 +8,7 @@ import { type CommandModule } from 'yargs';
import { FatalConfigError, getErrorMessage } from '@google/gemini-cli-core';
import { enableExtension } from '../../config/extension.js';
import { SettingScope } from '../../config/settings.js';
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
interface EnableArgs {
name: string;
@ -15,11 +16,16 @@ interface EnableArgs {
}
export function handleEnable(args: EnableArgs) {
const extensionEnablementManager = new ExtensionEnablementManager();
try {
if (args.scope?.toLowerCase() === 'workspace') {
enableExtension(args.name, SettingScope.Workspace);
enableExtension(
args.name,
SettingScope.Workspace,
extensionEnablementManager,
);
} else {
enableExtension(args.name, SettingScope.User);
enableExtension(args.name, SettingScope.User, extensionEnablementManager);
}
if (args.scope) {
console.log(
@ -46,7 +52,7 @@ export const enableCommand: CommandModule = {
})
.option('scope', {
describe:
'The scope to enable the extenison in. If not set, will be enabled in all scopes.',
'The scope to enable the extension in. If not set, will be enabled in all scopes.',
type: 'string',
})
.check((argv) => {

View file

@ -7,7 +7,6 @@
import type { CommandModule } from 'yargs';
import {
loadExtensions,
annotateActiveExtensions,
requestConsentNonInteractive,
} from '../../config/extension.js';
import {
@ -37,12 +36,7 @@ export async function handleUpdate(args: UpdateArgs) {
// ones.
args.name ? [args.name] : [],
);
const allExtensions = loadExtensions(extensionEnablementManager);
const extensions = annotateActiveExtensions(
allExtensions,
workingDir,
extensionEnablementManager,
);
const extensions = loadExtensions(extensionEnablementManager);
if (args.name) {
try {
const extension = extensions.find(
@ -58,7 +52,10 @@ export async function handleUpdate(args: UpdateArgs) {
);
return;
}
const updateState = await checkForExtensionUpdate(extension);
const updateState = await checkForExtensionUpdate(
extension,
extensionEnablementManager,
);
if (updateState !== ExtensionUpdateState.UPDATE_AVAILABLE) {
debugLogger.log(`Extension "${args.name}" is already up to date.`);
return;
@ -66,6 +63,7 @@ export async function handleUpdate(args: UpdateArgs) {
// TODO(chrstnb): we should list extensions if the requested extension is not installed.
const updatedExtensionInfo = (await updateExtension(
extension,
extensionEnablementManager,
workingDir,
requestConsentNonInteractive,
updateState,
@ -90,6 +88,7 @@ export async function handleUpdate(args: UpdateArgs) {
const extensionState = new Map();
await checkForAllExtensionUpdates(
extensions,
extensionEnablementManager,
(action) => {
if (action.type === 'SET_STATE') {
extensionState.set(action.payload.name, {
@ -104,6 +103,7 @@ export async function handleUpdate(args: UpdateArgs) {
requestConsentNonInteractive,
extensions,
extensionState,
extensionEnablementManager,
() => {},
);
updateInfos = updateInfos.filter(

View file

@ -31,7 +31,7 @@ async function getMcpServersFromConfig(): Promise<
}
mcpServers[key] = {
...server,
extensionName: extension.name,
extension,
};
});
}
@ -115,7 +115,7 @@ export async function listMcpServers(): Promise<void> {
let serverInfo =
serverName +
(server.extensionName ? ` (from ${server.extensionName})` : '') +
(server.extension?.name ? ` (from ${server.extension.name})` : '') +
': ';
if (server.httpUrl) {
serverInfo += `${server.httpUrl} (http)`;

View file

@ -243,38 +243,6 @@ describe('Configuration Integration Tests', () => {
});
});
describe('Extension Context Files', () => {
it('should have an empty array for extension context files by default', () => {
const configParams: ConfigParameters = {
sessionId: 'test-session',
cwd: '/tmp',
model: 'test-model',
embeddingModel: 'test-embedding-model',
sandbox: undefined,
targetDir: tempDir,
debugMode: false,
};
const config = new Config(configParams);
expect(config.getExtensionContextFilePaths()).toEqual([]);
});
it('should correctly store and return extension context file paths', () => {
const contextFiles = ['/path/to/file1.txt', '/path/to/file2.js'];
const configParams: ConfigParameters = {
sessionId: 'test-session',
cwd: '/tmp',
model: 'test-model',
embeddingModel: 'test-embedding-model',
sandbox: undefined,
targetDir: tempDir,
debugMode: false,
extensionContextFilePaths: contextFiles,
};
const config = new Config(configParams);
expect(config.getExtensionContextFilePaths()).toEqual(contextFiles);
});
});
describe('Approval Mode Integration Tests', () => {
let parseArguments: typeof import('./config.js').parseArguments;

File diff suppressed because it is too large Load diff

View file

@ -40,7 +40,6 @@ import {
} from '@google/gemini-cli-core';
import type { Settings } from './settings.js';
import { annotateActiveExtensions } from './extension.js';
import { getCliVersion } from '../utils/version.js';
import { loadSandboxConfig } from './sandboxConfig.js';
import { resolvePath } from '../utils/resolvePath.js';
@ -48,7 +47,6 @@ import { appEvents } from '../utils/events.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { createPolicyEngineConfig } from './policy.js';
import type { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
export interface CliArgs {
query: string | undefined;
@ -289,7 +287,7 @@ export async function loadHierarchicalGeminiMemory(
debugMode: boolean,
fileService: FileDiscoveryService,
settings: Settings,
extensionContextFilePaths: string[] = [],
extensions: GeminiCLIExtension[],
folderTrust: boolean,
memoryImportFormat: 'flat' | 'tree' = 'tree',
fileFilteringOptions?: FileFilteringOptions,
@ -315,7 +313,7 @@ export async function loadHierarchicalGeminiMemory(
includeDirectoriesToReadGemini,
debugMode,
fileService,
extensionContextFilePaths,
extensions,
folderTrust,
memoryImportFormat,
fileFilteringOptions,
@ -364,8 +362,7 @@ export function isDebugMode(argv: CliArgs): boolean {
export async function loadCliConfig(
settings: Settings,
extensions: GeminiCLIExtension[],
extensionEnablementManager: ExtensionEnablementManager,
allExtensions: GeminiCLIExtension[],
sessionId: string,
argv: CliArgs,
cwd: string = process.cwd(),
@ -379,16 +376,6 @@ export async function loadCliConfig(
const folderTrust = settings.security?.folderTrust?.enabled ?? false;
const trustedFolder = isWorkspaceTrusted(settings)?.isTrusted ?? true;
const allExtensions = annotateActiveExtensions(
extensions,
cwd,
extensionEnablementManager,
);
const activeExtensions = extensions.filter(
(_, i) => allExtensions[i].isActive,
);
// Set the context filename in the server's memoryTool module BEFORE loading memory
// TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed
// directly to the Config constructor in core, and have core handle setGeminiMdFilename.
@ -400,10 +387,6 @@ export async function loadCliConfig(
setServerGeminiMdFilename(getCurrentGeminiMdFilename());
}
const extensionContextFilePaths = activeExtensions.flatMap(
(e) => e.contextFiles,
);
const fileService = new FileDiscoveryService(cwd);
const fileFiltering = {
@ -425,13 +408,13 @@ export async function loadCliConfig(
debugMode,
fileService,
settings,
extensionContextFilePaths,
allExtensions,
trustedFolder,
memoryImportFormat,
fileFiltering,
);
let mcpServers = mergeMcpServers(settings, activeExtensions);
let mcpServers = mergeMcpServers(settings, allExtensions);
const question = argv.promptInteractive || argv.prompt || '';
// Determine approval mode with backward compatibility
@ -527,7 +510,7 @@ export async function loadCliConfig(
const excludeTools = mergeExcludeTools(
settings,
activeExtensions,
allExtensions,
extraExcludes.length > 0 ? extraExcludes : undefined,
);
const blockedMcpServers: Array<{ name: string; extensionName: string }> = [];
@ -618,10 +601,10 @@ export async function loadCliConfig(
fileDiscoveryService: fileService,
bugCommand: settings.advanced?.bugCommand,
model: resolvedModel,
extensionContextFilePaths,
maxSessionTurns: settings.model?.maxSessionTurns ?? -1,
experimentalZedIntegration: argv.experimentalAcp || false,
listExtensions: argv.listExtensions || false,
enabledExtensions: argv.extensions,
extensions: allExtensions,
blockedMcpServers,
noBrowser: !!process.env['NO_BROWSER'],
@ -668,7 +651,7 @@ function allowedMcpServers(
if (!isAllowed) {
blockedMcpServers.push({
name: key,
extensionName: server.extensionName || '',
extensionName: server.extension?.name || '',
});
}
return isAllowed;
@ -678,7 +661,7 @@ function allowedMcpServers(
blockedMcpServers.push(
...Object.entries(mcpServers).map(([key, server]) => ({
name: key,
extensionName: server.extensionName || '',
extensionName: server.extension?.name || '',
})),
);
mcpServers = {};
@ -689,6 +672,9 @@ function allowedMcpServers(
function mergeMcpServers(settings: Settings, extensions: GeminiCLIExtension[]) {
const mcpServers = { ...(settings.mcpServers || {}) };
for (const extension of extensions) {
if (!extension.isActive) {
continue;
}
Object.entries(extension.mcpServers || {}).forEach(([key, server]) => {
if (mcpServers[key]) {
debugLogger.warn(
@ -698,7 +684,7 @@ function mergeMcpServers(settings: Settings, extensions: GeminiCLIExtension[]) {
}
mcpServers[key] = {
...server,
extensionName: extension.name,
extension,
};
});
}
@ -715,6 +701,9 @@ function mergeExcludeTools(
...(extraExcludes || []),
]);
for (const extension of extensions) {
if (!extension.isActive) {
continue;
}
for (const tool of extension.excludeTools || []) {
allExcludeTools.add(tool);
}

View file

@ -14,7 +14,6 @@ import {
ExtensionStorage,
INSTALL_METADATA_FILENAME,
INSTALL_WARNING_MESSAGE,
annotateActiveExtensions,
disableExtension,
enableExtension,
installOrUpdateExtension,
@ -202,7 +201,7 @@ describe('extension tests', () => {
]);
});
it('should filter out disabled extensions', () => {
it('should annotate disabled extensions', () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'disabled-extension',
@ -213,20 +212,19 @@ describe('extension tests', () => {
name: 'enabled-extension',
version: '2.0.0',
});
const manager = new ExtensionEnablementManager();
disableExtension(
'disabled-extension',
SettingScope.User,
manager,
tempWorkspaceDir,
);
const manager = new ExtensionEnablementManager();
const extensions = loadExtensions(manager);
const activeExtensions = annotateActiveExtensions(
extensions,
tempWorkspaceDir,
manager,
).filter((e) => e.isActive);
expect(activeExtensions).toHaveLength(1);
expect(activeExtensions[0].name).toBe('enabled-extension');
expect(extensions).toHaveLength(2);
expect(extensions[0].name).toBe('disabled-extension');
expect(extensions[0].isActive).toBe(false);
expect(extensions[1].name).toBe('enabled-extension');
expect(extensions[1].isActive).toBe(true);
});
it('should hydrate variables', () => {
@ -477,6 +475,7 @@ describe('extension tests', () => {
const extension = loadExtension({
extensionDir: badExtDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager: new ExtensionEnablementManager(),
});
expect(extension).toBeNull();
@ -501,6 +500,7 @@ describe('extension tests', () => {
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager: new ExtensionEnablementManager(),
});
const expectedHash = createHash('sha256')
@ -523,6 +523,7 @@ describe('extension tests', () => {
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager: new ExtensionEnablementManager(),
});
const expectedHash = createHash('sha256')
@ -545,6 +546,7 @@ describe('extension tests', () => {
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager: new ExtensionEnablementManager(),
});
const expectedHash = createHash('sha256')
@ -567,6 +569,7 @@ describe('extension tests', () => {
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager: new ExtensionEnablementManager(),
});
const expectedHash = createHash('sha256')
@ -589,6 +592,7 @@ describe('extension tests', () => {
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager: new ExtensionEnablementManager(),
});
const expectedHash = createHash('sha256')
@ -616,6 +620,7 @@ describe('extension tests', () => {
const extension = loadExtension({
extensionDir: new ExtensionStorage(extensionName).getExtensionDir(),
workspaceDir: tempWorkspaceDir,
extensionEnablementManager: new ExtensionEnablementManager(),
});
const expectedHash = createHash('sha256')
@ -634,6 +639,7 @@ describe('extension tests', () => {
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager: new ExtensionEnablementManager(),
});
const expectedHash = createHash('sha256')
@ -644,182 +650,6 @@ describe('extension tests', () => {
});
});
describe('annotateActiveExtensions', () => {
const extensions: GeminiCLIExtension[] = [
{
path: '/path/to/ext1',
name: 'ext1',
version: '1.0.0',
contextFiles: [],
isActive: true,
},
{
path: '/path/to/ext2',
name: 'ext2',
version: '1.0.0',
contextFiles: [],
isActive: true,
},
{
path: '/path/to/ext3',
name: 'ext3',
version: '1.0.0',
contextFiles: [],
isActive: true,
},
];
it('should mark all extensions as active if no enabled extensions are provided', () => {
const activeExtensions = annotateActiveExtensions(
extensions,
'/path/to/workspace',
new ExtensionEnablementManager(),
);
expect(activeExtensions).toHaveLength(3);
expect(activeExtensions.every((e) => e.isActive)).toBe(true);
});
it('should mark only the enabled extensions as active', () => {
const activeExtensions = annotateActiveExtensions(
extensions,
'/path/to/workspace',
new ExtensionEnablementManager(['ext1', 'ext3']),
);
expect(activeExtensions).toHaveLength(3);
expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe(
true,
);
expect(activeExtensions.find((e) => e.name === 'ext2')?.isActive).toBe(
false,
);
expect(activeExtensions.find((e) => e.name === 'ext3')?.isActive).toBe(
true,
);
});
it('should mark all extensions as inactive when "none" is provided', () => {
const activeExtensions = annotateActiveExtensions(
extensions,
'/path/to/workspace',
new ExtensionEnablementManager(['none']),
);
expect(activeExtensions).toHaveLength(3);
expect(activeExtensions.every((e) => !e.isActive)).toBe(true);
});
it('should handle case-insensitivity', () => {
const activeExtensions = annotateActiveExtensions(
extensions,
'/path/to/workspace',
new ExtensionEnablementManager(['EXT1']),
);
expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe(
true,
);
});
it('should log an error for unknown extensions', () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
annotateActiveExtensions(
extensions,
'/path/to/workspace',
new ExtensionEnablementManager(['ext4']),
);
expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4');
consoleSpy.mockRestore();
});
describe('autoUpdate', () => {
it('should be false if autoUpdate is not set in install metadata', () => {
const activeExtensions = annotateActiveExtensions(
extensions,
tempHomeDir,
new ExtensionEnablementManager(),
);
expect(
activeExtensions.every(
(e) => e.installMetadata?.autoUpdate === false,
),
).toBe(false);
});
it('should be true if autoUpdate is true in install metadata', () => {
const extensionsWithAutoUpdate: GeminiCLIExtension[] = extensions.map(
(e) => ({
...e,
installMetadata: {
...e.installMetadata!,
autoUpdate: true,
},
}),
);
const activeExtensions = annotateActiveExtensions(
extensionsWithAutoUpdate,
tempHomeDir,
new ExtensionEnablementManager(),
);
expect(
activeExtensions.every((e) => e.installMetadata?.autoUpdate === true),
).toBe(true);
});
it('should respect the per-extension settings from install metadata', () => {
const extensionsWithAutoUpdate: GeminiCLIExtension[] = [
{
path: '/path/to/ext1',
name: 'ext1',
version: '1.0.0',
contextFiles: [],
installMetadata: {
source: 'test',
type: 'local',
autoUpdate: true,
},
isActive: true,
},
{
path: '/path/to/ext2',
name: 'ext2',
version: '1.0.0',
contextFiles: [],
installMetadata: {
source: 'test',
type: 'local',
autoUpdate: false,
},
isActive: true,
},
{
path: '/path/to/ext3',
name: 'ext3',
version: '1.0.0',
contextFiles: [],
isActive: true,
},
];
const activeExtensions = annotateActiveExtensions(
extensionsWithAutoUpdate,
tempHomeDir,
new ExtensionEnablementManager(),
);
expect(
activeExtensions.find((e) => e.name === 'ext1')?.installMetadata
?.autoUpdate,
).toBe(true);
expect(
activeExtensions.find((e) => e.name === 'ext2')?.installMetadata
?.autoUpdate,
).toBe(false);
expect(
activeExtensions.find((e) => e.name === 'ext3')?.installMetadata
?.autoUpdate,
).toBe(undefined);
});
});
});
describe('installExtension', () => {
it('should install an extension from a local path', async () => {
const sourceExtDir = createExtension({
@ -1194,6 +1024,7 @@ This extension will run the following MCP servers:
await loadExtensionConfig({
extensionDir: sourceExtDir,
workspaceDir: process.cwd(),
extensionEnablementManager: new ExtensionEnablementManager(),
}),
),
).resolves.toBe('my-local-extension');
@ -1512,7 +1343,11 @@ This extension will run the following MCP servers:
version: '1.0.0',
});
disableExtension('my-extension', SettingScope.User);
disableExtension(
'my-extension',
SettingScope.User,
new ExtensionEnablementManager(),
);
expect(
isEnabled({
name: 'my-extension',
@ -1531,6 +1366,7 @@ This extension will run the following MCP servers:
disableExtension(
'my-extension',
SettingScope.Workspace,
new ExtensionEnablementManager(),
tempWorkspaceDir,
);
expect(
@ -1554,8 +1390,16 @@ This extension will run the following MCP servers:
version: '1.0.0',
});
disableExtension('my-extension', SettingScope.User);
disableExtension('my-extension', SettingScope.User);
disableExtension(
'my-extension',
SettingScope.User,
new ExtensionEnablementManager(),
);
disableExtension(
'my-extension',
SettingScope.User,
new ExtensionEnablementManager(),
);
expect(
isEnabled({
name: 'my-extension',
@ -1566,7 +1410,11 @@ This extension will run the following MCP servers:
it('should throw an error if you request system scope', () => {
expect(() =>
disableExtension('my-extension', SettingScope.System),
disableExtension(
'my-extension',
SettingScope.System,
new ExtensionEnablementManager(),
),
).toThrow('System and SystemDefaults scopes are not supported.');
});
@ -1577,7 +1425,11 @@ This extension will run the following MCP servers:
version: '1.0.0',
});
disableExtension('ext1', SettingScope.Workspace);
disableExtension(
'ext1',
SettingScope.Workspace,
new ExtensionEnablementManager(),
);
expect(mockLogExtensionDisable).toHaveBeenCalled();
expect(ExtensionDisableEvent).toHaveBeenCalledWith(
@ -1595,12 +1447,7 @@ This extension will run the following MCP servers:
const getActiveExtensions = (): GeminiCLIExtension[] => {
const manager = new ExtensionEnablementManager();
const extensions = loadExtensions(manager);
const activeExtensions = annotateActiveExtensions(
extensions,
tempWorkspaceDir,
manager,
);
return activeExtensions.filter((e) => e.isActive);
return extensions.filter((e) => e.isActive);
};
it('should enable an extension at the user scope', () => {
@ -1609,11 +1456,12 @@ This extension will run the following MCP servers:
name: 'ext1',
version: '1.0.0',
});
disableExtension('ext1', SettingScope.User);
const extensionEnablementManager = new ExtensionEnablementManager();
disableExtension('ext1', SettingScope.User, extensionEnablementManager);
let activeExtensions = getActiveExtensions();
expect(activeExtensions).toHaveLength(0);
enableExtension('ext1', SettingScope.User);
enableExtension('ext1', SettingScope.User, extensionEnablementManager);
activeExtensions = getActiveExtensions();
expect(activeExtensions).toHaveLength(1);
expect(activeExtensions[0].name).toBe('ext1');
@ -1625,11 +1473,20 @@ This extension will run the following MCP servers:
name: 'ext1',
version: '1.0.0',
});
disableExtension('ext1', SettingScope.Workspace);
const extensionEnablementManager = new ExtensionEnablementManager();
disableExtension(
'ext1',
SettingScope.Workspace,
extensionEnablementManager,
);
let activeExtensions = getActiveExtensions();
expect(activeExtensions).toHaveLength(0);
enableExtension('ext1', SettingScope.Workspace);
enableExtension(
'ext1',
SettingScope.Workspace,
extensionEnablementManager,
);
activeExtensions = getActiveExtensions();
expect(activeExtensions).toHaveLength(1);
expect(activeExtensions[0].name).toBe('ext1');
@ -1641,8 +1498,17 @@ This extension will run the following MCP servers:
name: 'ext1',
version: '1.0.0',
});
disableExtension('ext1', SettingScope.Workspace);
enableExtension('ext1', SettingScope.Workspace);
const extensionEnablementManager = new ExtensionEnablementManager();
disableExtension(
'ext1',
SettingScope.Workspace,
extensionEnablementManager,
);
enableExtension(
'ext1',
SettingScope.Workspace,
extensionEnablementManager,
);
expect(mockLogExtensionEnable).toHaveBeenCalled();
expect(ExtensionEnableEvent).toHaveBeenCalledWith(

View file

@ -142,6 +142,7 @@ export function loadExtensions(
const extension = loadExtension({
extensionDir,
workspaceDir,
extensionEnablementManager,
});
if (extension != null) {
extensions.push(extension);
@ -151,10 +152,7 @@ export function loadExtensions(
const uniqueExtensions = new Map<string, GeminiCLIExtension>();
for (const extension of extensions) {
if (
!uniqueExtensions.has(extension.name) &&
extensionEnablementManager.isEnabled(extension.name, workspaceDir)
) {
if (!uniqueExtensions.has(extension.name)) {
uniqueExtensions.set(extension.name, extension);
}
}
@ -165,7 +163,7 @@ export function loadExtensions(
export function loadExtension(
context: LoadExtensionContext,
): GeminiCLIExtension | null {
const { extensionDir, workspaceDir } = context;
const { extensionDir, workspaceDir, extensionEnablementManager } = context;
if (!fs.statSync(extensionDir).isDirectory()) {
return null;
}
@ -181,6 +179,7 @@ export function loadExtension(
let config = loadExtensionConfig({
extensionDir: effectiveExtensionPath,
workspaceDir,
extensionEnablementManager,
});
config = resolveEnvVarsInObject(config);
@ -230,7 +229,7 @@ export function loadExtension(
installMetadata,
mcpServers: config.mcpServers,
excludeTools: config.excludeTools,
isActive: true, // Barring any other signals extensions should be considered Active.
isActive: extensionEnablementManager.isEnabled(config.name, workspaceDir),
id,
};
} catch (e) {
@ -245,6 +244,7 @@ export function loadExtension(
export function loadExtensionByName(
name: string,
extensionEnablementManager: ExtensionEnablementManager,
workspaceDir: string = process.cwd(),
): GeminiCLIExtension | null {
const userExtensionsDir = ExtensionStorage.getUserExtensionsDir();
@ -257,7 +257,11 @@ export function loadExtensionByName(
if (!fs.statSync(extensionDir).isDirectory()) {
continue;
}
const extension = loadExtension({ extensionDir, workspaceDir });
const extension = loadExtension({
extensionDir,
workspaceDir,
extensionEnablementManager,
});
if (extension && extension.name.toLowerCase() === name.toLowerCase()) {
return extension;
}
@ -294,25 +298,6 @@ function getContextFileNames(config: ExtensionConfig): string[] {
return config.contextFileName;
}
/**
* Returns an annotated list of extensions. If an extension is listed in enabledExtensionNames, it will be active.
* If enabledExtensionNames is empty, an extension is active unless it is disabled.
* @param extensions The base list of extensions.
* @param enabledExtensionNames The names of explicitly enabled extensions.
* @param workspaceDir The current workspace directory.
*/
export function annotateActiveExtensions(
extensions: GeminiCLIExtension[],
workspaceDir: string,
manager: ExtensionEnablementManager,
): GeminiCLIExtension[] {
manager.validateExtensionOverrides(extensions);
return extensions.map((extension) => ({
...extension,
isActive: manager.isEnabled(extension.name, workspaceDir),
}));
}
/**
* Requests consent from the user to perform an action, by reading a Y/n
* character from stdin.
@ -409,6 +394,7 @@ export async function installOrUpdateExtension(
const telemetryConfig = getTelemetryConfig(cwd);
let newExtensionConfig: ExtensionConfig | null = null;
let localSourcePath: string | undefined;
const extensionEnablementManager = new ExtensionEnablementManager();
try {
const settings = loadSettings(cwd).merged;
@ -480,6 +466,7 @@ export async function installOrUpdateExtension(
newExtensionConfig = loadExtensionConfig({
extensionDir: localSourcePath,
workspaceDir: cwd,
extensionEnablementManager,
});
const newExtensionName = newExtensionConfig.name;
@ -555,7 +542,11 @@ export async function installOrUpdateExtension(
'success',
),
);
enableExtension(newExtensionConfig.name, SettingScope.User);
enableExtension(
newExtensionConfig.name,
SettingScope.User,
extensionEnablementManager,
);
}
return newExtensionConfig!.name;
@ -567,6 +558,7 @@ export async function installOrUpdateExtension(
newExtensionConfig = loadExtensionConfig({
extensionDir: localSourcePath,
workspaceDir: cwd,
extensionEnablementManager,
});
} catch {
// Ignore error, this is just for logging.
@ -791,38 +783,38 @@ export function toOutputString(
export function disableExtension(
name: string,
scope: SettingScope,
extensionEnablementManager: ExtensionEnablementManager,
cwd: string = process.cwd(),
) {
const config = getTelemetryConfig(cwd);
if (scope === SettingScope.System || scope === SettingScope.SystemDefaults) {
throw new Error('System and SystemDefaults scopes are not supported.');
}
const extension = loadExtensionByName(name, cwd);
const extension = loadExtensionByName(name, extensionEnablementManager, cwd);
if (!extension) {
throw new Error(`Extension with name ${name} does not exist.`);
}
const manager = new ExtensionEnablementManager([name]);
const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir();
manager.disable(name, true, scopePath);
extensionEnablementManager.disable(name, true, scopePath);
logExtensionDisable(config, new ExtensionDisableEvent(name, scope));
}
export function enableExtension(
name: string,
scope: SettingScope,
extensionEnablementManager: ExtensionEnablementManager,
cwd: string = process.cwd(),
) {
if (scope === SettingScope.System || scope === SettingScope.SystemDefaults) {
throw new Error('System and SystemDefaults scopes are not supported.');
}
const extension = loadExtensionByName(name, cwd);
const extension = loadExtensionByName(name, extensionEnablementManager, cwd);
if (!extension) {
throw new Error(`Extension with name ${name} does not exist.`);
}
const manager = new ExtensionEnablementManager();
const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir();
manager.enable(name, true, scopePath);
extensionEnablementManager.enable(name, true, scopePath);
const config = getTelemetryConfig(cwd);
logExtensionEnable(config, new ExtensionEnableEvent(name, scope));
}

View file

@ -22,6 +22,7 @@ import * as path from 'node:path';
import * as tar from 'tar';
import * as archiver from 'archiver';
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
import { ExtensionEnablementManager } from './extensionEnablement.js';
const mockPlatform = vi.hoisted(() => vi.fn());
const mockArch = vi.hoisted(() => vi.fn());
@ -149,7 +150,10 @@ describe('git extension helpers', () => {
},
contextFiles: [],
};
const result = await checkForExtensionUpdate(extension);
const result = await checkForExtensionUpdate(
extension,
new ExtensionEnablementManager(),
);
expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE);
});
@ -166,7 +170,10 @@ describe('git extension helpers', () => {
contextFiles: [],
};
mockGit.getRemotes.mockResolvedValue([]);
const result = await checkForExtensionUpdate(extension);
const result = await checkForExtensionUpdate(
extension,
new ExtensionEnablementManager(),
);
expect(result).toBe(ExtensionUpdateState.ERROR);
});
@ -188,7 +195,10 @@ describe('git extension helpers', () => {
mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD');
mockGit.revparse.mockResolvedValue('local-hash');
const result = await checkForExtensionUpdate(extension);
const result = await checkForExtensionUpdate(
extension,
new ExtensionEnablementManager(),
);
expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE);
});
@ -210,7 +220,10 @@ describe('git extension helpers', () => {
mockGit.listRemote.mockResolvedValue('same-hash\tHEAD');
mockGit.revparse.mockResolvedValue('same-hash');
const result = await checkForExtensionUpdate(extension);
const result = await checkForExtensionUpdate(
extension,
new ExtensionEnablementManager(),
);
expect(result).toBe(ExtensionUpdateState.UP_TO_DATE);
});
@ -228,7 +241,10 @@ describe('git extension helpers', () => {
};
mockGit.getRemotes.mockRejectedValue(new Error('git error'));
const result = await checkForExtensionUpdate(extension);
const result = await checkForExtensionUpdate(
extension,
new ExtensionEnablementManager(),
);
expect(result).toBe(ExtensionUpdateState.ERROR);
});
});

View file

@ -20,6 +20,7 @@ import { EXTENSIONS_CONFIG_FILENAME, loadExtension } from '../extension.js';
import * as tar from 'tar';
import extract from 'extract-zip';
import { fetchJson, getGitHubToken } from './github_fetch.js';
import { type ExtensionEnablementManager } from './extensionEnablement.js';
/**
* Clones a Git repository to a specified local path.
@ -152,6 +153,7 @@ export async function fetchReleaseFromGithub(
export async function checkForExtensionUpdate(
extension: GeminiCLIExtension,
extensionEnablementManager: ExtensionEnablementManager,
cwd: string = process.cwd(),
): Promise<ExtensionUpdateState> {
const installMetadata = extension.installMetadata;
@ -159,6 +161,7 @@ export async function checkForExtensionUpdate(
const newExtension = loadExtension({
extensionDir: installMetadata.source,
workspaceDir: cwd,
extensionEnablementManager,
});
if (!newExtension) {
debugLogger.error(

View file

@ -11,7 +11,6 @@ import * as path from 'node:path';
import {
EXTENSIONS_CONFIG_FILENAME,
INSTALL_METADATA_FILENAME,
annotateActiveExtensions,
loadExtension,
} from '../extension.js';
import { checkForAllExtensionUpdates, updateExtension } from './update.js';
@ -128,18 +127,15 @@ describe('update tests', () => {
);
});
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir: targetExtDir,
workspaceDir: tempWorkspaceDir,
})!,
],
process.cwd(),
new ExtensionEnablementManager(),
)[0];
const extensionEnablementManager = new ExtensionEnablementManager();
const extension = loadExtension({
extensionDir: targetExtDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager,
})!;
const updateInfo = await updateExtension(
extension,
extensionEnablementManager,
tempHomeDir,
async (_) => true,
ExtensionUpdateState.UPDATE_AVAILABLE,
@ -185,18 +181,15 @@ describe('update tests', () => {
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const dispatch = vi.fn();
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
process.cwd(),
new ExtensionEnablementManager(),
)[0];
const extensionEnablementManager = new ExtensionEnablementManager();
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager,
})!;
await updateExtension(
extension,
extensionEnablementManager,
tempHomeDir,
async (_) => true,
ExtensionUpdateState.UPDATE_AVAILABLE,
@ -235,19 +228,16 @@ describe('update tests', () => {
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const dispatch = vi.fn();
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
process.cwd(),
new ExtensionEnablementManager(),
)[0];
const extensionEnablementManager = new ExtensionEnablementManager();
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager,
})!;
await expect(
updateExtension(
extension,
extensionEnablementManager,
tempHomeDir,
async (_) => true,
ExtensionUpdateState.UPDATE_AVAILABLE,
@ -283,16 +273,12 @@ describe('update tests', () => {
type: 'git',
},
});
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
process.cwd(),
new ExtensionEnablementManager(),
)[0];
const extensionEnablementManager = new ExtensionEnablementManager();
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager,
})!;
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
@ -303,6 +289,7 @@ describe('update tests', () => {
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
[extension],
extensionEnablementManager,
dispatch,
tempWorkspaceDir,
);
@ -325,16 +312,12 @@ describe('update tests', () => {
type: 'git',
},
});
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
process.cwd(),
new ExtensionEnablementManager(),
)[0];
const extensionEnablementManager = new ExtensionEnablementManager();
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager,
})!;
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
@ -345,6 +328,7 @@ describe('update tests', () => {
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
[extension],
extensionEnablementManager,
dispatch,
tempWorkspaceDir,
);
@ -371,19 +355,16 @@ describe('update tests', () => {
version: '1.0.0',
installMetadata: { source: sourceExtensionDir, type: 'local' },
});
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir: installedExtensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
process.cwd(),
new ExtensionEnablementManager(),
)[0];
const extensionEnablementManager = new ExtensionEnablementManager();
const extension = loadExtension({
extensionDir: installedExtensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager,
})!;
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
[extension],
extensionEnablementManager,
dispatch,
tempWorkspaceDir,
);
@ -410,19 +391,16 @@ describe('update tests', () => {
version: '1.0.0',
installMetadata: { source: sourceExtensionDir, type: 'local' },
});
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir: installedExtensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
process.cwd(),
new ExtensionEnablementManager(),
)[0];
const extensionEnablementManager = new ExtensionEnablementManager();
const extension = loadExtension({
extensionDir: installedExtensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager,
})!;
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
[extension],
extensionEnablementManager,
dispatch,
tempWorkspaceDir,
);
@ -445,22 +423,19 @@ describe('update tests', () => {
type: 'git',
},
});
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
process.cwd(),
new ExtensionEnablementManager(),
)[0];
const extensionEnablementManager = new ExtensionEnablementManager();
const extension = loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
extensionEnablementManager,
})!;
mockGit.getRemotes.mockRejectedValue(new Error('Git error'));
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
[extension],
extensionEnablementManager,
dispatch,
tempWorkspaceDir,
);

View file

@ -21,6 +21,7 @@ import { checkForExtensionUpdate } from './github.js';
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
import * as fs from 'node:fs';
import { getErrorMessage } from '../../utils/errors.js';
import { type ExtensionEnablementManager } from './extensionEnablement.js';
export interface ExtensionUpdateInfo {
name: string;
@ -30,6 +31,7 @@ export interface ExtensionUpdateInfo {
export async function updateExtension(
extension: GeminiCLIExtension,
extensionEnablementManager: ExtensionEnablementManager,
cwd: string = process.cwd(),
requestConsent: (consent: string) => Promise<boolean>,
currentState: ExtensionUpdateState,
@ -67,6 +69,7 @@ export async function updateExtension(
const previousExtensionConfig = await loadExtensionConfig({
extensionDir: extension.path,
workspaceDir: cwd,
extensionEnablementManager,
});
await installOrUpdateExtension(
installMetadata,
@ -79,6 +82,7 @@ export async function updateExtension(
const updatedExtension = loadExtension({
extensionDir: updatedExtensionStorage.getExtensionDir(),
workspaceDir: cwd,
extensionEnablementManager,
});
if (!updatedExtension) {
dispatchExtensionStateUpdate({
@ -120,6 +124,7 @@ export async function updateAllUpdatableExtensions(
requestConsent: (consent: string) => Promise<boolean>,
extensions: GeminiCLIExtension[],
extensionsState: Map<string, ExtensionUpdateStatus>,
extensionEnablementManager: ExtensionEnablementManager,
dispatch: (action: ExtensionUpdateAction) => void,
): Promise<ExtensionUpdateInfo[]> {
return (
@ -133,6 +138,7 @@ export async function updateAllUpdatableExtensions(
.map((extension) =>
updateExtension(
extension,
extensionEnablementManager,
cwd,
requestConsent,
extensionsState.get(extension.name)!.status,
@ -150,6 +156,7 @@ export interface ExtensionUpdateCheckResult {
export async function checkForAllExtensionUpdates(
extensions: GeminiCLIExtension[],
extensionEnablementManager: ExtensionEnablementManager,
dispatch: (action: ExtensionUpdateAction) => void,
cwd: string = process.cwd(),
): Promise<void> {
@ -174,11 +181,12 @@ export async function checkForAllExtensionUpdates(
},
});
promises.push(
checkForExtensionUpdate(extension, cwd).then((state) =>
dispatch({
type: 'SET_STATE',
payload: { name: extension.name, state },
}),
checkForExtensionUpdate(extension, extensionEnablementManager, cwd).then(
(state) =>
dispatch({
type: 'SET_STATE',
payload: { name: extension.name, state },
}),
),
);
}

View file

@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { ExtensionEnablementManager } from './extensionEnablement.js';
export interface VariableDefinition {
type: 'string';
description: string;
@ -18,6 +20,7 @@ export interface VariableSchema {
export interface LoadExtensionContext {
extensionDir: string;
workspaceDir: string;
extensionEnablementManager: ExtensionEnablementManager;
}
const PATH_SEPARATOR_DEFINITION = {

View file

@ -50,7 +50,7 @@ import {
import * as fs from 'node:fs'; // fs will be mocked separately
import stripJsonComments from 'strip-json-comments'; // Will be mocked separately
import { isWorkspaceTrusted } from './trustedFolders.js';
import { disableExtension } from './extension.js';
import { disableExtension, ExtensionStorage } from './extension.js';
// These imports will get the versions from the vi.mock('./settings.js', ...) factory.
import {
@ -65,7 +65,8 @@ import {
migrateDeprecatedSettings,
SettingScope,
} from './settings.js';
import { FatalConfigError, GEMINI_DIR } from '@google/gemini-cli-core';
import { FatalConfigError, GEMINI_DIR, Storage } from '@google/gemini-cli-core';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
const MOCK_WORKSPACE_DIR = '/mock/workspace';
// Use the (mocked) GEMINI_DIR for consistency
@ -93,9 +94,7 @@ vi.mock('fs', async (importOriginal) => {
};
});
vi.mock('./extension.js', () => ({
disableExtension: vi.fn(),
}));
vi.mock('./extension.js');
vi.mock('strip-json-comments', () => ({
default: vi.fn((content) => content),
@ -2349,7 +2348,9 @@ describe('Settings Loading and Merging', () => {
mockFsExistsSync = vi.mocked(fs.existsSync);
mockFsReadFileSync = vi.mocked(fs.readFileSync);
mockDisableExtension = vi.mocked(disableExtension);
vi.mocked(ExtensionStorage.getUserExtensionsDir).mockReturnValue(
new Storage(osActual.homedir()).getExtensionsDir(),
);
(mockFsExistsSync as Mock).mockReturnValue(true);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
@ -2392,11 +2393,13 @@ describe('Settings Loading and Merging', () => {
expect(mockDisableExtension).toHaveBeenCalledWith(
'user-ext-1',
SettingScope.User,
expect.any(ExtensionEnablementManager),
MOCK_WORKSPACE_DIR,
);
expect(mockDisableExtension).toHaveBeenCalledWith(
'shared-ext',
SettingScope.User,
expect.any(ExtensionEnablementManager),
MOCK_WORKSPACE_DIR,
);
@ -2404,11 +2407,13 @@ describe('Settings Loading and Merging', () => {
expect(mockDisableExtension).toHaveBeenCalledWith(
'workspace-ext-1',
SettingScope.Workspace,
expect.any(ExtensionEnablementManager),
MOCK_WORKSPACE_DIR,
);
expect(mockDisableExtension).toHaveBeenCalledWith(
'shared-ext',
SettingScope.Workspace,
expect.any(ExtensionEnablementManager),
MOCK_WORKSPACE_DIR,
);

View file

@ -31,6 +31,7 @@ import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
import { disableExtension } from './extension.js';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
let current: SettingDefinition | undefined = undefined;
@ -755,8 +756,14 @@ export function migrateDeprecatedSettings(
console.log(
`Migrating deprecated extensions.disabled settings from ${scope} settings...`,
);
const extensionEnablementManager = new ExtensionEnablementManager();
for (const extension of settings.extensions.disabled ?? []) {
disableExtension(extension, scope, workspaceDir);
disableExtension(
extension,
scope,
extensionEnablementManager,
workspaceDir,
);
}
const newExtensionsValue = { ...settings.extensions };

View file

@ -279,7 +279,7 @@ export async function main() {
? getNodeMemoryArgs(isDebugMode)
: [];
const sandboxConfig = await loadSandboxConfig(settings.merged, argv);
// We intentially omit the list of extensions here because extensions
// We intentionally omit the list of extensions here because extensions
// should not impact auth or setting up the sandbox.
// TODO(jacobr): refactor loadCliConfig so there is a minimal version
// that only initializes enough config to enable refreshAuth or find
@ -289,7 +289,6 @@ export async function main() {
const partialConfig = await loadCliConfig(
settings.merged,
[],
new ExtensionEnablementManager(),
sessionId,
argv,
);
@ -367,7 +366,6 @@ export async function main() {
const config = await loadCliConfig(
settings.merged,
extensions,
extensionEnablementManager,
sessionId,
argv,
);

View file

@ -90,6 +90,7 @@ import { useSessionStats } from './contexts/SessionContext.js';
import { useGitBranchName } from './hooks/useGitBranchName.js';
import { useExtensionUpdates } from './hooks/useExtensionUpdates.js';
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { ExtensionEnablementManager } from '../config/extensions/extensionEnablement.js';
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
@ -159,6 +160,9 @@ export const AppContainer = (props: AppContainerProps) => {
);
const extensions = config.getExtensions();
const [extensionEnablementManager] = useState<ExtensionEnablementManager>(
new ExtensionEnablementManager(config.getEnabledExtensions()),
);
const {
extensionsUpdateState,
extensionsUpdateStateInternal,
@ -167,6 +171,7 @@ export const AppContainer = (props: AppContainerProps) => {
addConfirmUpdateExtensionRequest,
} = useExtensionUpdates(
extensions,
extensionEnablementManager,
historyManager.addItem,
config.getWorkingDir(),
);
@ -529,7 +534,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
config.getDebugMode(),
config.getFileService(),
settings.merged,
config.getExtensionContextFilePaths(),
config.getExtensions(),
config.isTrustedFolder(),
settings.merged.context?.importFormat || 'tree', // Use setting or default to 'tree'
config.getFileFilteringOptions(),

View file

@ -44,7 +44,6 @@ describe('directoryCommand', () => {
shouldLoadMemoryFromIncludeDirectories: () => false,
getDebugMode: () => false,
getFileService: () => ({}),
getExtensionContextFilePaths: () => [],
getFileFilteringOptions: () => ({ ignore: [], include: [] }),
setUserMemory: vi.fn(),
setGeminiMdFileCount: vi.fn(),

View file

@ -103,7 +103,7 @@ export const directoryCommand: SlashCommand = {
],
config.getDebugMode(),
config.getFileService(),
config.getExtensionContextFilePaths(),
config.getExtensions(),
config.getFolderTrust(),
context.services.settings.merged.context?.importFormat ||
'tree', // Use setting or default to 'tree'

View file

@ -176,7 +176,7 @@ describe('memoryCommand', () => {
getWorkingDir: () => '/test/dir',
getDebugMode: () => false,
getFileService: () => ({}) as FileDiscoveryService,
getExtensionContextFilePaths: () => [],
getExtensions: () => [],
shouldLoadMemoryFromIncludeDirectories: () => false,
getWorkspaceContext: () => ({
getDirectories: () => [],

View file

@ -91,7 +91,7 @@ export const memoryCommand: SlashCommand = {
config.getDebugMode(),
config.getFileService(),
settings.merged,
config.getExtensionContextFilePaths(),
config.getExtensions(),
config.isTrustedFolder(),
settings.merged.context?.importFormat || 'tree',
config.getFileFilteringOptions(),

View file

@ -29,7 +29,6 @@ describe('<ExtensionsList />', () => {
const mockUIState = (
extensions: unknown[],
extensionsUpdateState: Map<string, ExtensionUpdateState>,
disabledExtensions: string[] = [],
) => {
mockUseUIState.mockReturnValue({
commandContext: createMockCommandContext({
@ -37,13 +36,6 @@ describe('<ExtensionsList />', () => {
config: {
getExtensions: () => extensions,
},
settings: {
merged: {
extensions: {
disabled: disabledExtensions,
},
},
},
},
}),
extensionsUpdateState,
@ -58,7 +50,7 @@ describe('<ExtensionsList />', () => {
});
it('should render a list of extensions with their version and status', () => {
mockUIState(mockExtensions, new Map(), ['ext-disabled']);
mockUIState(mockExtensions, new Map());
const { lastFrame } = render(<ExtensionsList />);
const output = lastFrame();
expect(output).toContain('ext-one (v1.0.0) - active');

View file

@ -11,8 +11,6 @@ import { ExtensionUpdateState } from '../../state/extensions.js';
export const ExtensionsList = () => {
const { commandContext, extensionsUpdateState } = useUIState();
const allExtensions = commandContext.services.config!.getExtensions();
const settings = commandContext.services.settings;
const disabledExtensions = settings.merged.extensions?.disabled ?? [];
if (allExtensions.length === 0) {
return <Text>No extensions installed.</Text>;
@ -24,8 +22,9 @@ export const ExtensionsList = () => {
<Box flexDirection="column" paddingLeft={2}>
{allExtensions.map((ext) => {
const state = extensionsUpdateState.get(ext.name);
const isActive = !disabledExtensions.includes(ext.name);
const isActive = ext.isActive;
const activeString = isActive ? 'active' : 'disabled';
const activeColor = isActive ? 'green' : 'grey';
let stateColor = 'gray';
const stateText = state || 'unknown state';
@ -55,7 +54,7 @@ export const ExtensionsList = () => {
<Box key={ext.name}>
<Text>
<Text color="cyan">{`${ext.name} (v${ext.version})`}</Text>
{` - ${activeString}`}
<Text color={activeColor}>{` - ${activeString}`}</Text>
{<Text color={stateColor}>{` (${stateText})`}</Text>}
</Text>
</Box>

View file

@ -115,8 +115,8 @@ export const McpStatus: React.FC<McpStatusProps> = ({
}
let serverDisplayName = serverName;
if (server.extensionName) {
serverDisplayName += ` (from ${server.extensionName})`;
if (server.extension?.name) {
serverDisplayName += ` (from ${server.extension?.name})`;
}
const toolCount = serverTools.length;

View file

@ -8,21 +8,18 @@ import { vi } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
annotateActiveExtensions,
loadExtension,
} from '../../config/extension.js';
import { loadExtension } from '../../config/extension.js';
import { createExtension } from '../../test-utils/createExtension.js';
import { useExtensionUpdates } from './useExtensionUpdates.js';
import { GEMINI_DIR, type GeminiCLIExtension } from '@google/gemini-cli-core';
import { renderHook, waitFor } from '@testing-library/react';
import { MessageType } from '../types.js';
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
import {
checkForAllExtensionUpdates,
updateExtension,
} from '../../config/extensions/update.js';
import { ExtensionUpdateState } from '../state/extensions.js';
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
vi.mock('os', async (importOriginal) => {
const mockedOs = await importOriginal<typeof os>();
@ -76,7 +73,7 @@ describe('useExtensionUpdates', () => {
const cwd = '/test/cwd';
vi.mocked(checkForAllExtensionUpdates).mockImplementation(
async (_extensions, dispatch, _cwd) => {
async (_extensions, _extensionEnablementManager, dispatch, _cwd) => {
dispatch({
type: 'SET_STATE',
payload: {
@ -88,7 +85,12 @@ describe('useExtensionUpdates', () => {
);
renderHook(() =>
useExtensionUpdates(extensions as GeminiCLIExtension[], addItem, cwd),
useExtensionUpdates(
extensions as GeminiCLIExtension[],
new ExtensionEnablementManager(),
addItem,
cwd,
),
);
await waitFor(() => {
@ -113,16 +115,17 @@ describe('useExtensionUpdates', () => {
autoUpdate: true,
},
});
const extension = annotateActiveExtensions(
[loadExtension({ extensionDir, workspaceDir: tempHomeDir })!],
tempHomeDir,
new ExtensionEnablementManager(),
)[0];
const extensionEnablementManager = new ExtensionEnablementManager();
const extension = loadExtension({
extensionDir,
workspaceDir: tempHomeDir,
extensionEnablementManager,
})!;
const addItem = vi.fn();
vi.mocked(checkForAllExtensionUpdates).mockImplementation(
async (_extensions, dispatch, _cwd) => {
async (_extensions, _extensionEnablementManager, dispatch, _cwd) => {
dispatch({
type: 'SET_STATE',
payload: {
@ -139,7 +142,14 @@ describe('useExtensionUpdates', () => {
name: '',
});
renderHook(() => useExtensionUpdates([extension], addItem, tempHomeDir));
renderHook(() =>
useExtensionUpdates(
[extension],
extensionEnablementManager,
addItem,
tempHomeDir,
),
);
await waitFor(
() => {
@ -177,25 +187,24 @@ describe('useExtensionUpdates', () => {
},
});
const extensions = annotateActiveExtensions(
[
loadExtension({
extensionDir: extensionDir1,
workspaceDir: tempHomeDir,
})!,
loadExtension({
extensionDir: extensionDir2,
workspaceDir: tempHomeDir,
})!,
],
tempHomeDir,
new ExtensionEnablementManager(),
);
const extensionEnablementManager = new ExtensionEnablementManager();
const extensions = [
loadExtension({
extensionDir: extensionDir1,
workspaceDir: tempHomeDir,
extensionEnablementManager,
})!,
loadExtension({
extensionDir: extensionDir2,
workspaceDir: tempHomeDir,
extensionEnablementManager,
})!,
];
const addItem = vi.fn();
vi.mocked(checkForAllExtensionUpdates).mockImplementation(
async (_extensions, dispatch, _cwd) => {
async (_extensions, _extensionEnablementManager, dispatch, _cwd) => {
dispatch({
type: 'SET_STATE',
payload: {
@ -225,7 +234,14 @@ describe('useExtensionUpdates', () => {
name: '',
});
renderHook(() => useExtensionUpdates(extensions, addItem, tempHomeDir));
renderHook(() =>
useExtensionUpdates(
extensions,
extensionEnablementManager,
addItem,
tempHomeDir,
),
);
await waitFor(
() => {
@ -282,7 +298,7 @@ describe('useExtensionUpdates', () => {
const cwd = '/test/cwd';
vi.mocked(checkForAllExtensionUpdates).mockImplementation(
async (_extensions, dispatch, _cwd) => {
async (_extensions, _extensionEnablementManager, dispatch, _cwd) => {
dispatch({ type: 'BATCH_CHECK_START' });
dispatch({
type: 'SET_STATE',
@ -303,8 +319,14 @@ describe('useExtensionUpdates', () => {
},
);
const extensionEnablementManager = new ExtensionEnablementManager();
renderHook(() =>
useExtensionUpdates(extensions as GeminiCLIExtension[], addItem, cwd),
useExtensionUpdates(
extensions as GeminiCLIExtension[],
extensionEnablementManager,
addItem,
cwd,
),
);
await waitFor(() => {

View file

@ -23,6 +23,7 @@ import {
type ExtensionUpdateInfo,
} from '../../config/extension.js';
import { checkExhaustive } from '../../utils/checks.js';
import type { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
type ConfirmationRequestWrapper = {
prompt: React.ReactNode;
@ -49,6 +50,7 @@ function confirmationRequestsReducer(
export const useExtensionUpdates = (
extensions: GeminiCLIExtension[],
extensionEnablementManager: ExtensionEnablementManager,
addItem: UseHistoryManagerReturn['addItem'],
cwd: string,
) => {
@ -93,11 +95,13 @@ export const useExtensionUpdates = (
if (extensionsToCheck.length === 0) return;
checkForAllExtensionUpdates(
extensionsToCheck,
extensionEnablementManager,
dispatchExtensionStateUpdate,
cwd,
);
}, [
extensions,
extensionEnablementManager,
extensionsUpdateState.extensionStatuses,
cwd,
dispatchExtensionStateUpdate,
@ -154,6 +158,7 @@ export const useExtensionUpdates = (
} else {
const updatePromise = updateExtension(
extension,
extensionEnablementManager,
cwd,
(description) =>
requestConsentInteractive(
@ -210,6 +215,7 @@ export const useExtensionUpdates = (
}
}, [
extensions,
extensionEnablementManager,
extensionsUpdateState,
addConfirmUpdateExtensionRequest,
addItem,

View file

@ -44,7 +44,6 @@ import { z } from 'zod';
import { randomUUID } from 'node:crypto';
import type { CliArgs } from '../config/config.js';
import { loadCliConfig } from '../config/config.js';
import { ExtensionEnablementManager } from '../config/extensions/extensionEnablement.js';
/**
* Resolves the model to use based on the current configuration.
@ -206,7 +205,6 @@ class GeminiAgent {
const config = await loadCliConfig(
settings,
this.extensions,
new ExtensionEnablementManager(this.argv.extensions),
sessionId,
this.argv,
cwd,

View file

@ -189,7 +189,7 @@ export class MCPServerConfig {
readonly description?: string,
readonly includeTools?: string[],
readonly excludeTools?: string[],
readonly extensionName?: string,
readonly extension?: GeminiCLIExtension,
// OAuth configuration
readonly oauth?: MCPOAuthConfig,
readonly authProviderType?: AuthProviderType,
@ -249,11 +249,11 @@ export interface ConfigParameters {
includeDirectories?: string[];
bugCommand?: BugCommandSettings;
model: string;
extensionContextFilePaths?: string[];
maxSessionTurns?: number;
experimentalZedIntegration?: boolean;
listExtensions?: boolean;
extensions?: GeminiCLIExtension[];
enabledExtensions?: string[];
blockedMcpServers?: Array<{ name: string; extensionName: string }>;
noBrowser?: boolean;
summarizeToolOutput?: Record<string, SummarizeToolOutputSettings>;
@ -332,7 +332,6 @@ export class Config {
private readonly cwd: string;
private readonly bugCommand: BugCommandSettings | undefined;
private model: string;
private readonly extensionContextFilePaths: string[];
private readonly noBrowser: boolean;
private readonly folderTrust: boolean;
private ideMode: boolean;
@ -341,6 +340,7 @@ export class Config {
private readonly maxSessionTurns: number;
private readonly listExtensions: boolean;
private readonly _extensions: GeminiCLIExtension[];
private readonly _enabledExtensions: string[];
private readonly _blockedMcpServers: Array<{
name: string;
extensionName: string;
@ -436,12 +436,12 @@ export class Config {
this.fileDiscoveryService = params.fileDiscoveryService ?? null;
this.bugCommand = params.bugCommand;
this.model = params.model;
this.extensionContextFilePaths = params.extensionContextFilePaths ?? [];
this.maxSessionTurns = params.maxSessionTurns ?? -1;
this.experimentalZedIntegration =
params.experimentalZedIntegration ?? false;
this.listExtensions = params.listExtensions ?? false;
this._extensions = params.extensions ?? [];
this._enabledExtensions = params.enabledExtensions ?? [];
this._blockedMcpServers = params.blockedMcpServers ?? [];
this.noBrowser = params.noBrowser ?? false;
this.summarizeToolOutput = params.summarizeToolOutput;
@ -542,7 +542,7 @@ export class Config {
async refreshAuth(authMethod: AuthType) {
// Vertex and Genai have incompatible encryption and sending history with
// throughtSignature from Genai to Vertex will fail, we need to strip them
// thoughtSignature from Genai to Vertex will fail, we need to strip them
if (
this.contentGeneratorConfig?.authType === AuthType.USE_GEMINI &&
authMethod === AuthType.LOGIN_WITH_GOOGLE
@ -869,10 +869,6 @@ export class Config {
return this.usageStatisticsEnabled;
}
getExtensionContextFilePaths(): string[] {
return this.extensionContextFilePaths;
}
getExperimentalZedIntegration(): boolean {
return this.experimentalZedIntegration;
}
@ -889,6 +885,12 @@ export class Config {
return this._extensions;
}
// The list of explicitly enabled extensions, if any were given, may contain
// the string "none".
getEnabledExtensions(): string[] {
return this._enabledExtensions;
}
getBlockedMcpServers(): Array<{ name: string; extensionName: string }> {
return this._blockedMcpServers;
}

View file

@ -8,8 +8,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { McpClientManager } from './mcp-client-manager.js';
import { McpClient } from './mcp-client.js';
import type { ToolRegistry } from './tool-registry.js';
import type { PromptRegistry } from '../prompts/prompt-registry.js';
import type { WorkspaceContext } from '../utils/workspaceContext.js';
import type { Config } from '../config/config.js';
vi.mock('./mcp-client.js', async () => {
@ -38,18 +36,16 @@ describe('McpClientManager', () => {
vi.mocked(McpClient).mockReturnValue(
mockedMcpClient as unknown as McpClient,
);
const manager = new McpClientManager(
{
'test-server': {},
},
'',
{} as ToolRegistry,
{} as PromptRegistry,
false,
{} as WorkspaceContext,
);
const manager = new McpClientManager({} as ToolRegistry);
await manager.discoverAllMcpTools({
isTrustedFolder: () => true,
getMcpServers: () => ({
'test-server': {},
}),
getMcpServerCommand: () => '',
getPromptRegistry: () => {},
getDebugMode: () => false,
getWorkspaceContext: () => {},
} as unknown as Config);
expect(mockedMcpClient.connect).toHaveBeenCalledOnce();
expect(mockedMcpClient.discover).toHaveBeenCalledOnce();
@ -65,18 +61,16 @@ describe('McpClientManager', () => {
vi.mocked(McpClient).mockReturnValue(
mockedMcpClient as unknown as McpClient,
);
const manager = new McpClientManager(
{
'test-server': {},
},
'',
{} as ToolRegistry,
{} as PromptRegistry,
false,
{} as WorkspaceContext,
);
const manager = new McpClientManager({} as ToolRegistry);
await manager.discoverAllMcpTools({
isTrustedFolder: () => false,
getMcpServers: () => ({
'test-server': {},
}),
getMcpServerCommand: () => '',
getPromptRegistry: () => {},
getDebugMode: () => false,
getWorkspaceContext: () => {},
} as unknown as Config);
expect(mockedMcpClient.connect).not.toHaveBeenCalled();
expect(mockedMcpClient.discover).not.toHaveBeenCalled();

View file

@ -4,9 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config, MCPServerConfig } from '../config/config.js';
import type { Config } from '../config/config.js';
import type { ToolRegistry } from './tool-registry.js';
import type { PromptRegistry } from '../prompts/prompt-registry.js';
import {
McpClient,
MCPDiscoveryState,
@ -14,7 +13,6 @@ import {
} from './mcp-client.js';
import { getErrorMessage } from '../utils/errors.js';
import type { EventEmitter } from 'node:events';
import type { WorkspaceContext } from '../utils/workspaceContext.js';
/**
* Manages the lifecycle of multiple MCP clients, including local child processes.
@ -23,30 +21,12 @@ import type { WorkspaceContext } from '../utils/workspaceContext.js';
*/
export class McpClientManager {
private clients: Map<string, McpClient> = new Map();
private readonly mcpServers: Record<string, MCPServerConfig>;
private readonly mcpServerCommand: string | undefined;
private readonly toolRegistry: ToolRegistry;
private readonly promptRegistry: PromptRegistry;
private readonly debugMode: boolean;
private readonly workspaceContext: WorkspaceContext;
private discoveryState: MCPDiscoveryState = MCPDiscoveryState.NOT_STARTED;
private readonly eventEmitter?: EventEmitter;
constructor(
mcpServers: Record<string, MCPServerConfig>,
mcpServerCommand: string | undefined,
toolRegistry: ToolRegistry,
promptRegistry: PromptRegistry,
debugMode: boolean,
workspaceContext: WorkspaceContext,
eventEmitter?: EventEmitter,
) {
this.mcpServers = mcpServers;
this.mcpServerCommand = mcpServerCommand;
constructor(toolRegistry: ToolRegistry, eventEmitter?: EventEmitter) {
this.toolRegistry = toolRegistry;
this.promptRegistry = promptRegistry;
this.debugMode = debugMode;
this.workspaceContext = workspaceContext;
this.eventEmitter = eventEmitter;
}
@ -62,22 +42,23 @@ export class McpClientManager {
await this.stop();
const servers = populateMcpServerCommand(
this.mcpServers,
this.mcpServerCommand,
cliConfig.getMcpServers() || {},
cliConfig.getMcpServerCommand(),
);
this.discoveryState = MCPDiscoveryState.IN_PROGRESS;
this.eventEmitter?.emit('mcp-client-update', this.clients);
const discoveryPromises = Object.entries(servers).map(
async ([name, config]) => {
const discoveryPromises = Object.entries(servers)
.filter(([_, config]) => !config.extension || config.extension.isActive)
.map(async ([name, config]) => {
const client = new McpClient(
name,
config,
this.toolRegistry,
this.promptRegistry,
this.workspaceContext,
this.debugMode,
cliConfig.getPromptRegistry(),
cliConfig.getWorkspaceContext(),
cliConfig.getDebugMode(),
);
this.clients.set(name, client);
@ -95,8 +76,7 @@ export class McpClientManager {
)}`,
);
}
},
);
});
await Promise.all(discoveryPromises);
this.discoveryState = MCPDiscoveryState.COMPLETED;

View file

@ -174,15 +174,7 @@ export class ToolRegistry {
constructor(config: Config, eventEmitter?: EventEmitter) {
this.config = config;
this.mcpClientManager = new McpClientManager(
this.config.getMcpServers() ?? {},
this.config.getMcpServerCommand(),
this,
this.config.getPromptRegistry(),
this.config.getDebugMode(),
this.config.getWorkspaceContext(),
eventEmitter,
);
this.mcpClientManager = new McpClientManager(this, eventEmitter);
}
/**

View file

@ -15,6 +15,7 @@ import {
} from '../tools/memoryTool.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { GEMINI_DIR } from './paths.js';
import type { GeminiCLIExtension } from '../config/config.js';
vi.mock('os', async (importOriginal) => {
const actualOs = await importOriginal<typeof os>();
@ -87,7 +88,7 @@ describe('loadServerHierarchicalMemory', () => {
[],
false,
new FileDiscoveryService(projectRoot),
[],
[], // extensions
false, // untrusted
);
@ -116,7 +117,7 @@ describe('loadServerHierarchicalMemory', () => {
[],
false,
new FileDiscoveryService(projectRoot),
[],
[], // extensions
false, // untrusted
);
@ -132,7 +133,7 @@ describe('loadServerHierarchicalMemory', () => {
[],
false,
new FileDiscoveryService(projectRoot),
[],
[], // extensions
DEFAULT_FOLDER_TRUST,
);
@ -154,7 +155,7 @@ describe('loadServerHierarchicalMemory', () => {
[],
false,
new FileDiscoveryService(projectRoot),
[],
[], // extensions
DEFAULT_FOLDER_TRUST,
);
@ -181,7 +182,7 @@ default context content
[],
false,
new FileDiscoveryService(projectRoot),
[],
[], // extensions
DEFAULT_FOLDER_TRUST,
);
@ -212,7 +213,7 @@ custom context content
[],
false,
new FileDiscoveryService(projectRoot),
[],
[], // extensions
DEFAULT_FOLDER_TRUST,
);
@ -247,7 +248,7 @@ cwd context content
[],
false,
new FileDiscoveryService(projectRoot),
[],
[], // extensions
DEFAULT_FOLDER_TRUST,
);
@ -279,7 +280,7 @@ Subdir custom memory
[],
false,
new FileDiscoveryService(projectRoot),
[],
[], // extensions
DEFAULT_FOLDER_TRUST,
);
@ -311,7 +312,7 @@ Src directory memory
[],
false,
new FileDiscoveryService(projectRoot),
[],
[], // extensions
DEFAULT_FOLDER_TRUST,
);
@ -355,7 +356,7 @@ Subdir memory
[],
false,
new FileDiscoveryService(projectRoot),
[],
[], // extensions
DEFAULT_FOLDER_TRUST,
);
@ -408,7 +409,7 @@ Subdir memory
[],
false,
new FileDiscoveryService(projectRoot),
[],
[], // extensions
DEFAULT_FOLDER_TRUST,
'tree',
{
@ -444,7 +445,7 @@ My code memory
[],
true,
new FileDiscoveryService(projectRoot),
[],
[], // extensions
DEFAULT_FOLDER_TRUST,
'tree', // importFormat
{
@ -466,7 +467,7 @@ My code memory
[],
false,
new FileDiscoveryService(projectRoot),
[],
[], // extensions
DEFAULT_FOLDER_TRUST,
);
@ -488,7 +489,12 @@ My code memory
[],
false,
new FileDiscoveryService(projectRoot),
[extensionFilePath],
[
{
contextFiles: [extensionFilePath],
isActive: true,
} as GeminiCLIExtension,
], // extensions
DEFAULT_FOLDER_TRUST,
);
@ -515,7 +521,7 @@ Extension memory content
[includedDir],
false,
new FileDiscoveryService(projectRoot),
[],
[], // extensions
DEFAULT_FOLDER_TRUST,
);
@ -550,7 +556,7 @@ included directory memory
createdFiles.map((f) => path.dirname(f)),
false,
new FileDiscoveryService(projectRoot),
[],
[], // extensions
DEFAULT_FOLDER_TRUST,
);
@ -585,7 +591,7 @@ included directory memory
[childDir, parentDir], // Deliberately include duplicates
false,
new FileDiscoveryService(projectRoot),
[],
[], // extensions
DEFAULT_FOLDER_TRUST,
);

View file

@ -15,6 +15,7 @@ import { processImports } from './memoryImportProcessor.js';
import type { FileFilteringOptions } from '../config/constants.js';
import { DEFAULT_MEMORY_FILE_FILTERING_OPTIONS } from '../config/constants.js';
import { GEMINI_DIR } from './paths.js';
import type { GeminiCLIExtension } from '../config/config.js';
// Simple console logger, similar to the one previously in CLI's config.ts
// TODO: Integrate with a more robust server-side logger if available/appropriate.
@ -84,7 +85,6 @@ async function getGeminiMdFilePathsInternal(
userHomePath: string,
debugMode: boolean,
fileService: FileDiscoveryService,
extensionContextFilePaths: string[] = [],
folderTrust: boolean,
fileFilteringOptions: FileFilteringOptions,
maxDirs: number,
@ -107,7 +107,6 @@ async function getGeminiMdFilePathsInternal(
userHomePath,
debugMode,
fileService,
extensionContextFilePaths,
folderTrust,
fileFilteringOptions,
maxDirs,
@ -137,7 +136,6 @@ async function getGeminiMdFilePathsInternalForEachDir(
userHomePath: string,
debugMode: boolean,
fileService: FileDiscoveryService,
extensionContextFilePaths: string[] = [],
folderTrust: boolean,
fileFilteringOptions: FileFilteringOptions,
maxDirs: number,
@ -226,11 +224,6 @@ async function getGeminiMdFilePathsInternalForEachDir(
}
}
// Add extension context file paths.
for (const extensionPath of extensionContextFilePaths) {
allPaths.add(extensionPath);
}
const finalPaths = Array.from(allPaths);
if (debugMode)
@ -343,7 +336,7 @@ export async function loadServerHierarchicalMemory(
includeDirectoriesToReadGemini: readonly string[],
debugMode: boolean,
fileService: FileDiscoveryService,
extensionContextFilePaths: string[] = [],
extensions: GeminiCLIExtension[],
folderTrust: boolean,
importFormat: 'flat' | 'tree' = 'tree',
fileFilteringOptions?: FileFilteringOptions,
@ -363,11 +356,18 @@ export async function loadServerHierarchicalMemory(
userHomePath,
debugMode,
fileService,
extensionContextFilePaths,
folderTrust,
fileFilteringOptions || DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
maxDirs,
);
// Add extension file paths separately since they may be conditionally enabled.
filePaths.push(
...extensions
.filter((ext) => ext.isActive)
.flatMap((ext) => ext.contextFiles),
);
if (filePaths.length === 0) {
if (debugMode)
logger.debug('No GEMINI.md files found in hierarchy of the workspace.');