[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
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 { listCommand } from './extensions/list.js';
import { updateCommand } from './extensions/update.js';
import { disableCommand } from './extensions/disable.js';
export const extensionsCommand: CommandModule = {
command: 'extensions <command>',
@@ -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: () => {

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string> {
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<void> {
export async function uninstallExtension(
extensionName: string,
cwd: string = process.cwd(),
): Promise<void> {
const installedExtensions = loadUserExtensions();
if (
!installedExtensions.some(
@@ -358,6 +370,11 @@ export async function uninstallExtension(extensionName: string): Promise<void> {
) {
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<ExtensionUpdateInfo | undefined> {
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);
}
}

View File

@@ -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',

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