mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-29 06:25:16 -07:00
Add extension linking capabilities in cli (#16040)
This commit is contained in:
@@ -31,6 +31,7 @@ import {
|
|||||||
inferInstallMetadata,
|
inferInstallMetadata,
|
||||||
} from '../../config/extension-manager.js';
|
} from '../../config/extension-manager.js';
|
||||||
import { SettingScope } from '../../config/settings.js';
|
import { SettingScope } from '../../config/settings.js';
|
||||||
|
import { stat } from 'node:fs/promises';
|
||||||
|
|
||||||
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
|
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
|
||||||
const actual =
|
const actual =
|
||||||
@@ -42,11 +43,16 @@ vi.mock('../../config/extension-manager.js', async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import open from 'open';
|
import open from 'open';
|
||||||
|
import type { Stats } from 'node:fs';
|
||||||
|
|
||||||
vi.mock('open', () => ({
|
vi.mock('open', () => ({
|
||||||
default: vi.fn(),
|
default: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('node:fs/promises', () => ({
|
||||||
|
stat: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../../config/extensions/update.js', () => ({
|
vi.mock('../../config/extensions/update.js', () => ({
|
||||||
updateExtension: vi.fn(),
|
updateExtension: vi.fn(),
|
||||||
checkForAllExtensionUpdates: vi.fn(),
|
checkForAllExtensionUpdates: vi.fn(),
|
||||||
@@ -493,34 +499,37 @@ describe('extensionsCommand', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('when enableExtensionReloading is true', () => {
|
describe('when enableExtensionReloading is true', () => {
|
||||||
it('should include enable, disable, install, and uninstall subcommands', () => {
|
it('should include enable, disable, install, link, and uninstall subcommands', () => {
|
||||||
const command = extensionsCommand(true);
|
const command = extensionsCommand(true);
|
||||||
const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
|
const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
|
||||||
expect(subCommandNames).toContain('enable');
|
expect(subCommandNames).toContain('enable');
|
||||||
expect(subCommandNames).toContain('disable');
|
expect(subCommandNames).toContain('disable');
|
||||||
expect(subCommandNames).toContain('install');
|
expect(subCommandNames).toContain('install');
|
||||||
|
expect(subCommandNames).toContain('link');
|
||||||
expect(subCommandNames).toContain('uninstall');
|
expect(subCommandNames).toContain('uninstall');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when enableExtensionReloading is false', () => {
|
describe('when enableExtensionReloading is false', () => {
|
||||||
it('should not include enable, disable, install, and uninstall subcommands', () => {
|
it('should not include enable, disable, install, link, and uninstall subcommands', () => {
|
||||||
const command = extensionsCommand(false);
|
const command = extensionsCommand(false);
|
||||||
const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
|
const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
|
||||||
expect(subCommandNames).not.toContain('enable');
|
expect(subCommandNames).not.toContain('enable');
|
||||||
expect(subCommandNames).not.toContain('disable');
|
expect(subCommandNames).not.toContain('disable');
|
||||||
expect(subCommandNames).not.toContain('install');
|
expect(subCommandNames).not.toContain('install');
|
||||||
|
expect(subCommandNames).not.toContain('link');
|
||||||
expect(subCommandNames).not.toContain('uninstall');
|
expect(subCommandNames).not.toContain('uninstall');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when enableExtensionReloading is not provided', () => {
|
describe('when enableExtensionReloading is not provided', () => {
|
||||||
it('should not include enable, disable, install, and uninstall subcommands by default', () => {
|
it('should not include enable, disable, install, link, and uninstall subcommands by default', () => {
|
||||||
const command = extensionsCommand();
|
const command = extensionsCommand();
|
||||||
const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
|
const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
|
||||||
expect(subCommandNames).not.toContain('enable');
|
expect(subCommandNames).not.toContain('enable');
|
||||||
expect(subCommandNames).not.toContain('disable');
|
expect(subCommandNames).not.toContain('disable');
|
||||||
expect(subCommandNames).not.toContain('install');
|
expect(subCommandNames).not.toContain('install');
|
||||||
|
expect(subCommandNames).not.toContain('link');
|
||||||
expect(subCommandNames).not.toContain('uninstall');
|
expect(subCommandNames).not.toContain('uninstall');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -617,6 +626,88 @@ describe('extensionsCommand', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('link', () => {
|
||||||
|
let linkAction: SlashCommand['action'];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
linkAction = extensionsCommand(true).subCommands?.find(
|
||||||
|
(cmd) => cmd.name === 'link',
|
||||||
|
)?.action;
|
||||||
|
|
||||||
|
expect(linkAction).not.toBeNull();
|
||||||
|
mockContext.invocation!.name = 'link';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show usage if no extension is provided', async () => {
|
||||||
|
await linkAction!(mockContext, '');
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: 'Usage: /extensions link <source>',
|
||||||
|
},
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
expect(mockInstallExtension).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call installExtension and show success message', async () => {
|
||||||
|
const packageName = 'test-extension-package';
|
||||||
|
mockInstallExtension.mockResolvedValue({ name: packageName });
|
||||||
|
vi.mocked(stat).mockResolvedValue({
|
||||||
|
size: 100,
|
||||||
|
} as Stats);
|
||||||
|
await linkAction!(mockContext, packageName);
|
||||||
|
expect(mockInstallExtension).toHaveBeenCalledWith({
|
||||||
|
source: packageName,
|
||||||
|
type: 'link',
|
||||||
|
});
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: `Linking extension from "${packageName}"...`,
|
||||||
|
},
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: `Extension "${packageName}" linked successfully.`,
|
||||||
|
},
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show error message on linking failure', async () => {
|
||||||
|
const packageName = 'test-extension-package';
|
||||||
|
const errorMessage = 'link failed';
|
||||||
|
mockInstallExtension.mockRejectedValue(new Error(errorMessage));
|
||||||
|
vi.mocked(stat).mockResolvedValue({
|
||||||
|
size: 100,
|
||||||
|
} as Stats);
|
||||||
|
|
||||||
|
await linkAction!(mockContext, packageName);
|
||||||
|
expect(mockInstallExtension).toHaveBeenCalledWith({
|
||||||
|
source: packageName,
|
||||||
|
type: 'link',
|
||||||
|
});
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: `Failed to link extension from "${packageName}": ${errorMessage}`,
|
||||||
|
},
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show error message for invalid source', async () => {
|
||||||
|
const packageName = 'test-extension-package';
|
||||||
|
const errorMessage = 'invalid path';
|
||||||
|
vi.mocked(stat).mockRejectedValue(new Error(errorMessage));
|
||||||
|
await linkAction!(mockContext, packageName);
|
||||||
|
expect(mockInstallExtension).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('uninstall', () => {
|
describe('uninstall', () => {
|
||||||
let uninstallAction: SlashCommand['action'];
|
let uninstallAction: SlashCommand['action'];
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { debugLogger, listExtensions } from '@google/gemini-cli-core';
|
import {
|
||||||
|
debugLogger,
|
||||||
|
listExtensions,
|
||||||
|
type ExtensionInstallMetadata,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
import type { ExtensionUpdateInfo } from '../../config/extension.js';
|
import type { ExtensionUpdateInfo } from '../../config/extension.js';
|
||||||
import { getErrorMessage } from '../../utils/errors.js';
|
import { getErrorMessage } from '../../utils/errors.js';
|
||||||
import {
|
import {
|
||||||
@@ -26,6 +30,7 @@ import {
|
|||||||
} from '../../config/extension-manager.js';
|
} from '../../config/extension-manager.js';
|
||||||
import { SettingScope } from '../../config/settings.js';
|
import { SettingScope } from '../../config/settings.js';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
|
import { stat } from 'node:fs/promises';
|
||||||
|
|
||||||
function showMessageIfNoExtensions(
|
function showMessageIfNoExtensions(
|
||||||
context: CommandContext,
|
context: CommandContext,
|
||||||
@@ -510,6 +515,88 @@ async function installAction(context: CommandContext, args: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function linkAction(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 sourceFilepath = args.trim();
|
||||||
|
if (!sourceFilepath) {
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: `Usage: /extensions link <source>`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (/[;&|`'"]/.test(sourceFilepath)) {
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: `Source file path contains disallowed characters: ${sourceFilepath}`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await stat(sourceFilepath);
|
||||||
|
} catch (error) {
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: `Invalid source: ${sourceFilepath}`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
debugLogger.error(
|
||||||
|
`Failed to stat path "${sourceFilepath}": ${getErrorMessage(error)}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: `Linking extension from "${sourceFilepath}"...`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const installMetadata: ExtensionInstallMetadata = {
|
||||||
|
source: sourceFilepath,
|
||||||
|
type: 'link',
|
||||||
|
};
|
||||||
|
const extension =
|
||||||
|
await extensionLoader.installOrUpdateExtension(installMetadata);
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: `Extension "${extension.name}" linked successfully.`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: `Failed to link extension from "${sourceFilepath}": ${getErrorMessage(
|
||||||
|
error,
|
||||||
|
)}`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function uninstallAction(context: CommandContext, args: string) {
|
async function uninstallAction(context: CommandContext, args: string) {
|
||||||
const extensionLoader = context.services.config?.getExtensionLoader();
|
const extensionLoader = context.services.config?.getExtensionLoader();
|
||||||
if (!(extensionLoader instanceof ExtensionManager)) {
|
if (!(extensionLoader instanceof ExtensionManager)) {
|
||||||
@@ -645,6 +732,14 @@ const installCommand: SlashCommand = {
|
|||||||
action: installAction,
|
action: installAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const linkCommand: SlashCommand = {
|
||||||
|
name: 'link',
|
||||||
|
description: 'Link an extension from a local path',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
autoExecute: false,
|
||||||
|
action: linkAction,
|
||||||
|
};
|
||||||
|
|
||||||
const uninstallCommand: SlashCommand = {
|
const uninstallCommand: SlashCommand = {
|
||||||
name: 'uninstall',
|
name: 'uninstall',
|
||||||
description: 'Uninstall an extension',
|
description: 'Uninstall an extension',
|
||||||
@@ -675,7 +770,13 @@ export function extensionsCommand(
|
|||||||
enableExtensionReloading?: boolean,
|
enableExtensionReloading?: boolean,
|
||||||
): SlashCommand {
|
): SlashCommand {
|
||||||
const conditionalCommands = enableExtensionReloading
|
const conditionalCommands = enableExtensionReloading
|
||||||
? [disableCommand, enableCommand, installCommand, uninstallCommand]
|
? [
|
||||||
|
disableCommand,
|
||||||
|
enableCommand,
|
||||||
|
installCommand,
|
||||||
|
uninstallCommand,
|
||||||
|
linkCommand,
|
||||||
|
]
|
||||||
: [];
|
: [];
|
||||||
return {
|
return {
|
||||||
name: 'extensions',
|
name: 'extensions',
|
||||||
|
|||||||
Reference in New Issue
Block a user