/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as fs from 'node:fs'; import * as path from 'node:path'; import { stat } from 'node:fs/promises'; import chalk from 'chalk'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; import { type MergedSettings, SettingScope } from './settings.js'; import { createHash, randomUUID } from 'node:crypto'; import { loadInstallMetadata, type ExtensionConfig } from './extension.js'; import { isWorkspaceTrusted, loadTrustedFolders, TrustLevel, } from './trustedFolders.js'; import { cloneFromGit, downloadFromGitHubRelease, tryParseGithubUrl, } from './extensions/github.js'; import { Config, debugLogger, ExtensionDisableEvent, ExtensionEnableEvent, ExtensionInstallEvent, ExtensionLoader, ExtensionUninstallEvent, ExtensionUpdateEvent, getErrorMessage, getRealPath, logExtensionDisable, logExtensionEnable, logExtensionInstallEvent, logExtensionUninstall, logExtensionUpdateEvent, loadSkillsFromDir, loadAgentsFromDirectory, homedir, ExtensionIntegrityManager, type IExtensionIntegrity, type IntegrityDataStatus, type ExtensionEvents, type MCPServerConfig, type ExtensionInstallMetadata, type GeminiCLIExtension, type HookDefinition, type HookEventName, type ResolvedExtensionSetting, coreEvents, applyAdminAllowlist, getAdminBlockedMcpServersMessage, CoreToolCallStatus, loadExtensionPolicies, isSubpath, type PolicyRule, type SafetyCheckerRule, HookType, } from '@google/gemini-cli-core'; import { maybeRequestConsentOrFail } from './extensions/consent.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { ExtensionStorage } from './extensions/storage.js'; import { EXTENSIONS_CONFIG_FILENAME, INSTALL_METADATA_FILENAME, recursivelyHydrateStrings, type JsonObject, type VariableContext, } from './extensions/variables.js'; import { getEnvContents, getEnvFilePath, maybePromptForSettings, getMissingSettings, type ExtensionSetting, getScopedEnvContents, ExtensionSettingScope, } from './extensions/extensionSettings.js'; import type { EventEmitter } from 'node:stream'; import { themeManager } from '../ui/themes/theme-manager.js'; import { getFormattedSettingValue } from '../commands/extensions/utils.js'; interface ExtensionManagerParams { enabledExtensionOverrides?: string[]; settings: MergedSettings; requestConsent: (consent: string) => Promise; requestSetting: ((setting: ExtensionSetting) => Promise) | null; workspaceDir: string; eventEmitter?: EventEmitter; clientVersion?: string; integrityManager?: IExtensionIntegrity; } /** * Actual implementation of an ExtensionLoader. * * You must call `loadExtensions` prior to calling other methods on this class. */ export class ExtensionManager extends ExtensionLoader { private extensionEnablementManager: ExtensionEnablementManager; private integrityManager: IExtensionIntegrity; private settings: MergedSettings; private requestConsent: (consent: string) => Promise; private requestSetting: | ((setting: ExtensionSetting) => Promise) | undefined; private telemetryConfig: Config; private workspaceDir: string; private loadedExtensions: GeminiCLIExtension[] | undefined; private loadingPromise: Promise | null = null; constructor(options: ExtensionManagerParams) { super(options.eventEmitter); this.workspaceDir = options.workspaceDir; this.extensionEnablementManager = new ExtensionEnablementManager( options.enabledExtensionOverrides, ); this.settings = options.settings; this.telemetryConfig = new Config({ telemetry: options.settings.telemetry, interactive: false, sessionId: randomUUID(), clientVersion: options.clientVersion ?? 'unknown', targetDir: options.workspaceDir, cwd: options.workspaceDir, model: '', debugMode: false, }); this.requestConsent = options.requestConsent; this.requestSetting = options.requestSetting ?? undefined; this.integrityManager = options.integrityManager ?? new ExtensionIntegrityManager(); } getEnablementManager(): ExtensionEnablementManager { return this.extensionEnablementManager; } async verifyExtensionIntegrity( extensionName: string, metadata: ExtensionInstallMetadata | undefined, ): Promise { return this.integrityManager.verify(extensionName, metadata); } async storeExtensionIntegrity( extensionName: string, metadata: ExtensionInstallMetadata, ): Promise { return this.integrityManager.store(extensionName, metadata); } setRequestConsent( requestConsent: (consent: string) => Promise, ): void { this.requestConsent = requestConsent; } setRequestSetting( requestSetting?: (setting: ExtensionSetting) => Promise, ): void { this.requestSetting = requestSetting; } getExtensions(): GeminiCLIExtension[] { if (!this.loadedExtensions) { throw new Error( 'Extensions not yet loaded, must call `loadExtensions` first', ); } return this.loadedExtensions; } async installOrUpdateExtension( installMetadata: ExtensionInstallMetadata, previousExtensionConfig?: ExtensionConfig, requestConsentOverride?: (consent: string) => Promise, ): Promise { if ((this.settings.security?.allowedExtensions?.length ?? 0) > 0) { const extensionAllowed = this.settings.security?.allowedExtensions.some( (pattern) => { try { return new RegExp(pattern).test( getRealPath(installMetadata.source), ); } catch (e) { throw new Error( `Invalid regex pattern in allowedExtensions setting: "${pattern}. Error: ${getErrorMessage(e)}`, ); } }, ); if (!extensionAllowed) { throw new Error( `Installing extension from source "${installMetadata.source}" is not allowed by the "allowedExtensions" security setting.`, ); } } else if ( (installMetadata.type === 'git' || installMetadata.type === 'github-release') && this.settings.security.blockGitExtensions ) { throw new Error( 'Installing extensions from remote sources is disallowed by your current settings.', ); } const isUpdate = !!previousExtensionConfig; let newExtensionConfig: ExtensionConfig | null = null; let localSourcePath: string | undefined; let extension: GeminiCLIExtension | null; try { if (!isWorkspaceTrusted(this.settings).isTrusted) { if ( await this.requestConsent( `The current workspace at "${this.workspaceDir}" is not trusted. Do you want to trust this workspace to install extensions?`, ) ) { const trustedFolders = loadTrustedFolders(); await trustedFolders.setValue( this.workspaceDir, TrustLevel.TRUST_FOLDER, ); } else { throw new Error( `Could not install extension because the current workspace at ${this.workspaceDir} is not trusted.`, ); } } const extensionsDir = ExtensionStorage.getUserExtensionsDir(); await fs.promises.mkdir(extensionsDir, { recursive: true }); if (installMetadata.type === 'local' || installMetadata.type === 'link') { installMetadata.source = path.isAbsolute(installMetadata.source) ? installMetadata.source : path.resolve(this.workspaceDir, installMetadata.source); } let tempDir: string | undefined; if ( installMetadata.type === 'git' || installMetadata.type === 'github-release' ) { tempDir = await ExtensionStorage.createTmpDir(); const parsedGithubParts = tryParseGithubUrl(installMetadata.source); if (!parsedGithubParts) { await cloneFromGit(installMetadata, tempDir); installMetadata.type = 'git'; } else { const result = await downloadFromGitHubRelease( installMetadata, tempDir, parsedGithubParts, ); if (result.success) { installMetadata.type = result.type; installMetadata.releaseTag = result.tagName; } else if ( // This repo has no github releases, and wasn't explicitly installed // from a github release, unconditionally just clone it. (result.failureReason === 'no release data' && installMetadata.type === 'git') || // Otherwise ask the user if they would like to try a git clone. (await (requestConsentOverride ?? this.requestConsent)( `Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}. Would you like to attempt to install via "git clone" instead?`, )) ) { await cloneFromGit(installMetadata, tempDir); installMetadata.type = 'git'; } else { throw new Error( `Failed to install extension ${installMetadata.source}: ${result.errorMessage}`, ); } } localSourcePath = tempDir; } else if ( installMetadata.type === 'local' || installMetadata.type === 'link' ) { localSourcePath = getRealPath(installMetadata.source); } else { throw new Error(`Unsupported install type: ${installMetadata.type}`); } try { newExtensionConfig = await this.loadExtensionConfig(localSourcePath); const newExtensionName = newExtensionConfig.name; const previousName = previousExtensionConfig?.name ?? newExtensionName; const previous = this.getExtensions().find( (installed) => installed.name === previousName, ); const nameConflict = this.getExtensions().find( (installed) => installed.name === newExtensionName && installed.name !== previousName, ); if (isUpdate && !previous) { throw new Error( `Extension "${previousName}" was not already installed, cannot update it.`, ); } else if (!isUpdate && previous) { throw new Error( `Extension "${newExtensionName}" is already installed. Please uninstall it first.`, ); } else if (isUpdate && nameConflict) { throw new Error( `Cannot update to "${newExtensionName}" because an extension with that name is already installed.`, ); } const newHasHooks = fs.existsSync( path.join(localSourcePath, 'hooks', 'hooks.json'), ); const previousHasHooks = !!( isUpdate && previous && previous.hooks && Object.keys(previous.hooks).length > 0 ); const newSkills = await loadSkillsFromDir( path.join(localSourcePath, 'skills'), ); const previousSkills = previous?.skills ?? []; const isMigrating = Boolean( previous && previous.installMetadata && previous.installMetadata.source !== installMetadata.source, ); await maybeRequestConsentOrFail( newExtensionConfig, requestConsentOverride ?? this.requestConsent, newHasHooks, previousExtensionConfig, previousHasHooks, newSkills, previousSkills, isMigrating, ); const extensionId = getExtensionId(newExtensionConfig, installMetadata); const destinationPath = new ExtensionStorage( newExtensionName, ).getExtensionDir(); if ( (!isUpdate || newExtensionName !== previousName) && fs.existsSync(destinationPath) ) { throw new Error( `Cannot install extension "${newExtensionName}" because a directory with that name already exists. Please remove it manually.`, ); } let previousSettings: Record | undefined; let wasEnabledGlobally = false; let wasEnabledWorkspace = false; if (isUpdate && previousExtensionConfig) { const previousExtensionId = previous?.installMetadata ? getExtensionId(previousExtensionConfig, previous.installMetadata) : extensionId; previousSettings = await getEnvContents( previousExtensionConfig, previousExtensionId, this.workspaceDir, ); if (newExtensionName !== previousName) { wasEnabledGlobally = this.extensionEnablementManager.isEnabled( previousName, homedir(), ); wasEnabledWorkspace = this.extensionEnablementManager.isEnabled( previousName, this.workspaceDir, ); this.extensionEnablementManager.remove(previousName); } await this.uninstallExtension(previousName, isUpdate); } await fs.promises.mkdir(destinationPath, { recursive: true }); if (this.requestSetting && this.settings.experimental.extensionConfig) { if (isUpdate) { await maybePromptForSettings( newExtensionConfig, extensionId, this.requestSetting, previousExtensionConfig, previousSettings, ); } else { await maybePromptForSettings( newExtensionConfig, extensionId, this.requestSetting, ); } } const missingSettings = this.settings.experimental.extensionConfig ? await getMissingSettings( newExtensionConfig, extensionId, this.workspaceDir, ) : []; if (missingSettings.length > 0) { const message = `Extension "${newExtensionConfig.name}" has missing settings: ${missingSettings .map((s) => s.name) .join( ', ', )}. Please run "gemini extensions config ${newExtensionConfig.name} [setting-name]" to configure them.`; debugLogger.warn(message); coreEvents.emitFeedback('warning', message); } if ( installMetadata.type === 'local' || installMetadata.type === 'git' || installMetadata.type === 'github-release' ) { await copyExtension(localSourcePath, destinationPath); } const metadataString = JSON.stringify(installMetadata, null, 2); const metadataPath = path.join( destinationPath, INSTALL_METADATA_FILENAME, ); await fs.promises.writeFile(metadataPath, metadataString); // Establish trust at point of installation await this.storeExtensionIntegrity( newExtensionConfig.name, installMetadata, ); // TODO: Gracefully handle this call failing, we should back up the old // extension prior to overwriting it and then restore and restart it. extension = await this.loadExtension(destinationPath); if (!extension) { throw new Error(`Extension not found`); } if (isUpdate) { await logExtensionUpdateEvent( this.telemetryConfig, new ExtensionUpdateEvent( newExtensionConfig.name, hashValue(newExtensionConfig.name), getExtensionId(newExtensionConfig, installMetadata), newExtensionConfig.version, previousExtensionConfig.version, installMetadata.type, CoreToolCallStatus.Success, ), ); if (newExtensionName !== previousName) { if (wasEnabledGlobally) { await this.enableExtension(newExtensionName, SettingScope.User); } if (wasEnabledWorkspace) { await this.enableExtension( newExtensionName, SettingScope.Workspace, ); } } } else { await logExtensionInstallEvent( this.telemetryConfig, new ExtensionInstallEvent( newExtensionConfig.name, hashValue(newExtensionConfig.name), getExtensionId(newExtensionConfig, installMetadata), newExtensionConfig.version, installMetadata.type, CoreToolCallStatus.Success, ), ); await this.enableExtension( newExtensionConfig.name, SettingScope.User, ); } } finally { if (tempDir) { await fs.promises.rm(tempDir, { recursive: true, force: true }); } } return extension; } catch (error) { // Attempt to load config from the source path even if installation fails // to get the name and version for logging. if (!newExtensionConfig && localSourcePath) { try { newExtensionConfig = await this.loadExtensionConfig(localSourcePath); } catch { // Ignore error, this is just for logging. } } const config = newExtensionConfig ?? previousExtensionConfig; const extensionId = config ? getExtensionId(config, installMetadata) : undefined; if (isUpdate) { await logExtensionUpdateEvent( this.telemetryConfig, new ExtensionUpdateEvent( config?.name ?? '', hashValue(config?.name ?? ''), extensionId ?? '', newExtensionConfig?.version ?? '', previousExtensionConfig.version, installMetadata.type, CoreToolCallStatus.Error, ), ); } else { await logExtensionInstallEvent( this.telemetryConfig, new ExtensionInstallEvent( newExtensionConfig?.name ?? '', hashValue(newExtensionConfig?.name ?? ''), extensionId ?? '', newExtensionConfig?.version ?? '', installMetadata.type, CoreToolCallStatus.Error, ), ); } throw error; } } async uninstallExtension( extensionIdentifier: string, isUpdate: boolean, ): Promise { const installedExtensions = this.getExtensions(); const extension = installedExtensions.find( (installed) => installed.name.toLowerCase() === extensionIdentifier.toLowerCase() || installed.installMetadata?.source.toLowerCase() === extensionIdentifier.toLowerCase(), ); if (!extension) { throw new Error(`Extension not found.`); } await this.unloadExtension(extension); const storage = new ExtensionStorage( extension.installMetadata?.type === 'link' ? extension.name : path.basename(extension.path), ); await fs.promises.rm(storage.getExtensionDir(), { recursive: true, force: true, }); // The rest of the cleanup below here is only for true uninstalls, not // uninstalls related to updates. if (isUpdate) return; this.extensionEnablementManager.remove(extension.name); await logExtensionUninstall( this.telemetryConfig, new ExtensionUninstallEvent( extension.name, hashValue(extension.name), extension.id, CoreToolCallStatus.Success, ), ); } protected override async startExtension(extension: GeminiCLIExtension) { await super.startExtension(extension); if (extension.themes && !themeManager.hasExtensionThemes(extension.name)) { themeManager.registerExtensionThemes(extension.name, extension.themes); } } protected override async stopExtension(extension: GeminiCLIExtension) { await super.stopExtension(extension); if (extension.themes) { themeManager.unregisterExtensionThemes(extension.name, extension.themes); } } /** * Loads all installed extensions, should only be called once. */ async loadExtensions(): Promise { if (this.loadedExtensions) { throw new Error('Extensions already loaded, only load extensions once.'); } if (this.loadingPromise) { return this.loadingPromise; } this.loadingPromise = (async () => { try { if (this.settings.admin.extensions.enabled === false) { this.loadedExtensions = []; return this.loadedExtensions; } const extensionsDir = ExtensionStorage.getUserExtensionsDir(); if (!fs.existsSync(extensionsDir)) { this.loadedExtensions = []; return this.loadedExtensions; } const subdirs = await fs.promises.readdir(extensionsDir); const extensionPromises = subdirs.map((subdir) => { const extensionDir = path.join(extensionsDir, subdir); return this._buildExtension(extensionDir); }); const builtExtensionsOrNull = await Promise.all(extensionPromises); const builtExtensions = builtExtensionsOrNull.filter( (ext): ext is GeminiCLIExtension => ext !== null, ); const seenNames = new Set(); for (const ext of builtExtensions) { if (seenNames.has(ext.name)) { throw new Error( `Extension with name ${ext.name} already was loaded.`, ); } seenNames.add(ext.name); } this.loadedExtensions = builtExtensions; // Register extension themes early so they're available at startup. for (const ext of this.loadedExtensions) { if (ext.isActive && ext.themes) { themeManager.registerExtensionThemes(ext.name, ext.themes); } } await Promise.all( this.loadedExtensions.map((ext) => this.maybeStartExtension(ext)), ); return this.loadedExtensions; } finally { this.loadingPromise = null; } })(); return this.loadingPromise; } /** * Adds `extension` to the list of extensions and starts it if appropriate. * * @internal visible for testing only */ async loadExtension( extensionDir: string, ): Promise { if (this.loadingPromise) { await this.loadingPromise; } this.loadedExtensions ??= []; const extension = await this._buildExtension(extensionDir); if (!extension) { return null; } if ( this.getExtensions().find( (installed) => installed.name === extension.name, ) ) { throw new Error( `Extension with name ${extension.name} already was loaded.`, ); } this.loadedExtensions = [...this.loadedExtensions, extension]; await this.maybeStartExtension(extension); return extension; } /** * Builds an extension without side effects (does not mutate loadedExtensions or start it). */ private async _buildExtension( extensionDir: string, ): Promise { try { const stats = await fs.promises.stat(extensionDir); if (!stats.isDirectory()) { return null; } } catch { return null; } const installMetadata = loadInstallMetadata(extensionDir); let effectiveExtensionPath = extensionDir; if ((this.settings.security?.allowedExtensions?.length ?? 0) > 0) { if (!installMetadata?.source) { throw new Error( `Failed to load extension ${extensionDir}. The ${INSTALL_METADATA_FILENAME} file is missing or misconfigured.`, ); } const extensionAllowed = this.settings.security?.allowedExtensions.some( (pattern) => { try { return new RegExp(pattern).test( getRealPath(installMetadata?.source ?? ''), ); } catch (e) { throw new Error( `Invalid regex pattern in allowedExtensions setting: "${pattern}. Error: ${getErrorMessage(e)}`, ); } }, ); if (!extensionAllowed) { debugLogger.warn( `Failed to load extension ${extensionDir}. This extension is not allowed by the "allowedExtensions" security setting.`, ); return null; } } else if ( (installMetadata?.type === 'git' || installMetadata?.type === 'github-release') && this.settings.security.blockGitExtensions ) { debugLogger.warn( `Failed to load extension ${extensionDir}. Extensions from remote sources is disallowed by your current settings.`, ); return null; } if (installMetadata?.type === 'link') { effectiveExtensionPath = installMetadata.source; } try { let config = await this.loadExtensionConfig(effectiveExtensionPath); const extensionId = getExtensionId(config, installMetadata); let userSettings: Record = {}; let workspaceSettings: Record = {}; if (this.settings.experimental.extensionConfig) { userSettings = await getScopedEnvContents( config, extensionId, ExtensionSettingScope.USER, ); if (isWorkspaceTrusted(this.settings).isTrusted) { workspaceSettings = await getScopedEnvContents( config, extensionId, ExtensionSettingScope.WORKSPACE, this.workspaceDir, ); } } const customEnv = { ...userSettings, ...workspaceSettings }; config = resolveEnvVarsInObject(config, customEnv); const resolvedSettings: ResolvedExtensionSetting[] = []; if (config.settings && this.settings.experimental.extensionConfig) { for (const setting of config.settings) { const value = customEnv[setting.envVar]; let scope: 'user' | 'workspace' | undefined; let source: string | undefined; // Note: strict check for undefined, as empty string is a valid value if (workspaceSettings[setting.envVar] !== undefined) { scope = 'workspace'; if (setting.sensitive) { source = 'Keychain'; } else { source = getEnvFilePath( config.name, ExtensionSettingScope.WORKSPACE, this.workspaceDir, ); } } else if (userSettings[setting.envVar] !== undefined) { scope = 'user'; if (setting.sensitive) { source = 'Keychain'; } else { source = getEnvFilePath(config.name, ExtensionSettingScope.USER); } } resolvedSettings.push({ name: setting.name, envVar: setting.envVar, value, sensitive: setting.sensitive ?? false, scope, source, }); } } if (config.mcpServers) { if (this.settings.admin.mcp.enabled === false) { config.mcpServers = undefined; } else { // Apply admin allowlist if configured const adminAllowlist = this.settings.admin.mcp.config; if (adminAllowlist && Object.keys(adminAllowlist).length > 0) { const result = applyAdminAllowlist( config.mcpServers, adminAllowlist, ); config.mcpServers = result.mcpServers; if (result.blockedServerNames.length > 0) { const message = getAdminBlockedMcpServersMessage( result.blockedServerNames, undefined, ); coreEvents.emitConsoleLog('warn', message); } } // Then apply local filtering/sanitization if (config.mcpServers) { config.mcpServers = Object.fromEntries( Object.entries(config.mcpServers).map(([key, value]) => [ key, filterMcpConfig(value), ]), ); } } } const contextFiles = getContextFileNames(config) .map((contextFileName) => { const contextFilePath = path.join( effectiveExtensionPath, contextFileName, ); if (!isSubpath(effectiveExtensionPath, contextFilePath)) { throw new Error( `Invalid context file path: "${contextFileName}". Context files must be within the extension directory.`, ); } return contextFilePath; }) .filter((contextFilePath) => fs.existsSync(contextFilePath)); const hydrationContext: VariableContext = { extensionPath: effectiveExtensionPath, workspacePath: this.workspaceDir, '/': path.sep, pathSeparator: path.sep, ...customEnv, }; let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined; if (this.settings.hooksConfig.enabled) { hooks = await this.loadExtensionHooks( effectiveExtensionPath, hydrationContext, ); } // Hydrate hooks with extension settings as environment variables if (hooks && config.settings) { const hookEnv: Record = {}; for (const setting of config.settings) { const value = customEnv[setting.envVar]; if (value !== undefined) { hookEnv[setting.envVar] = value; } } if (Object.keys(hookEnv).length > 0) { for (const eventName of Object.keys(hooks)) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const eventHooks = hooks[eventName as HookEventName]; if (eventHooks) { for (const definition of eventHooks) { for (const hook of definition.hooks) { if (hook.type === HookType.Command) { // Merge existing env with new env vars, giving extension settings precedence. hook.env = { ...hook.env, ...hookEnv }; } } } } } } } let skills = await loadSkillsFromDir( path.join(effectiveExtensionPath, 'skills'), ); skills = skills.map((skill) => ({ ...recursivelyHydrateStrings(skill, hydrationContext), extensionName: config.name, })); let rules: PolicyRule[] | undefined; let checkers: SafetyCheckerRule[] | undefined; const policyDir = path.join(effectiveExtensionPath, 'policies'); if (fs.existsSync(policyDir)) { const result = await loadExtensionPolicies(config.name, policyDir); rules = result.rules; checkers = result.checkers; if (result.errors.length > 0) { for (const error of result.errors) { debugLogger.warn( `[ExtensionManager] Error loading policies from ${config.name}: ${error.message}${error.details ? `\nDetails: ${error.details}` : ''}`, ); } } } const agentLoadResult = await loadAgentsFromDirectory( path.join(effectiveExtensionPath, 'agents'), ); agentLoadResult.agents = agentLoadResult.agents.map((agent) => ({ ...recursivelyHydrateStrings(agent, hydrationContext), extensionName: config.name, })); // Log errors but don't fail the entire extension load for (const error of agentLoadResult.errors) { debugLogger.warn( `[ExtensionManager] Error loading agent from ${config.name}: ${error.message}`, ); } return { name: config.name, version: config.version, path: effectiveExtensionPath, contextFiles, installMetadata, migratedTo: config.migratedTo, mcpServers: config.mcpServers, excludeTools: config.excludeTools, hooks, isActive: this.extensionEnablementManager.isEnabled( config.name, this.workspaceDir, ), id: getExtensionId(config, installMetadata), settings: config.settings, resolvedSettings, skills, agents: agentLoadResult.agents, themes: config.themes, rules, checkers, plan: config.plan, }; } catch (e) { const extName = path.basename(extensionDir); debugLogger.warn( `Warning: Removing broken extension ${extName}: ${getErrorMessage(e)}`, ); try { await fs.promises.rm(extensionDir, { recursive: true, force: true }); } catch (rmError) { debugLogger.error( `Failed to remove broken extension directory ${extensionDir}:`, rmError, ); } return null; } } override async restartExtension( extension: GeminiCLIExtension, ): Promise { const extensionDir = extension.path; await this.unloadExtension(extension); await this.loadExtension(extensionDir); } /** * Removes `extension` from the list of extensions and stops it if * appropriate. */ private unloadExtension( extension: GeminiCLIExtension, ): Promise | undefined { this.loadedExtensions = this.getExtensions().filter( (entry) => extension !== entry, ); return this.maybeStopExtension(extension); } async loadExtensionConfig(extensionDir: string): Promise { const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME); if (!fs.existsSync(configFilePath)) { throw new Error(`Configuration file not found at ${configFilePath}`); } try { const configContent = await fs.promises.readFile(configFilePath, 'utf-8'); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const rawConfig = JSON.parse(configContent) as ExtensionConfig; if (!rawConfig.name || !rawConfig.version) { throw new Error( `Invalid configuration in ${configFilePath}: missing ${!rawConfig.name ? '"name"' : '"version"'}`, ); } // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const config = recursivelyHydrateStrings( // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion rawConfig as unknown as JsonObject, { extensionPath: extensionDir, workspacePath: this.workspaceDir, '/': path.sep, pathSeparator: path.sep, }, ) as unknown as ExtensionConfig; validateName(config.name); return config; } catch (e) { throw new Error( `Failed to load extension config from ${configFilePath}: ${getErrorMessage( e, )}`, ); } } private async loadExtensionHooks( extensionDir: string, context: VariableContext, ): Promise<{ [K in HookEventName]?: HookDefinition[] } | undefined> { const hooksFilePath = path.join(extensionDir, 'hooks', 'hooks.json'); try { const hooksContent = await fs.promises.readFile(hooksFilePath, 'utf-8'); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const rawHooks = JSON.parse(hooksContent); if ( !rawHooks || typeof rawHooks !== 'object' || typeof rawHooks.hooks !== 'object' || rawHooks.hooks === null || Array.isArray(rawHooks.hooks) ) { debugLogger.warn( `Invalid hooks configuration in ${hooksFilePath}: "hooks" property must be an object`, ); return undefined; } // Hydrate variables in the hooks configuration const hydratedHooks = recursivelyHydrateStrings( // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion rawHooks.hooks as unknown as JsonObject, { ...context, '/': path.sep, pathSeparator: path.sep, }, ) as { [K in HookEventName]?: HookDefinition[] }; return hydratedHooks; } catch (e) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion if ((e as NodeJS.ErrnoException).code === 'ENOENT') { return undefined; // File not found is not an error here. } debugLogger.warn( `Failed to load extension hooks from ${hooksFilePath}: ${getErrorMessage( e, )}`, ); return undefined; } } toOutputString(extension: GeminiCLIExtension): string { const userEnabled = this.extensionEnablementManager.isEnabled( extension.name, homedir(), ); const workspaceEnabled = this.extensionEnablementManager.isEnabled( extension.name, this.workspaceDir, ); const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗'); let output = `${status} ${extension.name} (${extension.version})`; output += `\n ID: ${extension.id}`; output += `\n name: ${hashValue(extension.name)}`; output += `\n Path: ${extension.path}`; if (extension.installMetadata) { output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`; if (extension.installMetadata.ref) { output += `\n Ref: ${extension.installMetadata.ref}`; } if (extension.installMetadata.releaseTag) { output += `\n Release tag: ${extension.installMetadata.releaseTag}`; } } output += `\n Enabled (User): ${userEnabled}`; output += `\n Enabled (Workspace): ${workspaceEnabled}`; if (extension.contextFiles.length > 0) { output += `\n Context files:`; extension.contextFiles.forEach((contextFile) => { output += `\n ${contextFile}`; }); } if (extension.mcpServers) { output += `\n MCP servers:`; Object.keys(extension.mcpServers).forEach((key) => { output += `\n ${key}`; }); } if (extension.excludeTools) { output += `\n Excluded tools:`; extension.excludeTools.forEach((tool) => { output += `\n ${tool}`; }); } if (extension.skills && extension.skills.length > 0) { output += `\n Agent skills:`; extension.skills.forEach((skill) => { output += `\n ${skill.name}: ${skill.description}`; }); } const resolvedSettings = extension.resolvedSettings; if (resolvedSettings && resolvedSettings.length > 0) { output += `\n Settings:`; resolvedSettings.forEach((setting) => { let scope = ''; if (setting.scope) { scope = setting.scope === 'workspace' ? '(Workspace' : '(User'; if (setting.source) { scope += ` - ${setting.source}`; } scope += ')'; } output += `\n ${setting.name}: ${getFormattedSettingValue(setting)} ${scope}`; }); } return output; } async disableExtension(name: string, scope: SettingScope) { if ( scope === SettingScope.System || scope === SettingScope.SystemDefaults ) { throw new Error('System and SystemDefaults scopes are not supported.'); } const extension = this.getExtensions().find( (extension) => extension.name === name, ); if (!extension) { throw new Error(`Extension with name ${name} does not exist.`); } if (scope !== SettingScope.Session) { const scopePath = scope === SettingScope.Workspace ? this.workspaceDir : homedir(); this.extensionEnablementManager.disable(name, true, scopePath); } await logExtensionDisable( this.telemetryConfig, new ExtensionDisableEvent(name, hashValue(name), extension.id, scope), ); if (!this.config || this.config.getEnableExtensionReloading()) { // Only toggle the isActive state if we are actually going to disable it // in the current session, or we haven't been initialized yet. extension.isActive = false; } await this.maybeStopExtension(extension); } /** * Enables an existing extension for a given scope, and starts it if * appropriate. */ async enableExtension(name: string, scope: SettingScope) { if ( scope === SettingScope.System || scope === SettingScope.SystemDefaults ) { throw new Error('System and SystemDefaults scopes are not supported.'); } const extension = this.getExtensions().find( (extension) => extension.name === name, ); if (!extension) { throw new Error(`Extension with name ${name} does not exist.`); } if (scope !== SettingScope.Session) { const scopePath = scope === SettingScope.Workspace ? this.workspaceDir : homedir(); this.extensionEnablementManager.enable(name, true, scopePath); } await logExtensionEnable( this.telemetryConfig, new ExtensionEnableEvent(name, hashValue(name), extension.id, scope), ); if (!this.config || this.config.getEnableExtensionReloading()) { // Only toggle the isActive state if we are actually going to disable it // in the current session, or we haven't been initialized yet. extension.isActive = true; } await this.maybeStartExtension(extension); } } function filterMcpConfig(original: MCPServerConfig): MCPServerConfig { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { trust, ...rest } = original; return Object.freeze(rest); } export async function copyExtension( source: string, destination: string, ): Promise { await fs.promises.cp(source, destination, { recursive: true }); } function getContextFileNames(config: ExtensionConfig): string[] { if (!config.contextFileName) { return ['GEMINI.md']; } else if (!Array.isArray(config.contextFileName)) { return [config.contextFileName]; } return config.contextFileName; } function validateName(name: string) { if (!/^[a-zA-Z0-9-]+$/.test(name)) { throw new Error( `Invalid extension name: "${name}". Only letters (a-z, A-Z), numbers (0-9), and dashes (-) are allowed.`, ); } } export async function inferInstallMetadata( source: string, args: { ref?: string; autoUpdate?: boolean; allowPreRelease?: boolean; } = {}, ): Promise { if ( source.startsWith('http://') || source.startsWith('https://') || source.startsWith('git@') || source.startsWith('sso://') ) { return { source, type: 'git', ref: args.ref, autoUpdate: args.autoUpdate, allowPreRelease: args.allowPreRelease, }; } else { if (args.ref || args.autoUpdate) { throw new Error( '--ref and --auto-update are not applicable for local extensions.', ); } try { await stat(source); return { source, type: 'local', }; } catch { throw new Error('Install source not found.'); } } } export function getExtensionId( config: ExtensionConfig, installMetadata?: ExtensionInstallMetadata, ): string { // IDs are created by hashing details of the installation source in order to // deduplicate extensions with conflicting names and also obfuscate any // potentially sensitive information such as private git urls, system paths, // or project names. let idValue = config.name; const githubUrlParts = installMetadata && (installMetadata.type === 'git' || installMetadata.type === 'github-release') ? tryParseGithubUrl(installMetadata.source) : null; if (githubUrlParts) { // For github repos, we use the https URI to the repo as the ID. idValue = `https://github.com/${githubUrlParts.owner}/${githubUrlParts.repo}`; } else { idValue = installMetadata?.source ?? config.name; } return hashValue(idValue); } export function hashValue(value: string): string { return createHash('sha256').update(value).digest('hex'); }