mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-11 06:31:01 -07:00
Add experimental in-CLI extension install and uninstall subcommands (#15178)
Co-authored-by: Christine Betts <chrstn@google.com>
This commit is contained in:
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user