From ade703944d9c8b867be0ad60af6820ae2f0ef46b Mon Sep 17 00:00:00 2001 From: christine betts Date: Mon, 25 Aug 2025 17:40:15 +0000 Subject: [PATCH] [extensions] Add extensions uninstall command (#6877) --- packages/cli/src/commands/extensions.tsx | 2 + .../src/commands/extensions/uninstall.test.ts | 18 ++++++ .../cli/src/commands/extensions/uninstall.ts | 46 ++++++++++++++ packages/cli/src/config/extension.test.ts | 60 +++++++++++++++++++ packages/cli/src/config/extension.ts | 16 +++++ 5 files changed, 142 insertions(+) create mode 100644 packages/cli/src/commands/extensions/uninstall.test.ts create mode 100644 packages/cli/src/commands/extensions/uninstall.ts diff --git a/packages/cli/src/commands/extensions.tsx b/packages/cli/src/commands/extensions.tsx index 5ce89d36..ca86eb27 100644 --- a/packages/cli/src/commands/extensions.tsx +++ b/packages/cli/src/commands/extensions.tsx @@ -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 ', @@ -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: () => { diff --git a/packages/cli/src/commands/extensions/uninstall.test.ts b/packages/cli/src/commands/extensions/uninstall.test.ts new file mode 100644 index 00000000..927e805b --- /dev/null +++ b/packages/cli/src/commands/extensions/uninstall.test.ts @@ -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', + ); + }); +}); diff --git a/packages/cli/src/commands/extensions/uninstall.ts b/packages/cli/src/commands/extensions/uninstall.ts new file mode 100644 index 00000000..a9cab4b9 --- /dev/null +++ b/packages/cli/src/commands/extensions/uninstall.ts @@ -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 ', + 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, + }); + }, +}; diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 20ae8889..15098c84 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -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, diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 63f80831..d2c366f5 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -338,3 +338,19 @@ export async function installExtension( return newExtensionName; } + +export async function uninstallExtension(extensionName: string): Promise { + 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, + }); +}