Fix -e <extension> for disabled extensions (#9994)

This commit is contained in:
Jacob MacDonald
2025-09-29 06:53:19 -07:00
committed by GitHub
parent d1485d4672
commit ea061f52b0
12 changed files with 1260 additions and 208 deletions
+10 -2
View File
@@ -8,6 +8,7 @@ import type { CommandModule } from 'yargs';
import { import {
loadExtensions, loadExtensions,
annotateActiveExtensions, annotateActiveExtensions,
ExtensionStorage,
requestConsentNonInteractive, requestConsentNonInteractive,
} from '../../config/extension.js'; } from '../../config/extension.js';
import { import {
@@ -19,6 +20,7 @@ import {
import { checkForExtensionUpdate } from '../../config/extensions/github.js'; import { checkForExtensionUpdate } from '../../config/extensions/github.js';
import { getErrorMessage } from '../../utils/errors.js'; import { getErrorMessage } from '../../utils/errors.js';
import { ExtensionUpdateState } from '../../ui/state/extensions.js'; import { ExtensionUpdateState } from '../../ui/state/extensions.js';
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
interface UpdateArgs { interface UpdateArgs {
name?: string; name?: string;
@@ -30,11 +32,17 @@ const updateOutput = (info: ExtensionUpdateInfo) =>
export async function handleUpdate(args: UpdateArgs) { export async function handleUpdate(args: UpdateArgs) {
const workingDir = process.cwd(); const workingDir = process.cwd();
const allExtensions = loadExtensions(); const extensionEnablementManager = new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
// Force enable named extensions, otherwise we will only update the enabled
// ones.
args.name ? [args.name] : [],
);
const allExtensions = loadExtensions(extensionEnablementManager);
const extensions = annotateActiveExtensions( const extensions = annotateActiveExtensions(
allExtensions, allExtensions,
allExtensions.map((e) => e.config.name),
workingDir, workingDir,
extensionEnablementManager,
); );
if (args.name) { if (args.name) {
try { try {
+8 -1
View File
@@ -7,7 +7,7 @@
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { listMcpServers } from './list.js'; import { listMcpServers } from './list.js';
import { loadSettings } from '../../config/settings.js'; import { loadSettings } from '../../config/settings.js';
import { loadExtensions } from '../../config/extension.js'; import { ExtensionStorage, loadExtensions } from '../../config/extension.js';
import { createTransport } from '@google/gemini-cli-core'; import { createTransport } from '@google/gemini-cli-core';
import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js';
@@ -16,6 +16,9 @@ vi.mock('../../config/settings.js', () => ({
})); }));
vi.mock('../../config/extension.js', () => ({ vi.mock('../../config/extension.js', () => ({
loadExtensions: vi.fn(), loadExtensions: vi.fn(),
ExtensionStorage: {
getUserExtensionsDir: vi.fn(),
},
})); }));
vi.mock('@google/gemini-cli-core', () => ({ vi.mock('@google/gemini-cli-core', () => ({
createTransport: vi.fn(), createTransport: vi.fn(),
@@ -34,6 +37,7 @@ vi.mock('@google/gemini-cli-core', () => ({
})); }));
vi.mock('@modelcontextprotocol/sdk/client/index.js'); vi.mock('@modelcontextprotocol/sdk/client/index.js');
const mockedExtensionStorage = ExtensionStorage as vi.Mock;
const mockedLoadSettings = loadSettings as vi.Mock; const mockedLoadSettings = loadSettings as vi.Mock;
const mockedLoadExtensions = loadExtensions as vi.Mock; const mockedLoadExtensions = loadExtensions as vi.Mock;
const mockedCreateTransport = createTransport as vi.Mock; const mockedCreateTransport = createTransport as vi.Mock;
@@ -69,6 +73,9 @@ describe('mcp list command', () => {
MockedClient.mockImplementation(() => mockClient); MockedClient.mockImplementation(() => mockClient);
mockedCreateTransport.mockResolvedValue(mockTransport); mockedCreateTransport.mockResolvedValue(mockTransport);
mockedLoadExtensions.mockReturnValue([]); mockedLoadExtensions.mockReturnValue([]);
mockedExtensionStorage.getUserExtensionsDir.mockReturnValue(
'/mocked/extensions/dir',
);
}); });
afterEach(() => { afterEach(() => {
+5 -2
View File
@@ -10,7 +10,8 @@ import { loadSettings } from '../../config/settings.js';
import type { MCPServerConfig } from '@google/gemini-cli-core'; import type { MCPServerConfig } from '@google/gemini-cli-core';
import { MCPServerStatus, createTransport } from '@google/gemini-cli-core'; import { MCPServerStatus, createTransport } from '@google/gemini-cli-core';
import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { loadExtensions } from '../../config/extension.js'; import { ExtensionStorage, loadExtensions } from '../../config/extension.js';
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
const COLOR_GREEN = '\u001b[32m'; const COLOR_GREEN = '\u001b[32m';
const COLOR_YELLOW = '\u001b[33m'; const COLOR_YELLOW = '\u001b[33m';
@@ -21,7 +22,9 @@ async function getMcpServersFromConfig(): Promise<
Record<string, MCPServerConfig> Record<string, MCPServerConfig>
> { > {
const settings = loadSettings(); const settings = loadSettings();
const extensions = loadExtensions(); const extensions = loadExtensions(
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
);
const mcpServers = { ...(settings.merged.mcpServers || {}) }; const mcpServers = { ...(settings.merged.mcpServers || {}) };
for (const extension of extensions) { for (const extension of extensions) {
Object.entries(extension.config.mcpServers || {}).forEach( Object.entries(extension.config.mcpServers || {}).forEach(
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -45,6 +45,7 @@ import { appEvents } from '../utils/events.js';
import { isWorkspaceTrusted } from './trustedFolders.js'; import { isWorkspaceTrusted } from './trustedFolders.js';
import { createPolicyEngineConfig } from './policy.js'; import { createPolicyEngineConfig } from './policy.js';
import type { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
// Simple console logger for now - replace with actual logger if available // Simple console logger for now - replace with actual logger if available
const logger = { const logger = {
@@ -408,6 +409,7 @@ export function isDebugMode(argv: CliArgs): boolean {
export async function loadCliConfig( export async function loadCliConfig(
settings: Settings, settings: Settings,
extensions: Extension[], extensions: Extension[],
extensionEnablementManager: ExtensionEnablementManager,
sessionId: string, sessionId: string,
argv: CliArgs, argv: CliArgs,
cwd: string = process.cwd(), cwd: string = process.cwd(),
@@ -423,8 +425,8 @@ export async function loadCliConfig(
const allExtensions = annotateActiveExtensions( const allExtensions = annotateActiveExtensions(
extensions, extensions,
argv.extensions || [],
cwd, cwd,
extensionEnablementManager,
); );
const activeExtensions = extensions.filter( const activeExtensions = extensions.filter(
+88 -25
View File
@@ -10,6 +10,7 @@ import * as os from 'node:os';
import * as path from 'node:path'; import * as path from 'node:path';
import { import {
EXTENSIONS_CONFIG_FILENAME, EXTENSIONS_CONFIG_FILENAME,
ExtensionStorage,
INSTALL_METADATA_FILENAME, INSTALL_METADATA_FILENAME,
annotateActiveExtensions, annotateActiveExtensions,
disableExtension, disableExtension,
@@ -152,7 +153,9 @@ describe('extension tests', () => {
version: '1.0.0', version: '1.0.0',
}); });
const extensions = loadExtensions(); const extensions = loadExtensions(
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
);
expect(extensions).toHaveLength(1); expect(extensions).toHaveLength(1);
expect(extensions[0].path).toBe(extensionDir); expect(extensions[0].path).toBe(extensionDir);
expect(extensions[0].config.name).toBe('test-extension'); expect(extensions[0].config.name).toBe('test-extension');
@@ -171,7 +174,9 @@ describe('extension tests', () => {
version: '2.0.0', version: '2.0.0',
}); });
const extensions = loadExtensions(); const extensions = loadExtensions(
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
);
expect(extensions).toHaveLength(2); expect(extensions).toHaveLength(2);
const ext1 = extensions.find((e) => e.config.name === 'ext1'); const ext1 = extensions.find((e) => e.config.name === 'ext1');
@@ -191,7 +196,9 @@ describe('extension tests', () => {
contextFileName: 'my-context-file.md', contextFileName: 'my-context-file.md',
}); });
const extensions = loadExtensions(); const extensions = loadExtensions(
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
);
expect(extensions).toHaveLength(1); expect(extensions).toHaveLength(1);
const ext1 = extensions.find((e) => e.config.name === 'ext1'); const ext1 = extensions.find((e) => e.config.name === 'ext1');
@@ -216,11 +223,14 @@ describe('extension tests', () => {
SettingScope.User, SettingScope.User,
tempWorkspaceDir, tempWorkspaceDir,
); );
const extensions = loadExtensions(); const manager = new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
);
const extensions = loadExtensions(manager);
const activeExtensions = annotateActiveExtensions( const activeExtensions = annotateActiveExtensions(
extensions, extensions,
[],
tempWorkspaceDir, tempWorkspaceDir,
manager,
).filter((e) => e.isActive); ).filter((e) => e.isActive);
expect(activeExtensions).toHaveLength(1); expect(activeExtensions).toHaveLength(1);
expect(activeExtensions[0].name).toBe('enabled-extension'); expect(activeExtensions[0].name).toBe('enabled-extension');
@@ -240,7 +250,9 @@ describe('extension tests', () => {
}, },
}); });
const extensions = loadExtensions(); const extensions = loadExtensions(
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
);
expect(extensions).toHaveLength(1); expect(extensions).toHaveLength(1);
const loadedConfig = extensions[0].config; const loadedConfig = extensions[0].config;
const expectedCwd = path.join( const expectedCwd = path.join(
@@ -269,7 +281,9 @@ describe('extension tests', () => {
); );
expect(extensionName).toEqual('my-linked-extension'); expect(extensionName).toEqual('my-linked-extension');
const extensions = loadExtensions(); const extensions = loadExtensions(
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
);
expect(extensions).toHaveLength(1); expect(extensions).toHaveLength(1);
const linkedExt = extensions[0]; const linkedExt = extensions[0];
@@ -318,7 +332,11 @@ describe('extension tests', () => {
}; };
fs.writeFileSync(configPath, JSON.stringify(extensionConfig)); fs.writeFileSync(configPath, JSON.stringify(extensionConfig));
const extensions = loadExtensions(); const extensions = loadExtensions(
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
),
);
expect(extensions).toHaveLength(1); expect(extensions).toHaveLength(1);
const extension = extensions[0]; const extension = extensions[0];
@@ -369,7 +387,9 @@ describe('extension tests', () => {
JSON.stringify(extensionConfig), JSON.stringify(extensionConfig),
); );
const extensions = loadExtensions(); const extensions = loadExtensions(
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
);
expect(extensions).toHaveLength(1); expect(extensions).toHaveLength(1);
const extension = extensions[0]; const extension = extensions[0];
@@ -397,7 +417,9 @@ describe('extension tests', () => {
const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME); const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME);
fs.writeFileSync(badConfigPath, '{ "name": "bad-ext"'); // Malformed fs.writeFileSync(badConfigPath, '{ "name": "bad-ext"'); // Malformed
const extensions = loadExtensions(); const extensions = loadExtensions(
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
);
expect(extensions).toHaveLength(1); expect(extensions).toHaveLength(1);
expect(extensions[0].config.name).toBe('good-ext'); expect(extensions[0].config.name).toBe('good-ext');
@@ -429,7 +451,9 @@ describe('extension tests', () => {
const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME); const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME);
fs.writeFileSync(badConfigPath, JSON.stringify({ version: '1.0.0' })); fs.writeFileSync(badConfigPath, JSON.stringify({ version: '1.0.0' }));
const extensions = loadExtensions(); const extensions = loadExtensions(
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
);
expect(extensions).toHaveLength(1); expect(extensions).toHaveLength(1);
expect(extensions[0].config.name).toBe('good-ext'); expect(extensions[0].config.name).toBe('good-ext');
@@ -457,7 +481,9 @@ describe('extension tests', () => {
}, },
}); });
const extensions = loadExtensions(); const extensions = loadExtensions(
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
);
expect(extensions).toHaveLength(1); expect(extensions).toHaveLength(1);
const loadedConfig = extensions[0].config; const loadedConfig = extensions[0].config;
expect(loadedConfig.mcpServers?.['test-server'].trust).toBeUndefined(); expect(loadedConfig.mcpServers?.['test-server'].trust).toBeUndefined();
@@ -508,8 +534,8 @@ describe('extension tests', () => {
it('should mark all extensions as active if no enabled extensions are provided', () => { it('should mark all extensions as active if no enabled extensions are provided', () => {
const activeExtensions = annotateActiveExtensions( const activeExtensions = annotateActiveExtensions(
extensions, extensions,
[],
'/path/to/workspace', '/path/to/workspace',
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
); );
expect(activeExtensions).toHaveLength(3); expect(activeExtensions).toHaveLength(3);
expect(activeExtensions.every((e) => e.isActive)).toBe(true); expect(activeExtensions.every((e) => e.isActive)).toBe(true);
@@ -518,8 +544,11 @@ describe('extension tests', () => {
it('should mark only the enabled extensions as active', () => { it('should mark only the enabled extensions as active', () => {
const activeExtensions = annotateActiveExtensions( const activeExtensions = annotateActiveExtensions(
extensions, extensions,
['ext1', 'ext3'],
'/path/to/workspace', '/path/to/workspace',
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
['ext1', 'ext3'],
),
); );
expect(activeExtensions).toHaveLength(3); expect(activeExtensions).toHaveLength(3);
expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe( expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe(
@@ -536,8 +565,11 @@ describe('extension tests', () => {
it('should mark all extensions as inactive when "none" is provided', () => { it('should mark all extensions as inactive when "none" is provided', () => {
const activeExtensions = annotateActiveExtensions( const activeExtensions = annotateActiveExtensions(
extensions, extensions,
['none'],
'/path/to/workspace', '/path/to/workspace',
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
['none'],
),
); );
expect(activeExtensions).toHaveLength(3); expect(activeExtensions).toHaveLength(3);
expect(activeExtensions.every((e) => !e.isActive)).toBe(true); expect(activeExtensions.every((e) => !e.isActive)).toBe(true);
@@ -546,8 +578,11 @@ describe('extension tests', () => {
it('should handle case-insensitivity', () => { it('should handle case-insensitivity', () => {
const activeExtensions = annotateActiveExtensions( const activeExtensions = annotateActiveExtensions(
extensions, extensions,
['EXT1'],
'/path/to/workspace', '/path/to/workspace',
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
['EXT1'],
),
); );
expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe( expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe(
true, true,
@@ -558,7 +593,14 @@ describe('extension tests', () => {
const consoleSpy = vi const consoleSpy = vi
.spyOn(console, 'error') .spyOn(console, 'error')
.mockImplementation(() => {}); .mockImplementation(() => {});
annotateActiveExtensions(extensions, ['ext4'], '/path/to/workspace'); annotateActiveExtensions(
extensions,
'/path/to/workspace',
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
['ext4'],
),
);
expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4'); expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4');
consoleSpy.mockRestore(); consoleSpy.mockRestore();
}); });
@@ -567,8 +609,10 @@ describe('extension tests', () => {
it('should be false if autoUpdate is not set in install metadata', () => { it('should be false if autoUpdate is not set in install metadata', () => {
const activeExtensions = annotateActiveExtensions( const activeExtensions = annotateActiveExtensions(
extensions, extensions,
[],
tempHomeDir, tempHomeDir,
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
),
); );
expect( expect(
activeExtensions.every( activeExtensions.every(
@@ -587,8 +631,10 @@ describe('extension tests', () => {
})); }));
const activeExtensions = annotateActiveExtensions( const activeExtensions = annotateActiveExtensions(
extensionsWithAutoUpdate, extensionsWithAutoUpdate,
[],
tempHomeDir, tempHomeDir,
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
),
); );
expect( expect(
activeExtensions.every((e) => e.installMetadata?.autoUpdate === true), activeExtensions.every((e) => e.installMetadata?.autoUpdate === true),
@@ -625,8 +671,10 @@ describe('extension tests', () => {
]; ];
const activeExtensions = annotateActiveExtensions( const activeExtensions = annotateActiveExtensions(
extensionsWithAutoUpdate, extensionsWithAutoUpdate,
[],
tempHomeDir, tempHomeDir,
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
),
); );
expect( expect(
activeExtensions.find((e) => e.name === 'ext1')?.installMetadata activeExtensions.find((e) => e.name === 'ext1')?.installMetadata
@@ -1015,7 +1063,13 @@ This extension will run the following MCP servers:
await uninstallExtension('my-local-extension'); await uninstallExtension('my-local-extension');
expect(fs.existsSync(sourceExtDir)).toBe(false); expect(fs.existsSync(sourceExtDir)).toBe(false);
expect(loadExtensions()).toHaveLength(1); expect(
loadExtensions(
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
),
),
).toHaveLength(1);
expect(fs.existsSync(otherExtDir)).toBe(true); expect(fs.existsSync(otherExtDir)).toBe(true);
}); });
@@ -1154,7 +1208,11 @@ This extension will run the following MCP servers:
], ],
async (_) => true, async (_) => true,
); );
const extensions = loadExtensions(); const extensions = loadExtensions(
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
),
);
expect(extensions).toEqual([]); expect(extensions).toEqual([]);
}); });
@@ -1194,7 +1252,9 @@ This extension will run the following MCP servers:
'extensions', 'extensions',
); );
const userExt1Path = path.join(userExtensionsDir, 'ext1'); const userExt1Path = path.join(userExtensionsDir, 'ext1');
const extensions = loadExtensions(); const extensions = loadExtensions(
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
);
expect(extensions).toHaveLength(2); expect(extensions).toHaveLength(2);
const metadataPath = path.join(userExt1Path, INSTALL_METADATA_FILENAME); const metadataPath = path.join(userExt1Path, INSTALL_METADATA_FILENAME);
@@ -1326,11 +1386,14 @@ This extension will run the following MCP servers:
}); });
const getActiveExtensions = (): GeminiCLIExtension[] => { const getActiveExtensions = (): GeminiCLIExtension[] => {
const extensions = loadExtensions(); const manager = new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
);
const extensions = loadExtensions(manager);
const activeExtensions = annotateActiveExtensions( const activeExtensions = annotateActiveExtensions(
extensions, extensions,
[],
tempWorkspaceDir, tempWorkspaceDir,
manager,
); );
return activeExtensions.filter((e) => e.isActive); return activeExtensions.filter((e) => e.isActive);
}; };
+13 -60
View File
@@ -146,6 +146,7 @@ function getTelemetryConfig(cwd: string) {
} }
export function loadExtensions( export function loadExtensions(
extensionEnablementManager: ExtensionEnablementManager,
workspaceDir: string = process.cwd(), workspaceDir: string = process.cwd(),
): Extension[] { ): Extension[] {
const settings = loadSettings(workspaceDir).merged; const settings = loadSettings(workspaceDir).merged;
@@ -160,14 +161,11 @@ export function loadExtensions(
} }
const uniqueExtensions = new Map<string, Extension>(); const uniqueExtensions = new Map<string, Extension>();
const manager = new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
);
for (const extension of allExtensions) { for (const extension of allExtensions) {
if ( if (
!uniqueExtensions.has(extension.config.name) && !uniqueExtensions.has(extension.config.name) &&
manager.isEnabled(extension.config.name, workspaceDir) extensionEnablementManager.isEnabled(extension.config.name, workspaceDir)
) { ) {
uniqueExtensions.set(extension.config.name, extension); uniqueExtensions.set(extension.config.name, extension);
} }
@@ -323,64 +321,17 @@ function getContextFileNames(config: ExtensionConfig): string[] {
*/ */
export function annotateActiveExtensions( export function annotateActiveExtensions(
extensions: Extension[], extensions: Extension[],
enabledExtensionNames: string[],
workspaceDir: string, workspaceDir: string,
manager: ExtensionEnablementManager,
): GeminiCLIExtension[] { ): GeminiCLIExtension[] {
const manager = new ExtensionEnablementManager( manager.validateExtensionOverrides(extensions);
ExtensionStorage.getUserExtensionsDir(), return extensions.map((extension) => ({
); name: extension.config.name,
const annotatedExtensions: GeminiCLIExtension[] = []; version: extension.config.version,
if (enabledExtensionNames.length === 0) { isActive: manager.isEnabled(extension.config.name, workspaceDir),
return extensions.map((extension) => ({ path: extension.path,
name: extension.config.name, installMetadata: extension.installMetadata,
version: extension.config.version, }));
isActive: manager.isEnabled(extension.config.name, workspaceDir),
path: extension.path,
installMetadata: extension.installMetadata,
}));
}
const lowerCaseEnabledExtensions = new Set(
enabledExtensionNames.map((e) => e.trim().toLowerCase()),
);
if (
lowerCaseEnabledExtensions.size === 1 &&
lowerCaseEnabledExtensions.has('none')
) {
return extensions.map((extension) => ({
name: extension.config.name,
version: extension.config.version,
isActive: false,
path: extension.path,
installMetadata: extension.installMetadata,
}));
}
const notFoundNames = new Set(lowerCaseEnabledExtensions);
for (const extension of extensions) {
const lowerCaseName = extension.config.name.toLowerCase();
const isActive = lowerCaseEnabledExtensions.has(lowerCaseName);
if (isActive) {
notFoundNames.delete(lowerCaseName);
}
annotatedExtensions.push({
name: extension.config.name,
version: extension.config.version,
isActive,
path: extension.path,
installMetadata: extension.installMetadata,
});
}
for (const requestedName of notFoundNames) {
console.error(`Extension not found: ${requestedName}`);
}
return annotatedExtensions;
} }
/** /**
@@ -711,6 +662,7 @@ export async function uninstallExtension(
} }
const manager = new ExtensionEnablementManager( const manager = new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(), ExtensionStorage.getUserExtensionsDir(),
[extensionName],
); );
manager.remove(extensionName); manager.remove(extensionName);
const storage = new ExtensionStorage(extensionName); const storage = new ExtensionStorage(extensionName);
@@ -789,6 +741,7 @@ export function disableExtension(
const manager = new ExtensionEnablementManager( const manager = new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(), ExtensionStorage.getUserExtensionsDir(),
[name],
); );
const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir(); const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir();
manager.disable(name, true, scopePath); manager.disable(name, true, scopePath);
@@ -6,6 +6,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { type Extension } from '../extension.js';
export interface ExtensionEnablementConfig { export interface ExtensionEnablementConfig {
overrides: string[]; overrides: string[];
@@ -104,24 +105,56 @@ function globToRegex(glob: string): RegExp {
return new RegExp(`^${regexString}$`); return new RegExp(`^${regexString}$`);
} }
/**
* Determines if an extension is enabled based on the configuration and current path.
* The last matching rule in the overrides list wins.
*
* @param config The enablement configuration for a single extension.
* @param currentPath The absolute path of the current working directory.
* @returns True if the extension is enabled, false otherwise.
*/
export class ExtensionEnablementManager { export class ExtensionEnablementManager {
private configFilePath: string; private configFilePath: string;
private configDir: string; private configDir: string;
// If non-empty, this overrides all other extension configuration and enables
// only the ones in this list.
private enabledExtensionNamesOverride: string[];
constructor(configDir: string) { constructor(configDir: string, enabledExtensionNames?: string[]) {
this.configDir = configDir; this.configDir = configDir;
this.configFilePath = path.join(configDir, 'extension-enablement.json'); this.configFilePath = path.join(configDir, 'extension-enablement.json');
this.enabledExtensionNamesOverride =
enabledExtensionNames?.map((name) => name.toLowerCase()) ?? [];
} }
validateExtensionOverrides(extensions: Extension[]) {
for (const name of this.enabledExtensionNamesOverride) {
if (
!extensions.some(
(ext) => ext.config.name.toLowerCase() === name.toLowerCase(),
)
) {
console.error(`Extension not found: ${name}`);
}
}
}
/**
* Determines if an extension is enabled based on its name and the current
* path. The last matching rule in the overrides list wins.
*
* @param extensionName The name of the extension.
* @param currentPath The absolute path of the current working directory.
* @returns True if the extension is enabled, false otherwise.
*/
isEnabled(extensionName: string, currentPath: string): boolean { isEnabled(extensionName: string, currentPath: string): boolean {
// If we have a single override called 'none', this disables all extensions.
// Typically, this comes from the user passing `-e none`.
if (
this.enabledExtensionNamesOverride.length === 1 &&
this.enabledExtensionNamesOverride[0] === 'none'
) {
return false;
}
// If we have explicit overrides, only enable those extensions.
if (this.enabledExtensionNamesOverride.length > 0) {
return this.enabledExtensionNamesOverride.includes(extensionName);
}
// Otherwise, we use the configuration settings
const config = this.readConfig(); const config = this.readConfig();
const extensionConfig = config[extensionName]; const extensionConfig = config[extensionName];
// Extensions are enabled by default. // Extensions are enabled by default.
@@ -10,6 +10,7 @@ import * as os from 'node:os';
import * as path from 'node:path'; import * as path from 'node:path';
import { import {
EXTENSIONS_CONFIG_FILENAME, EXTENSIONS_CONFIG_FILENAME,
ExtensionStorage,
INSTALL_METADATA_FILENAME, INSTALL_METADATA_FILENAME,
annotateActiveExtensions, annotateActiveExtensions,
loadExtension, loadExtension,
@@ -19,6 +20,7 @@ import { GEMINI_DIR } from '@google/gemini-cli-core';
import { isWorkspaceTrusted } from '../trustedFolders.js'; import { isWorkspaceTrusted } from '../trustedFolders.js';
import { ExtensionUpdateState } from '../../ui/state/extensions.js'; import { ExtensionUpdateState } from '../../ui/state/extensions.js';
import { createExtension } from '../../test-utils/createExtension.js'; import { createExtension } from '../../test-utils/createExtension.js';
import { ExtensionEnablementManager } from './extensionEnablement.js';
const mockGit = { const mockGit = {
clone: vi.fn(), clone: vi.fn(),
@@ -134,8 +136,8 @@ describe('update tests', () => {
workspaceDir: tempWorkspaceDir, workspaceDir: tempWorkspaceDir,
})!, })!,
], ],
[],
process.cwd(), process.cwd(),
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
)[0]; )[0];
const updateInfo = await updateExtension( const updateInfo = await updateExtension(
extension, extension,
@@ -192,8 +194,8 @@ describe('update tests', () => {
workspaceDir: tempWorkspaceDir, workspaceDir: tempWorkspaceDir,
})!, })!,
], ],
[],
process.cwd(), process.cwd(),
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
)[0]; )[0];
await updateExtension( await updateExtension(
extension, extension,
@@ -234,8 +236,8 @@ describe('update tests', () => {
workspaceDir: tempWorkspaceDir, workspaceDir: tempWorkspaceDir,
})!, })!,
], ],
[],
process.cwd(), process.cwd(),
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
)[0]; )[0];
await expect( await expect(
updateExtension( updateExtension(
@@ -274,8 +276,8 @@ describe('update tests', () => {
workspaceDir: tempWorkspaceDir, workspaceDir: tempWorkspaceDir,
})!, })!,
], ],
[],
process.cwd(), process.cwd(),
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
)[0]; )[0];
mockGit.getRemotes.mockResolvedValue([ mockGit.getRemotes.mockResolvedValue([
@@ -317,8 +319,8 @@ describe('update tests', () => {
workspaceDir: tempWorkspaceDir, workspaceDir: tempWorkspaceDir,
})!, })!,
], ],
[],
process.cwd(), process.cwd(),
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
)[0]; )[0];
mockGit.getRemotes.mockResolvedValue([ mockGit.getRemotes.mockResolvedValue([
@@ -364,8 +366,8 @@ describe('update tests', () => {
workspaceDir: tempWorkspaceDir, workspaceDir: tempWorkspaceDir,
})!, })!,
], ],
[],
process.cwd(), process.cwd(),
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
)[0]; )[0];
let extensionState = new Map(); let extensionState = new Map();
const results = await checkForAllExtensionUpdates( const results = await checkForAllExtensionUpdates(
@@ -405,8 +407,8 @@ describe('update tests', () => {
workspaceDir: tempWorkspaceDir, workspaceDir: tempWorkspaceDir,
})!, })!,
], ],
[],
process.cwd(), process.cwd(),
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
)[0]; )[0];
let extensionState = new Map(); let extensionState = new Map();
const results = await checkForAllExtensionUpdates( const results = await checkForAllExtensionUpdates(
@@ -442,8 +444,8 @@ describe('update tests', () => {
workspaceDir: tempWorkspaceDir, workspaceDir: tempWorkspaceDir,
})!, })!,
], ],
[],
process.cwd(), process.cwd(),
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
)[0]; )[0];
mockGit.getRemotes.mockRejectedValue(new Error('Git error')); mockGit.getRemotes.mockRejectedValue(new Error('Git error'));
+9 -2
View File
@@ -26,7 +26,7 @@ import { getStartupWarnings } from './utils/startupWarnings.js';
import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import { runNonInteractive } from './nonInteractiveCli.js'; import { runNonInteractive } from './nonInteractiveCli.js';
import { loadExtensions } from './config/extension.js'; import { ExtensionStorage, loadExtensions } from './config/extension.js';
import { import {
cleanupCheckpoints, cleanupCheckpoints,
registerCleanup, registerCleanup,
@@ -113,6 +113,7 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] {
import { runZedIntegration } from './zed-integration/zedIntegration.js'; import { runZedIntegration } from './zed-integration/zedIntegration.js';
import { loadSandboxConfig } from './config/sandboxConfig.js'; import { loadSandboxConfig } from './config/sandboxConfig.js';
import { ExtensionEnablementManager } from './config/extensions/extensionEnablement.js';
export function setupUnhandledRejectionHandler() { export function setupUnhandledRejectionHandler() {
let unhandledRejectionOccurred = false; let unhandledRejectionOccurred = false;
@@ -266,6 +267,7 @@ export async function main() {
const partialConfig = await loadCliConfig( const partialConfig = await loadCliConfig(
settings.merged, settings.merged,
[], [],
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
sessionId, sessionId,
argv, argv,
); );
@@ -336,10 +338,15 @@ export async function main() {
// to run Gemini CLI. It is now safe to perform expensive initialization that // to run Gemini CLI. It is now safe to perform expensive initialization that
// may have side effects. // may have side effects.
{ {
const extensions = loadExtensions(); const extensionEnablementManager = new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
);
const extensions = loadExtensions(extensionEnablementManager);
const config = await loadCliConfig( const config = await loadCliConfig(
settings.merged, settings.merged,
extensions, extensions,
extensionEnablementManager,
sessionId, sessionId,
argv, argv,
); );
@@ -10,6 +10,7 @@ import * as os from 'node:os';
import * as path from 'node:path'; import * as path from 'node:path';
import { import {
EXTENSIONS_CONFIG_FILENAME, EXTENSIONS_CONFIG_FILENAME,
ExtensionStorage,
annotateActiveExtensions, annotateActiveExtensions,
loadExtension, loadExtension,
} from '../../config/extension.js'; } from '../../config/extension.js';
@@ -19,6 +20,7 @@ import { GEMINI_DIR, type GeminiCLIExtension } from '@google/gemini-cli-core';
import { isWorkspaceTrusted } from '../../config/trustedFolders.js'; import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
import { renderHook, waitFor } from '@testing-library/react'; import { renderHook, waitFor } from '@testing-library/react';
import { MessageType } from '../types.js'; import { MessageType } from '../types.js';
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
const mockGit = { const mockGit = {
clone: vi.fn(), clone: vi.fn(),
@@ -163,8 +165,8 @@ describe('useExtensionUpdates', () => {
}); });
const extension = annotateActiveExtensions( const extension = annotateActiveExtensions(
[loadExtension({ extensionDir, workspaceDir: tempHomeDir })!], [loadExtension({ extensionDir, workspaceDir: tempHomeDir })!],
[],
tempHomeDir, tempHomeDir,
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
)[0]; )[0];
const addItem = vi.fn(); const addItem = vi.fn();
@@ -40,9 +40,10 @@ import * as path from 'node:path';
import { z } from 'zod'; import { z } from 'zod';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { Extension } from '../config/extension.js'; import { ExtensionStorage, type Extension } from '../config/extension.js';
import type { CliArgs } from '../config/config.js'; import type { CliArgs } from '../config/config.js';
import { loadCliConfig } from '../config/config.js'; import { loadCliConfig } from '../config/config.js';
import { ExtensionEnablementManager } from '../config/extensions/extensionEnablement.js';
/** /**
* Resolves the model to use based on the current configuration. * Resolves the model to use based on the current configuration.
@@ -204,6 +205,10 @@ class GeminiAgent {
const config = await loadCliConfig( const config = await loadCliConfig(
settings, settings,
this.extensions, this.extensions,
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
this.argv.extensions,
),
sessionId, sessionId,
this.argv, this.argv,
cwd, cwd,