Add ExtensionLoader interface, use that on Config object (#12116)

This commit is contained in:
Jacob MacDonald
2025-10-28 09:04:30 -07:00
committed by GitHub
parent 25f27509c0
commit 1b302deeff
35 changed files with 619 additions and 505 deletions
+9 -2
View File
@@ -17,7 +17,10 @@ import type {
ServerGeminiToolCallRequestEvent,
Config,
} from '@google/gemini-cli-core';
import { GeminiEventType } from '@google/gemini-cli-core';
import {
GeminiEventType,
SimpleExtensionLoader,
} from '@google/gemini-cli-core';
import { v4 as uuidv4 } from 'uuid';
import { logger } from '../utils/logger.js';
@@ -96,7 +99,11 @@ export class CoderAgentExecutor implements AgentExecutor {
loadEnvironment(); // Will override any global env with workspace envs
const settings = loadSettings(workspaceRoot);
const extensions = loadExtensions(workspaceRoot);
return await loadConfig(settings, extensions, taskId);
return await loadConfig(
settings,
new SimpleExtensionLoader(extensions),
taskId,
);
}
/**
+5 -4
View File
@@ -21,6 +21,7 @@ import {
DEFAULT_GEMINI_EMBEDDING_MODEL,
DEFAULT_GEMINI_MODEL,
type GeminiCLIExtension,
type ExtensionLoader,
debugLogger,
} from '@google/gemini-cli-core';
@@ -30,10 +31,10 @@ import { type AgentSettings, CoderAgentEvent } from '../types.js';
export async function loadConfig(
settings: Settings,
extensions: GeminiCLIExtension[],
extensionLoader: ExtensionLoader,
taskId: string,
): Promise<Config> {
const mcpServers = mergeMcpServers(settings, extensions);
const mcpServers = mergeMcpServers(settings, extensionLoader.getExtensions());
const workspaceDir = process.cwd();
const adcFilePath = process.env['GOOGLE_APPLICATION_CREDENTIALS'];
@@ -71,7 +72,7 @@ export async function loadConfig(
},
ideMode: false,
folderTrust: settings.folderTrust === true,
extensions,
extensionLoader,
};
const fileService = new FileDiscoveryService(workspaceDir);
@@ -80,7 +81,7 @@ export async function loadConfig(
[workspaceDir],
false,
fileService,
extensions,
extensionLoader,
settings.folderTrust === true,
);
configParams.userMemory = memoryContent;
+6 -1
View File
@@ -20,6 +20,7 @@ import { loadConfig, loadEnvironment, setTargetDir } from '../config/config.js';
import { loadSettings } from '../config/settings.js';
import { loadExtensions } from '../config/extension.js';
import { commandRegistry } from '../commands/command-registry.js';
import { SimpleExtensionLoader } from '@google/gemini-cli-core';
const coderAgentCard: AgentCard = {
name: 'Gemini SDLC Agent',
@@ -70,7 +71,11 @@ export async function createApp() {
loadEnvironment();
const settings = loadSettings(workspaceRoot);
const extensions = loadExtensions(workspaceRoot);
const config = await loadConfig(settings, extensions, 'a2a-server');
const config = await loadConfig(
settings,
new SimpleExtensionLoader(extensions),
'a2a-server',
);
// loadEnvironment() is called within getConfig now
const bucketName = process.env['GCS_BUCKET_NAME'];
@@ -23,8 +23,9 @@ export function handleDisable(args: DisableArgs) {
workspaceDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
loadedSettings: loadSettings(workspaceDir),
settings: loadSettings(workspaceDir).merged,
});
extensionManager.loadExtensions();
try {
if (args.scope?.toLowerCase() === 'workspace') {
@@ -26,8 +26,10 @@ export function handleEnable(args: EnableArgs) {
workspaceDir: workingDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
loadedSettings: loadSettings(workingDir),
settings: loadSettings(workingDir).merged,
});
extensionManager.loadExtensions();
try {
if (args.scope?.toLowerCase() === 'workspace') {
extensionManager.enableExtension(args.name, SettingScope.Workspace);
@@ -23,6 +23,7 @@ vi.mock('../../config/extension-manager.ts', async (importOriginal) => {
...actual,
ExtensionManager: vi.fn().mockImplementation(() => ({
installOrUpdateExtension: mockInstallOrUpdateExtension,
loadExtensions: vi.fn(),
})),
};
});
@@ -74,8 +74,9 @@ export async function handleInstall(args: InstallArgs) {
workspaceDir,
requestConsent,
requestSetting: promptForSetting,
loadedSettings: loadSettings(workspaceDir),
settings: loadSettings(workspaceDir).merged,
});
extensionManager.loadExtensions();
const name =
await extensionManager.installOrUpdateExtension(installMetadata);
debugLogger.log(`Extension "${name}" installed successfully and enabled.`);
+2 -1
View File
@@ -31,8 +31,9 @@ export async function handleLink(args: InstallArgs) {
workspaceDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
loadedSettings: loadSettings(workspaceDir),
settings: loadSettings(workspaceDir).merged,
});
extensionManager.loadExtensions();
const extensionName =
await extensionManager.installOrUpdateExtension(installMetadata);
debugLogger.log(
+1 -1
View File
@@ -19,7 +19,7 @@ export async function handleList() {
workspaceDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
loadedSettings: loadSettings(workspaceDir),
settings: loadSettings(workspaceDir).merged,
});
const extensions = extensionManager.loadExtensions();
if (extensions.length === 0) {
@@ -23,8 +23,9 @@ export async function handleUninstall(args: UninstallArgs) {
workspaceDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
loadedSettings: loadSettings(workspaceDir),
settings: loadSettings(workspaceDir).merged,
});
extensionManager.loadExtensions();
await extensionManager.uninstallExtension(args.name, false);
debugLogger.log(`Extension "${args.name}" successfully uninstalled.`);
} catch (error) {
@@ -34,7 +34,7 @@ export async function handleUpdate(args: UpdateArgs) {
workspaceDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
loadedSettings: loadSettings(workspaceDir),
settings: loadSettings(workspaceDir).merged,
});
const extensions = extensionManager.loadExtensions();
+1 -1
View File
@@ -28,7 +28,7 @@ async function getMcpServersFromConfig(): Promise<
> {
const settings = loadSettings();
const extensionManager = new ExtensionManager({
loadedSettings: settings,
settings: settings.merged,
workspaceDir: process.cwd(),
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
File diff suppressed because it is too large Load Diff
+19 -7
View File
@@ -48,6 +48,10 @@ import { appEvents } from '../utils/events.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { createPolicyEngineConfig } from './policy.js';
import { ExtensionManager } from './extension-manager.js';
import type { ExtensionLoader } from '@google/gemini-cli-core/src/utils/extensionLoader.js';
import { requestConsentNonInteractive } from './extensions/consent.js';
import { promptForSetting } from './extensions/extensionSettings.js';
export interface CliArgs {
query: string | undefined;
@@ -293,7 +297,7 @@ export async function loadHierarchicalGeminiMemory(
debugMode: boolean,
fileService: FileDiscoveryService,
settings: Settings,
extensions: GeminiCLIExtension[],
extensionLoader: ExtensionLoader,
folderTrust: boolean,
memoryImportFormat: 'flat' | 'tree' = 'tree',
fileFilteringOptions?: FileFilteringOptions,
@@ -319,7 +323,7 @@ export async function loadHierarchicalGeminiMemory(
includeDirectoriesToReadGemini,
debugMode,
fileService,
extensions,
extensionLoader,
folderTrust,
memoryImportFormat,
fileFilteringOptions,
@@ -368,7 +372,6 @@ export function isDebugMode(argv: CliArgs): boolean {
export async function loadCliConfig(
settings: Settings,
allExtensions: GeminiCLIExtension[],
sessionId: string,
argv: CliArgs,
cwd: string = process.cwd(),
@@ -413,6 +416,15 @@ export async function loadCliConfig(
.map(resolvePath)
.concat((argv.includeDirectories || []).map(resolvePath));
const extensionManager = new ExtensionManager({
settings,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
workspaceDir: cwd,
enabledExtensionOverrides: argv.extensions,
});
extensionManager.loadExtensions();
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
const { memoryContent, fileCount, filePaths } =
await loadHierarchicalGeminiMemory(
@@ -423,13 +435,13 @@ export async function loadCliConfig(
debugMode,
fileService,
settings,
allExtensions,
extensionManager,
trustedFolder,
memoryImportFormat,
memoryFileFiltering,
);
let mcpServers = mergeMcpServers(settings, allExtensions);
let mcpServers = mergeMcpServers(settings, extensionManager.getExtensions());
const question = argv.promptInteractive || argv.prompt || '';
// Determine approval mode with backward compatibility
@@ -540,7 +552,7 @@ export async function loadCliConfig(
const excludeTools = mergeExcludeTools(
settings,
allExtensions,
extensionManager.getExtensions(),
extraExcludes.length > 0 ? extraExcludes : undefined,
);
const blockedMcpServers: Array<{ name: string; extensionName: string }> = [];
@@ -636,7 +648,7 @@ export async function loadCliConfig(
experimentalZedIntegration: argv.experimentalAcp || false,
listExtensions: argv.listExtensions || false,
enabledExtensions: argv.extensions,
extensions: allExtensions,
extensionLoader: extensionManager,
blockedMcpServers,
noBrowser: !!process.env['NO_BROWSER'],
summarizeToolOutput: settings.model?.summarizeToolOutput,
+132 -92
View File
@@ -9,7 +9,7 @@ import * as path from 'node:path';
import * as os from 'node:os';
import chalk from 'chalk';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import { type LoadedSettings, SettingScope } from './settings.js';
import { type Settings, SettingScope } from './settings.js';
import { createHash, randomUUID } from 'node:crypto';
import { loadInstallMetadata, type ExtensionConfig } from './extension.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
@@ -50,33 +50,45 @@ import {
maybePromptForSettings,
type ExtensionSetting,
} from './extensions/extensionSettings.js';
import type {
ExtensionEvents,
ExtensionLoader,
} from '@google/gemini-cli-core/src/utils/extensionLoader.js';
import { EventEmitter } from 'node:events';
interface ExtensionManagerParams {
enabledExtensionOverrides?: string[];
loadedSettings: LoadedSettings;
settings: Settings;
requestConsent: (consent: string) => Promise<boolean>;
requestSetting: ((setting: ExtensionSetting) => Promise<string>) | null;
workspaceDir: string;
}
export class ExtensionManager {
/**
* Actual implementation of an ExtensionLoader.
*
* You must call `loadExtensions` prior to calling other methods on this class.
*/
export class ExtensionManager implements ExtensionLoader {
private extensionEnablementManager: ExtensionEnablementManager;
private loadedSettings: LoadedSettings;
private settings: Settings;
private requestConsent: (consent: string) => Promise<boolean>;
private requestSetting:
| ((setting: ExtensionSetting) => Promise<string>)
| null;
| undefined;
private telemetryConfig: Config;
private workspaceDir: string;
private loadedExtensions: GeminiCLIExtension[] | undefined;
private eventEmitter: EventEmitter<ExtensionEvents>;
constructor(options: ExtensionManagerParams) {
this.workspaceDir = options.workspaceDir;
this.extensionEnablementManager = new ExtensionEnablementManager(
options.enabledExtensionOverrides,
);
this.loadedSettings = options.loadedSettings;
this.settings = options.settings;
this.telemetryConfig = new Config({
telemetry: options.loadedSettings.merged.telemetry,
telemetry: options.settings.telemetry,
interactive: false,
sessionId: randomUUID(),
targetDir: options.workspaceDir,
@@ -85,19 +97,45 @@ export class ExtensionManager {
debugMode: false,
});
this.requestConsent = options.requestConsent;
this.requestSetting = options.requestSetting;
this.requestSetting = options.requestSetting ?? undefined;
this.eventEmitter = new EventEmitter();
}
setRequestConsent(
requestConsent: (consent: string) => Promise<boolean>,
): void {
this.requestConsent = requestConsent;
}
setRequestSetting(
requestSetting?: (setting: ExtensionSetting) => Promise<string>,
): void {
this.requestSetting = requestSetting;
}
getExtensions(): GeminiCLIExtension[] {
if (!this.loadedExtensions) {
throw new Error(
'Extensions not yet loaded, must call `loadExtensions` first',
);
}
return this.loadedExtensions!;
}
extensionEvents(): EventEmitter<ExtensionEvents> {
return this.eventEmitter;
}
async installOrUpdateExtension(
installMetadata: ExtensionInstallMetadata,
previousExtensionConfig?: ExtensionConfig,
): Promise<string> {
): Promise<GeminiCLIExtension> {
const isUpdate = !!previousExtensionConfig;
let newExtensionConfig: ExtensionConfig | null = null;
let localSourcePath: string | undefined;
let extension: GeminiCLIExtension;
try {
const settings = this.loadedSettings.merged;
if (!isWorkspaceTrusted(settings).isTrusted) {
if (!isWorkspaceTrusted(this.settings).isTrusted) {
throw new Error(
`Could not install extension from untrusted folder at ${installMetadata.source}`,
);
@@ -187,17 +225,17 @@ export class ExtensionManager {
}
const newExtensionName = newExtensionConfig.name;
if (!isUpdate) {
const installedExtensions = this.loadExtensions();
if (
installedExtensions.some(
(installed) => installed.name === newExtensionName,
)
) {
throw new Error(
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
);
}
const previous = this.getExtensions().find(
(installed) => installed.name === newExtensionName,
);
if (isUpdate && !previous) {
throw new Error(
`Extension "${newExtensionName}" was not already installed, cannot update it.`,
);
} else if (!isUpdate && previous) {
throw new Error(
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
);
}
await maybeRequestConsentOrFail(
@@ -245,39 +283,43 @@ export class ExtensionManager {
INSTALL_METADATA_FILENAME,
);
await fs.promises.writeFile(metadataPath, metadataString);
// TODO: Gracefully handle this call failing, we should back up the old
// extension prior to overwriting it and then restore it.
extension = this.loadExtension(destinationPath)!;
if (isUpdate) {
logExtensionUpdateEvent(
this.telemetryConfig,
new ExtensionUpdateEvent(
hashValue(newExtensionConfig.name),
getExtensionId(newExtensionConfig, installMetadata),
newExtensionConfig.version,
previousExtensionConfig.version,
installMetadata.type,
'success',
),
);
this.eventEmitter.emit('extensionUpdated', { extension });
} else {
logExtensionInstallEvent(
this.telemetryConfig,
new ExtensionInstallEvent(
hashValue(newExtensionConfig.name),
getExtensionId(newExtensionConfig, installMetadata),
newExtensionConfig.version,
installMetadata.type,
'success',
),
);
this.eventEmitter.emit('extensionInstalled', { extension });
this.enableExtension(newExtensionConfig.name, SettingScope.User);
}
} finally {
if (tempDir) {
await fs.promises.rm(tempDir, { recursive: true, force: true });
}
}
if (isUpdate) {
logExtensionUpdateEvent(
this.telemetryConfig,
new ExtensionUpdateEvent(
hashValue(newExtensionConfig.name),
getExtensionId(newExtensionConfig, installMetadata),
newExtensionConfig.version,
previousExtensionConfig.version,
installMetadata.type,
'success',
),
);
} else {
logExtensionInstallEvent(
this.telemetryConfig,
new ExtensionInstallEvent(
hashValue(newExtensionConfig.name),
getExtensionId(newExtensionConfig, installMetadata),
newExtensionConfig.version,
installMetadata.type,
'success',
),
);
this.enableExtension(newExtensionConfig.name, SettingScope.User);
}
return newExtensionConfig!.name;
return extension;
} catch (error) {
// Attempt to load config from the source path even if installation fails
// to get the name and version for logging.
@@ -324,7 +366,7 @@ export class ExtensionManager {
extensionIdentifier: string,
isUpdate: boolean,
): Promise<void> {
const installedExtensions = this.loadExtensions();
const installedExtensions = this.getExtensions();
const extension = installedExtensions.find(
(installed) =>
installed.name.toLowerCase() === extensionIdentifier.toLowerCase() ||
@@ -334,6 +376,7 @@ export class ExtensionManager {
if (!extension) {
throw new Error(`Extension not found.`);
}
this.unloadExtension(extension);
const storage = new ExtensionStorage(extension.name);
await fs.promises.rm(storage.getExtensionDir(), {
@@ -355,36 +398,28 @@ export class ExtensionManager {
'success',
),
);
this.eventEmitter.emit('extensionUninstalled', { extension });
}
loadExtensions(): GeminiCLIExtension[] {
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
if (!fs.existsSync(extensionsDir)) {
return [];
if (this.loadedExtensions) {
throw new Error('Extensions already loaded, only load extensions once.');
}
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
this.loadedExtensions = [];
if (!fs.existsSync(extensionsDir)) {
return this.loadedExtensions;
}
const extensions: GeminiCLIExtension[] = [];
for (const subdir of fs.readdirSync(extensionsDir)) {
const extensionDir = path.join(extensionsDir, subdir);
const extension = this.loadExtension(extensionDir);
if (extension != null) {
extensions.push(extension);
}
this.loadExtension(extensionDir);
}
const uniqueExtensions = new Map<string, GeminiCLIExtension>();
for (const extension of extensions) {
if (!uniqueExtensions.has(extension.name)) {
uniqueExtensions.set(extension.name, extension);
}
}
return Array.from(uniqueExtensions.values());
return this.loadedExtensions;
}
loadExtension(extensionDir: string): GeminiCLIExtension | null {
private loadExtension(extensionDir: string): GeminiCLIExtension | null {
this.loadedExtensions ??= [];
if (!fs.statSync(extensionDir).isDirectory()) {
return null;
}
@@ -398,6 +433,13 @@ export class ExtensionManager {
try {
let config = this.loadExtensionConfig(effectiveExtensionPath);
if (
this.getExtensions().find((extension) => extension.name === config.name)
) {
throw new Error(
`Extension with name ${config.name} already was loaded.`,
);
}
const customEnv = getEnvContents(new ExtensionStorage(config.name));
config = resolveEnvVarsInObject(config, customEnv);
@@ -417,7 +459,7 @@ export class ExtensionManager {
)
.filter((contextFilePath) => fs.existsSync(contextFilePath));
return {
const extension = {
name: config.name,
version: config.version,
path: effectiveExtensionPath,
@@ -431,6 +473,9 @@ export class ExtensionManager {
),
id: getExtensionId(config, installMetadata),
};
this.eventEmitter.emit('extensionLoaded', { extension });
this.getExtensions().push(extension);
return extension;
} catch (e) {
debugLogger.error(
`Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage(
@@ -441,24 +486,11 @@ export class ExtensionManager {
}
}
loadExtensionByName(name: string): GeminiCLIExtension | null {
const userExtensionsDir = ExtensionStorage.getUserExtensionsDir();
if (!fs.existsSync(userExtensionsDir)) {
return null;
}
for (const subdir of fs.readdirSync(userExtensionsDir)) {
const extensionDir = path.join(userExtensionsDir, subdir);
if (!fs.statSync(extensionDir).isDirectory()) {
continue;
}
const extension = this.loadExtension(extensionDir);
if (extension && extension.name.toLowerCase() === name.toLowerCase()) {
return extension;
}
}
return null;
private unloadExtension(extension: GeminiCLIExtension) {
this.loadedExtensions = this.getExtensions().filter(
(entry) => extension !== entry,
);
this.eventEmitter.emit('extensionUnloaded', { extension });
}
loadExtensionConfig(extensionDir: string): ExtensionConfig {
@@ -548,7 +580,9 @@ export class ExtensionManager {
) {
throw new Error('System and SystemDefaults scopes are not supported.');
}
const extension = this.loadExtensionByName(name);
const extension = this.getExtensions().find(
(extension) => extension.name === name,
);
if (!extension) {
throw new Error(`Extension with name ${name} does not exist.`);
}
@@ -560,6 +594,8 @@ export class ExtensionManager {
this.telemetryConfig,
new ExtensionDisableEvent(hashValue(name), extension.id, scope),
);
extension.isActive = false;
this.eventEmitter.emit('extensionDisabled', { extension });
}
enableExtension(name: string, scope: SettingScope) {
@@ -569,7 +605,9 @@ export class ExtensionManager {
) {
throw new Error('System and SystemDefaults scopes are not supported.');
}
const extension = this.loadExtensionByName(name);
const extension = this.getExtensions().find(
(extension) => extension.name === name,
);
if (!extension) {
throw new Error(`Extension with name ${name} does not exist.`);
}
@@ -580,6 +618,8 @@ export class ExtensionManager {
this.telemetryConfig,
new ExtensionEnableEvent(hashValue(name), extension.id, scope),
);
extension.isActive = true;
this.eventEmitter.emit('extensionEnabled', { extension });
}
}
+84 -31
View File
@@ -140,7 +140,7 @@ describe('extension tests', () => {
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
loadedSettings: loadSettings(tempWorkspaceDir),
settings: loadSettings(tempWorkspaceDir).merged,
});
});
@@ -220,11 +220,12 @@ describe('extension tests', () => {
name: 'enabled-extension',
version: '2.0.0',
});
extensionManager.loadExtensions();
extensionManager.disableExtension(
'disabled-extension',
SettingScope.User,
);
const extensions = extensionManager.loadExtensions();
const extensions = extensionManager.getExtensions();
expect(extensions).toHaveLength(2);
expect(extensions[0].name).toBe('disabled-extension');
expect(extensions[0].isActive).toBe(false);
@@ -265,13 +266,14 @@ describe('extension tests', () => {
});
fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context');
const extensionName = await extensionManager.installOrUpdateExtension({
extensionManager.loadExtensions();
const extension = await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'link',
});
expect(extensionName).toEqual('my-linked-extension');
const extensions = extensionManager.loadExtensions();
expect(extension.name).toEqual('my-linked-extension');
const extensions = extensionManager.getExtensions();
expect(extensions).toHaveLength(1);
const linkedExt = extensions[0];
@@ -301,12 +303,13 @@ describe('extension tests', () => {
},
});
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'link',
});
const extensions = extensionManager.loadExtensions();
const extensions = extensionManager.getExtensions();
expect(extensions).toHaveLength(1);
expect(extensions[0].mcpServers?.['test-server'].cwd).toBe(
path.join(sourceExtDir, 'server'),
@@ -525,15 +528,17 @@ describe('extension tests', () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const badExtDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'bad_name',
version: '1.0.0',
});
const extension = extensionManager.loadExtension(badExtDir);
const extension = extensionManager
.loadExtensions()
.find((e) => e.name === 'bad_name');
expect(extension).toBeNull();
expect(extension).toBeUndefined();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid extension name: "bad_name"'),
);
@@ -542,7 +547,7 @@ describe('extension tests', () => {
describe('id generation', () => {
it('should generate id from source for non-github git urls', () => {
const extensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
@@ -552,12 +557,14 @@ describe('extension tests', () => {
},
});
const extension = extensionManager.loadExtension(extensionDir);
const extension = extensionManager
.loadExtensions()
.find((e) => e.name === 'my-ext');
expect(extension?.id).toBe(hashValue('http://somehost.com/foo/bar'));
});
it('should generate id from owner/repo for github http urls', () => {
const extensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
@@ -567,12 +574,14 @@ describe('extension tests', () => {
},
});
const extension = extensionManager.loadExtension(extensionDir);
const extension = extensionManager
.loadExtensions()
.find((e) => e.name === 'my-ext');
expect(extension?.id).toBe(hashValue('https://github.com/foo/bar'));
});
it('should generate id from owner/repo for github ssh urls', () => {
const extensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
@@ -582,12 +591,14 @@ describe('extension tests', () => {
},
});
const extension = extensionManager.loadExtension(extensionDir);
const extension = extensionManager
.loadExtensions()
.find((e) => e.name === 'my-ext');
expect(extension?.id).toBe(hashValue('https://github.com/foo/bar'));
});
it('should generate id from source for github-release extension', () => {
const extensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
@@ -597,12 +608,14 @@ describe('extension tests', () => {
},
});
const extension = extensionManager.loadExtension(extensionDir);
const extension = extensionManager
.loadExtensions()
.find((e) => e.name === 'my-ext');
expect(extension?.id).toBe(hashValue('https://github.com/foo/bar'));
});
it('should generate id from the original source for local extension', () => {
const extensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'local-ext-name',
version: '1.0.0',
@@ -612,7 +625,9 @@ describe('extension tests', () => {
},
});
const extension = extensionManager.loadExtension(extensionDir);
const extension = extensionManager
.loadExtensions()
.find((e) => e.name === 'local-ext-name');
expect(extension?.id).toBe(hashValue('/some/path'));
});
@@ -623,25 +638,28 @@ describe('extension tests', () => {
name: 'link-ext-name',
version: '1.0.0',
});
const extensionName = await extensionManager.installOrUpdateExtension({
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
type: 'link',
source: actualExtensionDir,
});
const extension = extensionManager.loadExtension(
new ExtensionStorage(extensionName).getExtensionDir(),
);
const extension = extensionManager
.getExtensions()
.find((e) => e.name === 'link-ext-name');
expect(extension?.id).toBe(hashValue(actualExtensionDir));
});
it('should generate id from name for extension with no install metadata', () => {
const extensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'no-meta-name',
version: '1.0.0',
});
const extension = extensionManager.loadExtension(extensionDir);
const extension = extensionManager
.loadExtensions()
.find((e) => e.name === 'no-meta-name');
expect(extension?.id).toBe(hashValue('no-meta-name'));
});
});
@@ -657,6 +675,7 @@ describe('extension tests', () => {
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
@@ -678,6 +697,7 @@ describe('extension tests', () => {
name: 'my-local-extension',
version: '1.0.0',
});
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
@@ -771,6 +791,7 @@ describe('extension tests', () => {
type: 'github-release',
});
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: gitUrl,
type: 'git',
@@ -795,6 +816,7 @@ describe('extension tests', () => {
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME);
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'link',
@@ -824,6 +846,7 @@ describe('extension tests', () => {
name: 'my-local-extension',
version: '1.1.0',
});
extensionManager.loadExtensions();
if (isUpdate) {
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
@@ -897,12 +920,15 @@ describe('extension tests', () => {
},
});
extensionManager.loadExtensions();
await expect(
extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
}),
).resolves.toBe('my-local-extension');
).resolves.toMatchObject({
name: 'my-local-extension',
});
expect(mockRequestConsent).toHaveBeenCalledWith(
`Installing extension "my-local-extension".
@@ -926,12 +952,13 @@ This extension will run the following MCP servers:
},
});
extensionManager.loadExtensions();
await expect(
extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
}),
).resolves.toBe('my-local-extension');
).resolves.toMatchObject({ name: 'my-local-extension' });
});
it('should cancel installation if user declines prompt for local extension with mcp servers', async () => {
@@ -947,6 +974,7 @@ This extension will run the following MCP servers:
},
});
mockRequestConsent.mockResolvedValue(false);
extensionManager.loadExtensions();
await expect(
extensionManager.installOrUpdateExtension({
source: sourceExtDir,
@@ -964,6 +992,7 @@ This extension will run the following MCP servers:
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
@@ -994,6 +1023,7 @@ This extension will run the following MCP servers:
},
});
extensionManager.loadExtensions();
// Install it with hard coded consent first.
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
@@ -1008,7 +1038,7 @@ This extension will run the following MCP servers:
// Provide its own existing config as the previous config.
await extensionManager.loadExtensionConfig(sourceExtDir),
),
).resolves.toBe('my-local-extension');
).resolves.toMatchObject({ name: 'my-local-extension' });
// Still only called once
expect(mockRequestConsent).toHaveBeenCalledOnce();
@@ -1028,6 +1058,7 @@ This extension will run the following MCP servers:
],
});
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
@@ -1054,9 +1085,10 @@ This extension will run the following MCP servers:
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: null,
loadedSettings: loadSettings(tempWorkspaceDir),
settings: loadSettings(tempWorkspaceDir).merged,
});
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
type: 'local',
@@ -1079,6 +1111,7 @@ This extension will run the following MCP servers:
});
mockPromptForSettings.mockResolvedValueOnce('old-api-key');
extensionManager.loadExtensions();
// Install it so it exists in the userExtensionsDir
await extensionManager.installOrUpdateExtension({
source: oldSourceExtDir,
@@ -1148,6 +1181,7 @@ This extension will run the following MCP servers:
},
],
});
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: oldSourceExtDir,
type: 'local',
@@ -1239,6 +1273,7 @@ This extension will run the following MCP servers:
join(tempDir, extensionName),
);
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: gitUrl,
type: 'github-release',
@@ -1263,6 +1298,7 @@ This extension will run the following MCP servers:
type: 'github-release',
});
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension(
{ source: gitUrl, type: 'github-release' }, // Use github-release to force consent
);
@@ -1293,6 +1329,7 @@ This extension will run the following MCP servers:
});
mockRequestConsent.mockResolvedValue(false);
extensionManager.loadExtensions();
await expect(
extensionManager.installOrUpdateExtension({
source: gitUrl,
@@ -1317,6 +1354,7 @@ This extension will run the following MCP servers:
type: 'github-release',
});
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: gitUrl,
type: 'git',
@@ -1347,6 +1385,7 @@ This extension will run the following MCP servers:
type: 'github-release',
});
extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension(
{ source: gitUrl, type: 'github-release' }, // Note the type
);
@@ -1369,6 +1408,7 @@ This extension will run the following MCP servers:
version: '1.0.0',
});
extensionManager.loadExtensions();
await extensionManager.uninstallExtension('my-local-extension', false);
expect(fs.existsSync(sourceExtDir)).toBe(false);
@@ -1386,14 +1426,16 @@ This extension will run the following MCP servers:
version: '1.0.0',
});
extensionManager.loadExtensions();
await extensionManager.uninstallExtension('my-local-extension', false);
expect(fs.existsSync(sourceExtDir)).toBe(false);
expect(extensionManager.loadExtensions()).toHaveLength(1);
expect(extensionManager.getExtensions()).toHaveLength(1);
expect(fs.existsSync(otherExtDir)).toBe(true);
});
it('should throw an error if the extension does not exist', async () => {
extensionManager.loadExtensions();
await expect(
extensionManager.uninstallExtension('nonexistent-extension', false),
).rejects.toThrow('Extension not found.');
@@ -1411,6 +1453,7 @@ This extension will run the following MCP servers:
},
});
extensionManager.loadExtensions();
await extensionManager.uninstallExtension(
'my-local-extension',
isUpdate,
@@ -1438,6 +1481,7 @@ This extension will run the following MCP servers:
const enablementManager = new ExtensionEnablementManager();
enablementManager.enable('test-extension', true, '/some/scope');
extensionManager.loadExtensions();
await extensionManager.uninstallExtension('test-extension', isUpdate);
const config = enablementManager.readConfig()['test-extension'];
@@ -1462,6 +1506,7 @@ This extension will run the following MCP servers:
},
});
extensionManager.loadExtensions();
await extensionManager.uninstallExtension(gitUrl, false);
expect(fs.existsSync(sourceExtDir)).toBe(false);
@@ -1481,6 +1526,7 @@ This extension will run the following MCP servers:
// No installMetadata provided
});
extensionManager.loadExtensions();
await expect(
extensionManager.uninstallExtension(
'https://github.com/google/no-metadata-extension',
@@ -1498,6 +1544,7 @@ This extension will run the following MCP servers:
version: '1.0.0',
});
extensionManager.loadExtensions();
extensionManager.disableExtension('my-extension', SettingScope.User);
expect(
isEnabled({
@@ -1514,6 +1561,7 @@ This extension will run the following MCP servers:
version: '1.0.0',
});
extensionManager.loadExtensions();
extensionManager.disableExtension('my-extension', SettingScope.Workspace);
expect(
isEnabled({
@@ -1536,6 +1584,7 @@ This extension will run the following MCP servers:
version: '1.0.0',
});
extensionManager.loadExtensions();
extensionManager.disableExtension('my-extension', SettingScope.User);
extensionManager.disableExtension('my-extension', SettingScope.User);
expect(
@@ -1563,6 +1612,7 @@ This extension will run the following MCP servers:
},
});
extensionManager.loadExtensions();
extensionManager.disableExtension('ext1', SettingScope.Workspace);
expect(mockLogExtensionDisable).toHaveBeenCalled();
@@ -1580,7 +1630,7 @@ This extension will run the following MCP servers:
});
const getActiveExtensions = (): GeminiCLIExtension[] => {
const extensions = extensionManager.loadExtensions();
const extensions = extensionManager.getExtensions();
return extensions.filter((e) => e.isActive);
};
@@ -1590,6 +1640,7 @@ This extension will run the following MCP servers:
name: 'ext1',
version: '1.0.0',
});
extensionManager.loadExtensions();
extensionManager.disableExtension('ext1', SettingScope.User);
let activeExtensions = getActiveExtensions();
expect(activeExtensions).toHaveLength(0);
@@ -1606,6 +1657,7 @@ This extension will run the following MCP servers:
name: 'ext1',
version: '1.0.0',
});
extensionManager.loadExtensions();
extensionManager.disableExtension('ext1', SettingScope.Workspace);
let activeExtensions = getActiveExtensions();
expect(activeExtensions).toHaveLength(0);
@@ -1626,6 +1678,7 @@ This extension will run the following MCP servers:
type: 'local',
},
});
extensionManager.loadExtensions();
extensionManager.disableExtension('ext1', SettingScope.Workspace);
extensionManager.enableExtension('ext1', SettingScope.Workspace);
@@ -170,7 +170,7 @@ describe('git extension helpers', () => {
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
loadedSettings: loadSettings(tempWorkspaceDir),
settings: loadSettings(tempWorkspaceDir).merged,
});
});
+5 -3
View File
@@ -157,14 +157,16 @@ export async function checkForExtensionUpdate(
): Promise<ExtensionUpdateState> {
const installMetadata = extension.installMetadata;
if (installMetadata?.type === 'local') {
const newExtension = extensionManager.loadExtension(installMetadata.source);
if (!newExtension) {
const latestConfig = extensionManager.loadExtensionConfig(
installMetadata.source,
);
if (!latestConfig) {
debugLogger.error(
`Failed to check for update for local extension "${extension.name}". Could not load extension from source path: ${installMetadata.source}`,
);
return ExtensionUpdateState.ERROR;
}
if (newExtension.version !== extension.version) {
if (latestConfig.version !== extension.version) {
return ExtensionUpdateState.UPDATE_AVAILABLE;
}
return ExtensionUpdateState.UP_TO_DATE;
@@ -109,7 +109,7 @@ describe('update tests', () => {
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
loadedSettings: loadSettings(tempWorkspaceDir),
settings: loadSettings(tempWorkspaceDir).merged,
});
});
@@ -145,7 +145,9 @@ describe('update tests', () => {
);
});
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const extension = extensionManager.loadExtension(targetExtDir)!;
const extension = extensionManager
.loadExtensions()
.find((e) => e.name === extensionName)!;
const updateInfo = await updateExtension(
extension,
extensionManager,
@@ -170,7 +172,7 @@ describe('update tests', () => {
it('should call setExtensionUpdateState with UPDATING and then UPDATED_NEEDS_RESTART on success', async () => {
const extensionName = 'test-extension';
const extensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: extensionName,
version: '1.0.0',
@@ -192,7 +194,10 @@ describe('update tests', () => {
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const dispatch = vi.fn();
const extension = extensionManager.loadExtension(extensionDir)!;
const extension = extensionManager
.loadExtensions()
.find((e) => e.name === extensionName)!;
await updateExtension(
extension,
extensionManager,
@@ -218,7 +223,7 @@ describe('update tests', () => {
it('should call setExtensionUpdateState with ERROR on failure', async () => {
const extensionName = 'test-extension';
const extensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: extensionName,
version: '1.0.0',
@@ -232,7 +237,9 @@ describe('update tests', () => {
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const dispatch = vi.fn();
const extension = extensionManager.loadExtension(extensionDir)!;
const extension = extensionManager
.loadExtensions()
.find((e) => e.name === extensionName)!;
await expect(
updateExtension(
extension,
@@ -261,7 +268,7 @@ describe('update tests', () => {
describe('checkForAllExtensionUpdates', () => {
it('should return UpdateAvailable for a git extension with updates', async () => {
const extensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
@@ -270,7 +277,6 @@ describe('update tests', () => {
type: 'git',
},
});
const extension = extensionManager.loadExtension(extensionDir)!;
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
@@ -280,7 +286,7 @@ describe('update tests', () => {
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
[extension],
extensionManager.loadExtensions(),
extensionManager,
dispatch,
);
@@ -294,7 +300,7 @@ describe('update tests', () => {
});
it('should return UpToDate for a git extension with no updates', async () => {
const extensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
@@ -303,7 +309,6 @@ describe('update tests', () => {
type: 'git',
},
});
const extension = extensionManager.loadExtension(extensionDir)!;
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
@@ -313,7 +318,7 @@ describe('update tests', () => {
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
[extension],
extensionManager.loadExtensions(),
extensionManager,
dispatch,
);
@@ -334,16 +339,15 @@ describe('update tests', () => {
version: '1.0.0',
});
const installedExtensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'local-extension',
version: '1.0.0',
installMetadata: { source: sourceExtensionDir, type: 'local' },
});
const extension = extensionManager.loadExtension(installedExtensionDir)!;
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
[extension],
extensionManager.loadExtensions(),
extensionManager,
dispatch,
);
@@ -360,20 +364,19 @@ describe('update tests', () => {
const localExtensionSourcePath = path.join(tempHomeDir, 'local-source');
const sourceExtensionDir = createExtension({
extensionsDir: localExtensionSourcePath,
name: 'my-local-ext',
name: 'local-extension',
version: '1.1.0',
});
const installedExtensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'local-extension',
version: '1.0.0',
installMetadata: { source: sourceExtensionDir, type: 'local' },
});
const extension = extensionManager.loadExtension(installedExtensionDir)!;
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
[extension],
extensionManager.loadExtensions(),
extensionManager,
dispatch,
);
@@ -387,7 +390,7 @@ describe('update tests', () => {
});
it('should return Error when git check fails', async () => {
const extensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'error-extension',
version: '1.0.0',
@@ -396,13 +399,12 @@ describe('update tests', () => {
type: 'git',
},
});
const extension = extensionManager.loadExtension(extensionDir)!;
mockGit.getRemotes.mockRejectedValue(new Error('Git error'));
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
[extension],
extensionManager.loadExtensions(),
extensionManager,
dispatch,
);
+10 -10
View File
@@ -61,20 +61,20 @@ export async function updateExtension(
const previousExtensionConfig = await extensionManager.loadExtensionConfig(
extension.path,
);
await extensionManager.installOrUpdateExtension(
installMetadata,
previousExtensionConfig,
);
const updatedExtensionStorage = new ExtensionStorage(extension.name);
const updatedExtension = extensionManager.loadExtension(
updatedExtensionStorage.getExtensionDir(),
);
if (!updatedExtension) {
let updatedExtension: GeminiCLIExtension;
try {
updatedExtension = await extensionManager.installOrUpdateExtension(
installMetadata,
previousExtensionConfig,
);
} catch (e) {
dispatchExtensionStateUpdate({
type: 'SET_STATE',
payload: { name: extension.name, state: ExtensionUpdateState.ERROR },
});
throw new Error('Updated extension not found after installation.');
throw new Error(
`Updated extension not found after installation, got error:\n${e}`,
);
}
const updatedVersion = updatedExtension.version;
dispatchExtensionStateUpdate({
+2 -2
View File
@@ -2433,7 +2433,7 @@ describe('Settings Loading and Merging', () => {
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
const extensionManager = new ExtensionManager({
loadedSettings,
settings: loadedSettings.merged,
workspaceDir: MOCK_WORKSPACE_DIR,
requestConsent: vi.fn(),
requestSetting: vi.fn(),
@@ -2506,7 +2506,7 @@ describe('Settings Loading and Merging', () => {
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
const extensionManager = new ExtensionManager({
loadedSettings,
settings: loadedSettings.merged,
workspaceDir: MOCK_WORKSPACE_DIR,
requestConsent: vi.fn(),
requestSetting: vi.fn(),
+5 -22
View File
@@ -67,8 +67,8 @@ import {
} from './utils/relaunch.js';
import { loadSandboxConfig } from './config/sandboxConfig.js';
import { ExtensionManager } from './config/extension-manager.js';
import { requestConsentNonInteractive } from './config/extensions/consent.js';
import { createPolicyUpdater } from './config/policy.js';
import { requestConsentNonInteractive } from './config/extensions/consent.js';
export function validateDnsResolutionOrder(
order: string | undefined,
@@ -230,7 +230,7 @@ export async function main() {
// Temporary extension manager only used during this non-interactive UI phase.
new ExtensionManager({
workspaceDir: process.cwd(),
loadedSettings: settings,
settings: settings.merged,
enabledExtensionOverrides: [],
requestConsent: requestConsentNonInteractive,
requestSetting: null,
@@ -299,7 +299,6 @@ export async function main() {
if (sandboxConfig) {
const partialConfig = await loadCliConfig(
settings.merged,
[],
sessionId,
argv,
);
@@ -370,23 +369,7 @@ export async function main() {
// to run Gemini CLI. It is now safe to perform expensive initialization that
// may have side effects.
{
// Eventually, `extensions` should move off of `config` entirely and into
// the UI state instead.
const extensionManager = new ExtensionManager({
loadedSettings: settings,
workspaceDir: process.cwd(),
// At this stage, we still don't have an interactive UI.
requestConsent: requestConsentNonInteractive,
requestSetting: null,
enabledExtensionOverrides: argv.extensions,
});
const extensions = extensionManager.loadExtensions();
const config = await loadCliConfig(
settings.merged,
extensions,
sessionId,
argv,
);
const config = await loadCliConfig(settings.merged, sessionId, argv);
const policyEngine = config.getPolicyEngine();
const messageBus = config.getMessageBus();
@@ -397,7 +380,7 @@ export async function main() {
if (config.getListExtensions()) {
debugLogger.log('Installed extensions:');
for (const extension of extensions) {
for (const extension of config.getExtensions()) {
debugLogger.log(`- ${extension.name}`);
}
process.exit(0);
@@ -434,7 +417,7 @@ export async function main() {
}
if (config.getExperimentalZedIntegration()) {
return runZedIntegration(config, settings, extensions, argv);
return runZedIntegration(config, settings, argv);
}
let input = config.getQuestion();
+12
View File
@@ -12,6 +12,7 @@ import {
beforeEach,
afterEach,
type Mock,
type MockedObject,
} from 'vitest';
import { render, cleanup } from 'ink-testing-library';
import { AppContainer } from './AppContainer.js';
@@ -131,11 +132,13 @@ import { useKeypress, type Key } from './hooks/useKeypress.js';
import { measureElement } from 'ink';
import { useTerminalSize } from './hooks/useTerminalSize.js';
import { ShellExecutionService } from '@google/gemini-cli-core';
import { type ExtensionManager } from '../config/extension-manager.js';
describe('AppContainer State Management', () => {
let mockConfig: Config;
let mockSettings: LoadedSettings;
let mockInitResult: InitializationResult;
let mockExtensionManager: MockedObject<ExtensionManager>;
// Create typed mocks for all hooks
const mockedUseQuotaAndFallback = useQuotaAndFallback as Mock;
@@ -282,6 +285,15 @@ describe('AppContainer State Management', () => {
// Mock config's getTargetDir to return consistent workspace directory
vi.spyOn(mockConfig, 'getTargetDir').mockReturnValue('/test/workspace');
mockExtensionManager = vi.mockObject({
getExtensions: vi.fn().mockReturnValue([]),
setRequestConsent: vi.fn(),
setRequestSetting: vi.fn(),
} as unknown as ExtensionManager);
vi.spyOn(mockConfig, 'getExtensionLoader').mockReturnValue(
mockExtensionManager,
);
// Mock LoadedSettings
mockSettings = {
merged: {
+8 -17
View File
@@ -98,7 +98,7 @@ import {
useExtensionUpdates,
} from './hooks/useExtensionUpdates.js';
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { ExtensionManager } from '../config/extension-manager.js';
import { type ExtensionManager } from '../config/extension-manager.js';
import { requestConsentInteractive } from '../config/extensions/consent.js';
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
@@ -168,21 +168,12 @@ export const AppContainer = (props: AppContainerProps) => {
null,
);
const extensions = config.getExtensions();
const [extensionManager] = useState<ExtensionManager>(
new ExtensionManager({
enabledExtensionOverrides: config.getEnabledExtensions(),
workspaceDir: config.getWorkingDir(),
requestConsent: (description) =>
requestConsentInteractive(
description,
addConfirmUpdateExtensionRequest,
),
// TODO: Support requesting settings in the interactive CLI
requestSetting: null,
loadedSettings: settings,
}),
const extensionManager = config.getExtensionLoader() as ExtensionManager;
// We are in the interactive CLI, update how we request consent and settings.
extensionManager.setRequestConsent((description) =>
requestConsentInteractive(description, addConfirmUpdateExtensionRequest),
);
extensionManager.setRequestSetting();
const { addConfirmUpdateExtensionRequest, confirmUpdateExtensionRequests } =
useConfirmUpdateRequests();
@@ -190,7 +181,7 @@ export const AppContainer = (props: AppContainerProps) => {
extensionsUpdateState,
extensionsUpdateStateInternal,
dispatchExtensionStateUpdate,
} = useExtensionUpdates(extensions, extensionManager, historyManager.addItem);
} = useExtensionUpdates(extensionManager, historyManager.addItem);
const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false);
const openPermissionsDialog = useCallback(
@@ -548,7 +539,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
config.getDebugMode(),
config.getFileService(),
settings.merged,
config.getExtensions(),
config.getExtensionLoader(),
config.isTrustedFolder(),
settings.merged.context?.importFormat || 'tree', // Use setting or default to 'tree'
config.getFileFilteringOptions(),
@@ -103,7 +103,7 @@ export const directoryCommand: SlashCommand = {
],
config.getDebugMode(),
config.getFileService(),
config.getExtensions(),
config.getExtensionLoader(),
config.getFolderTrust(),
context.services.settings.merged.context?.importFormat ||
'tree', // Use setting or default to 'tree'
@@ -13,6 +13,7 @@ import { MessageType } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
import {
getErrorMessage,
SimpleExtensionLoader,
type FileDiscoveryService,
} from '@google/gemini-cli-core';
import type { LoadServerHierarchicalMemoryResponse } from '@google/gemini-cli-core/index.js';
@@ -72,6 +73,7 @@ describe('memoryCommand', () => {
config: {
getUserMemory: mockGetUserMemory,
getGeminiMdFileCount: mockGetGeminiMdFileCount,
getExtensionLoader: () => new SimpleExtensionLoader([]),
},
},
});
@@ -176,6 +178,7 @@ describe('memoryCommand', () => {
getWorkingDir: () => '/test/dir',
getDebugMode: () => false,
getFileService: () => ({}) as FileDiscoveryService,
getExtensionLoader: () => new SimpleExtensionLoader([]),
getExtensions: () => [],
shouldLoadMemoryFromIncludeDirectories: () => false,
getWorkspaceContext: () => ({
@@ -91,7 +91,7 @@ export const memoryCommand: SlashCommand = {
config.getDebugMode(),
config.getFileService(),
settings.merged,
config.getExtensions(),
config.getExtensionLoader(),
config.isTrustedFolder(),
settings.merged.context?.importFormat || 'tree',
config.getFileFilteringOptions(),
@@ -10,7 +10,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { createExtension } from '../../test-utils/createExtension.js';
import { useExtensionUpdates } from './useExtensionUpdates.js';
import { GEMINI_DIR, type GeminiCLIExtension } from '@google/gemini-cli-core';
import { GEMINI_DIR } from '@google/gemini-cli-core';
import { render } from 'ink-testing-library';
import { MessageType } from '../types.js';
import {
@@ -57,7 +57,7 @@ describe('useExtensionUpdates', () => {
workspaceDir: tempHomeDir,
requestConsent: vi.fn(),
requestSetting: vi.fn(),
loadedSettings: loadSettings(),
settings: loadSettings().merged,
});
});
@@ -66,11 +66,10 @@ describe('useExtensionUpdates', () => {
});
it('should check for updates and log a message if an update is available', async () => {
const extensions = [
vi.spyOn(extensionManager, 'getExtensions').mockReturnValue([
{
name: 'test-extension',
id: 'test-extension-id',
type: 'git',
version: '1.0.0',
path: '/some/path',
isActive: true,
@@ -81,7 +80,7 @@ describe('useExtensionUpdates', () => {
},
contextFiles: [],
},
];
]);
const addItem = vi.fn();
vi.mocked(checkForAllExtensionUpdates).mockImplementation(
@@ -97,11 +96,7 @@ describe('useExtensionUpdates', () => {
);
function TestComponent() {
useExtensionUpdates(
extensions as GeminiCLIExtension[],
extensionManager,
addItem,
);
useExtensionUpdates(extensionManager, addItem);
return null;
}
@@ -119,7 +114,7 @@ describe('useExtensionUpdates', () => {
});
it('should check for updates and automatically update if autoUpdate is true', async () => {
const extensionDir = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
@@ -129,7 +124,6 @@ describe('useExtensionUpdates', () => {
autoUpdate: true,
},
});
const extension = extensionManager.loadExtension(extensionDir)!;
const addItem = vi.fn();
@@ -151,8 +145,9 @@ describe('useExtensionUpdates', () => {
name: '',
});
extensionManager.loadExtensions();
function TestComponent() {
useExtensionUpdates([extension], extensionManager, addItem);
useExtensionUpdates(extensionManager, addItem);
return null;
}
@@ -173,7 +168,7 @@ describe('useExtensionUpdates', () => {
});
it('should batch update notifications for multiple extensions', async () => {
const extensionDir1 = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension-1',
version: '1.0.0',
@@ -183,7 +178,7 @@ describe('useExtensionUpdates', () => {
autoUpdate: true,
},
});
const extensionDir2 = createExtension({
createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension-2',
version: '2.0.0',
@@ -194,10 +189,7 @@ describe('useExtensionUpdates', () => {
},
});
const extensions = [
extensionManager.loadExtension(extensionDir1)!,
extensionManager.loadExtension(extensionDir2)!,
];
extensionManager.loadExtensions();
const addItem = vi.fn();
@@ -233,7 +225,7 @@ describe('useExtensionUpdates', () => {
});
function TestComponent() {
useExtensionUpdates(extensions, extensionManager, addItem);
useExtensionUpdates(extensionManager, addItem);
return null;
}
@@ -262,11 +254,10 @@ describe('useExtensionUpdates', () => {
});
it('should batch update notifications for multiple extensions with autoUpdate: false', async () => {
const extensions = [
vi.spyOn(extensionManager, 'getExtensions').mockReturnValue([
{
name: 'test-extension-1',
id: 'test-extension-1-id',
type: 'git',
version: '1.0.0',
path: '/some/path1',
isActive: true,
@@ -281,7 +272,6 @@ describe('useExtensionUpdates', () => {
name: 'test-extension-2',
id: 'test-extension-2-id',
type: 'git',
version: '2.0.0',
path: '/some/path2',
isActive: true,
@@ -292,7 +282,7 @@ describe('useExtensionUpdates', () => {
},
contextFiles: [],
},
];
]);
const addItem = vi.fn();
vi.mocked(checkForAllExtensionUpdates).mockImplementation(
@@ -318,11 +308,7 @@ describe('useExtensionUpdates', () => {
);
function TestComponent() {
useExtensionUpdates(
extensions as GeminiCLIExtension[],
extensionManager,
addItem,
);
useExtensionUpdates(extensionManager, addItem);
return null;
}
@@ -78,7 +78,6 @@ export const useConfirmUpdateRequests = () => {
};
export const useExtensionUpdates = (
extensions: GeminiCLIExtension[],
extensionManager: ExtensionManager,
addItem: UseHistoryManagerReturn['addItem'],
) => {
@@ -86,6 +85,7 @@ export const useExtensionUpdates = (
extensionUpdatesReducer,
initialExtensionUpdatesState,
);
const extensions = extensionManager.getExtensions();
useEffect(() => {
const extensionsToCheck = extensions.filter((extension) => {
@@ -11,7 +11,6 @@ import type {
GeminiChat,
ToolResult,
ToolCallConfirmationDetails,
GeminiCLIExtension,
FilterFilesOptions,
} from '@google/gemini-cli-core';
import {
@@ -63,7 +62,6 @@ export function resolveModel(model: string, isInFallbackMode: boolean): string {
export async function runZedIntegration(
config: Config,
settings: LoadedSettings,
extensions: GeminiCLIExtension[],
argv: CliArgs,
) {
const stdout = Writable.toWeb(process.stdout) as WritableStream;
@@ -76,8 +74,7 @@ export async function runZedIntegration(
console.debug = console.error;
new acp.AgentSideConnection(
(client: acp.Client) =>
new GeminiAgent(config, settings, extensions, argv, client),
(client: acp.Client) => new GeminiAgent(config, settings, argv, client),
stdout,
stdin,
);
@@ -90,7 +87,6 @@ class GeminiAgent {
constructor(
private config: Config,
private settings: LoadedSettings,
private extensions: GeminiCLIExtension[],
private argv: CliArgs,
private client: acp.Client,
) {}
@@ -204,13 +200,7 @@ class GeminiAgent {
const settings = { ...this.settings.merged, mcpServers: mergedMcpServers };
const config = await loadCliConfig(
settings,
this.extensions,
sessionId,
this.argv,
cwd,
);
const config = await loadCliConfig(settings, sessionId, this.argv, cwd);
await config.initialize();
return config;
+13 -4
View File
@@ -154,6 +154,10 @@ import {
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
} from './constants.js';
import { debugLogger } from '../utils/debugLogger.js';
import {
type ExtensionLoader,
SimpleExtensionLoader,
} from '../utils/extensionLoader.js';
export type { FileFilteringOptions };
export {
@@ -248,7 +252,7 @@ export interface ConfigParameters {
maxSessionTurns?: number;
experimentalZedIntegration?: boolean;
listExtensions?: boolean;
extensions?: GeminiCLIExtension[];
extensionLoader?: ExtensionLoader;
enabledExtensions?: string[];
blockedMcpServers?: Array<{ name: string; extensionName: string }>;
noBrowser?: boolean;
@@ -337,7 +341,7 @@ export class Config {
private inFallbackMode = false;
private readonly maxSessionTurns: number;
private readonly listExtensions: boolean;
private readonly _extensions: GeminiCLIExtension[];
private readonly _extensionLoader: ExtensionLoader;
private readonly _enabledExtensions: string[];
private readonly _blockedMcpServers: Array<{
name: string;
@@ -440,7 +444,8 @@ export class Config {
this.experimentalZedIntegration =
params.experimentalZedIntegration ?? false;
this.listExtensions = params.listExtensions ?? false;
this._extensions = params.extensions ?? [];
this._extensionLoader =
params.extensionLoader ?? new SimpleExtensionLoader([]);
this._enabledExtensions = params.enabledExtensions ?? [];
this._blockedMcpServers = params.blockedMcpServers ?? [];
this.noBrowser = params.noBrowser ?? false;
@@ -885,7 +890,11 @@ export class Config {
}
getExtensions(): GeminiCLIExtension[] {
return this._extensions;
return this._extensionLoader.getExtensions();
}
getExtensionLoader(): ExtensionLoader {
return this._extensionLoader;
}
// The list of explicitly enabled extensions, if any were given, may contain
+1
View File
@@ -66,6 +66,7 @@ export * from './utils/promptIdContext.js';
export * from './utils/thoughtUtils.js';
export * from './utils/debugLogger.js';
export * from './utils/events.js';
export * from './utils/extensionLoader.js';
// Export services
export * from './services/fileDiscoveryService.js';
@@ -0,0 +1,48 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { EventEmitter } from 'node:events';
import type { GeminiCLIExtension } from '../config/config.js';
export interface ExtensionLoader {
getExtensions(): GeminiCLIExtension[];
extensionEvents(): EventEmitter<ExtensionEvents>;
}
export interface ExtensionEvents {
extensionEnabled: ExtensionEnableEvent[];
extensionDisabled: ExtensionDisableEvent[];
extensionLoaded: ExtensionLoadEvent[];
extensionUnloaded: ExtensionUnloadEvent[];
extensionInstalled: ExtensionInstallEvent[];
extensionUninstalled: ExtensionUninstallEvent[];
extensionUpdated: ExtensionUpdateEvent[];
}
interface BaseExtensionEvent {
extension: GeminiCLIExtension;
}
export type ExtensionDisableEvent = BaseExtensionEvent;
export type ExtensionEnableEvent = BaseExtensionEvent;
export type ExtensionInstallEvent = BaseExtensionEvent;
export type ExtensionLoadEvent = BaseExtensionEvent;
export type ExtensionUnloadEvent = BaseExtensionEvent;
export type ExtensionUninstallEvent = BaseExtensionEvent;
export type ExtensionUpdateEvent = BaseExtensionEvent;
export class SimpleExtensionLoader implements ExtensionLoader {
private _eventEmitter = new EventEmitter<ExtensionEvents>();
constructor(private readonly extensions: GeminiCLIExtension[]) {}
extensionEvents(): EventEmitter<ExtensionEvents> {
return this._eventEmitter;
}
getExtensions(): GeminiCLIExtension[] {
return this.extensions;
}
}
+19 -18
View File
@@ -16,6 +16,7 @@ import {
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { GEMINI_DIR } from './paths.js';
import type { GeminiCLIExtension } from '../config/config.js';
import { SimpleExtensionLoader } from './extensionLoader.js';
vi.mock('os', async (importOriginal) => {
const actualOs = await importOriginal<typeof os>();
@@ -88,7 +89,7 @@ describe('loadServerHierarchicalMemory', () => {
[],
false,
new FileDiscoveryService(projectRoot),
[], // extensions
new SimpleExtensionLoader([]),
false, // untrusted
);
@@ -117,7 +118,7 @@ describe('loadServerHierarchicalMemory', () => {
[],
false,
new FileDiscoveryService(projectRoot),
[], // extensions
new SimpleExtensionLoader([]),
false, // untrusted
);
@@ -133,7 +134,7 @@ describe('loadServerHierarchicalMemory', () => {
[],
false,
new FileDiscoveryService(projectRoot),
[], // extensions
new SimpleExtensionLoader([]),
DEFAULT_FOLDER_TRUST,
);
@@ -155,7 +156,7 @@ describe('loadServerHierarchicalMemory', () => {
[],
false,
new FileDiscoveryService(projectRoot),
[], // extensions
new SimpleExtensionLoader([]),
DEFAULT_FOLDER_TRUST,
);
@@ -182,7 +183,7 @@ default context content
[],
false,
new FileDiscoveryService(projectRoot),
[], // extensions
new SimpleExtensionLoader([]),
DEFAULT_FOLDER_TRUST,
);
@@ -213,7 +214,7 @@ custom context content
[],
false,
new FileDiscoveryService(projectRoot),
[], // extensions
new SimpleExtensionLoader([]),
DEFAULT_FOLDER_TRUST,
);
@@ -248,7 +249,7 @@ cwd context content
[],
false,
new FileDiscoveryService(projectRoot),
[], // extensions
new SimpleExtensionLoader([]),
DEFAULT_FOLDER_TRUST,
);
@@ -280,7 +281,7 @@ Subdir custom memory
[],
false,
new FileDiscoveryService(projectRoot),
[], // extensions
new SimpleExtensionLoader([]),
DEFAULT_FOLDER_TRUST,
);
@@ -312,7 +313,7 @@ Src directory memory
[],
false,
new FileDiscoveryService(projectRoot),
[], // extensions
new SimpleExtensionLoader([]),
DEFAULT_FOLDER_TRUST,
);
@@ -356,7 +357,7 @@ Subdir memory
[],
false,
new FileDiscoveryService(projectRoot),
[], // extensions
new SimpleExtensionLoader([]),
DEFAULT_FOLDER_TRUST,
);
@@ -409,7 +410,7 @@ Subdir memory
[],
false,
new FileDiscoveryService(projectRoot),
[], // extensions
new SimpleExtensionLoader([]),
DEFAULT_FOLDER_TRUST,
'tree',
{
@@ -445,7 +446,7 @@ My code memory
[],
true,
new FileDiscoveryService(projectRoot),
[], // extensions
new SimpleExtensionLoader([]),
DEFAULT_FOLDER_TRUST,
'tree', // importFormat
{
@@ -467,7 +468,7 @@ My code memory
[],
false,
new FileDiscoveryService(projectRoot),
[], // extensions
new SimpleExtensionLoader([]),
DEFAULT_FOLDER_TRUST,
);
@@ -489,12 +490,12 @@ My code memory
[],
false,
new FileDiscoveryService(projectRoot),
[
new SimpleExtensionLoader([
{
contextFiles: [extensionFilePath],
isActive: true,
} as GeminiCLIExtension,
], // extensions
]),
DEFAULT_FOLDER_TRUST,
);
@@ -521,7 +522,7 @@ Extension memory content
[includedDir],
false,
new FileDiscoveryService(projectRoot),
[], // extensions
new SimpleExtensionLoader([]),
DEFAULT_FOLDER_TRUST,
);
@@ -556,7 +557,7 @@ included directory memory
createdFiles.map((f) => path.dirname(f)),
false,
new FileDiscoveryService(projectRoot),
[], // extensions
new SimpleExtensionLoader([]),
DEFAULT_FOLDER_TRUST,
);
@@ -591,7 +592,7 @@ included directory memory
[childDir, parentDir], // Deliberately include duplicates
false,
new FileDiscoveryService(projectRoot),
[], // extensions
new SimpleExtensionLoader([]),
DEFAULT_FOLDER_TRUST,
);
+4 -3
View File
@@ -15,7 +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';
import type { ExtensionLoader } from './extensionLoader.js';
import { debugLogger } from './debugLogger.js';
// Simple console logger, similar to the one previously in CLI's config.ts
@@ -338,7 +338,7 @@ export async function loadServerHierarchicalMemory(
includeDirectoriesToReadGemini: readonly string[],
debugMode: boolean,
fileService: FileDiscoveryService,
extensions: GeminiCLIExtension[],
extensionLoader: ExtensionLoader,
folderTrust: boolean,
importFormat: 'flat' | 'tree' = 'tree',
fileFilteringOptions?: FileFilteringOptions,
@@ -365,7 +365,8 @@ export async function loadServerHierarchicalMemory(
// Add extension file paths separately since they may be conditionally enabled.
filePaths.push(
...extensions
...extensionLoader
.getExtensions()
.filter((ext) => ext.isActive)
.flatMap((ext) => ext.contextFiles),
);