Add a command line option to enable and list extensions (#3191)

This commit is contained in:
Billy Biggs 2025-07-08 12:57:34 -04:00 committed by GitHub
parent f1647d9e19
commit c0940a194e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 220 additions and 10 deletions

View File

@ -311,6 +311,12 @@ Arguments passed directly when running the CLI can override other configurations
- Enables logging of prompts for telemetry. See [telemetry](../telemetry.md) for more information.
- **`--checkpointing`**:
- Enables [checkpointing](./commands.md#checkpointing-commands).
- **`--extensions <extension_name ...>`** (**`-e <extension_name ...>`**):
- Specifies a list of extensions to use for the session. If not provided, all available extensions are used.
- Use the special term `gemini -e none` to disable all extensions.
- Example: `gemini -e my-extension -e my-other-extension`
- **`--list-extensions`** (**`-l`**):
- Lists all available extensions and exits.
- **`--version`**:
- Displays the version of the CLI.

View File

@ -555,3 +555,41 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
expect(config.getMcpServers()).toEqual(baseSettings.mcpServers);
});
});
describe('loadCliConfig extensions', () => {
const mockExtensions: Extension[] = [
{
config: { name: 'ext1', version: '1.0.0' },
contextFiles: ['/path/to/ext1.md'],
},
{
config: { name: 'ext2', version: '1.0.0' },
contextFiles: ['/path/to/ext2.md'],
},
];
it('should not filter extensions if --extensions flag is not used', async () => {
process.argv = ['node', 'script.js'];
const settings: Settings = {};
const config = await loadCliConfig(
settings,
mockExtensions,
'test-session',
);
expect(config.getExtensionContextFilePaths()).toEqual([
'/path/to/ext1.md',
'/path/to/ext2.md',
]);
});
it('should filter extensions if --extensions flag is used', async () => {
process.argv = ['node', 'script.js', '--extensions', 'ext1'];
const settings: Settings = {};
const config = await loadCliConfig(
settings,
mockExtensions,
'test-session',
);
expect(config.getExtensionContextFilePaths()).toEqual(['/path/to/ext1.md']);
});
});

View File

