2025-06-10 15:48:39 -07:00
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
2025-08-26 00:04:53 +02:00
import type {
2025-08-20 10:55:47 +09:00
MCPServerConfig ,
GeminiCLIExtension ,
2025-09-17 18:14:01 -04:00
ExtensionInstallMetadata ,
2025-08-20 10:55:47 +09:00
} from '@google/gemini-cli-core' ;
2025-09-09 12:12:56 -04:00
import {
GEMINI_DIR ,
Storage ,
Config ,
ExtensionInstallEvent ,
2025-09-12 13:38:54 -04:00
ExtensionUninstallEvent ,
2025-10-10 14:28:13 -07:00
ExtensionUpdateEvent ,
2025-09-23 14:37:35 -04:00
ExtensionDisableEvent ,
2025-09-22 12:55:43 -04:00
ExtensionEnableEvent ,
logExtensionEnable ,
logExtensionInstallEvent ,
logExtensionUninstall ,
2025-10-10 14:28:13 -07:00
logExtensionUpdateEvent ,
2025-09-23 14:37:35 -04:00
logExtensionDisable ,
2025-09-09 12:12:56 -04:00
} from '@google/gemini-cli-core' ;
2025-08-25 22:11:27 +02:00
import * as fs from 'node:fs' ;
import * as path from 'node:path' ;
import * as os from 'node:os' ;
2025-08-26 14:36:55 +00:00
import { SettingScope , loadSettings } from '../config/settings.js' ;
import { getErrorMessage } from '../utils/errors.js' ;
2025-10-10 13:40:13 -07:00
import {
recursivelyHydrateStrings ,
type JsonObject ,
} from './extensions/variables.js' ;
2025-08-28 16:46:45 -04:00
import { isWorkspaceTrusted } from './trustedFolders.js' ;
2025-08-29 19:53:39 +02:00
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js' ;
2025-09-09 12:12:56 -04:00
import { randomUUID } from 'node:crypto' ;
2025-09-17 18:14:01 -04:00
import {
cloneFromGit ,
downloadFromGitHubRelease ,
} from './extensions/github.js' ;
2025-09-17 04:23:12 +00:00
import type { LoadExtensionContext } from './extensions/variableSchema.js' ;
2025-09-16 15:51:46 -04:00
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js' ;
2025-09-25 12:30:25 -04:00
import chalk from 'chalk' ;
2025-09-29 14:19:19 -07:00
import type { ConfirmationRequest } from '../ui/types.js' ;
2025-10-08 22:41:22 +02:00
import { escapeAnsiCtrlCodes } from '../ui/utils/textUtils.js' ;
2025-08-25 17:02:10 +00:00
2025-08-28 16:46:45 -04:00
export const EXTENSIONS_DIRECTORY_NAME = path . join ( GEMINI_DIR , 'extensions' ) ;
2025-06-10 15:48:39 -07:00
export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json' ;
2025-08-25 17:02:10 +00:00
export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json' ;
2025-06-10 15:48:39 -07:00
2025-10-08 07:31:41 -07:00
/**
* Extension definition as written to disk in gemini-extension.json files.
* This should *not* be referenced outside of the logic for reading files.
* If information is required for manipulating extensions (load, unload, update)
* outside of the loading process that data needs to be stored on the
* GeminiCLIExtension class defined in Core.
*/
interface ExtensionConfig {
2025-06-10 15:48:39 -07:00
name : string ;
version : string ;
mcpServers? : Record < string , MCPServerConfig > ;
2025-06-13 09:19:08 -07:00
contextFileName? : string | string [ ] ;
2025-07-01 16:13:46 -07:00
excludeTools? : string [ ] ;
2025-06-10 15:48:39 -07:00
}
2025-08-25 19:41:15 +00:00
export interface ExtensionUpdateInfo {
2025-08-29 17:24:17 +00:00
name : string ;
2025-08-25 19:41:15 +00:00
originalVersion : string ;
updatedVersion : string ;
}
2025-08-25 17:02:10 +00:00
export class ExtensionStorage {
private readonly extensionName : string ;
constructor ( extensionName : string ) {
this . extensionName = extensionName ;
}
getExtensionDir ( ) : string {
return path . join (
ExtensionStorage . getUserExtensionsDir ( ) ,
this . extensionName ,
) ;
}
getConfigPath ( ) : string {
return path . join ( this . getExtensionDir ( ) , EXTENSIONS_CONFIG_FILENAME ) ;
}
static getUserExtensionsDir ( ) : string {
const storage = new Storage ( os . homedir ( ) ) ;
return storage . getExtensionsDir ( ) ;
}
static async createTmpDir ( ) : Promise < string > {
return await fs . promises . mkdtemp (
path . join ( os . tmpdir ( ) , 'gemini-extension' ) ,
) ;
}
}
2025-10-08 07:31:41 -07:00
export function getWorkspaceExtensions (
workspaceDir : string ,
) : GeminiCLIExtension [ ] {
2025-09-09 20:23:46 -04:00
// If the workspace dir is the user extensions dir, there are no workspace extensions.
if ( path . resolve ( workspaceDir ) === path . resolve ( os . homedir ( ) ) ) {
return [ ] ;
}
2025-08-27 00:43:02 +00:00
return loadExtensionsFromDir ( workspaceDir ) ;
}
2025-09-18 14:49:47 -07:00
export async function copyExtension (
2025-08-27 00:43:02 +00:00
source : string ,
destination : string ,
) : Promise < void > {
await fs . promises . cp ( source , destination , { recursive : true } ) ;
}
export async function performWorkspaceExtensionMigration (
2025-10-08 07:31:41 -07:00
extensions : GeminiCLIExtension [ ] ,
2025-09-25 10:57:59 -07:00
requestConsent : ( consent : string ) = > Promise < boolean > ,
2025-08-27 00:43:02 +00:00
) : Promise < string [ ] > {
const failedInstallNames : string [ ] = [ ] ;
for ( const extension of extensions ) {
try {
const installMetadata : ExtensionInstallMetadata = {
source : extension.path ,
type : 'local' ,
} ;
2025-10-10 14:28:13 -07:00
await installOrUpdateExtension ( installMetadata , requestConsent ) ;
2025-08-27 00:43:02 +00:00
} catch ( _ ) {
2025-10-08 07:31:41 -07:00
failedInstallNames . push ( extension . name ) ;
2025-08-27 00:43:02 +00:00
}
}
return failedInstallNames ;
}
2025-09-22 12:55:43 -04:00
function getTelemetryConfig ( cwd : string ) {
const settings = loadSettings ( cwd ) ;
2025-09-12 13:38:54 -04:00
const config = new Config ( {
2025-09-22 12:55:43 -04:00
telemetry : settings.merged.telemetry ,
interactive : false ,
2025-09-12 13:38:54 -04:00
sessionId : randomUUID ( ) ,
targetDir : cwd ,
cwd ,
model : '' ,
debugMode : false ,
} ) ;
2025-09-22 12:55:43 -04:00
return config ;
2025-09-12 13:38:54 -04:00
}
2025-09-03 10:41:53 -07:00
export function loadExtensions (
2025-09-29 06:53:19 -07:00
extensionEnablementManager : ExtensionEnablementManager ,
2025-09-03 10:41:53 -07:00
workspaceDir : string = process . cwd ( ) ,
2025-10-08 07:31:41 -07:00
) : GeminiCLIExtension [ ] {
2025-08-27 00:43:02 +00:00
const settings = loadSettings ( workspaceDir ) . merged ;
const allExtensions = [ . . . loadUserExtensions ( ) ] ;
2025-08-28 16:46:45 -04:00
if (
2025-10-10 13:40:13 -07:00
isWorkspaceTrusted ( settings ) . isTrusted &&
2025-09-05 11:44:41 -07:00
// Default management setting to true
! ( settings . experimental ? . extensionManagement ? ? true )
2025-08-28 16:46:45 -04:00
) {
2025-08-27 00:43:02 +00:00
allExtensions . push ( . . . getWorkspaceExtensions ( workspaceDir ) ) ;
}
2025-06-10 15:48:39 -07:00
2025-10-08 07:31:41 -07:00
const uniqueExtensions = new Map < string , GeminiCLIExtension > ( ) ;
2025-09-16 15:51:46 -04:00
2025-06-10 15:48:39 -07:00
for ( const extension of allExtensions ) {
2025-08-27 00:43:02 +00:00
if (
2025-10-08 07:31:41 -07:00
! uniqueExtensions . has ( extension . name ) &&
extensionEnablementManager . isEnabled ( extension . name , workspaceDir )
2025-08-27 00:43:02 +00:00
) {
2025-10-08 07:31:41 -07:00
uniqueExtensions . set ( extension . name , extension ) ;
2025-06-10 15:48:39 -07:00
}
}
2025-07-08 12:57:34 -04:00
return Array . from ( uniqueExtensions . values ( ) ) ;
2025-06-10 15:48:39 -07:00
}
2025-10-08 07:31:41 -07:00
export function loadUserExtensions ( ) : GeminiCLIExtension [ ] {
2025-08-25 17:02:10 +00:00
const userExtensions = loadExtensionsFromDir ( os . homedir ( ) ) ;
2025-10-08 07:31:41 -07:00
const uniqueExtensions = new Map < string , GeminiCLIExtension > ( ) ;
2025-08-25 17:02:10 +00:00
for ( const extension of userExtensions ) {
2025-10-08 07:31:41 -07:00
if ( ! uniqueExtensions . has ( extension . name ) ) {
uniqueExtensions . set ( extension . name , extension ) ;
2025-08-25 17:02:10 +00:00
}
}
return Array . from ( uniqueExtensions . values ( ) ) ;
}
2025-10-08 07:31:41 -07:00
export function loadExtensionsFromDir ( dir : string ) : GeminiCLIExtension [ ] {
2025-08-20 10:55:47 +09:00
const storage = new Storage ( dir ) ;
const extensionsDir = storage . getExtensionsDir ( ) ;
2025-06-10 15:48:39 -07:00
if ( ! fs . existsSync ( extensionsDir ) ) {
return [ ] ;
}
2025-10-08 07:31:41 -07:00
const extensions : GeminiCLIExtension [ ] = [ ] ;
2025-06-10 15:48:39 -07:00
for ( const subdir of fs . readdirSync ( extensionsDir ) ) {
const extensionDir = path . join ( extensionsDir , subdir ) ;
2025-09-17 04:23:12 +00:00
const extension = loadExtension ( { extensionDir , workspaceDir : dir } ) ;
2025-06-13 13:57:00 -07:00
if ( extension != null ) {
extensions . push ( extension ) ;
2025-06-10 15:48:39 -07:00
}
2025-06-13 13:57:00 -07:00
}
return extensions ;
}
2025-06-10 15:48:39 -07:00
2025-10-08 07:31:41 -07:00
export function loadExtension (
context : LoadExtensionContext ,
) : GeminiCLIExtension | null {
2025-09-17 04:23:12 +00:00
const { extensionDir , workspaceDir } = context ;
2025-06-13 13:57:00 -07:00
if ( ! fs . statSync ( extensionDir ) . isDirectory ( ) ) {
return null ;
}
2025-06-10 15:48:39 -07:00
2025-09-02 10:15:42 -07:00
const installMetadata = loadInstallMetadata ( extensionDir ) ;
let effectiveExtensionPath = extensionDir ;
if ( installMetadata ? . type === 'link' ) {
effectiveExtensionPath = installMetadata . source ;
}
2025-06-13 13:57:00 -07:00
try {
2025-09-22 09:34:52 +00:00
let config = loadExtensionConfig ( {
extensionDir : effectiveExtensionPath ,
workspaceDir ,
} ) ;
2025-06-13 13:57:00 -07:00
2025-08-29 19:53:39 +02:00
config = resolveEnvVarsInObject ( config ) ;
2025-09-19 21:15:40 -04:00
if ( config . mcpServers ) {
config . mcpServers = Object . fromEntries (
Object . entries ( config . mcpServers ) . map ( ( [ key , value ] ) = > [
key ,
filterMcpConfig ( value ) ,
] ) ,
) ;
}
2025-06-13 13:57:00 -07:00
const contextFiles = getContextFileNames ( config )
2025-09-02 10:15:42 -07:00
. map ( ( contextFileName ) = >
path . join ( effectiveExtensionPath , contextFileName ) ,
)
2025-06-13 13:57:00 -07:00
. filter ( ( contextFilePath ) = > fs . existsSync ( contextFilePath ) ) ;
return {
2025-10-08 07:31:41 -07:00
name : config.name ,
version : config.version ,
2025-09-02 10:15:42 -07:00
path : effectiveExtensionPath ,
2025-06-13 13:57:00 -07:00
contextFiles ,
2025-09-02 10:15:42 -07:00
installMetadata ,
2025-10-08 07:31:41 -07:00
mcpServers : config.mcpServers ,
excludeTools : config.excludeTools ,
isActive : true , // Barring any other signals extensions should be considered Active.
2025-06-13 13:57:00 -07:00
} ;
} catch ( e ) {
console . error (
2025-09-22 09:34:52 +00:00
` Warning: Skipping extension in ${ effectiveExtensionPath } : ${ getErrorMessage (
2025-08-26 14:36:55 +00:00
e ,
) } ` ,
2025-06-13 13:57:00 -07:00
) ;
return null ;
2025-06-10 15:48:39 -07:00
}
2025-06-13 13:57:00 -07:00
}
2025-06-10 15:48:39 -07:00
2025-09-25 21:44:28 -04:00
export function loadExtensionByName (
name : string ,
workspaceDir : string = process . cwd ( ) ,
2025-10-08 07:31:41 -07:00
) : GeminiCLIExtension | null {
2025-09-25 21:44:28 -04:00
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 = loadExtension ( { extensionDir , workspaceDir } ) ;
2025-10-08 07:31:41 -07:00
if ( extension && extension . name . toLowerCase ( ) === name . toLowerCase ( ) ) {
2025-09-25 21:44:28 -04:00
return extension ;
}
}
return null ;
}
2025-09-19 21:15:40 -04:00
function filterMcpConfig ( original : MCPServerConfig ) : MCPServerConfig {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { trust , . . . rest } = original ;
return Object . freeze ( rest ) ;
}
2025-09-18 14:49:47 -07:00
export function loadInstallMetadata (
2025-08-25 17:02:10 +00:00
extensionDir : string ,
) : ExtensionInstallMetadata | undefined {
const metadataFilePath = path . join ( extensionDir , INSTALL_METADATA_FILENAME ) ;
try {
const configContent = fs . readFileSync ( metadataFilePath , 'utf-8' ) ;
const metadata = JSON . parse ( configContent ) as ExtensionInstallMetadata ;
return metadata ;
} catch ( _e ) {
return undefined ;
}
}
2025-06-13 13:57:00 -07:00
function getContextFileNames ( config : ExtensionConfig ) : string [ ] {
if ( ! config . contextFileName ) {
2025-06-13 14:51:29 -07:00
return [ 'GEMINI.md' ] ;
2025-06-13 13:57:00 -07:00
} else if ( ! Array . isArray ( config . contextFileName ) ) {
return [ config . contextFileName ] ;
}
return config . contextFileName ;
2025-06-10 15:48:39 -07:00
}
2025-07-08 12:57:34 -04:00
2025-08-26 14:36:55 +00:00
/**
* Returns an annotated list of extensions. If an extension is listed in enabledExtensionNames, it will be active.
2025-09-16 15:51:46 -04:00
* If enabledExtensionNames is empty, an extension is active unless it is disabled.
2025-08-26 14:36:55 +00:00
* @param extensions The base list of extensions.
* @param enabledExtensionNames The names of explicitly enabled extensions.
* @param workspaceDir The current workspace directory.
*/
2025-07-18 20:45:00 +02:00
export function annotateActiveExtensions (
2025-10-08 07:31:41 -07:00
extensions : GeminiCLIExtension [ ] ,
2025-08-26 14:36:55 +00:00
workspaceDir : string ,
2025-09-29 06:53:19 -07:00
manager : ExtensionEnablementManager ,
2025-07-18 20:45:00 +02:00
) : GeminiCLIExtension [ ] {
2025-09-29 06:53:19 -07:00
manager . validateExtensionOverrides ( extensions ) ;
return extensions . map ( ( extension ) = > ( {
2025-10-08 07:31:41 -07:00
. . . extension ,
isActive : manager.isEnabled ( extension . name , workspaceDir ) ,
2025-09-29 06:53:19 -07:00
} ) ) ;
2025-07-08 12:57:34 -04:00
}
2025-08-25 17:02:10 +00:00
2025-09-12 10:53:30 -04:00
/**
2025-09-25 10:57:59 -07:00
* Requests consent from the user to perform an action, by reading a Y/n
* character from stdin.
*
* This should not be called from interactive mode as it will break the CLI.
*
* @param consentDescription The description of the thing they will be consenting to.
* @returns boolean, whether they consented or not.
*/
export async function requestConsentNonInteractive (
consentDescription : string ,
) : Promise < boolean > {
console . info ( consentDescription ) ;
2025-09-29 14:19:19 -07:00
const result = await promptForConsentNonInteractive (
2025-09-25 10:57:59 -07:00
'Do you want to continue? [Y/n]: ' ,
) ;
return result ;
}
/**
* Requests consent from the user to perform an action, in interactive mode.
*
* This should not be called from non-interactive mode as it will not work.
*
* @param consentDescription The description of the thing they will be consenting to.
2025-09-29 14:19:19 -07:00
* @param setExtensionUpdateConfirmationRequest A function to actually add a prompt to the UI.
2025-09-25 10:57:59 -07:00
* @returns boolean, whether they consented or not.
*/
export async function requestConsentInteractive (
2025-09-29 14:19:19 -07:00
consentDescription : string ,
addExtensionUpdateConfirmationRequest : ( value : ConfirmationRequest ) = > void ,
2025-09-25 10:57:59 -07:00
) : Promise < boolean > {
2025-09-29 14:19:19 -07:00
return await promptForConsentInteractive (
consentDescription + '\n\nDo you want to continue?' ,
addExtensionUpdateConfirmationRequest ,
2025-09-25 10:57:59 -07:00
) ;
}
/**
* Asks users a prompt and awaits for a y/n response on stdin.
*
* This should not be called from interactive mode as it will break the CLI.
*
2025-09-12 10:53:30 -04:00
* @param prompt A yes/no prompt to ask the user
2025-09-22 19:50:12 -04:00
* @returns Whether or not the user answers 'y' (yes). Defaults to 'yes' on enter.
2025-09-12 10:53:30 -04:00
*/
2025-09-29 14:19:19 -07:00
async function promptForConsentNonInteractive (
2025-09-25 10:57:59 -07:00
prompt : string ,
) : Promise < boolean > {
2025-09-12 10:53:30 -04:00
const readline = await import ( 'node:readline' ) ;
const rl = readline . createInterface ( {
input : process.stdin ,
output : process.stdout ,
} ) ;
return new Promise ( ( resolve ) = > {
rl . question ( prompt , ( answer ) = > {
rl . close ( ) ;
2025-09-22 19:50:12 -04:00
resolve ( [ 'y' , '' ] . includes ( answer . trim ( ) . toLowerCase ( ) ) ) ;
2025-09-12 10:53:30 -04:00
} ) ;
} ) ;
}
2025-09-29 14:19:19 -07:00
/**
* Asks users an interactive yes/no prompt.
*
* This should not be called from non-interactive mode as it will break the CLI.
*
* @param prompt A markdown prompt to ask the user
* @param setExtensionUpdateConfirmationRequest Function to update the UI state with the confirmation request.
* @returns Whether or not the user answers yes.
*/
async function promptForConsentInteractive (
prompt : string ,
addExtensionUpdateConfirmationRequest : ( value : ConfirmationRequest ) = > void ,
) : Promise < boolean > {
return await new Promise < boolean > ( ( resolve ) = > {
addExtensionUpdateConfirmationRequest ( {
prompt ,
onConfirm : ( resolvedConfirmed ) = > {
resolve ( resolvedConfirmed ) ;
} ,
} ) ;
} ) ;
}
2025-10-10 14:28:13 -07:00
export async function installOrUpdateExtension (
2025-08-25 17:02:10 +00:00
installMetadata : ExtensionInstallMetadata ,
2025-09-25 10:57:59 -07:00
requestConsent : ( consent : string ) = > Promise < boolean > ,
2025-08-26 14:36:55 +00:00
cwd : string = process . cwd ( ) ,
2025-09-25 10:57:59 -07:00
previousExtensionConfig? : ExtensionConfig ,
2025-08-25 17:02:10 +00:00
) : Promise < string > {
2025-10-10 14:28:13 -07:00
const isUpdate = ! ! previousExtensionConfig ;
2025-09-22 12:55:43 -04:00
const telemetryConfig = getTelemetryConfig ( cwd ) ;
2025-09-09 12:12:56 -04:00
let newExtensionConfig : ExtensionConfig | null = null ;
let localSourcePath : string | undefined ;
2025-09-02 10:15:42 -07:00
2025-08-25 17:02:10 +00:00
try {
2025-09-09 12:12:56 -04:00
const settings = loadSettings ( cwd ) . merged ;
2025-10-10 13:40:13 -07:00
if ( ! isWorkspaceTrusted ( settings ) . isTrusted ) {
2025-08-25 17:02:10 +00:00
throw new Error (
2025-09-09 12:12:56 -04:00
` Could not install extension from untrusted folder at ${ installMetadata . source } ` ,
2025-08-25 17:02:10 +00:00
) ;
}
2025-09-09 12:12:56 -04:00
const extensionsDir = ExtensionStorage . getUserExtensionsDir ( ) ;
await fs . promises . mkdir ( extensionsDir , { recursive : true } ) ;
2025-08-25 17:02:10 +00:00
if (
2025-09-09 12:12:56 -04:00
! path . isAbsolute ( installMetadata . source ) &&
( installMetadata . type === 'local' || installMetadata . type === 'link' )
2025-08-25 17:02:10 +00:00
) {
2025-09-09 12:12:56 -04:00
installMetadata . source = path . resolve ( cwd , installMetadata . source ) ;
2025-08-25 17:02:10 +00:00
}
2025-09-09 12:12:56 -04:00
let tempDir : string | undefined ;
2025-09-02 10:15:42 -07:00
2025-09-17 18:14:01 -04:00
if (
installMetadata . type === 'git' ||
installMetadata . type === 'github-release'
) {
2025-09-09 12:12:56 -04:00
tempDir = await ExtensionStorage . createTmpDir ( ) ;
2025-10-15 14:29:16 -07:00
const result = await downloadFromGitHubRelease ( installMetadata , tempDir ) ;
if ( result . success ) {
2025-09-22 19:50:12 -04:00
installMetadata . type = result . type ;
installMetadata . releaseTag = result . tagName ;
2025-10-15 14:29:16 -07:00
} 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 requestConsent (
` Error downloading github release for ${ installMetadata . source } with the following error: ${ result . errorMessage } . \ n \ nWould you like to attempt to install via "git clone" instead? ` ,
) )
) {
2025-09-17 18:14:01 -04:00
await cloneFromGit ( installMetadata , tempDir ) ;
installMetadata . type = 'git' ;
2025-10-15 14:29:16 -07:00
} else {
throw new Error (
` Failed to install extension ${ installMetadata . source } : ${ result . errorMessage } ` ,
) ;
2025-09-17 18:14:01 -04:00
}
2025-09-09 12:12:56 -04:00
localSourcePath = tempDir ;
} else if (
installMetadata . type === 'local' ||
installMetadata . type === 'link'
) {
localSourcePath = installMetadata . source ;
} else {
throw new Error ( ` Unsupported install type: ${ installMetadata . type } ` ) ;
2025-09-02 10:15:42 -07:00
}
2025-08-25 17:02:10 +00:00
2025-09-09 12:12:56 -04:00
try {
2025-09-22 09:34:52 +00:00
newExtensionConfig = loadExtensionConfig ( {
2025-09-17 04:23:12 +00:00
extensionDir : localSourcePath ,
workspaceDir : cwd ,
} ) ;
2025-09-09 12:12:56 -04:00
const newExtensionName = newExtensionConfig . name ;
2025-10-10 14:28:13 -07:00
if ( ! isUpdate ) {
const installedExtensions = loadUserExtensions ( ) ;
if (
installedExtensions . some (
( installed ) = > installed . name === newExtensionName ,
)
) {
throw new Error (
` Extension " ${ newExtensionName } " is already installed. Please uninstall it first. ` ,
) ;
}
2025-09-09 12:12:56 -04:00
}
2025-10-10 14:28:13 -07:00
2025-09-25 10:57:59 -07:00
await maybeRequestConsentOrFail (
newExtensionConfig ,
requestConsent ,
previousExtensionConfig ,
) ;
2025-10-10 14:28:13 -07:00
const extensionStorage = new ExtensionStorage ( newExtensionName ) ;
const destinationPath = extensionStorage . getExtensionDir ( ) ;
if ( isUpdate ) {
await uninstallExtension ( newExtensionName , isUpdate , cwd ) ;
}
2025-09-09 12:12:56 -04:00
await fs . promises . mkdir ( destinationPath , { recursive : true } ) ;
2025-09-17 18:14:01 -04:00
if (
installMetadata . type === 'local' ||
installMetadata . type === 'git' ||
installMetadata . type === 'github-release'
) {
2025-09-09 12:12:56 -04:00
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 ) ;
} finally {
if ( tempDir ) {
await fs . promises . rm ( tempDir , { recursive : true , force : true } ) ;
}
2025-08-25 17:02:10 +00:00
}
2025-10-10 14:28:13 -07:00
if ( isUpdate ) {
logExtensionUpdateEvent (
telemetryConfig ,
new ExtensionUpdateEvent (
newExtensionConfig . name ,
newExtensionConfig . version ,
previousExtensionConfig . version ,
installMetadata . source ,
'success' ,
) ,
) ;
} else {
logExtensionInstallEvent (
telemetryConfig ,
new ExtensionInstallEvent (
newExtensionConfig . name ,
newExtensionConfig . version ,
installMetadata . source ,
'success' ,
) ,
) ;
enableExtension ( newExtensionConfig . name , SettingScope . User ) ;
}
2025-09-09 12:12:56 -04:00
return newExtensionConfig ! . name ;
} 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 ) {
2025-09-22 09:34:52 +00:00
try {
newExtensionConfig = loadExtensionConfig ( {
extensionDir : localSourcePath ,
workspaceDir : cwd ,
} ) ;
} catch {
// Ignore error, this is just for logging.
}
2025-09-09 12:12:56 -04:00
}
2025-10-10 14:28:13 -07:00
if ( isUpdate ) {
logExtensionUpdateEvent (
telemetryConfig ,
new ExtensionUpdateEvent (
newExtensionConfig ? . name ? ? previousExtensionConfig . name ,
newExtensionConfig ? . version ? ? '' ,
previousExtensionConfig . version ,
installMetadata . source ,
'error' ,
) ,
) ;
} else {
logExtensionInstallEvent (
telemetryConfig ,
new ExtensionInstallEvent (
newExtensionConfig ? . name ? ? '' ,
newExtensionConfig ? . version ? ? '' ,
installMetadata . source ,
'error' ,
) ,
) ;
}
2025-09-09 12:12:56 -04:00
throw error ;
}
2025-08-25 17:02:10 +00:00
}
2025-08-25 17:40:15 +00:00
2025-09-25 10:57:59 -07:00
/**
* Builds a consent string for installing an extension based on it's
* extensionConfig.
*/
function extensionConsentString ( extensionConfig : ExtensionConfig ) : string {
2025-10-08 22:41:22 +02:00
const sanitizedConfig = escapeAnsiCtrlCodes ( extensionConfig ) ;
2025-09-22 19:50:12 -04:00
const output : string [ ] = [ ] ;
2025-10-08 22:41:22 +02:00
const mcpServerEntries = Object . entries ( sanitizedConfig . mcpServers || { } ) ;
output . push ( ` Installing extension " ${ sanitizedConfig . name } ". ` ) ;
2025-09-22 19:50:12 -04:00
output . push (
2025-09-29 14:19:19 -07:00
'**Extensions may introduce unexpected behavior. Ensure you have investigated the extension source and trust the author.**' ,
2025-09-22 19:50:12 -04:00
) ;
2025-09-17 09:24:38 -04:00
if ( mcpServerEntries . length ) {
2025-09-22 19:50:12 -04:00
output . push ( 'This extension will run the following MCP servers:' ) ;
2025-09-17 09:24:38 -04:00
for ( const [ key , mcpServer ] of mcpServerEntries ) {
const isLocal = ! ! mcpServer . command ;
2025-09-22 19:50:12 -04:00
const source =
mcpServer . httpUrl ? ?
` ${ mcpServer . command || '' } ${ mcpServer . args ? ' ' + mcpServer . args . join ( ' ' ) : '' } ` ;
output . push ( ` * ${ key } ( ${ isLocal ? 'local' : 'remote' } ): ${ source } ` ) ;
2025-09-17 09:24:38 -04:00
}
2025-09-22 19:50:12 -04:00
}
2025-10-08 22:41:22 +02:00
if ( sanitizedConfig . contextFileName ) {
2025-09-22 19:50:12 -04:00
output . push (
2025-10-08 22:41:22 +02:00
` This extension will append info to your gemini.md context using ${ sanitizedConfig . contextFileName } ` ,
2025-09-17 09:24:38 -04:00
) ;
2025-09-22 19:50:12 -04:00
}
2025-10-08 22:41:22 +02:00
if ( sanitizedConfig . excludeTools ) {
2025-09-22 19:50:12 -04:00
output . push (
2025-10-08 22:41:22 +02:00
` This extension will exclude the following core tools: ${ sanitizedConfig . excludeTools } ` ,
2025-09-22 19:50:12 -04:00
) ;
}
2025-09-25 10:57:59 -07:00
return output . join ( '\n' ) ;
}
/**
* Requests consent from the user to install an extension (extensionConfig), if
* there is any difference between the consent string for `extensionConfig` and
* `previousExtensionConfig`.
*
* Always requests consent if previousExtensionConfig is null.
*
* Throws if the user does not consent.
*/
async function maybeRequestConsentOrFail (
extensionConfig : ExtensionConfig ,
requestConsent : ( consent : string ) = > Promise < boolean > ,
previousExtensionConfig? : ExtensionConfig ,
) {
const extensionConsent = extensionConsentString ( extensionConfig ) ;
if ( previousExtensionConfig ) {
const previousExtensionConsent = extensionConsentString (
previousExtensionConfig ,
) ;
if ( previousExtensionConsent === extensionConsent ) {
return ;
}
}
if ( ! ( await requestConsent ( extensionConsent ) ) ) {
2025-09-29 14:19:19 -07:00
throw new Error ( ` Installation cancelled for " ${ extensionConfig . name } ". ` ) ;
2025-09-17 09:24:38 -04:00
}
}
2025-09-25 14:05:49 -04:00
export 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. ` ,
) ;
}
}
2025-09-22 09:34:52 +00:00
export function loadExtensionConfig (
2025-09-17 04:23:12 +00:00
context : LoadExtensionContext ,
2025-09-22 09:34:52 +00:00
) : ExtensionConfig {
2025-09-17 04:23:12 +00:00
const { extensionDir , workspaceDir } = context ;
2025-09-02 10:15:42 -07:00
const configFilePath = path . join ( extensionDir , EXTENSIONS_CONFIG_FILENAME ) ;
if ( ! fs . existsSync ( configFilePath ) ) {
2025-09-22 09:34:52 +00:00
throw new Error ( ` Configuration file not found at ${ configFilePath } ` ) ;
2025-09-02 10:15:42 -07:00
}
try {
const configContent = fs . readFileSync ( configFilePath , 'utf-8' ) ;
2025-10-10 13:40:13 -07:00
const rawConfig = JSON . parse ( configContent ) as ExtensionConfig ;
if ( ! rawConfig . name || ! rawConfig . version ) {
2025-09-22 09:34:52 +00:00
throw new Error (
2025-10-10 13:40:13 -07:00
` Invalid configuration in ${ configFilePath } : missing ${ ! rawConfig . name ? '"name"' : '"version"' } ` ,
2025-09-22 09:34:52 +00:00
) ;
2025-09-02 10:15:42 -07:00
}
2025-10-10 13:40:13 -07:00
const installDir = new ExtensionStorage ( rawConfig . name ) . getExtensionDir ( ) ;
const config = recursivelyHydrateStrings (
rawConfig as unknown as JsonObject ,
{
extensionPath : installDir ,
workspacePath : workspaceDir ,
'/' : path . sep ,
pathSeparator : path.sep ,
} ,
) as unknown as ExtensionConfig ;
2025-09-25 14:05:49 -04:00
validateName ( config . name ) ;
2025-09-02 10:15:42 -07:00
return config ;
2025-09-22 09:34:52 +00:00
} catch ( e ) {
throw new Error (
` Failed to load extension config from ${ configFilePath } : ${ getErrorMessage (
e ,
) } ` ,
) ;
2025-09-02 10:15:42 -07:00
}
}
2025-08-26 14:36:55 +00:00
export async function uninstallExtension (
2025-09-18 16:00:28 +00:00
extensionIdentifier : string ,
2025-10-10 14:28:13 -07:00
isUpdate : boolean ,
2025-08-26 14:36:55 +00:00
cwd : string = process . cwd ( ) ,
) : Promise < void > {
2025-08-25 17:40:15 +00:00
const installedExtensions = loadUserExtensions ( ) ;
2025-09-18 16:00:28 +00:00
const extensionName = installedExtensions . find (
( installed ) = >
2025-10-08 07:31:41 -07:00
installed . name . toLowerCase ( ) === extensionIdentifier . toLowerCase ( ) ||
2025-09-18 16:00:28 +00:00
installed . installMetadata ? . source . toLowerCase ( ) ===
extensionIdentifier . toLowerCase ( ) ,
2025-10-08 07:31:41 -07:00
) ? . name ;
2025-09-18 16:00:28 +00:00
if ( ! extensionName ) {
throw new Error ( ` Extension not found. ` ) ;
2025-08-25 17:40:15 +00:00
}
const storage = new ExtensionStorage ( extensionName ) ;
2025-09-12 13:38:54 -04:00
await fs . promises . rm ( storage . getExtensionDir ( ) , {
2025-08-25 17:40:15 +00:00
recursive : true ,
force : true ,
} ) ;
2025-10-10 14:28:13 -07:00
// The rest of the cleanup below here is only for true uninstalls, not
// uninstalls related to updates.
if ( isUpdate ) return ;
const manager = new ExtensionEnablementManager (
ExtensionStorage . getUserExtensionsDir ( ) ,
[ extensionName ] ,
) ;
manager . remove ( extensionName ) ;
const telemetryConfig = getTelemetryConfig ( cwd ) ;
2025-09-22 12:55:43 -04:00
logExtensionUninstall (
telemetryConfig ,
2025-09-12 13:38:54 -04:00
new ExtensionUninstallEvent ( extensionName , 'success' ) ,
) ;
2025-08-25 17:40:15 +00:00
}
2025-08-25 18:27:38 +00:00
2025-09-25 12:30:25 -04:00
export function toOutputString (
2025-10-08 07:31:41 -07:00
extension : GeminiCLIExtension ,
2025-09-25 21:44:28 -04:00
workspaceDir : string ,
2025-09-25 12:30:25 -04:00
) : string {
2025-09-25 21:44:28 -04:00
const manager = new ExtensionEnablementManager (
ExtensionStorage . getUserExtensionsDir ( ) ,
) ;
2025-10-08 07:31:41 -07:00
const userEnabled = manager . isEnabled ( extension . name , os . homedir ( ) ) ;
const workspaceEnabled = manager . isEnabled ( extension . name , workspaceDir ) ;
2025-09-25 21:44:28 -04:00
const status = workspaceEnabled ? chalk . green ( '✓' ) : chalk . red ( '✗' ) ;
2025-10-08 07:31:41 -07:00
let output = ` ${ status } ${ extension . name } ( ${ extension . version } ) ` ;
2025-08-25 18:27:38 +00:00
output += ` \ n Path: ${ extension . path } ` ;
if ( extension . installMetadata ) {
2025-09-02 10:15:42 -07:00
output += ` \ n Source: ${ extension . installMetadata . source } (Type: ${ extension . installMetadata . type } ) ` ;
2025-09-10 09:35:48 -07:00
if ( extension . installMetadata . ref ) {
output += ` \ n Ref: ${ extension . installMetadata . ref } ` ;
}
2025-09-22 09:42:35 -07:00
if ( extension . installMetadata . releaseTag ) {
output += ` \ n Release tag: ${ extension . installMetadata . releaseTag } ` ;
}
2025-08-25 18:27:38 +00:00
}
2025-09-25 21:44:28 -04:00
output += ` \ n Enabled (User): ${ userEnabled } ` ;
output += ` \ n Enabled (Workspace): ${ workspaceEnabled } ` ;
2025-08-25 18:27:38 +00:00
if ( extension . contextFiles . length > 0 ) {
output += ` \ n Context files: ` ;
extension . contextFiles . forEach ( ( contextFile ) = > {
output += ` \ n ${ contextFile } ` ;
} ) ;
}
2025-10-08 07:31:41 -07:00
if ( extension . mcpServers ) {
2025-08-25 18:27:38 +00:00
output += ` \ n MCP servers: ` ;
2025-10-08 07:31:41 -07:00
Object . keys ( extension . mcpServers ) . forEach ( ( key ) = > {
2025-08-25 18:27:38 +00:00
output += ` \ n ${ key } ` ;
} ) ;
}
2025-10-08 07:31:41 -07:00
if ( extension . excludeTools ) {
2025-08-25 18:27:38 +00:00
output += ` \ n Excluded tools: ` ;
2025-10-08 07:31:41 -07:00
extension . excludeTools . forEach ( ( tool ) = > {
2025-08-25 18:27:38 +00:00
output += ` \ n ${ tool } ` ;
} ) ;
}
return output ;
}
2025-08-25 19:41:15 +00:00
2025-08-26 14:36:55 +00:00
export function disableExtension (
name : string ,
scope : SettingScope ,
cwd : string = process . cwd ( ) ,
) {
2025-09-23 14:37:35 -04:00
const config = getTelemetryConfig ( cwd ) ;
2025-08-26 14:36:55 +00:00
if ( scope === SettingScope . System || scope === SettingScope . SystemDefaults ) {
throw new Error ( 'System and SystemDefaults scopes are not supported.' ) ;
}
2025-09-25 21:44:28 -04:00
const extension = loadExtensionByName ( name , cwd ) ;
if ( ! extension ) {
throw new Error ( ` Extension with name ${ name } does not exist. ` ) ;
}
2025-08-26 14:36:55 +00:00
2025-09-16 15:51:46 -04:00
const manager = new ExtensionEnablementManager (
ExtensionStorage . getUserExtensionsDir ( ) ,
2025-09-29 06:53:19 -07:00
[ name ] ,
2025-09-16 15:51:46 -04:00
) ;
const scopePath = scope === SettingScope . Workspace ? cwd : os.homedir ( ) ;
manager . disable ( name , true , scopePath ) ;
2025-09-23 14:37:35 -04:00
logExtensionDisable ( config , new ExtensionDisableEvent ( name , scope ) ) ;
2025-08-26 15:49:00 +00:00
}
2025-09-16 15:51:46 -04:00
export function enableExtension (
2025-08-26 14:36:55 +00:00
name : string ,
2025-09-16 15:51:46 -04:00
scope : SettingScope ,
2025-08-26 14:36:55 +00:00
cwd : string = process . cwd ( ) ,
) {
2025-09-16 15:51:46 -04:00
if ( scope === SettingScope . System || scope === SettingScope . SystemDefaults ) {
throw new Error ( 'System and SystemDefaults scopes are not supported.' ) ;
2025-08-26 14:36:55 +00:00
}
2025-09-25 21:44:28 -04:00
const extension = loadExtensionByName ( name , cwd ) ;
if ( ! extension ) {
throw new Error ( ` Extension with name ${ name } does not exist. ` ) ;
}
2025-09-16 15:51:46 -04:00
const manager = new ExtensionEnablementManager (
ExtensionStorage . getUserExtensionsDir ( ) ,
) ;
const scopePath = scope === SettingScope . Workspace ? cwd : os.homedir ( ) ;
manager . enable ( name , true , scopePath ) ;
2025-09-22 12:55:43 -04:00
const config = getTelemetryConfig ( cwd ) ;
logExtensionEnable ( config , new ExtensionEnableEvent ( name , scope ) ) ;
2025-08-26 14:36:55 +00:00
}