mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-07 16:47:21 -07:00
feat(extensions): add programmatic search command
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user