feat(extensions): add programmatic search command

This commit is contained in:
mkorwel
2026-03-15 18:21:02 -07:00
parent 9f7691fd88
commit 2eb8c0f132
5 changed files with 261 additions and 4 deletions
+10
View File
@@ -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 <query>` to find available extensions in the gallery.
- Use `/extensions list` to see installed extensions.
- Use `/extensions install <url>` 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.
@@ -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<Partial<RegistryExtension>> = [
{
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<Partial<RegistryExtension>> = [
{
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');
});
});
+73 -2
View File
@@ -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<CommandExecutionResponse> {
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<CommandExecutionResponse> {
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[],
@@ -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
@@ -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<void> {
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,
],