diff --git a/GEMINI.md b/GEMINI.md index f7017eab40..b71b6275e0 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -92,3 +92,13 @@ powerful tool for developers. - Documentation is located in the `docs/` directory. - Suggest documentation updates when code changes render existing documentation obsolete or incomplete. + +## Extension Management + +- Gemini CLI supports programmatic extension management. +- Use `/extensions search ` to find available extensions in the gallery. +- Use `/extensions list` to see installed extensions. +- Use `/extensions install ` to install an extension (e.g., from a GitHub + URL found via search). +- If a user asks for a capability that isn't built-in, proactively search for + extensions that might provide it. diff --git a/packages/cli/src/acp/commands/extensions.test.ts b/packages/cli/src/acp/commands/extensions.test.ts new file mode 100644 index 0000000000..a4388e9153 --- /dev/null +++ b/packages/cli/src/acp/commands/extensions.test.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SearchExtensionsCommand } from './extensions.js'; +import { + ExtensionRegistryClient, + type RegistryExtension, +} from '../../config/extensionRegistryClient.js'; +import type { CommandContext } from './types.js'; + +vi.mock('../../config/extensionRegistryClient.js'); + +describe('SearchExtensionsCommand', () => { + let command: SearchExtensionsCommand; + let mockContext: CommandContext; + + beforeEach(() => { + command = new SearchExtensionsCommand(); + mockContext = { + config: { + getExtensionRegistryURI: vi.fn().mockReturnValue(undefined), + }, + } as unknown as CommandContext; + vi.clearAllMocks(); + }); + + it('should list top extensions when no args provided', async () => { + const mockExtensions: Array> = [ + { + extensionName: 'Ext 1', + fullName: 'user/ext1', + extensionDescription: 'Desc 1', + rank: 1, + url: 'https://github.com/user/ext1', + hasMCP: true, + }, + { + extensionName: 'Ext 2', + fullName: 'user/ext2', + extensionDescription: 'Desc 2', + rank: 2, + url: 'https://github.com/user/ext2', + hasSkills: true, + }, + ]; + + vi.mocked( + ExtensionRegistryClient.prototype.searchExtensions, + ).mockResolvedValue(mockExtensions as RegistryExtension[]); + + const response = await command.execute(mockContext, []); + + expect(response.name).toBe('extensions search'); + expect(response.data).toContain('Top 2 available extensions:'); + expect(response.data).toContain('* Ext 1 (user/ext1)'); + expect(response.data).toContain('[MCP]'); + expect(response.data).toContain( + '/extensions install https://github.com/user/ext1', + ); + expect(response.data).toContain('* Ext 2 (user/ext2)'); + expect(response.data).toContain('[Skills]'); + }); + + it('should search extensions when args provided', async () => { + const mockExtensions: Array> = [ + { + extensionName: 'Search Ext', + fullName: 'user/search-ext', + extensionDescription: 'Matching description', + rank: 5, + url: 'https://github.com/user/search-ext', + }, + ]; + + vi.mocked( + ExtensionRegistryClient.prototype.searchExtensions, + ).mockResolvedValue(mockExtensions as RegistryExtension[]); + + const response = await command.execute(mockContext, ['search']); + + expect( + vi.mocked(ExtensionRegistryClient.prototype.searchExtensions), + ).toHaveBeenCalledWith('search'); + expect(response.data).toContain('Found 1 extensions matching "search":'); + expect(response.data).toContain('* Search Ext (user/search-ext)'); + }); + + it('should show error message when fetch fails', async () => { + vi.mocked( + ExtensionRegistryClient.prototype.searchExtensions, + ).mockRejectedValue(new Error('Network error')); + + const response = await command.execute(mockContext, []); + + expect(response.data).toContain( + 'Failed to fetch extensions: Network error', + ); + expect(response.data).toContain('/extensions explore'); + }); +}); diff --git a/packages/cli/src/acp/commands/extensions.ts b/packages/cli/src/acp/commands/extensions.ts index c2bd0e7190..1d1164a685 100644 --- a/packages/cli/src/acp/commands/extensions.ts +++ b/packages/cli/src/acp/commands/extensions.ts @@ -9,11 +9,13 @@ import { type Config, getErrorMessage, } from '@google/gemini-cli-core'; +import open from 'open'; import { SettingScope } from '../../config/settings.js'; import { ExtensionManager, inferInstallMetadata, } from '../../config/extension-manager.js'; +import { ExtensionRegistryClient } from '../../config/extensionRegistryClient.js'; import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js'; import { stat } from 'node:fs/promises'; import type { @@ -28,6 +30,7 @@ export class ExtensionsCommand implements Command { readonly subCommands = [ new ListExtensionsCommand(), new ExploreExtensionsCommand(), + new SearchExtensionsCommand(), new EnableExtensionCommand(), new DisableExtensionCommand(), new InstallExtensionCommand(), @@ -62,20 +65,88 @@ export class ListExtensionsCommand implements Command { export class ExploreExtensionsCommand implements Command { readonly name = 'extensions explore'; - readonly description = 'Explore available extensions.'; + readonly description = 'Explore available extensions in your browser.'; async execute( _context: CommandContext, _: string[], ): Promise { const extensionsUrl = 'https://geminicli.com/extensions/'; + await open(extensionsUrl); return { name: this.name, - data: `View or install available extensions at ${extensionsUrl}`, + data: `Opening ${extensionsUrl} in your browser...`, }; } } +export class SearchExtensionsCommand implements Command { + readonly name = 'extensions search'; + readonly description = 'Search for available extensions.'; + + async execute( + context: CommandContext, + args: string[], + ): Promise { + const registryURI = context.config.getExtensionRegistryURI(); + const client = new ExtensionRegistryClient(registryURI); + + try { + const searchTerm = args.join(' ').trim(); + let extensions = await client.searchExtensions(searchTerm); + + const isSearch = searchTerm.length > 0; + if (!isSearch) { + // If no search term, just show top 10 ranked extensions + extensions = extensions.sort((a, b) => a.rank - b.rank).slice(0, 10); + } + + if (extensions.length === 0) { + return { + name: this.name, + data: `No extensions found matching "${searchTerm}".`, + }; + } + + const output = extensions + .map((ext) => { + const description = + ext.extensionDescription || ext.repoDescription || ''; + let entry = `* ${ext.extensionName} (${ext.fullName})\n ${description}`; + const capabilities: string[] = []; + if (ext.hasMCP) capabilities.push('MCP'); + if (ext.hasSkills) capabilities.push('Skills'); + if (ext.hasHooks) capabilities.push('Hooks'); + if (ext.hasContext) capabilities.push('Context'); + if (ext.hasCustomCommands) capabilities.push('Commands'); + + if (capabilities.length > 0) { + entry += ` [${capabilities.join(', ')}]`; + } + entry += `\n Install: /extensions install ${ext.url}`; + return entry; + }) + .join('\n\n'); + + const header = isSearch + ? `Found ${extensions.length} extensions matching "${searchTerm}":` + : `Top ${extensions.length} available extensions:`; + + const footer = `\n\nRun "/extensions explore" to view the full gallery in your browser.`; + + return { + name: this.name, + data: `${header}\n\n${output}${footer}`, + }; + } catch (error) { + return { + name: this.name, + data: `Failed to fetch extensions: ${getErrorMessage(error)}\nRun "/extensions explore" to view available extensions in your browser.`, + }; + } + } +} + function getEnableDisableContext( config: Config, args: string[], diff --git a/packages/cli/src/config/extensionRegistryClient.ts b/packages/cli/src/config/extensionRegistryClient.ts index 4b47c215ec..63330209fd 100644 --- a/packages/cli/src/config/extensionRegistryClient.ts +++ b/packages/cli/src/config/extensionRegistryClient.ts @@ -90,7 +90,7 @@ export class ExtensionRegistryClient { const fzf = new AsyncFzf(allExtensions, { selector: (ext: RegistryExtension) => - `${ext.extensionName} ${ext.extensionDescription} ${ext.fullName}`, + `${ext.extensionName} ${ext.extensionDescription} ${ext.repoDescription} ${ext.fullName}`, fuzzy: true, }); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 8fe206bfc4..c99dfd8827 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -29,6 +29,7 @@ import { ExtensionManager, inferInstallMetadata, } from '../../config/extension-manager.js'; +import { ExtensionRegistryClient } from '../../config/extensionRegistryClient.js'; import { SettingScope } from '../../config/settings.js'; import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js'; import { theme } from '../semantic-colors.js'; @@ -293,7 +294,6 @@ async function exploreAction( const extensionsUrl = 'https://geminicli.com/extensions/'; - // Only check for NODE_ENV for explicit test mode, not for unit test framework if (process.env['NODE_ENV'] === 'test') { context.ui.addItem({ type: MessageType.INFO, @@ -323,6 +323,69 @@ async function exploreAction( } } +async function searchAction( + context: CommandContext, + args: string, +): Promise { + const registryURI = context.services.config?.getExtensionRegistryURI(); + const client = new ExtensionRegistryClient(registryURI); + + try { + const searchTerm = args.trim(); + let extensions = await client.searchExtensions(searchTerm); + + const isSearch = searchTerm.length > 0; + if (!isSearch) { + // If no search term, just show top 10 ranked extensions + extensions = extensions.sort((a, b) => a.rank - b.rank).slice(0, 10); + } + + if (extensions.length === 0) { + context.ui.addItem({ + type: MessageType.INFO, + text: `No extensions found matching "${searchTerm}".`, + }); + return; + } + + const output = extensions + .map((ext) => { + const description = + ext.extensionDescription || ext.repoDescription || ''; + let entry = `* ${ext.extensionName} (${ext.fullName})\n ${description}`; + const capabilities: string[] = []; + if (ext.hasMCP) capabilities.push('MCP'); + if (ext.hasSkills) capabilities.push('Skills'); + if (ext.hasHooks) capabilities.push('Hooks'); + if (ext.hasContext) capabilities.push('Context'); + if (ext.hasCustomCommands) capabilities.push('Commands'); + + if (capabilities.length > 0) { + entry += ` [${capabilities.join(', ')}]`; + } + entry += `\n Install: /extensions install ${ext.url}`; + return entry; + }) + .join('\n\n'); + + const header = isSearch + ? `Found ${extensions.length} extensions matching "${searchTerm}":` + : `Top ${extensions.length} available extensions:`; + + const footer = `\n\nRun "/extensions explore" to view the full gallery in your browser.`; + + context.ui.addItem({ + type: MessageType.INFO, + text: `${header}\n\n${output}${footer}`, + }); + } catch (error) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Failed to fetch extensions: ${getErrorMessage(error)}\nRun "/extensions explore" to view available extensions in your browser.`, + }); + } +} + function getEnableDisableContext( context: CommandContext, argumentsString: string, @@ -832,6 +895,14 @@ const exploreExtensionsCommand: SlashCommand = { action: exploreAction, }; +const searchExtensionsCommand: SlashCommand = { + name: 'search', + description: 'Search for available extensions from the gallery', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: searchAction, +}; + const reloadCommand: SlashCommand = { name: 'reload', altNames: ['restart'], @@ -872,6 +943,7 @@ export function extensionsCommand( listExtensionsCommand, updateExtensionsCommand, exploreExtensionsCommand, + searchExtensionsCommand, reloadCommand, ...conditionalCommands, ],