Add extension linking capabilities in cli (#16040)

This commit is contained in:
kevinjwang1
2026-01-08 10:37:16 -08:00
committed by GitHub
parent eb3f3cfdb8
commit 02cf264ee1
2 changed files with 197 additions and 5 deletions

View File

@@ -31,6 +31,7 @@ import {
inferInstallMetadata,
} from '../../config/extension-manager.js';
import { SettingScope } from '../../config/settings.js';
import { stat } from 'node:fs/promises';
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
const actual =
@@ -42,11 +43,16 @@ vi.mock('../../config/extension-manager.js', async (importOriginal) => {
});
import open from 'open';
import type { Stats } from 'node:fs';
vi.mock('open', () => ({
default: vi.fn(),
}));
vi.mock('node:fs/promises', () => ({
stat: vi.fn(),
}));
vi.mock('../../config/extensions/update.js', () => ({
updateExtension: vi.fn(),
checkForAllExtensionUpdates: vi.fn(),
@@ -493,34 +499,37 @@ describe('extensionsCommand', () => {
});
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 subCommandNames = command.subCommands?.map((cmd) => cmd.name);
expect(subCommandNames).toContain('enable');
expect(subCommandNames).toContain('disable');
expect(subCommandNames).toContain('install');
expect(subCommandNames).toContain('link');
expect(subCommandNames).toContain('uninstall');
});
});
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 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('link');
expect(subCommandNames).not.toContain('uninstall');
});
});
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 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('link');
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', () => {
let uninstallAction: SlashCommand['action'];

View File

@@ -4,7 +4,11 @@
* 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 { getErrorMessage } from '../../utils/errors.js';
import {
@@ -26,6 +30,7 @@ import {
} from '../../config/extension-manager.js';
import { SettingScope } from '../../config/settings.js';
import { theme } from '../semantic-colors.js';
import { stat } from 'node:fs/promises';
function showMessageIfNoExtensions(
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) {
const extensionLoader = context.services.config?.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
@@ -645,6 +732,14 @@ const installCommand: SlashCommand = {
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 = {
name: 'uninstall',
description: 'Uninstall an extension',
@@ -675,7 +770,13 @@ export function extensionsCommand(
enableExtensionReloading?: boolean,
): SlashCommand {
const conditionalCommands = enableExtensionReloading
? [disableCommand, enableCommand, installCommand, uninstallCommand]
? [
disableCommand,
enableCommand,
installCommand,
uninstallCommand,
linkCommand,
]
: [];
return {
name: 'extensions',