Add experimental in-CLI extension install and uninstall subcommands (#15178)

Co-authored-by: Christine Betts <chrstn@google.com>
This commit is contained in:
christine betts
2025-12-23 13:48:27 -06:00
committed by GitHub
parent 5f28614760
commit 563d81e08e
6 changed files with 435 additions and 49 deletions

View File

@@ -4,11 +4,23 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, type MockInstance, type Mock } from 'vitest';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type MockInstance,
type Mock,
} from 'vitest';
import { handleInstall, installCommand } from './install.js';
import yargs from 'yargs';
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
import type { ExtensionManager } from '../../config/extension-manager.js';
import type {
ExtensionManager,
inferInstallMetadata,
} from '../../config/extension-manager.js';
import type { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import type * as fs from 'node:fs/promises';
import type { Stats } from 'node:fs';
@@ -20,12 +32,15 @@ const mockRequestConsentNonInteractive: Mock<
typeof requestConsentNonInteractive
> = vi.hoisted(() => vi.fn());
const mockStat: Mock<typeof fs.stat> = vi.hoisted(() => vi.fn());
const mockInferInstallMetadata: Mock<typeof inferInstallMetadata> = vi.hoisted(
() => vi.fn(),
);
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: mockRequestConsentNonInteractive,
}));
vi.mock('../../config/extension-manager.ts', async (importOriginal) => {
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../config/extension-manager.js')>();
return {
@@ -34,6 +49,7 @@ vi.mock('../../config/extension-manager.ts', async (importOriginal) => {
installOrUpdateExtension: mockInstallOrUpdateExtension,
loadExtensions: vi.fn(),
})),
inferInstallMetadata: mockInferInstallMetadata,
};
});
@@ -72,12 +88,31 @@ describe('handleInstall', () => {
processSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
mockInferInstallMetadata.mockImplementation(async (source, args) => {
if (
source.startsWith('http://') ||
source.startsWith('https://') ||
source.startsWith('git@') ||
source.startsWith('sso://')
) {
return {
source,
type: 'git',
ref: args?.ref,
autoUpdate: args?.autoUpdate,
allowPreRelease: args?.allowPreRelease,
};
}
return { source, type: 'local' };
});
});
afterEach(() => {
mockInstallOrUpdateExtension.mockClear();
mockRequestConsentNonInteractive.mockClear();
mockStat.mockClear();
mockInferInstallMetadata.mockClear();
vi.clearAllMocks();
});
@@ -124,7 +159,9 @@ describe('handleInstall', () => {
});
it('throws an error from an unknown source', async () => {
mockStat.mockRejectedValue(new Error('ENOENT: no such file or directory'));
mockInferInstallMetadata.mockRejectedValue(
new Error('Install source not found.'),
);
await handleInstall({
source: 'test://google.com',
});

View File

@@ -5,17 +5,16 @@
*/
import type { CommandModule } from 'yargs';
import {
debugLogger,
type ExtensionInstallMetadata,
} from '@google/gemini-cli-core';
import { debugLogger } from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import { stat } from 'node:fs/promises';
import {
INSTALL_WARNING_MESSAGE,
requestConsentNonInteractive,
} from '../../config/extensions/consent.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import {
ExtensionManager,
inferInstallMetadata,
} from '../../config/extension-manager.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
@@ -30,37 +29,12 @@ interface InstallArgs {
export async function handleInstall(args: InstallArgs) {
try {
let installMetadata: ExtensionInstallMetadata;
const { source } = args;
if (
source.startsWith('http://') ||
source.startsWith('https://') ||
source.startsWith('git@') ||
source.startsWith('sso://')
) {
installMetadata = {
source,
type: 'git',
ref: args.ref,
autoUpdate: args.autoUpdate,
allowPreRelease: args.allowPreRelease,
};
} else {
if (args.ref || args.autoUpdate) {
throw new Error(
'--ref and --auto-update are not applicable for local extensions.',
);
}
try {
await stat(source);
installMetadata = {
source,
type: 'local',
};
} catch {
throw new Error('Install source not found.');
}
}
const installMetadata = await inferInstallMetadata(source, {
ref: args.ref,
autoUpdate: args.autoUpdate,
allowPreRelease: args.allowPreRelease,
});
const requestConsent = args.consent
? () => Promise.resolve(true)

View File

@@ -5,7 +5,15 @@
*/
import * as fs from 'node:fs';
import { describe, it, expect, vi, type MockInstance } from 'vitest';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type MockInstance,
} from 'vitest';
import { handleValidate, validateCommand } from './validate.js';
import yargs from 'yargs';
import { createExtension } from '../../test-utils/createExtension.js';

View File

@@ -7,6 +7,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { stat } from 'node:fs/promises';
import chalk from 'chalk';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import { type Settings, SettingScope } from './settings.js';
@@ -198,7 +199,9 @@ export class ExtensionManager extends ExtensionLoader {
installMetadata.type === 'git') ||
// Otherwise ask the user if they would like to try a git clone.
(await this.requestConsent(
`Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}.\n\nWould you like to attempt to install via "git clone" instead?`,
`Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}.
Would you like to attempt to install via "git clone" instead?`,
))
) {
await cloneFromGit(installMetadata, tempDir);
@@ -797,7 +800,46 @@ function validateName(name: string) {
}
}
function getExtensionId(
export async function inferInstallMetadata(
source: string,
args: {
ref?: string;
autoUpdate?: boolean;
allowPreRelease?: boolean;
} = {},
): Promise<ExtensionInstallMetadata> {
if (
source.startsWith('http://') ||
source.startsWith('https://') ||
source.startsWith('git@') ||
source.startsWith('sso://')
) {
return {
source,
type: 'git',
ref: args.ref,
autoUpdate: args.autoUpdate,
allowPreRelease: args.allowPreRelease,
};
} else {
if (args.ref || args.autoUpdate) {
throw new Error(
'--ref and --auto-update are not applicable for local extensions.',
);
}
try {
await stat(source);
return {
source,
type: 'local',
};
} catch {
throw new Error('Install source not found.');
}
}
}
export function getExtensionId(
config: ExtensionConfig,
installMetadata?: ExtensionInstallMetadata,
): string {

View File

@@ -26,9 +26,21 @@ import {
type MockedFunction,
} from 'vitest';
import { type ExtensionUpdateAction } from '../state/extensions.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import {
ExtensionManager,
inferInstallMetadata,
} from '../../config/extension-manager.js';
import { SettingScope } from '../../config/settings.js';
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../config/extension-manager.js')>();
return {
...actual,
inferInstallMetadata: vi.fn(),
};
});
import open from 'open';
vi.mock('open', () => ({
@@ -42,6 +54,8 @@ vi.mock('../../config/extensions/update.js', () => ({
const mockDisableExtension = vi.fn();
const mockEnableExtension = vi.fn();
const mockInstallExtension = vi.fn();
const mockUninstallExtension = vi.fn();
const mockGetExtensions = vi.fn();
const inactiveExt: GeminiCLIExtension = {
@@ -102,6 +116,8 @@ describe('extensionsCommand', () => {
Object.assign(actual, {
enableExtension: mockEnableExtension,
disableExtension: mockDisableExtension,
installOrUpdateExtension: mockInstallExtension,
uninstallExtension: mockUninstallExtension,
getExtensions: mockGetExtensions,
});
return actual;
@@ -477,29 +493,189 @@ describe('extensionsCommand', () => {
});
describe('when enableExtensionReloading is true', () => {
it('should include enable and disable subcommands', () => {
it('should include enable, disable, install, and uninstall subcommands', () => {
const command = extensionsCommand(true);
const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
expect(subCommandNames).toContain('enable');
expect(subCommandNames).toContain('disable');
expect(subCommandNames).toContain('install');
expect(subCommandNames).toContain('uninstall');
});
});
describe('when enableExtensionReloading is false', () => {
it('should not include enable and disable subcommands', () => {
it('should not include enable, disable, install, and uninstall subcommands', () => {
const command = extensionsCommand(false);
const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
expect(subCommandNames).not.toContain('enable');
expect(subCommandNames).not.toContain('disable');
expect(subCommandNames).not.toContain('install');
expect(subCommandNames).not.toContain('uninstall');
});
});
describe('when enableExtensionReloading is not provided', () => {
it('should not include enable and disable subcommands by default', () => {
it('should not include enable, disable, install, and uninstall subcommands by default', () => {
const command = extensionsCommand();
const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
expect(subCommandNames).not.toContain('enable');
expect(subCommandNames).not.toContain('disable');
expect(subCommandNames).not.toContain('install');
expect(subCommandNames).not.toContain('uninstall');
});
});
describe('install', () => {
let installAction: SlashCommand['action'];
beforeEach(() => {
installAction = extensionsCommand(true).subCommands?.find(
(cmd) => cmd.name === 'install',
)?.action;
expect(installAction).not.toBeNull();
mockContext.invocation!.name = 'install';
});
it('should show usage if no extension name is provided', async () => {
await installAction!(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Usage: /extensions install <source>',
},
expect.any(Number),
);
expect(mockInstallExtension).not.toHaveBeenCalled();
});
it('should call installExtension and show success message', async () => {
const packageName = 'test-extension-package';
vi.mocked(inferInstallMetadata).mockResolvedValue({
source: packageName,
type: 'git',
});
mockInstallExtension.mockResolvedValue({ name: packageName });
await installAction!(mockContext, packageName);
expect(inferInstallMetadata).toHaveBeenCalledWith(packageName);
expect(mockInstallExtension).toHaveBeenCalledWith({
source: packageName,
type: 'git',
});
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Installing extension from "${packageName}"...`,
},
expect.any(Number),
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Extension "${packageName}" installed successfully.`,
},
expect.any(Number),
);
});
it('should show error message on installation failure', async () => {
const packageName = 'failed-extension';
const errorMessage = 'install failed';
vi.mocked(inferInstallMetadata).mockResolvedValue({
source: packageName,
type: 'git',
});
mockInstallExtension.mockRejectedValue(new Error(errorMessage));
await installAction!(mockContext, packageName);
expect(inferInstallMetadata).toHaveBeenCalledWith(packageName);
expect(mockInstallExtension).toHaveBeenCalledWith({
source: packageName,
type: 'git',
});
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: `Failed to install extension from "${packageName}": ${errorMessage}`,
},
expect.any(Number),
);
});
it('should show error message for invalid source', async () => {
const invalidSource = 'a;b';
await installAction!(mockContext, invalidSource);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: `Invalid source: ${invalidSource}`,
},
expect.any(Number),
);
expect(mockInstallExtension).not.toHaveBeenCalled();
});
});
describe('uninstall', () => {
let uninstallAction: SlashCommand['action'];
beforeEach(() => {
uninstallAction = extensionsCommand(true).subCommands?.find(
(cmd) => cmd.name === 'uninstall',
)?.action;
expect(uninstallAction).not.toBeNull();
mockContext.invocation!.name = 'uninstall';
});
it('should show usage if no extension name is provided', async () => {
await uninstallAction!(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Usage: /extensions uninstall <extension-name>',
},
expect.any(Number),
);
expect(mockUninstallExtension).not.toHaveBeenCalled();
});
it('should call uninstallExtension and show success message', async () => {
const extensionName = 'test-extension';
await uninstallAction!(mockContext, extensionName);
expect(mockUninstallExtension).toHaveBeenCalledWith(extensionName, false);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Uninstalling extension "${extensionName}"...`,
},
expect.any(Number),
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Extension "${extensionName}" uninstalled successfully.`,
},
expect.any(Number),
);
});
it('should show error message on uninstallation failure', async () => {
const extensionName = 'failed-extension';
const errorMessage = 'uninstall failed';
mockUninstallExtension.mockRejectedValue(new Error(errorMessage));
await uninstallAction!(mockContext, extensionName);
expect(mockUninstallExtension).toHaveBeenCalledWith(extensionName, false);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: `Failed to uninstall extension "${extensionName}": ${errorMessage}`,
},
expect.any(Number),
);
});
});

View File

@@ -20,7 +20,10 @@ import {
} from './types.js';
import open from 'open';
import process from 'node:process';
import { ExtensionManager } from '../../config/extension-manager.js';
import {
ExtensionManager,
inferInstallMetadata,
} from '../../config/extension-manager.js';
import { SettingScope } from '../../config/settings.js';
import { theme } from '../semantic-colors.js';
@@ -429,6 +432,135 @@ async function enableAction(context: CommandContext, args: string) {
}
}
async function installAction(context: CommandContext, args: string) {
const extensionLoader = context.services.config?.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
debugLogger.error(
`Cannot ${context.invocation?.name} extensions in this environment`,
);
return;
}
const source = args.trim();
if (!source) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Usage: /extensions install <source>`,
},
Date.now(),
);
return;
}
// Validate that the source is either a valid URL or a valid file path.
let isValid = false;
try {
// Check if it's a valid URL.
new URL(source);
isValid = true;
} catch {
// If not a URL, check for characters that are disallowed in file paths
// and could be used for command injection.
if (!/[;&|`'"]/.test(source)) {
isValid = true;
}
}
if (!isValid) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Invalid source: ${source}`,
},
Date.now(),
);
return;
}
context.ui.addItem(
{
type: MessageType.INFO,
text: `Installing extension from "${source}"...`,
},
Date.now(),
);
try {
const installMetadata = await inferInstallMetadata(source);
const extension =
await extensionLoader.installOrUpdateExtension(installMetadata);
context.ui.addItem(
{
type: MessageType.INFO,
text: `Extension "${extension.name}" installed successfully.`,
},
Date.now(),
);
} catch (error) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Failed to install extension from "${source}": ${getErrorMessage(
error,
)}`,
},
Date.now(),
);
}
}
async function uninstallAction(context: CommandContext, args: string) {
const extensionLoader = context.services.config?.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
debugLogger.error(
`Cannot ${context.invocation?.name} extensions in this environment`,
);
return;
}
const name = args.trim();
if (!name) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Usage: /extensions uninstall <extension-name>`,
},
Date.now(),
);
return;
}
context.ui.addItem(
{
type: MessageType.INFO,
text: `Uninstalling extension "${name}"...`,
},
Date.now(),
);
try {
await extensionLoader.uninstallExtension(name, false);
context.ui.addItem(
{
type: MessageType.INFO,
text: `Extension "${name}" uninstalled successfully.`,
},
Date.now(),
);
} catch (error) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Failed to uninstall extension "${name}": ${getErrorMessage(
error,
)}`,
},
Date.now(),
);
}
}
/**
* Exported for testing.
*/
@@ -505,6 +637,23 @@ const enableCommand: SlashCommand = {
completion: completeExtensionsAndScopes,
};
const installCommand: SlashCommand = {
name: 'install',
description: 'Install an extension from a git repo or local path',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: installAction,
};
const uninstallCommand: SlashCommand = {
name: 'uninstall',
description: 'Uninstall an extension',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: uninstallAction,
completion: completeExtensions,
};
const exploreExtensionsCommand: SlashCommand = {
name: 'explore',
description: 'Open extensions page in your browser',
@@ -526,7 +675,7 @@ export function extensionsCommand(
enableExtensionReloading?: boolean,
): SlashCommand {
const conditionalCommands = enableExtensionReloading
? [disableCommand, enableCommand]
? [disableCommand, enableCommand, installCommand, uninstallCommand]
: [];
return {
name: 'extensions',