[extensions] Add disable command (#7001)

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
christine betts 2025-08-26 14:36:55 +00:00 committed by GitHub
parent d77391b3cd
commit dff175c4f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 291 additions and 29 deletions

View file

@ -9,6 +9,7 @@ import { installCommand } from './extensions/install.js';
import { uninstallCommand } from './extensions/uninstall.js'; import { uninstallCommand } from './extensions/uninstall.js';
import { listCommand } from './extensions/list.js'; import { listCommand } from './extensions/list.js';
import { updateCommand } from './extensions/update.js'; import { updateCommand } from './extensions/update.js';
import { disableCommand } from './extensions/disable.js';
export const extensionsCommand: CommandModule = { export const extensionsCommand: CommandModule = {
command: 'extensions <command>', command: 'extensions <command>',
@ -19,6 +20,7 @@ export const extensionsCommand: CommandModule = {
.command(uninstallCommand) .command(uninstallCommand)
.command(listCommand) .command(listCommand)
.command(updateCommand) .command(updateCommand)
.command(disableCommand)
.demandCommand(1, 'You need at least one command before continuing.') .demandCommand(1, 'You need at least one command before continuing.')
.version(false), .version(false),
handler: () => { handler: () => {

View file

@ -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] <name>',
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,
});
},
};

View file

@ -10,6 +10,8 @@ import {
type ExtensionInstallMetadata, type ExtensionInstallMetadata,
} from '../../config/extension.js'; } from '../../config/extension.js';
import { getErrorMessage } from '../../utils/errors.js';
interface InstallArgs { interface InstallArgs {
source?: string; source?: string;
path?: string; path?: string;
@ -26,7 +28,7 @@ export async function handleInstall(args: InstallArgs) {
`Extension "${extensionName}" installed successfully and enabled.`, `Extension "${extensionName}" installed successfully and enabled.`,
); );
} catch (error) { } catch (error) {
console.error((error as Error).message); console.error(getErrorMessage(error));
process.exit(1); process.exit(1);
} }
} }

View file

@ -6,6 +6,7 @@
import type { CommandModule } from 'yargs'; import type { CommandModule } from 'yargs';
import { loadUserExtensions, toOutputString } from '../../config/extension.js'; import { loadUserExtensions, toOutputString } from '../../config/extension.js';
import { getErrorMessage } from '../../utils/errors.js';
export async function handleList() { export async function handleList() {
try { try {
@ -20,7 +21,7 @@ export async function handleList() {
.join('\n\n'), .join('\n\n'),
); );
} catch (error) { } catch (error) {
console.error((error as Error).message); console.error(getErrorMessage(error));
process.exit(1); process.exit(1);
} }
} }

View file

@ -6,6 +6,7 @@
import type { CommandModule } from 'yargs'; import type { CommandModule } from 'yargs';
import { uninstallExtension } from '../../config/extension.js'; import { uninstallExtension } from '../../config/extension.js';
import { getErrorMessage } from '../../utils/errors.js';
interface UninstallArgs { interface UninstallArgs {
name: string; name: string;
@ -16,7 +17,7 @@ export async function handleUninstall(args: UninstallArgs) {
await uninstallExtension(args.name); await uninstallExtension(args.name);
console.log(`Extension "${args.name}" successfully uninstalled.`); console.log(`Extension "${args.name}" successfully uninstalled.`);
} catch (error) { } catch (error) {
console.error((error as Error).message); console.error(getErrorMessage(error));
process.exit(1); process.exit(1);
} }
} }

View file

@ -6,6 +6,7 @@
import type { CommandModule } from 'yargs'; import type { CommandModule } from 'yargs';
import { updateExtension } from '../../config/extension.js'; import { updateExtension } from '../../config/extension.js';
import { getErrorMessage } from '../../utils/errors.js';
interface UpdateArgs { interface UpdateArgs {
name: string; name: string;
@ -23,7 +24,7 @@ export async function handleUpdate(args: UpdateArgs) {
`Extension "${args.name}" successfully updated: ${updatedExtensionInfo.originalVersion}${updatedExtensionInfo.updatedVersion}.`, `Extension "${args.name}" successfully updated: ${updatedExtensionInfo.originalVersion}${updatedExtensionInfo.updatedVersion}.`,
); );
} catch (error) { } catch (error) {
console.error((error as Error).message); console.error(getErrorMessage(error));
process.exit(1); process.exit(1);
} }
} }

View file

