mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-04 08:54:28 -07:00
Add ExtensionLoader interface, use that on Config object (#12116)
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.`);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user