[extensions] Add extensions uninstall command (#6877)

This commit is contained in:
christine betts
2025-08-25 17:40:15 +00:00
committed by GitHub
parent 0bd496bd51
commit ade703944d
5 changed files with 142 additions and 0 deletions

View File

@@ -6,6 +6,7 @@
import { CommandModule } from 'yargs';
import { installCommand } from './extensions/install.js';
import { uninstallCommand } from './extensions/uninstall.js';
export const extensionsCommand: CommandModule = {
command: 'extensions <command>',
@@ -13,6 +14,7 @@ export const extensionsCommand: CommandModule = {
builder: (yargs) =>
yargs
.command(installCommand)
.command(uninstallCommand)
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {

View File

@@ -0,0 +1,18 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { uninstallCommand } from './uninstall.js';
import yargs from 'yargs';
describe('extensions uninstall command', () => {
it('should fail if no source is provided', () => {
const validationParser = yargs([]).command(uninstallCommand).fail(false);
expect(() => validationParser.parse('uninstall')).toThrow(
'Not enough non-option arguments: got 0, need at least 1',
);
});
});

View File

@@ -0,0 +1,46 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandModule } from 'yargs';
import { uninstallExtension } from '../../config/extension.js';
interface UninstallArgs {
name: string;
}
export async function handleUninstall(args: UninstallArgs) {
try {
await uninstallExtension(args.name);
console.log(`Extension "${args.name}" successfully uninstalled.`);
} catch (error) {
console.error((error as Error).message);
process.exit(1);
}
}
export const uninstallCommand: CommandModule = {
command: 'uninstall <name>',
describe: 'Uninstalls an extension.',
builder: (yargs) =>
yargs
.positional('name', {
describe: 'The name of the extension to uninstall.',
type: 'string',
})
.check((argv) => {
if (!argv.name) {
throw new Error(
'Please include the name of the extension to uninstall as a positional argument.',
);
}
return true;
}),
handler: async (argv) => {
await handleUninstall({
name: argv['name'] as string,
});
},
};

View File

@@ -14,6 +14,7 @@ import {
annotateActiveExtensions,
installExtension,
loadExtensions,
uninstallExtension,
} from './extension.js';
import { execSync } from 'child_process';
import { SimpleGit, simpleGit } from 'simple-git';
@@ -280,6 +281,65 @@ describe('installExtension', () => {
});
});
describe('uninstallExtension', () => {
let tempHomeDir: string;
let userExtensionsDir: string;
beforeEach(() => {
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
userExtensionsDir = path.join(tempHomeDir, '.gemini', 'extensions');
// Clean up before each test
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
fs.mkdirSync(userExtensionsDir, { recursive: true });
vi.mocked(execSync).mockClear();
});
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
});
it('should uninstall an extension by name', async () => {
const sourceExtDir = createExtension(
userExtensionsDir,
'my-local-extension',
'1.0.0',
);
await uninstallExtension('my-local-extension');
expect(fs.existsSync(sourceExtDir)).toBe(false);
});
it('should uninstall an extension by name and retain existing extensions', async () => {
const sourceExtDir = createExtension(
userExtensionsDir,
'my-local-extension',
'1.0.0',
);
const otherExtDir = createExtension(
userExtensionsDir,
'other-extension',
'1.0.0',
);
await uninstallExtension('my-local-extension');
expect(fs.existsSync(sourceExtDir)).toBe(false);
expect(loadExtensions(tempHomeDir)).toHaveLength(1);
expect(fs.existsSync(otherExtDir)).toBe(true);
});
it('should throw an error if the extension does not exist', async () => {
await expect(uninstallExtension('nonexistent-extension')).rejects.toThrow(
'Error: Extension "nonexistent-extension" not found.',
);
});
});
function createExtension(
extensionsDir: string,
name: string,

View File

@@ -338,3 +338,19 @@ export async function installExtension(
return newExtensionName;
}
export async function uninstallExtension(extensionName: string): Promise<void> {
const installedExtensions = loadUserExtensions();
if (
!installedExtensions.some(
(installed) => installed.config.name === extensionName,
)
) {
throw new Error(`Error: Extension "${extensionName}" not found.`);
}
const storage = new ExtensionStorage(extensionName);
return await fs.promises.rm(storage.getExtensionDir(), {
recursive: true,
force: true,
});
}