@ -334,6 +334,7 @@ export async function loadCliConfig(
const allExtensions = annotateActiveExtensions( const allExtensions = annotateActiveExtensions(
extensions, extensions,
argv.extensions || [], argv.extensions || [],
cwd,
); );
const activeExtensions = extensions.filter( const activeExtensions = extensions.filter(

View file

@ -12,6 +12,7 @@ import {
EXTENSIONS_CONFIG_FILENAME, EXTENSIONS_CONFIG_FILENAME,
INSTALL_METADATA_FILENAME, INSTALL_METADATA_FILENAME,
annotateActiveExtensions, annotateActiveExtensions,
disableExtension,
installExtension, installExtension,
loadExtensions, loadExtensions,
uninstallExtension, uninstallExtension,
@ -19,6 +20,7 @@ import {
} from './extension.js'; } from './extension.js';
import { type MCPServerConfig } from '@google/gemini-cli-core'; import { type MCPServerConfig } from '@google/gemini-cli-core';
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { SettingScope, loadSettings } from './settings.js';
import { type SimpleGit, simpleGit } from 'simple-git'; import { type SimpleGit, simpleGit } from 'simple-git';
vi.mock('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', () => { it('should hydrate variables', () => {
const workspaceExtensionsDir = path.join( const workspaceExtensionsDir = path.join(
tempWorkspaceDir, tempWorkspaceDir,
@ -164,22 +193,39 @@ describe('loadExtensions', () => {
describe('annotateActiveExtensions', () => { describe('annotateActiveExtensions', () => {
const extensions = [ const extensions = [
{ config: { name: 'ext1', version: '1.0.0' }, contextFiles: [] }, {
{ config: { name: 'ext2', version: '1.0.0' }, contextFiles: [] }, path: '/path/to/ext1',
{ config: { name: 'ext3', version: '1.0.0' }, contextFiles: [] }, 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', () => { 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).toHaveLength(3);
expect(activeExtensions.every((e) => e.isActive)).toBe(true); expect(activeExtensions.every((e) => e.isActive)).toBe(true);
}); });
it('should mark only the enabled extensions as active', () => { it('should mark only the enabled extensions as active', () => {
const activeExtensions = annotateActiveExtensions(extensions, [ const activeExtensions = annotateActiveExtensions(
'ext1', extensions,
'ext3', ['ext1', 'ext3'],
]); '/path/to/workspace',
);
expect(activeExtensions).toHaveLength(3); expect(activeExtensions).toHaveLength(3);
expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe( expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe(
true, true,
@ -193,13 +239,21 @@ describe('annotateActiveExtensions', () => {
}); });
it('should mark all extensions as inactive when "none" is provided', () => { 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).toHaveLength(3);
expect(activeExtensions.every((e) => !e.isActive)).toBe(true); expect(activeExtensions.every((e) => !e.isActive)).toBe(true);
}); });
it('should handle case-insensitivity', () => { 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( expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe(
true, true,
); );
@ -207,7 +261,7 @@ describe('annotateActiveExtensions', () => {
it('should log an error for unknown extensions', () => { it('should log an error for unknown extensions', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
annotateActiveExtensions(extensions, ['ext4']); annotateActiveExtensions(extensions, ['ext4'], '/path/to/workspace');
expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4'); expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4');
consoleSpy.mockRestore(); consoleSpy.mockRestore();
}); });
@ -470,3 +524,55 @@ describe('updateExtension', () => {
expect(updatedConfig.version).toBe('1.1.0'); 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.',
);
});
});

View file

@ -13,6 +13,8 @@ import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import * as os from 'node:os'; import * as os from 'node:os';
import { simpleGit } from 'simple-git'; import { simpleGit } from 'simple-git';
import { SettingScope, loadSettings } from '../config/settings.js';
import { getErrorMessage } from '../utils/errors.js';
import { recursivelyHydrateStrings } from './extensions/variables.js'; import { recursivelyHydrateStrings } from './extensions/variables.js';
export const EXTENSIONS_DIRECTORY_NAME = '.gemini/extensions'; export const EXTENSIONS_DIRECTORY_NAME = '.gemini/extensions';
@ -63,10 +65,6 @@ export class ExtensionStorage {
return path.join(this.getExtensionDir(), EXTENSIONS_CONFIG_FILENAME); return path.join(this.getExtensionDir(), EXTENSIONS_CONFIG_FILENAME);
} }
static getSettingsPath(): string {
return process.cwd();
}
static getUserExtensionsDir(): string { static getUserExtensionsDir(): string {
const storage = new Storage(os.homedir()); const storage = new Storage(os.homedir());
return storage.getExtensionsDir(); return storage.getExtensionsDir();
@ -169,7 +167,9 @@ export function loadExtension(extensionDir: string): Extension | null {
}; };
} catch (e) { } catch (e) {
console.error( console.error(
`Warning: error parsing extension config in ${configFilePath}: ${e}`, `Warning: error parsing extension config in ${configFilePath}: ${getErrorMessage(
e,
)}`,
); );
return null; return null;
} }
@ -197,17 +197,28 @@ function getContextFileNames(config: ExtensionConfig): string[] {
return config.contextFileName; 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( export function annotateActiveExtensions(
extensions: Extension[], extensions: Extension[],
enabledExtensionNames: string[], enabledExtensionNames: string[],
workspaceDir: string,
): GeminiCLIExtension[] { ): GeminiCLIExtension[] {
const settings = loadSettings(workspaceDir).merged;
const disabledExtensions = settings.extensions?.disabled ?? [];
const annotatedExtensions: GeminiCLIExtension[] = []; const annotatedExtensions: GeminiCLIExtension[] = [];
if (enabledExtensionNames.length === 0) { if (enabledExtensionNames.length === 0) {
return extensions.map((extension) => ({ return extensions.map((extension) => ({
name: extension.config.name, name: extension.config.name,
version: extension.config.version, version: extension.config.version,
isActive: true, isActive: !disabledExtensions.includes(extension.config.name),
path: extension.path, path: extension.path,
})); }));
} }
@ -286,6 +297,7 @@ async function copyExtension(
export async function installExtension( export async function installExtension(
installMetadata: ExtensionInstallMetadata, installMetadata: ExtensionInstallMetadata,
cwd: string = process.cwd(),
): Promise<string> { ): Promise<string> {
const extensionsDir = ExtensionStorage.getUserExtensionsDir(); const extensionsDir = ExtensionStorage.getUserExtensionsDir();
await fs.promises.mkdir(extensionsDir, { recursive: true }); await fs.promises.mkdir(extensionsDir, { recursive: true });
@ -295,10 +307,7 @@ export async function installExtension(
installMetadata.type === 'local' && installMetadata.type === 'local' &&
!path.isAbsolute(installMetadata.source) !path.isAbsolute(installMetadata.source)
) { ) {
installMetadata.source = path.resolve( installMetadata.source = path.resolve(cwd, installMetadata.source);
process.cwd(),
installMetadata.source,
);
} }
let localSourcePath: string; let localSourcePath: string;
@ -349,7 +358,10 @@ export async function installExtension(
return newExtensionName; return newExtensionName;
} }
export async function uninstallExtension(extensionName: string): Promise<void> { export async function uninstallExtension(
extensionName: string,
cwd: string = process.cwd(),
): Promise<void> {
const installedExtensions = loadUserExtensions(); const installedExtensions = loadUserExtensions();
if ( if (
!installedExtensions.some( !installedExtensions.some(
@ -358,6 +370,11 @@ export async function uninstallExtension(extensionName: string): Promise<void> {
) { ) {
throw new Error(`Extension "${extensionName}" not found.`); throw new Error(`Extension "${extensionName}" not found.`);
} }
removeFromDisabledExtensions(
extensionName,
[SettingScope.User, SettingScope.Workspace],
cwd,
);
const storage = new ExtensionStorage(extensionName); const storage = new ExtensionStorage(extensionName);
return await fs.promises.rm(storage.getExtensionDir(), { return await fs.promises.rm(storage.getExtensionDir(), {
recursive: true, recursive: true,
@ -394,6 +411,7 @@ export function toOutputString(extension: Extension): string {
export async function updateExtension( export async function updateExtension(
extensionName: string, extensionName: string,
cwd: string = process.cwd(),
): Promise<ExtensionUpdateInfo | undefined> { ): Promise<ExtensionUpdateInfo | undefined> {
const installedExtensions = loadUserExtensions(); const installedExtensions = loadUserExtensions();
const extension = installedExtensions.find( const extension = installedExtensions.find(
@ -413,8 +431,8 @@ export async function updateExtension(
const tempDir = await ExtensionStorage.createTmpDir(); const tempDir = await ExtensionStorage.createTmpDir();
try { try {
await copyExtension(extension.path, tempDir); await copyExtension(extension.path, tempDir);
await uninstallExtension(extensionName); await uninstallExtension(extensionName, cwd);
await installExtension(extension.installMetadata); await installExtension(extension.installMetadata, cwd);
const updatedExtension = loadExtension(extension.path); const updatedExtension = loadExtension(extension.path);
if (!updatedExtension) { if (!updatedExtension) {
@ -426,10 +444,57 @@ export async function updateExtension(
updatedVersion, updatedVersion,
}; };
} catch (e) { } catch (e) {
console.error(`Error updating extension, rolling back. ${e}`); console.error(
`Error updating extension, rolling back. ${getErrorMessage(e)}`,
);
await copyExtension(tempDir, extension.path); await copyExtension(tempDir, extension.path);
throw e; throw e;
} finally { } finally {
await fs.promises.rm(tempDir, { recursive: true, force: true }); 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);
}
}

View file

@ -544,6 +544,26 @@ export const SETTINGS_SCHEMA = {
description: 'Enable extension management features.', description: 'Enable extension management features.',
showInDialog: false, 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: { skipNextSpeakerCheck: {
type: 'boolean', type: 'boolean',
label: 'Skip Next Speaker Check', label: 'Skip Next Speaker Check',

View file

@ -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);
}