Create ExtensionManager class which manages all high level extension tasks (#11667)

This commit is contained in:
Jacob MacDonald
2025-10-23 11:39:36 -07:00
committed by GitHub
parent 3a501196f0
commit c4c0c0d182
31 changed files with 1450 additions and 1568 deletions
+14 -14
View File
@@ -5,11 +5,12 @@
*/
import { type CommandModule } from 'yargs';
import { disableExtension } from '../../config/extension.js';
import { SettingScope } from '../../config/settings.js';
import { loadSettings, 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';
import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
interface DisableArgs {
name: string;
@@ -17,20 +18,19 @@ interface DisableArgs {
}
export function handleDisable(args: DisableArgs) {
const extensionEnablementManager = new ExtensionEnablementManager();
const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
loadedSettings: loadSettings(workspaceDir),
});
try {
if (args.scope?.toLowerCase() === 'workspace') {
disableExtension(
args.name,
SettingScope.Workspace,
extensionEnablementManager,
);
extensionManager.disableExtension(args.name, SettingScope.Workspace);
} else {
disableExtension(
args.name,
SettingScope.User,
extensionEnablementManager,
);
extensionManager.disableExtension(args.name, SettingScope.User);
}
debugLogger.log(
`Extension "${args.name}" successfully disabled for scope "${args.scope}".`,
+13 -10
View File
@@ -5,14 +5,15 @@
*/
import { type CommandModule } from 'yargs';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import {
debugLogger,
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';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
interface EnableArgs {
name: string;
@@ -20,16 +21,18 @@ interface EnableArgs {
}
export function handleEnable(args: EnableArgs) {
const extensionEnablementManager = new ExtensionEnablementManager();
const workingDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir: workingDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
loadedSettings: loadSettings(workingDir),
});
try {
if (args.scope?.toLowerCase() === 'workspace') {
enableExtension(
args.name,
SettingScope.Workspace,
extensionEnablementManager,
);
extensionManager.enableExtension(args.name, SettingScope.Workspace);
} else {
enableExtension(args.name, SettingScope.User, extensionEnablementManager);
extensionManager.enableExtension(args.name, SettingScope.User);
}
if (args.scope) {
debugLogger.log(
@@ -12,11 +12,21 @@ const mockInstallOrUpdateExtension = vi.hoisted(() => vi.fn());
const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn());
const mockStat = vi.hoisted(() => vi.fn());
vi.mock('../../config/extension.js', () => ({
installOrUpdateExtension: mockInstallOrUpdateExtension,
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: mockRequestConsentNonInteractive,
}));
vi.mock('../../config/extension-manager.ts', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../config/extension-manager.js')>();
return {
...actual,
ExtensionManager: vi.fn().mockImplementation(() => ({
installOrUpdateExtension: mockInstallOrUpdateExtension,
})),
};
});
vi.mock('../../utils/errors.js', () => ({
getErrorMessage: vi.fn((error: Error) => error.message),
}));
@@ -54,7 +64,7 @@ describe('handleInstall', () => {
mockInstallOrUpdateExtension.mockClear();
mockRequestConsentNonInteractive.mockClear();
mockStat.mockClear();
vi.resetAllMocks();
vi.clearAllMocks();
});
it('should install an extension from a http source', async () => {
+15 -11
View File
@@ -5,17 +5,18 @@
*/
import type { CommandModule } from 'yargs';
import {
INSTALL_WARNING_MESSAGE,
installOrUpdateExtension,
requestConsentNonInteractive,
} from '../../config/extension.js';
import {
debugLogger,
type ExtensionInstallMetadata,
} from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import { stat } from 'node:fs/promises';
import {
INSTALL_WARNING_MESSAGE,
requestConsentNonInteractive,
} from '../../config/extensions/consent.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
interface InstallArgs {
@@ -67,13 +68,16 @@ export async function handleInstall(args: InstallArgs) {
debugLogger.log('You have consented to the following:');
debugLogger.log(INSTALL_WARNING_MESSAGE);
}
const name = await installOrUpdateExtension(
installMetadata,
const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent,
process.cwd(),
undefined,
promptForSetting,
);
requestSetting: promptForSetting,
loadedSettings: loadSettings(workspaceDir),
});
const name =
await extensionManager.installOrUpdateExtension(installMetadata);
debugLogger.log(`Extension "${name}" installed successfully and enabled.`);
} catch (error) {
debugLogger.error(getErrorMessage(error));
+13 -8
View File
@@ -5,16 +5,16 @@
*/
import type { CommandModule } from 'yargs';
import {
installOrUpdateExtension,
requestConsentNonInteractive,
} from '../../config/extension.js';
import {
debugLogger,
type ExtensionInstallMetadata,
} from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
interface InstallArgs {
path: string;
@@ -26,10 +26,15 @@ export async function handleLink(args: InstallArgs) {
source: args.path,
type: 'link',
};
const extensionName = await installOrUpdateExtension(
installMetadata,
requestConsentNonInteractive,
);
const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
loadedSettings: loadSettings(workspaceDir),
});
const extensionName =
await extensionManager.installOrUpdateExtension(installMetadata);
debugLogger.log(
`Extension "${extensionName}" linked successfully and enabled.`,
);
+15 -7
View File
@@ -5,24 +5,32 @@
*/
import type { CommandModule } from 'yargs';
import { loadExtensions, toOutputString } from '../../config/extension.js';
import { getErrorMessage } from '../../utils/errors.js';
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
import { debugLogger } from '@google/gemini-cli-core';
import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
export async function handleList() {
try {
const extensions = loadExtensions(
new ExtensionEnablementManager(),
process.cwd(),
);
const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
loadedSettings: loadSettings(workspaceDir),
});
const extensions = extensionManager.loadExtensions();
if (extensions.length === 0) {
debugLogger.log('No extensions installed.');
return;
}
debugLogger.log(
extensions
.map((extension, _): string => toOutputString(extension, process.cwd()))
.map((extension, _): string =>
extensionManager.toOutputString(extension),
)
.join('\n\n'),
);
} catch (error) {
@@ -5,9 +5,12 @@
*/
import type { CommandModule } from 'yargs';
import { uninstallExtension } from '../../config/extension.js';
import { getErrorMessage } from '../../utils/errors.js';
import { debugLogger } from '@google/gemini-cli-core';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
interface UninstallArgs {
name: string; // can be extension name or source URL.
@@ -15,7 +18,14 @@ interface UninstallArgs {
export async function handleUninstall(args: UninstallArgs) {
try {
await uninstallExtension(args.name, false);
const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
loadedSettings: loadSettings(workspaceDir),
});
await extensionManager.uninstallExtension(args.name, false);
debugLogger.log(`Extension "${args.name}" successfully uninstalled.`);
} catch (error) {
debugLogger.error(getErrorMessage(error));
+17 -21
View File
@@ -5,10 +5,6 @@
*/
import type { CommandModule } from 'yargs';
import {
loadExtensions,
requestConsentNonInteractive,
} from '../../config/extension.js';
import {
updateAllUpdatableExtensions,
type ExtensionUpdateInfo,
@@ -18,8 +14,11 @@ import {
import { checkForExtensionUpdate } from '../../config/extensions/github.js';
import { getErrorMessage } from '../../utils/errors.js';
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
import { debugLogger } from '@google/gemini-cli-core';
import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
interface UpdateArgs {
name?: string;
@@ -30,13 +29,15 @@ const updateOutput = (info: ExtensionUpdateInfo) =>
`Extension "${info.name}" successfully updated: ${info.originalVersion}${info.updatedVersion}.`;
export async function handleUpdate(args: UpdateArgs) {
const workingDir = process.cwd();
const extensionEnablementManager = new ExtensionEnablementManager(
// Force enable named extensions, otherwise we will only update the enabled
// ones.
args.name ? [args.name] : [],
);
const extensions = loadExtensions(extensionEnablementManager);
const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
loadedSettings: loadSettings(workspaceDir),
});
const extensions = extensionManager.loadExtensions();
if (args.name) {
try {
const extension = extensions.find(
@@ -54,7 +55,7 @@ export async function handleUpdate(args: UpdateArgs) {
}
const updateState = await checkForExtensionUpdate(
extension,
extensionEnablementManager,
extensionManager,
);
if (updateState !== ExtensionUpdateState.UPDATE_AVAILABLE) {
debugLogger.log(`Extension "${args.name}" is already up to date.`);
@@ -63,9 +64,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,
extensionManager,
updateState,
() => {},
))!;
@@ -88,7 +87,7 @@ export async function handleUpdate(args: UpdateArgs) {
const extensionState = new Map();
await checkForAllExtensionUpdates(
extensions,
extensionEnablementManager,
extensionManager,
(action) => {
if (action.type === 'SET_STATE') {
extensionState.set(action.payload.name, {
@@ -96,14 +95,11 @@ export async function handleUpdate(args: UpdateArgs) {
});
}
},
workingDir,
);
let updateInfos = await updateAllUpdatableExtensions(
workingDir,
requestConsentNonInteractive,
extensions,
extensionState,
extensionEnablementManager,
extensionManager,
() => {},
);
updateInfos = updateInfos.filter(
+16 -6
View File
@@ -7,19 +7,20 @@
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
import { listMcpServers } from './list.js';
import { loadSettings } from '../../config/settings.js';
import { ExtensionStorage, loadExtensions } from '../../config/extension.js';
import { createTransport, debugLogger } from '@google/gemini-cli-core';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ExtensionStorage } from '../../config/extensions/storage.js';
import { ExtensionManager } from '../../config/extension-manager.js';
vi.mock('../../config/settings.js', () => ({
loadSettings: vi.fn(),
}));
vi.mock('../../config/extension.js', () => ({
loadExtensions: vi.fn(),
vi.mock('../../config/extensions/storage.js', () => ({
ExtensionStorage: {
getUserExtensionsDir: vi.fn(),
},
}));
vi.mock('../../config/extension-manager.js');
vi.mock('@google/gemini-cli-core', () => ({
createTransport: vi.fn(),
MCPServerStatus: {
@@ -46,9 +47,9 @@ vi.mock('@modelcontextprotocol/sdk/client/index.js');
const mockedGetUserExtensionsDir =
ExtensionStorage.getUserExtensionsDir as Mock;
const mockedLoadSettings = loadSettings as Mock;
const mockedLoadExtensions = loadExtensions as Mock;
const mockedCreateTransport = createTransport as Mock;
const MockedClient = Client as Mock;
const MockedExtensionManager = ExtensionManager as Mock;
interface MockClient {
connect: Mock;
@@ -56,12 +57,17 @@ interface MockClient {
close: Mock;
}
interface MockExtensionManager {
loadExtensions: Mock;
}
interface MockTransport {
close: Mock;
}
describe('mcp list command', () => {
let mockClient: MockClient;
let mockExtensionManager: MockExtensionManager;
let mockTransport: MockTransport;
beforeEach(() => {
@@ -73,10 +79,14 @@ describe('mcp list command', () => {
ping: vi.fn(),
close: vi.fn(),
};
mockExtensionManager = {
loadExtensions: vi.fn(),
};
MockedClient.mockImplementation(() => mockClient);
MockedExtensionManager.mockImplementation(() => mockExtensionManager);
mockedCreateTransport.mockResolvedValue(mockTransport);
mockedLoadExtensions.mockReturnValue([]);
mockExtensionManager.loadExtensions.mockReturnValue([]);
mockedGetUserExtensionsDir.mockReturnValue('/mocked/extensions/dir');
});
@@ -149,7 +159,7 @@ describe('mcp list command', () => {
},
});
mockedLoadExtensions.mockReturnValue([
mockExtensionManager.loadExtensions.mockReturnValue([
{
name: 'test-extension',
mcpServers: { 'extension-server': { command: '/ext/server' } },
+10 -3
View File
@@ -14,8 +14,9 @@ import {
debugLogger,
} from '@google/gemini-cli-core';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { loadExtensions } from '../../config/extension.js';
import { ExtensionEnablementManager } from '../../config/extensions/extensionEnablement.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
const COLOR_GREEN = '\u001b[32m';
const COLOR_YELLOW = '\u001b[33m';
@@ -26,7 +27,13 @@ async function getMcpServersFromConfig(): Promise<
Record<string, MCPServerConfig>
> {
const settings = loadSettings();
const extensions = loadExtensions(new ExtensionEnablementManager());
const extensionManager = new ExtensionManager({
loadedSettings: settings,
workspaceDir: process.cwd(),
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
});
const extensions = extensionManager.loadExtensions();
const mcpServers = { ...(settings.merged.mcpServers || {}) };
for (const extension of extensions) {
Object.entries(extension.mcpServers || {}).forEach(([key, server]) => {