@ -20,7 +20,7 @@ import {
} from '@google/gemini-cli-core';
import { Settings } from './settings.js';
import { Extension } from './extension.js';
import { Extension, filterActiveExtensions } from './extension.js';
import { getCliVersion } from '../utils/version.js';
import { loadSandboxConfig } from './sandboxConfig.js';
@ -49,6 +49,8 @@ interface CliArgs {
telemetryOtlpEndpoint: string | undefined;
telemetryLogPrompts: boolean | undefined;
'allowed-mcp-server-names': string | undefined;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
}
async function parseArguments(): Promise<CliArgs> {
@ -133,6 +135,18 @@ async function parseArguments(): Promise<CliArgs> {
type: 'string',
description: 'Allowed MCP server names',
})
.option('extensions', {
alias: 'e',
type: 'array',
string: true,
description:
'A list of extensions to use. If not provided, all extensions are used.',
})
.option('list-extensions', {
alias: 'l',
type: 'boolean',
description: 'List all available extensions and exit.',
})
.version(await getCliVersion()) // This will enable the --version flag based on package.json
.alias('v', 'version')
.help()
@ -174,6 +188,11 @@ export async function loadCliConfig(
const argv = await parseArguments();
const debugMode = argv.debug || false;
const activeExtensions = filterActiveExtensions(
extensions,
argv.extensions || [],
);
// Set the context filename in the server's memoryTool module BEFORE loading memory
// TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed
// directly to the Config constructor in core, and have core handle setGeminiMdFilename.
@ -185,7 +204,9 @@ export async function loadCliConfig(
setServerGeminiMdFilename(getCurrentGeminiMdFilename());
}
const extensionContextFilePaths = extensions.flatMap((e) => e.contextFiles);
const extensionContextFilePaths = activeExtensions.flatMap(
(e) => e.contextFiles,
);
const fileService = new FileDiscoveryService(process.cwd());
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
@ -196,8 +217,8 @@ export async function loadCliConfig(
extensionContextFilePaths,
);
let mcpServers = mergeMcpServers(settings, extensions);
const excludeTools = mergeExcludeTools(settings, extensions);
let mcpServers = mergeMcpServers(settings, activeExtensions);
const excludeTools = mergeExcludeTools(settings, activeExtensions);
if (argv['allowed-mcp-server-names']) {
const allowedNames = new Set(
@ -262,6 +283,11 @@ export async function loadCliConfig(
bugCommand: settings.bugCommand,
model: argv.model!,
extensionContextFilePaths,
listExtensions: argv.listExtensions || false,
activeExtensions: activeExtensions.map((e) => ({
name: e.config.name,
version: e.config.version,
})),
});
}

View File

@ -11,6 +11,7 @@ import * as path from 'path';
import {
EXTENSIONS_CONFIG_FILENAME,
EXTENSIONS_DIRECTORY_NAME,
filterActiveExtensions,
loadExtensions,
} from './extension.js';
@ -85,6 +86,47 @@ describe('loadExtensions', () => {
});
});
describe('filterActiveExtensions', () => {
const extensions = [
{ config: { name: 'ext1', version: '1.0.0' }, contextFiles: [] },
{ config: { name: 'ext2', version: '1.0.0' }, contextFiles: [] },
{ config: { name: 'ext3', version: '1.0.0' }, contextFiles: [] },
];
it('should return all extensions if no enabled extensions are provided', () => {
const activeExtensions = filterActiveExtensions(extensions, []);
expect(activeExtensions).toHaveLength(3);
});
it('should return only the enabled extensions', () => {
const activeExtensions = filterActiveExtensions(extensions, [
'ext1',
'ext3',
]);
expect(activeExtensions).toHaveLength(2);
expect(activeExtensions.some((e) => e.config.name === 'ext1')).toBe(true);
expect(activeExtensions.some((e) => e.config.name === 'ext3')).toBe(true);
});
it('should return no extensions when "none" is provided', () => {
const activeExtensions = filterActiveExtensions(extensions, ['none']);
expect(activeExtensions).toHaveLength(0);
});
it('should handle case-insensitivity', () => {
const activeExtensions = filterActiveExtensions(extensions, ['EXT1']);
expect(activeExtensions).toHaveLength(1);
expect(activeExtensions[0].config.name).toBe('ext1');
});
it('should log an error for unknown extensions', () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
filterActiveExtensions(extensions, ['ext4']);
expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4');
consoleSpy.mockRestore();
});
});
function createExtension(
extensionsDir: string,
name: string,

View File

@ -31,19 +31,17 @@ export function loadExtensions(workspaceDir: string): Extension[] {
...loadExtensionsFromDir(os.homedir()),
];
const uniqueExtensions: Extension[] = [];
const seenNames = new Set<string>();
const uniqueExtensions = new Map<string, Extension>();
for (const extension of allExtensions) {
if (!seenNames.has(extension.config.name)) {
if (!uniqueExtensions.has(extension.config.name)) {
console.log(
`Loading extension: ${extension.config.name} (version: ${extension.config.version})`,
);
uniqueExtensions.push(extension);
seenNames.add(extension.config.name);
uniqueExtensions.set(extension.config.name, extension);
}
}
return uniqueExtensions;
return Array.from(uniqueExtensions.values());
}
function loadExtensionsFromDir(dir: string): Extension[] {
@ -114,3 +112,48 @@ function getContextFileNames(config: ExtensionConfig): string[] {
}
return config.contextFileName;
}
export function filterActiveExtensions(
extensions: Extension[],
enabledExtensionNames: string[],
): Extension[] {
if (enabledExtensionNames.length === 0) {
return extensions;
}
const lowerCaseEnabledExtensions = new Set(
enabledExtensionNames.map((e) => e.trim().toLowerCase()),
);
if (
lowerCaseEnabledExtensions.size === 1 &&
lowerCaseEnabledExtensions.has('none')
) {
if (extensions.length > 0) {
console.log('All extensions are disabled.');
}
return [];
}
const activeExtensions: Extension[] = [];
const notFoundNames = new Set(lowerCaseEnabledExtensions);
for (const extension of extensions) {
const lowerCaseName = extension.config.name.toLowerCase();
if (lowerCaseEnabledExtensions.has(lowerCaseName)) {
console.log(
`Activated extension: ${extension.config.name} (version: ${extension.config.version})`,
);
activeExtensions.push(extension);
notFoundNames.delete(lowerCaseName);
} else {
console.log(`Disabled extension: ${extension.config.name}`);
}
}
for (const requestedName of notFoundNames) {
console.log(`Extension not found: ${requestedName}`);
}
return activeExtensions;
}

View File

@ -103,6 +103,14 @@ export async function main() {
const extensions = loadExtensions(workspaceRoot);
const config = await loadCliConfig(settings.merged, extensions, sessionId);
if (config.getListExtensions()) {
console.log('Installed extensions:');
for (const extension of extensions) {
console.log(`- ${extension.config.name}`);
}
process.exit(0);
}
// Set a default auth type if one isn't set for a couple of known cases.
if (!settings.merged.selectedAuthType) {
if (process.env.GEMINI_API_KEY) {

View File

@ -493,6 +493,34 @@ export const useSlashCommandProcessor = (
});
},
},
{
name: 'extensions',
description: 'list active extensions',
action: async () => {
const activeExtensions = config?.getActiveExtensions();
if (!activeExtensions || activeExtensions.length === 0) {
addMessage({
type: MessageType.INFO,
content: 'No active extensions.',
timestamp: new Date(),
});
return;
}
let message = 'Active extensions:\n\n';
for (const ext of activeExtensions) {
message += ` - \u001b[36m${ext.name} (v${ext.version})\u001b[0m\n`;
}
// Make sure to reset any ANSI formatting at the end to prevent it from affecting the terminal
message += '\u001b[0m';
addMessage({
type: MessageType.INFO,
content: message,
timestamp: new Date(),
});
},
},
{
name: 'tools',
description: 'list available Gemini CLI tools',

View File

@ -66,6 +66,11 @@ export interface TelemetrySettings {
logPrompts?: boolean;
}
export interface ActiveExtension {
name: string;
version: string;
}
export class MCPServerConfig {
constructor(
// For stdio transport
@ -133,6 +138,8 @@ export interface ConfigParameters {
bugCommand?: BugCommandSettings;
model: string;
extensionContextFilePaths?: string[];
listExtensions?: boolean;
activeExtensions?: ActiveExtension[];
}
export class Config {
@ -172,6 +179,8 @@ export class Config {
private readonly model: string;
private readonly extensionContextFilePaths: string[];
private modelSwitchedDuringSession: boolean = false;
private readonly listExtensions: boolean;
private readonly _activeExtensions: ActiveExtension[];
flashFallbackHandler?: FlashFallbackHandler;
constructor(params: ConfigParameters) {
@ -214,6 +223,8 @@ export class Config {
this.bugCommand = params.bugCommand;
this.model = params.model;
this.extensionContextFilePaths = params.extensionContextFilePaths ?? [];
this.listExtensions = params.listExtensions ?? false;
this._activeExtensions = params.activeExtensions ?? [];
if (params.contextFileName) {
setGeminiMdFilename(params.contextFileName);
@ -446,6 +457,14 @@ export class Config {
return this.extensionContextFilePaths;
}
getListExtensions(): boolean {
return this.listExtensions;
}
getActiveExtensions(): ActiveExtension[] {
return this._activeExtensions;
}
async getGitService(): Promise<GitService> {
if (!this.gitService) {
this.gitService = new GitService(this.targetDir);