Skip to content

Commit c0940a1

Browse files
authored
Add a command line option to enable and list extensions (google-gemini#3191)
1 parent f1647d9 commit c0940a1

8 files changed

Lines changed: 220 additions & 10 deletions

File tree

docs/cli/configuration.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,12 @@ Arguments passed directly when running the CLI can override other configurations
311311
- Enables logging of prompts for telemetry. See [telemetry](../telemetry.md) for more information.
312312
- **`--checkpointing`**:
313313
- Enables [checkpointing](./commands.md#checkpointing-commands).
314+
- **`--extensions <extension_name ...>`** (**`-e <extension_name ...>`**):
315+
- Specifies a list of extensions to use for the session. If not provided, all available extensions are used.
316+
- Use the special term `gemini -e none` to disable all extensions.
317+
- Example: `gemini -e my-extension -e my-other-extension`
318+
- **`--list-extensions`** (**`-l`**):
319+
- Lists all available extensions and exits.
314320
- **`--version`**:
315321
- Displays the version of the CLI.
316322

packages/cli/src/config/config.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,3 +555,41 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
555555
expect(config.getMcpServers()).toEqual(baseSettings.mcpServers);
556556
});
557557
});
558+
559+
describe('loadCliConfig extensions', () => {
560+
const mockExtensions: Extension[] = [
561+
{
562+
config: { name: 'ext1', version: '1.0.0' },
563+
contextFiles: ['/path/to/ext1.md'],
564+
},
565+
{
566+
config: { name: 'ext2', version: '1.0.0' },
567+
contextFiles: ['/path/to/ext2.md'],
568+
},
569+
];
570+
571+
it('should not filter extensions if --extensions flag is not used', async () => {
572+
process.argv = ['node', 'script.js'];
573+
const settings: Settings = {};
574+
const config = await loadCliConfig(
575+
settings,
576+
mockExtensions,
577+
'test-session',
578+
);
579+
expect(config.getExtensionContextFilePaths()).toEqual([
580+
'/path/to/ext1.md',
581+
'/path/to/ext2.md',
582+
]);
583+
});
584+
585+
it('should filter extensions if --extensions flag is used', async () => {
586+
process.argv = ['node', 'script.js', '--extensions', 'ext1'];
587+
const settings: Settings = {};
588+
const config = await loadCliConfig(
589+
settings,
590+
mockExtensions,
591+
'test-session',
592+
);
593+
expect(config.getExtensionContextFilePaths()).toEqual(['/path/to/ext1.md']);
594+
});
595+
});

packages/cli/src/config/config.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
} from '@google/gemini-cli-core';
2121
import { Settings } from './settings.js';
2222

23-
import { Extension } from './extension.js';
23+
import { Extension, filterActiveExtensions } from './extension.js';
2424
import { getCliVersion } from '../utils/version.js';
2525
import { loadSandboxConfig } from './sandboxConfig.js';
2626

@@ -49,6 +49,8 @@ interface CliArgs {
4949
telemetryOtlpEndpoint: string | undefined;
5050
telemetryLogPrompts: boolean | undefined;
5151
'allowed-mcp-server-names': string | undefined;
52+
extensions: string[] | undefined;
53+
listExtensions: boolean | undefined;
5254
}
5355

5456
async function parseArguments(): Promise<CliArgs> {
@@ -133,6 +135,18 @@ async function parseArguments(): Promise<CliArgs> {
133135
type: 'string',
134136
description: 'Allowed MCP server names',
135137
})
138+
.option('extensions', {
139+
alias: 'e',
140+
type: 'array',
141+
string: true,
142+
description:
143+
'A list of extensions to use. If not provided, all extensions are used.',
144+
})
145+
.option('list-extensions', {
146+
alias: 'l',
147+
type: 'boolean',
148+
description: 'List all available extensions and exit.',
149+
})
136150
.version(await getCliVersion()) // This will enable the --version flag based on package.json
137151
.alias('v', 'version')
138152
.help()
@@ -174,6 +188,11 @@ export async function loadCliConfig(
174188
const argv = await parseArguments();
175189
const debugMode = argv.debug || false;
176190

191+
const activeExtensions = filterActiveExtensions(
192+
extensions,
193+
argv.extensions || [],
194+
);
195+
177196
// Set the context filename in the server's memoryTool module BEFORE loading memory
178197
// TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed
179198
// directly to the Config constructor in core, and have core handle setGeminiMdFilename.
@@ -185,7 +204,9 @@ export async function loadCliConfig(
185204
setServerGeminiMdFilename(getCurrentGeminiMdFilename());
186205
}
187206

188-
const extensionContextFilePaths = extensions.flatMap((e) => e.contextFiles);
207+
const extensionContextFilePaths = activeExtensions.flatMap(
208+
(e) => e.contextFiles,
209+
);
189210

190211
const fileService = new FileDiscoveryService(process.cwd());
191212
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
@@ -196,8 +217,8 @@ export async function loadCliConfig(
196217
extensionContextFilePaths,
197218
);
198219

199-
let mcpServers = mergeMcpServers(settings, extensions);
200-
const excludeTools = mergeExcludeTools(settings, extensions);
220+
let mcpServers = mergeMcpServers(settings, activeExtensions);
221+
const excludeTools = mergeExcludeTools(settings, activeExtensions);
201222

202223
if (argv['allowed-mcp-server-names']) {
203224
const allowedNames = new Set(
@@ -262,6 +283,11 @@ export async function loadCliConfig(
262283
bugCommand: settings.bugCommand,
263284
model: argv.model!,
264285
extensionContextFilePaths,
286+
listExtensions: argv.listExtensions || false,
287+
activeExtensions: activeExtensions.map((e) => ({
288+
name: e.config.name,
289+
version: e.config.version,
290+
})),
265291
});
266292
}
267293

packages/cli/src/config/extension.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import * as path from 'path';
1111
import {
1212
EXTENSIONS_CONFIG_FILENAME,
1313
EXTENSIONS_DIRECTORY_NAME,
14+
filterActiveExtensions,
1415
loadExtensions,
1516
} from './extension.js';
1617

@@ -85,6 +86,47 @@ describe('loadExtensions', () => {
8586
});
8687
});
8788

89+
describe('filterActiveExtensions', () => {
90+
const extensions = [
91+
{ config: { name: 'ext1', version: '1.0.0' }, contextFiles: [] },
92+
{ config: { name: 'ext2', version: '1.0.0' }, contextFiles: [] },
93+
{ config: { name: 'ext3', version: '1.0.0' }, contextFiles: [] },
94+
];
95+
96+
it('should return all extensions if no enabled extensions are provided', () => {
97+
const activeExtensions = filterActiveExtensions(extensions, []);
98+
expect(activeExtensions).toHaveLength(3);
99+
});
100+
101+
it('should return only the enabled extensions', () => {
102+
const activeExtensions = filterActiveExtensions(extensions, [
103+
'ext1',
104+
'ext3',
105+
]);
106+
expect(activeExtensions).toHaveLength(2);
107+
expect(activeExtensions.some((e) => e.config.name === 'ext1')).toBe(true);
108+
expect(activeExtensions.some((e) => e.config.name === 'ext3')).toBe(true);
109+
});
110+
111+
it('should return no extensions when "none" is provided', () => {
112+
const activeExtensions = filterActiveExtensions(extensions, ['none']);
113+
expect(activeExtensions).toHaveLength(0);
114+
});
115+
116+
it('should handle case-insensitivity', () => {
117+
const activeExtensions = filterActiveExtensions(extensions, ['EXT1']);
118+
expect(activeExtensions).toHaveLength(1);
119+
expect(activeExtensions[0].config.name).toBe('ext1');
120+
});
121+
122+
it('should log an error for unknown extensions', () => {
123+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
124+
filterActiveExtensions(extensions, ['ext4']);
125+
expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4');
126+
consoleSpy.mockRestore();
127+
});
128+
});
129+
88130
function createExtension(
89131
extensionsDir: string,
90132
name: string,

packages/cli/src/config/extension.ts

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,19 +31,17 @@ export function loadExtensions(workspaceDir: string): Extension[] {
3131
...loadExtensionsFromDir(os.homedir()),
3232
];
3333

34-
const uniqueExtensions: Extension[] = [];
35-
const seenNames = new Set<string>();
34+
const uniqueExtensions = new Map<string, Extension>();
3635
for (const extension of allExtensions) {
37-
if (!seenNames.has(extension.config.name)) {
36+
if (!uniqueExtensions.has(extension.config.name)) {
3837
console.log(
3938
`Loading extension: ${extension.config.name} (version: ${extension.config.version})`,
4039
);
41-
uniqueExtensions.push(extension);
42-
seenNames.add(extension.config.name);
40+
uniqueExtensions.set(extension.config.name, extension);
4341
}
4442
}
4543

46-
return uniqueExtensions;
44+
return Array.from(uniqueExtensions.values());
4745
}
4846

4947
function loadExtensionsFromDir(dir: string): Extension[] {
@@ -114,3 +112,48 @@ function getContextFileNames(config: ExtensionConfig): string[] {
114112
}
115113
return config.contextFileName;
116114
}
115+
116+
export function filterActiveExtensions(
117+
extensions: Extension[],
118+
enabledExtensionNames: string[],
119+
): Extension[] {
120+
if (enabledExtensionNames.length === 0) {
121+
return extensions;
122+
}
123+
124+
const lowerCaseEnabledExtensions = new Set(
125+
enabledExtensionNames.map((e) => e.trim().toLowerCase()),
126+
);
127+
128+
if (
129+
lowerCaseEnabledExtensions.size === 1 &&
130+
lowerCaseEnabledExtensions.has('none')
131+
) {
132+
if (extensions.length > 0) {
133+
console.log('All extensions are disabled.');
134+
}
135+
return [];
136+
}
137+
138+
const activeExtensions: Extension[] = [];
139+
const notFoundNames = new Set(lowerCaseEnabledExtensions);
140+
141+
for (const extension of extensions) {
142+
const lowerCaseName = extension.config.name.toLowerCase();
143+
if (lowerCaseEnabledExtensions.has(lowerCaseName)) {
144+
console.log(
145+
`Activated extension: ${extension.config.name} (version: ${extension.config.version})`,
146+
);
147+
activeExtensions.push(extension);
148+
notFoundNames.delete(lowerCaseName);
149+
} else {
150+
console.log(`Disabled extension: ${extension.config.name}`);
151+
}
152+
}
153+
154+
for (const requestedName of notFoundNames) {
155+
console.log(`Extension not found: ${requestedName}`);
156+
}
157+
158+
return activeExtensions;
159+
}

packages/cli/src/gemini.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,14 @@ export async function main() {
103103
const extensions = loadExtensions(workspaceRoot);
104104
const config = await loadCliConfig(settings.merged, extensions, sessionId);
105105

106+
if (config.getListExtensions()) {
107+
console.log('Installed extensions:');
108+
for (const extension of extensions) {
109+
console.log(`- ${extension.config.name}`);
110+
}
111+
process.exit(0);
112+
}
113+
106114
// Set a default auth type if one isn't set for a couple of known cases.
107115
if (!settings.merged.selectedAuthType) {
108116
if (process.env.GEMINI_API_KEY) {

packages/cli/src/ui/hooks/slashCommandProcessor.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,34 @@ export const useSlashCommandProcessor = (
493493
});
494494
},
495495
},
496+
{
497+
name: 'extensions',
498+
description: 'list active extensions',
499+
action: async () => {
500+
const activeExtensions = config?.getActiveExtensions();
501+
if (!activeExtensions || activeExtensions.length === 0) {
502+
addMessage({
503+
type: MessageType.INFO,
504+
content: 'No active extensions.',
505+
timestamp: new Date(),
506+
});
507+
return;
508+
}
509+
510+
let message = 'Active extensions:\n\n';
511+
for (const ext of activeExtensions) {
512+
message += ` - \u001b[36m${ext.name} (v${ext.version})\u001b[0m\n`;
513+
}
514+
// Make sure to reset any ANSI formatting at the end to prevent it from affecting the terminal
515+
message += '\u001b[0m';
516+
517+
addMessage({
518+
type: MessageType.INFO,
519+
content: message,
520+
timestamp: new Date(),
521+
});
522+
},
523+
},
496524
{
497525
name: 'tools',
498526
description: 'list available Gemini CLI tools',

packages/core/src/config/config.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ export interface TelemetrySettings {
6666
logPrompts?: boolean;
6767
}
6868

69+
export interface ActiveExtension {
70+
name: string;
71+
version: string;
72+
}
73+
6974
export class MCPServerConfig {
7075
constructor(
7176
// For stdio transport
@@ -133,6 +138,8 @@ export interface ConfigParameters {
133138
bugCommand?: BugCommandSettings;
134139
model: string;
135140
extensionContextFilePaths?: string[];
141+
listExtensions?: boolean;
142+
activeExtensions?: ActiveExtension[];
136143
}
137144

138145
export class Config {
@@ -172,6 +179,8 @@ export class Config {
172179
private readonly model: string;
173180
private readonly extensionContextFilePaths: string[];
174181
private modelSwitchedDuringSession: boolean = false;
182+
private readonly listExtensions: boolean;
183+
private readonly _activeExtensions: ActiveExtension[];
175184
flashFallbackHandler?: FlashFallbackHandler;
176185

177186
constructor(params: ConfigParameters) {
@@ -214,6 +223,8 @@ export class Config {
214223
this.bugCommand = params.bugCommand;
215224
this.model = params.model;
216225
this.extensionContextFilePaths = params.extensionContextFilePaths ?? [];
226+
this.listExtensions = params.listExtensions ?? false;
227+
this._activeExtensions = params.activeExtensions ?? [];
217228

218229
if (params.contextFileName) {
219230
setGeminiMdFilename(params.contextFileName);
@@ -446,6 +457,14 @@ export class Config {
446457
return this.extensionContextFilePaths;
447458
}
448459

460+
getListExtensions(): boolean {
461+
return this.listExtensions;
462+
}
463+
464+
getActiveExtensions(): ActiveExtension[] {
465+
return this._activeExtensions;
466+
}
467+
449468
async getGitService(): Promise<GitService> {
450469
if (!this.gitService) {
451470
this.gitService = new GitService(this.targetDir);

0 commit comments

Comments
 (0)