From dff175c4f4ce140c426a02000aa650672d12960d Mon Sep 17 00:00:00 2001 From: christine betts Date: Tue, 26 Aug 2025 14:36:55 +0000 Subject: [PATCH] [extensions] Add disable command (#7001) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/cli/src/commands/extensions.tsx | 2 + .../cli/src/commands/extensions/disable.ts | 51 +++++++ .../cli/src/commands/extensions/install.ts | 4 +- packages/cli/src/commands/extensions/list.ts | 3 +- .../cli/src/commands/extensions/uninstall.ts | 3 +- .../cli/src/commands/extensions/update.ts | 3 +- packages/cli/src/config/config.ts | 1 + packages/cli/src/config/extension.test.ts | 128 ++++++++++++++++-- packages/cli/src/config/extension.ts | 93 +++++++++++-- packages/cli/src/config/settingsSchema.ts | 20 +++ packages/cli/src/utils/errors.ts | 12 ++ 11 files changed, 291 insertions(+), 29 deletions(-) create mode 100644 packages/cli/src/commands/extensions/disable.ts create mode 100644 packages/cli/src/utils/errors.ts diff --git a/packages/cli/src/commands/extensions.tsx b/packages/cli/src/commands/extensions.tsx index 53116c36dc..8bfd0a88f7 100644 --- a/packages/cli/src/commands/extensions.tsx +++ b/packages/cli/src/commands/extensions.tsx @@ -9,6 +9,7 @@ import { installCommand } from './extensions/install.js'; import { uninstallCommand } from './extensions/uninstall.js'; import { listCommand } from './extensions/list.js'; import { updateCommand } from './extensions/update.js'; +import { disableCommand } from './extensions/disable.js'; export const extensionsCommand: CommandModule = { command: 'extensions ', @@ -19,6 +20,7 @@ export const extensionsCommand: CommandModule = { .command(uninstallCommand) .command(listCommand) .command(updateCommand) + .command(disableCommand) .demandCommand(1, 'You need at least one command before continuing.') .version(false), handler: () => { diff --git a/packages/cli/src/commands/extensions/disable.ts b/packages/cli/src/commands/extensions/disable.ts new file mode 100644 index 0000000000..139e7da8c4 --- /dev/null +++ b/packages/cli/src/commands/extensions/disable.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type CommandModule } from 'yargs'; +import { disableExtension } from '../../config/extension.js'; +import { SettingScope } from '../../config/settings.js'; +import { getErrorMessage } from '../../utils/errors.js'; + +interface DisableArgs { + name: string; + scope: SettingScope; +} + +export async function handleDisable(args: DisableArgs) { + try { + disableExtension(args.name, args.scope); + console.log( + `Extension "${args.name}" successfully disabled for scope "${args.scope}".`, + ); + } catch (error) { + console.error(getErrorMessage(error)); + process.exit(1); + } +} + +export const disableCommand: CommandModule = { + command: 'disable [--scope] ', + describe: 'Disables an extension.', + builder: (yargs) => + yargs + .positional('name', { + describe: 'The name of the extension to disable.', + type: 'string', + }) + .option('scope', { + describe: 'The scope to disable the extenison in.', + type: 'string', + default: SettingScope.User, + choices: [SettingScope.User, SettingScope.Workspace], + }) + .check((_argv) => true), + handler: async (argv) => { + await handleDisable({ + name: argv['name'] as string, + scope: argv['scope'] as SettingScope, + }); + }, +}; diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index f635148153..af411c3d47 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -10,6 +10,8 @@ import { type ExtensionInstallMetadata, } from '../../config/extension.js'; +import { getErrorMessage } from '../../utils/errors.js'; + interface InstallArgs { source?: string; path?: string; @@ -26,7 +28,7 @@ export async function handleInstall(args: InstallArgs) { `Extension "${extensionName}" installed successfully and enabled.`, ); } catch (error) { - console.error((error as Error).message); + console.error(getErrorMessage(error)); process.exit(1); } } diff --git a/packages/cli/src/commands/extensions/list.ts b/packages/cli/src/commands/extensions/list.ts index e076d34715..4611062505 100644 --- a/packages/cli/src/commands/extensions/list.ts +++ b/packages/cli/src/commands/extensions/list.ts @@ -6,6 +6,7 @@ import type { CommandModule } from 'yargs'; import { loadUserExtensions, toOutputString } from '../../config/extension.js'; +import { getErrorMessage } from '../../utils/errors.js'; export async function handleList() { try { @@ -20,7 +21,7 @@ export async function handleList() { .join('\n\n'), ); } catch (error) { - console.error((error as Error).message); + console.error(getErrorMessage(error)); process.exit(1); } } diff --git a/packages/cli/src/commands/extensions/uninstall.ts b/packages/cli/src/commands/extensions/uninstall.ts index f34190db7b..ff93b79723 100644 --- a/packages/cli/src/commands/extensions/uninstall.ts +++ b/packages/cli/src/commands/extensions/uninstall.ts @@ -6,6 +6,7 @@ import type { CommandModule } from 'yargs'; import { uninstallExtension } from '../../config/extension.js'; +import { getErrorMessage } from '../../utils/errors.js'; interface UninstallArgs { name: string; @@ -16,7 +17,7 @@ export async function handleUninstall(args: UninstallArgs) { await uninstallExtension(args.name); console.log(`Extension "${args.name}" successfully uninstalled.`); } catch (error) { - console.error((error as Error).message); + console.error(getErrorMessage(error)); process.exit(1); } } diff --git a/packages/cli/src/commands/extensions/update.ts b/packages/cli/src/commands/extensions/update.ts index 1a03a76343..43ac6de8b1 100644 --- a/packages/cli/src/commands/extensions/update.ts +++ b/packages/cli/src/commands/extensions/update.ts @@ -6,6 +6,7 @@ import type { CommandModule } from 'yargs'; import { updateExtension } from '../../config/extension.js'; +import { getErrorMessage } from '../../utils/errors.js'; interface UpdateArgs { name: string; @@ -23,7 +24,7 @@ export async function handleUpdate(args: UpdateArgs) { `Extension "${args.name}" successfully updated: ${updatedExtensionInfo.originalVersion} → ${updatedExtensionInfo.updatedVersion}.`, ); } catch (error) { - console.error((error as Error).message); + console.error(getErrorMessage(error)); process.exit(1); } } diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 7a5de02e5a..c7296a5e27 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -334,6 +334,7 @@ export async function loadCliConfig( const allExtensions = annotateActiveExtensions( extensions, argv.extensions || [], + cwd, ); const activeExtensions = extensions.filter( diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 6c589bc0fb..8c4c7b9cef 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -12,6 +12,7 @@ import { EXTENSIONS_CONFIG_FILENAME, INSTALL_METADATA_FILENAME, annotateActiveExtensions, + disableExtension, installExtension, loadExtensions, uninstallExtension, @@ -19,6 +20,7 @@ import { } from './extension.js'; import { type MCPServerConfig } from '@google/gemini-cli-core'; import { execSync } from 'node:child_process'; +import { SettingScope, loadSettings } from './settings.js'; import { type SimpleGit, simpleGit } from 'simple-git'; vi.mock('simple-git', () => ({ @@ -130,6 +132,33 @@ describe('loadExtensions', () => { ]); }); + it('should filter out disabled extensions', () => { + const workspaceExtensionsDir = path.join( + tempWorkspaceDir, + EXTENSIONS_DIRECTORY_NAME, + ); + fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); + + createExtension(workspaceExtensionsDir, 'ext1', '1.0.0'); + createExtension(workspaceExtensionsDir, 'ext2', '2.0.0'); + + const settingsDir = path.join(tempWorkspaceDir, '.gemini'); + fs.mkdirSync(settingsDir, { recursive: true }); + fs.writeFileSync( + path.join(settingsDir, 'settings.json'), + JSON.stringify({ extensions: { disabled: ['ext1'] } }), + ); + + const extensions = loadExtensions(tempWorkspaceDir); + const activeExtensions = annotateActiveExtensions( + extensions, + [], + tempWorkspaceDir, + ).filter((e) => e.isActive); + expect(activeExtensions).toHaveLength(1); + expect(activeExtensions[0].name).toBe('ext2'); + }); + it('should hydrate variables', () => { const workspaceExtensionsDir = path.join( tempWorkspaceDir, @@ -164,22 +193,39 @@ describe('loadExtensions', () => { describe('annotateActiveExtensions', () => { const extensions = [ - { config: { name: 'ext1', version: '1.0.0' }, contextFiles: [] }, - { config: { name: 'ext2', version: '1.0.0' }, contextFiles: [] }, - { config: { name: 'ext3', version: '1.0.0' }, contextFiles: [] }, + { + path: '/path/to/ext1', + config: { name: 'ext1', version: '1.0.0' }, + contextFiles: [], + }, + { + path: '/path/to/ext2', + config: { name: 'ext2', version: '1.0.0' }, + contextFiles: [], + }, + { + path: '/path/to/ext3', + config: { name: 'ext3', version: '1.0.0' }, + contextFiles: [], + }, ]; it('should mark all extensions as active if no enabled extensions are provided', () => { - const activeExtensions = annotateActiveExtensions(extensions, []); + const activeExtensions = annotateActiveExtensions( + extensions, + [], + '/path/to/workspace', + ); expect(activeExtensions).toHaveLength(3); expect(activeExtensions.every((e) => e.isActive)).toBe(true); }); it('should mark only the enabled extensions as active', () => { - const activeExtensions = annotateActiveExtensions(extensions, [ - 'ext1', - 'ext3', - ]); + const activeExtensions = annotateActiveExtensions( + extensions, + ['ext1', 'ext3'], + '/path/to/workspace', + ); expect(activeExtensions).toHaveLength(3); expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe( true, @@ -193,13 +239,21 @@ describe('annotateActiveExtensions', () => { }); it('should mark all extensions as inactive when "none" is provided', () => { - const activeExtensions = annotateActiveExtensions(extensions, ['none']); + const activeExtensions = annotateActiveExtensions( + extensions, + ['none'], + '/path/to/workspace', + ); expect(activeExtensions).toHaveLength(3); expect(activeExtensions.every((e) => !e.isActive)).toBe(true); }); it('should handle case-insensitivity', () => { - const activeExtensions = annotateActiveExtensions(extensions, ['EXT1']); + const activeExtensions = annotateActiveExtensions( + extensions, + ['EXT1'], + '/path/to/workspace', + ); expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe( true, ); @@ -207,7 +261,7 @@ describe('annotateActiveExtensions', () => { it('should log an error for unknown extensions', () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - annotateActiveExtensions(extensions, ['ext4']); + annotateActiveExtensions(extensions, ['ext4'], '/path/to/workspace'); expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4'); consoleSpy.mockRestore(); }); @@ -470,3 +524,55 @@ describe('updateExtension', () => { expect(updatedConfig.version).toBe('1.1.0'); }); }); + +describe('disableExtension', () => { + let tempWorkspaceDir: string; + let tempHomeDir: string; + + beforeEach(() => { + tempWorkspaceDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-test-workspace-'), + ); + tempHomeDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-test-home-'), + ); + vi.mocked(os.homedir).mockReturnValue(tempHomeDir); + vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); + }); + + afterEach(() => { + fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); + fs.rmSync(tempHomeDir, { recursive: true, force: true }); + }); + + it('should disable an extension at the user scope', () => { + disableExtension('my-extension', SettingScope.User); + const settings = loadSettings(tempWorkspaceDir); + expect( + settings.forScope(SettingScope.User).settings.extensions?.disabled, + ).toEqual(['my-extension']); + }); + + it('should disable an extension at the workspace scope', () => { + disableExtension('my-extension', SettingScope.Workspace); + const settings = loadSettings(tempWorkspaceDir); + expect( + settings.forScope(SettingScope.Workspace).settings.extensions?.disabled, + ).toEqual(['my-extension']); + }); + + it('should handle disabling the same extension twice', () => { + disableExtension('my-extension', SettingScope.User); + disableExtension('my-extension', SettingScope.User); + const settings = loadSettings(tempWorkspaceDir); + expect( + settings.forScope(SettingScope.User).settings.extensions?.disabled, + ).toEqual(['my-extension']); + }); + + it('should throw an error if you request system scope', () => { + expect(() => disableExtension('my-extension', SettingScope.System)).toThrow( + 'System and SystemDefaults scopes are not supported.', + ); + }); +}); diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 4d37be2c67..231828f36b 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -13,6 +13,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { simpleGit } from 'simple-git'; +import { SettingScope, loadSettings } from '../config/settings.js'; +import { getErrorMessage } from '../utils/errors.js'; import { recursivelyHydrateStrings } from './extensions/variables.js'; export const EXTENSIONS_DIRECTORY_NAME = '.gemini/extensions'; @@ -63,10 +65,6 @@ export class ExtensionStorage { return path.join(this.getExtensionDir(), EXTENSIONS_CONFIG_FILENAME); } - static getSettingsPath(): string { - return process.cwd(); - } - static getUserExtensionsDir(): string { const storage = new Storage(os.homedir()); return storage.getExtensionsDir(); @@ -169,7 +167,9 @@ export function loadExtension(extensionDir: string): Extension | null { }; } catch (e) { console.error( - `Warning: error parsing extension config in ${configFilePath}: ${e}`, + `Warning: error parsing extension config in ${configFilePath}: ${getErrorMessage( + e, + )}`, ); return null; } @@ -197,17 +197,28 @@ function getContextFileNames(config: ExtensionConfig): string[] { return config.contextFileName; } +/** + * 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. + */ export function annotateActiveExtensions( extensions: Extension[], enabledExtensionNames: string[], + workspaceDir: string, ): GeminiCLIExtension[] { + const settings = loadSettings(workspaceDir).merged; + const disabledExtensions = settings.extensions?.disabled ?? []; + const annotatedExtensions: GeminiCLIExtension[] = []; if (enabledExtensionNames.length === 0) { return extensions.map((extension) => ({ name: extension.config.name, version: extension.config.version, - isActive: true, + isActive: !disabledExtensions.includes(extension.config.name), path: extension.path, })); } @@ -286,6 +297,7 @@ async function copyExtension( export async function installExtension( installMetadata: ExtensionInstallMetadata, + cwd: string = process.cwd(), ): Promise { const extensionsDir = ExtensionStorage.getUserExtensionsDir(); await fs.promises.mkdir(extensionsDir, { recursive: true }); @@ -295,10 +307,7 @@ export async function installExtension( installMetadata.type === 'local' && !path.isAbsolute(installMetadata.source) ) { - installMetadata.source = path.resolve( - process.cwd(), - installMetadata.source, - ); + installMetadata.source = path.resolve(cwd, installMetadata.source); } let localSourcePath: string; @@ -349,7 +358,10 @@ export async function installExtension( return newExtensionName; } -export async function uninstallExtension(extensionName: string): Promise { +export async function uninstallExtension( + extensionName: string, + cwd: string = process.cwd(), +): Promise { const installedExtensions = loadUserExtensions(); if ( !installedExtensions.some( @@ -358,6 +370,11 @@ export async function uninstallExtension(extensionName: string): Promise { ) { throw new Error(`Extension "${extensionName}" not found.`); } + removeFromDisabledExtensions( + extensionName, + [SettingScope.User, SettingScope.Workspace], + cwd, + ); const storage = new ExtensionStorage(extensionName); return await fs.promises.rm(storage.getExtensionDir(), { recursive: true, @@ -394,6 +411,7 @@ export function toOutputString(extension: Extension): string { export async function updateExtension( extensionName: string, + cwd: string = process.cwd(), ): Promise { const installedExtensions = loadUserExtensions(); const extension = installedExtensions.find( @@ -413,8 +431,8 @@ export async function updateExtension( const tempDir = await ExtensionStorage.createTmpDir(); try { await copyExtension(extension.path, tempDir); - await uninstallExtension(extensionName); - await installExtension(extension.installMetadata); + await uninstallExtension(extensionName, cwd); + await installExtension(extension.installMetadata, cwd); const updatedExtension = loadExtension(extension.path); if (!updatedExtension) { @@ -426,10 +444,57 @@ export async function updateExtension( updatedVersion, }; } catch (e) { - console.error(`Error updating extension, rolling back. ${e}`); + console.error( + `Error updating extension, rolling back. ${getErrorMessage(e)}`, + ); await copyExtension(tempDir, extension.path); throw e; } finally { await fs.promises.rm(tempDir, { recursive: true, force: true }); } } + +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); + } +} + +/** + * 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); + } +} diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index b7cedb30e8..89461b528e 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -544,6 +544,26 @@ export const SETTINGS_SCHEMA = { description: 'Enable extension management features.', showInDialog: false, }, + extensions: { + type: 'object', + label: 'Extensions', + category: 'Extensions', + requiresRestart: true, + default: {}, + description: 'Settings for extensions.', + showInDialog: false, + properties: { + disabled: { + type: 'array', + label: 'Disabled Extensions', + category: 'Extensions', + requiresRestart: true, + default: [] as string[], + description: 'List of disabled extensions.', + showInDialog: false, + }, + }, + }, skipNextSpeakerCheck: { type: 'boolean', label: 'Skip Next Speaker Check', diff --git a/packages/cli/src/utils/errors.ts b/packages/cli/src/utils/errors.ts new file mode 100644 index 0000000000..c1544dd9b4 --- /dev/null +++ b/packages/cli/src/utils/errors.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +}