diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index 4af145b631..55f20eb25d 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -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 ', + }, + 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']; diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 99ea05bccf..7c21115880 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -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 `, + }, + 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',