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 ,
} from '@google/gemini-cli-core' ;
2025-08-26 00:04:53 +02:00
import { Storage } 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-25 17:02:10 +00:00
import { simpleGit } from 'simple-git' ;
2025-08-26 14:36:55 +00:00
import { SettingScope , loadSettings } from '../config/settings.js' ;
import { getErrorMessage } from '../utils/errors.js' ;
2025-08-26 02:13:16 +00:00
import { recursivelyHydrateStrings } from './extensions/variables.js' ;
2025-08-25 17:02:10 +00:00
export const EXTENSIONS_DIRECTORY_NAME = '.gemini/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-06-13 13:57:00 -07:00
export interface Extension {
2025-07-28 18:40:47 -07:00
path : string ;
2025-06-13 13:57:00 -07:00
config : ExtensionConfig ;
contextFiles : string [ ] ;
2025-08-25 17:02:10 +00:00
installMetadata? : ExtensionInstallMetadata | undefined ;
2025-06-13 13:57:00 -07:00
}
2025-06-10 15:48:39 -07:00
export interface ExtensionConfig {
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 17:02:10 +00:00
export interface ExtensionInstallMetadata {
source : string ;
type : 'git' | 'local' ;
}
2025-08-25 19:41:15 +00:00
export interface ExtensionUpdateInfo {
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-06-13 13:57:00 -07:00
export function loadExtensions ( workspaceDir : string ) : Extension [ ] {
2025-06-10 15:48:39 -07:00
const allExtensions = [
. . . loadExtensionsFromDir ( workspaceDir ) ,
. . . loadExtensionsFromDir ( os . homedir ( ) ) ,
] ;
2025-07-08 12:57:34 -04:00
const uniqueExtensions = new Map < string , Extension > ( ) ;
2025-06-10 15:48:39 -07:00
for ( const extension of allExtensions ) {
2025-07-08 12:57:34 -04:00
if ( ! uniqueExtensions . has ( extension . config . name ) ) {
uniqueExtensions . set ( extension . config . 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-08-25 17:02:10 +00:00
export function loadUserExtensions ( ) : Extension [ ] {
const userExtensions = loadExtensionsFromDir ( os . homedir ( ) ) ;
const uniqueExtensions = new Map < string , Extension > ( ) ;
for ( const extension of userExtensions ) {
if ( ! uniqueExtensions . has ( extension . config . name ) ) {
uniqueExtensions . set ( extension . config . name , extension ) ;
}
}
return Array . from ( uniqueExtensions . values ( ) ) ;
}
export function loadExtensionsFromDir ( dir : string ) : Extension [ ] {
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-06-13 13:57:00 -07:00
const extensions : Extension [ ] = [ ] ;
2025-06-10 15:48:39 -07:00
for ( const subdir of fs . readdirSync ( extensionsDir ) ) {
const extensionDir = path . join ( extensionsDir , subdir ) ;
2025-06-13 13:57:00 -07:00
const extension = loadExtension ( extensionDir ) ;
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-08-25 17:02:10 +00:00
export function loadExtension ( extensionDir : string ) : Extension | null {
2025-06-13 13:57:00 -07:00
if ( ! fs . statSync ( extensionDir ) . isDirectory ( ) ) {
console . error (
` Warning: unexpected file ${ extensionDir } in extensions directory. ` ,
) ;
return null ;
}
2025-06-10 15:48:39 -07:00
2025-06-13 13:57:00 -07:00
const configFilePath = path . join ( extensionDir , EXTENSIONS_CONFIG_FILENAME ) ;
if ( ! fs . existsSync ( configFilePath ) ) {
console . error (
` Warning: extension directory ${ extensionDir } does not contain a config file ${ configFilePath } . ` ,
) ;
return null ;
}
try {
const configContent = fs . readFileSync ( configFilePath , 'utf-8' ) ;
2025-08-26 02:13:16 +00:00
const config = recursivelyHydrateStrings ( JSON . parse ( configContent ) , {
extensionPath : extensionDir ,
'/' : path . sep ,
pathSeparator : path.sep ,
} ) as unknown as ExtensionConfig ;
2025-06-13 13:57:00 -07:00
if ( ! config . name || ! config . version ) {
2025-06-10 15:48:39 -07:00
console . error (
2025-06-13 13:57:00 -07:00
` Invalid extension config in ${ configFilePath } : missing name or version. ` ,
2025-06-10 15:48:39 -07:00
) ;
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
const contextFiles = getContextFileNames ( config )
. map ( ( contextFileName ) = > path . join ( extensionDir , contextFileName ) )
. filter ( ( contextFilePath ) = > fs . existsSync ( contextFilePath ) ) ;
return {
2025-07-28 18:40:47 -07:00
path : extensionDir ,
2025-06-13 13:57:00 -07:00
config ,
contextFiles ,
2025-08-25 17:02:10 +00:00
installMetadata : loadInstallMetadata ( extensionDir ) ,
2025-06-13 13:57:00 -07:00
} ;
} catch ( e ) {
console . error (
2025-08-26 14:36:55 +00:00
` Warning: error parsing extension config in ${ configFilePath } : ${ getErrorMessage (
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-08-25 17:02:10 +00:00
function loadInstallMetadata (
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 .
* If enabledExtensionNames is empty , an extension is active unless it is in list of disabled extensions in settings .
* @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-07-08 12:57:34 -04:00
extensions : Extension [ ] ,
enabledExtensionNames : string [ ] ,
2025-08-26 14:36:55 +00:00
workspaceDir : string ,
2025-07-18 20:45:00 +02:00
) : GeminiCLIExtension [ ] {
2025-08-26 14:36:55 +00:00
const settings = loadSettings ( workspaceDir ) . merged ;
const disabledExtensions = settings . extensions ? . disabled ? ? [ ] ;
2025-07-18 20:45:00 +02:00
const annotatedExtensions : GeminiCLIExtension [ ] = [ ] ;
2025-07-08 12:57:34 -04:00
if ( enabledExtensionNames . length === 0 ) {
2025-07-18 20:45:00 +02:00
return extensions . map ( ( extension ) = > ( {
name : extension.config.name ,
version : extension.config.version ,
2025-08-26 14:36:55 +00:00
isActive : ! disabledExtensions . includes ( extension . config . name ) ,
2025-07-28 18:40:47 -07:00
path : extension.path ,
2025-07-18 20:45:00 +02:00
} ) ) ;
2025-07-08 12:57:34 -04:00
}
const lowerCaseEnabledExtensions = new Set (
enabledExtensionNames . map ( ( e ) = > e . trim ( ) . toLowerCase ( ) ) ,
) ;
if (
lowerCaseEnabledExtensions . size === 1 &&
lowerCaseEnabledExtensions . has ( 'none' )
) {
2025-07-18 20:45:00 +02:00
return extensions . map ( ( extension ) = > ( {
name : extension.config.name ,
version : extension.config.version ,
isActive : false ,
2025-07-28 18:40:47 -07:00
path : extension.path ,
2025-07-18 20:45:00 +02:00
} ) ) ;
2025-07-08 12:57:34 -04:00
}
const notFoundNames = new Set ( lowerCaseEnabledExtensions ) ;
for ( const extension of extensions ) {
const lowerCaseName = extension . config . name . toLowerCase ( ) ;
2025-07-18 20:45:00 +02:00
const isActive = lowerCaseEnabledExtensions . has ( lowerCaseName ) ;
if ( isActive ) {
2025-07-08 12:57:34 -04:00
notFoundNames . delete ( lowerCaseName ) ;
}
2025-07-18 20:45:00 +02:00
annotatedExtensions . push ( {
name : extension.config.name ,
version : extension.config.version ,
isActive ,
2025-07-28 18:40:47 -07:00
path : extension.path ,
2025-07-18 20:45:00 +02:00
} ) ;
2025-07-08 12:57:34 -04:00
}
for ( const requestedName of notFoundNames ) {
2025-07-18 20:45:00 +02:00
console . error ( ` Extension not found: ${ requestedName } ` ) ;
2025-07-08 12:57:34 -04:00
}
2025-07-18 20:45:00 +02:00
return annotatedExtensions ;
2025-07-08 12:57:34 -04:00
}
2025-08-25 17:02:10 +00:00
/ * *
* Clones a Git repository to a specified local path .
* @param gitUrl The Git URL to clone .
* @param destination The destination path to clone the repository to .
* /
async function cloneFromGit (
gitUrl : string ,
destination : string ,
) : Promise < void > {
try {
// TODO(chrstnb): Download the archive instead to avoid unnecessary .git info.
await simpleGit ( ) . clone ( gitUrl , destination , [ '--depth' , '1' ] ) ;
} catch ( error ) {
throw new Error ( ` Failed to clone Git repository from ${ gitUrl } ` , {
cause : error ,
} ) ;
}
}
/ * *
* Copies an extension from a source to a destination path .
* @param source The source path of the extension .
* @param destination The destination path to copy the extension to .
* /
async function copyExtension (
source : string ,
destination : string ,
) : Promise < void > {
await fs . promises . cp ( source , destination , { recursive : true } ) ;
}
export async function installExtension (
installMetadata : ExtensionInstallMetadata ,
2025-08-26 14:36:55 +00:00
cwd : string = process . cwd ( ) ,
2025-08-25 17:02:10 +00:00
) : Promise < string > {
const extensionsDir = ExtensionStorage . getUserExtensionsDir ( ) ;
await fs . promises . mkdir ( extensionsDir , { recursive : true } ) ;
// Convert relative paths to absolute paths for the metadata file.
if (
installMetadata . type === 'local' &&
! path . isAbsolute ( installMetadata . source )
) {
2025-08-26 14:36:55 +00:00
installMetadata . source = path . resolve ( cwd , installMetadata . source ) ;
2025-08-25 17:02:10 +00:00
}
let localSourcePath : string ;
let tempDir : string | undefined ;
if ( installMetadata . type === 'git' ) {
tempDir = await ExtensionStorage . createTmpDir ( ) ;
await cloneFromGit ( installMetadata . source , tempDir ) ;
localSourcePath = tempDir ;
} else {
localSourcePath = installMetadata . source ;
}
let newExtensionName : string | undefined ;
try {
const newExtension = loadExtension ( localSourcePath ) ;
if ( ! newExtension ) {
throw new Error (
` Invalid extension at ${ installMetadata . source } . Please make sure it has a valid gemini-extension.json file. ` ,
) ;
}
// ~/.gemini/extensions/{ExtensionConfig.name}.
newExtensionName = newExtension . config . name ;
const extensionStorage = new ExtensionStorage ( newExtensionName ) ;
const destinationPath = extensionStorage . getExtensionDir ( ) ;
const installedExtensions = loadUserExtensions ( ) ;
if (
installedExtensions . some (
( installed ) = > installed . config . name === newExtensionName ,
)
) {
throw new Error (
2025-08-25 19:41:15 +00:00
` Extension " ${ newExtensionName } " is already installed. Please uninstall it first. ` ,
2025-08-25 17:02:10 +00: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 } ) ;
}
}
return newExtensionName ;
}
2025-08-25 17:40:15 +00:00
2025-08-26 14:36:55 +00:00
export async function uninstallExtension (
extensionName : string ,
cwd : string = process . cwd ( ) ,
) : Promise < void > {
2025-08-25 17:40:15 +00:00
const installedExtensions = loadUserExtensions ( ) ;
if (
! installedExtensions . some (
( installed ) = > installed . config . name === extensionName ,
)
) {
2025-08-25 19:41:15 +00:00
throw new Error ( ` Extension " ${ extensionName } " not found. ` ) ;
2025-08-25 17:40:15 +00:00
}
2025-08-26 14:36:55 +00:00
removeFromDisabledExtensions (
extensionName ,
[ SettingScope . User , SettingScope . Workspace ] ,
cwd ,
) ;
2025-08-25 17:40:15 +00:00
const storage = new ExtensionStorage ( extensionName ) ;
return await fs . promises . rm ( storage . getExtensionDir ( ) , {
recursive : true ,
force : true ,
} ) ;
}
2025-08-25 18:27:38 +00:00
export function toOutputString ( extension : Extension ) : string {
let output = ` ${ extension . config . name } ( ${ extension . config . version } ) ` ;
output += ` \ n Path: ${ extension . path } ` ;
if ( extension . installMetadata ) {
output += ` \ n Source: ${ extension . installMetadata . source } ` ;
}
if ( extension . contextFiles . length > 0 ) {
output += ` \ n Context files: ` ;
extension . contextFiles . forEach ( ( contextFile ) = > {
output += ` \ n ${ contextFile } ` ;
} ) ;
}
if ( extension . config . mcpServers ) {
output += ` \ n MCP servers: ` ;
Object . keys ( extension . config . mcpServers ) . forEach ( ( key ) = > {
output += ` \ n ${ key } ` ;
} ) ;
}
if ( extension . config . excludeTools ) {
output += ` \ n Excluded tools: ` ;
extension . config . excludeTools . forEach ( ( tool ) = > {
output += ` \ n ${ tool } ` ;
} ) ;
}
return output ;
}
2025-08-25 19:41:15 +00:00
export async function updateExtension (
extensionName : string ,
2025-08-26 14:36:55 +00:00
cwd : string = process . cwd ( ) ,
2025-08-25 19:41:15 +00:00
) : Promise < ExtensionUpdateInfo | undefined > {
const installedExtensions = loadUserExtensions ( ) ;
const extension = installedExtensions . find (
( installed ) = > installed . config . name === extensionName ,
) ;
if ( ! extension ) {
throw new Error (
` Extension " ${ extensionName } " not found. Run gemini extensions list to see available extensions. ` ,
) ;
}
if ( ! extension . installMetadata ) {
throw new Error (
` Extension cannot be updated because it is missing the .gemini-extension.install.json file. To update manually, uninstall and then reinstall the updated version. ` ,
) ;
}
const originalVersion = extension . config . version ;
const tempDir = await ExtensionStorage . createTmpDir ( ) ;
try {
await copyExtension ( extension . path , tempDir ) ;
2025-08-26 14:36:55 +00:00
await uninstallExtension ( extensionName , cwd ) ;
await installExtension ( extension . installMetadata , cwd ) ;
2025-08-25 19:41:15 +00:00
const updatedExtension = loadExtension ( extension . path ) ;
if ( ! updatedExtension ) {
throw new Error ( 'Updated extension not found after installation.' ) ;
}
const updatedVersion = updatedExtension . config . version ;
return {
originalVersion ,
updatedVersion ,
} ;
} catch ( e ) {
2025-08-26 14:36:55 +00:00
console . error (
` Error updating extension, rolling back. ${ getErrorMessage ( e ) } ` ,
) ;
2025-08-25 19:41:15 +00:00
await copyExtension ( tempDir , extension . path ) ;
throw e ;
} finally {
await fs . promises . rm ( tempDir , { recursive : true , force : true } ) ;
}
}
2025-08-26 14:36:55 +00:00
export function disableExtension (
name : string ,
scope : SettingScope ,
cwd : string = process . cwd ( ) ,
) {
if ( scope === SettingScope . System || scope === SettingScope . SystemDefaults ) {
throw new Error ( 'System and SystemDefaults scopes are not supported.' ) ;
}
const settings = loadSettings ( cwd ) ;
const settingsFile = settings . forScope ( scope ) ;
const extensionSettings = settingsFile . settings . extensions || {
disabled : [ ] ,
} ;
const disabledExtensions = extensionSettings . disabled || [ ] ;
if ( ! disabledExtensions . includes ( name ) ) {
disabledExtensions . push ( name ) ;
extensionSettings . disabled = disabledExtensions ;
settings . setValue ( scope , 'extensions' , extensionSettings ) ;
}
}
2025-08-26 15:49:00 +00:00
export function enableExtension ( name : string , scopes : SettingScope [ ] ) {
removeFromDisabledExtensions ( name , scopes ) ;
}
2025-08-26 14:36:55 +00:00
/ * *
* Removes an extension from the list of disabled extensions .
* @param name The name of the extension to remove .
* @param scope The scopes to remove the name from .
* /
function removeFromDisabledExtensions (
name : string ,
scopes : SettingScope [ ] ,
cwd : string = process . cwd ( ) ,
) {
const settings = loadSettings ( cwd ) ;
for ( const scope of scopes ) {
const settingsFile = settings . forScope ( scope ) ;
const extensionSettings = settingsFile . settings . extensions || {
disabled : [ ] ,
} ;
const disabledExtensions = extensionSettings . disabled || [ ] ;
extensionSettings . disabled = disabledExtensions . filter (
( extension ) = > extension !== name ,
) ;
settings . setValue ( scope , 'extensions' , extensionSettings ) ;
}
}