Add a command line option to enable and list extensions (#3191)
This commit is contained in:
parent
f1647d9e19
commit
c0940a194e
|
@ -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.
|
- Enables logging of prompts for telemetry. See [telemetry](../telemetry.md) for more information.
|
||||||
- **`--checkpointing`**:
|
- **`--checkpointing`**:
|
||||||
- Enables [checkpointing](./commands.md#checkpointing-commands).
|
- 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`**:
|
- **`--version`**:
|
||||||
- Displays the version of the CLI.
|
- Displays the version of the CLI.
|
||||||
|
|
||||||
|
|
|
@ -555,3 +555,41 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
|
||||||
expect(config.getMcpServers()).toEqual(baseSettings.mcpServers);
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -20,7 +20,7 @@ import {
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { Settings } from './settings.js';
|
import { Settings } from './settings.js';
|
||||||
|
|
||||||
import { Extension } from './extension.js';
|
import { Extension, filterActiveExtensions } from './extension.js';
|
||||||
import { getCliVersion } from '../utils/version.js';
|
import { getCliVersion } from '../utils/version.js';
|
||||||
import { loadSandboxConfig } from './sandboxConfig.js';
|
import { loadSandboxConfig } from './sandboxConfig.js';
|
||||||
|
|
||||||
|
@ -49,6 +49,8 @@ interface CliArgs {
|
||||||
telemetryOtlpEndpoint: string | undefined;
|
telemetryOtlpEndpoint: string | undefined;
|
||||||
telemetryLogPrompts: boolean | undefined;
|
telemetryLogPrompts: boolean | undefined;
|
||||||
'allowed-mcp-server-names': string | undefined;
|
'allowed-mcp-server-names': string | undefined;
|
||||||
|
extensions: string[] | undefined;
|
||||||
|
listExtensions: boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function parseArguments(): Promise<CliArgs> {
|
async function parseArguments(): Promise<CliArgs> {
|
||||||
|
@ -133,6 +135,18 @@ async function parseArguments(): Promise<CliArgs> {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'Allowed MCP server names',
|
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
|
.version(await getCliVersion()) // This will enable the --version flag based on package.json
|
||||||
.alias('v', 'version')
|
.alias('v', 'version')
|
||||||
.help()
|
.help()
|
||||||
|
@ -174,6 +188,11 @@ export async function loadCliConfig(
|
||||||
const argv = await parseArguments();
|
const argv = await parseArguments();
|
||||||
const debugMode = argv.debug || false;
|
const debugMode = argv.debug || false;
|
||||||
|
|
||||||
|
const activeExtensions = filterActiveExtensions(
|
||||||
|
extensions,
|
||||||
|
argv.extensions || [],
|
||||||
|
);
|
||||||
|
|
||||||
// Set the context filename in the server's memoryTool module BEFORE loading memory
|
// 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
|
// 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.
|
// directly to the Config constructor in core, and have core handle setGeminiMdFilename.
|
||||||
|
@ -185,7 +204,9 @@ export async function loadCliConfig(
|
||||||
setServerGeminiMdFilename(getCurrentGeminiMdFilename());
|
setServerGeminiMdFilename(getCurrentGeminiMdFilename());
|
||||||
}
|
}
|
||||||
|
|
||||||
const extensionContextFilePaths = extensions.flatMap((e) => e.contextFiles);
|
const extensionContextFilePaths = activeExtensions.flatMap(
|
||||||
|
(e) => e.contextFiles,
|
||||||
|
);
|
||||||
|
|
||||||
const fileService = new FileDiscoveryService(process.cwd());
|
const fileService = new FileDiscoveryService(process.cwd());
|
||||||
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
|
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
|
||||||
|
@ -196,8 +217,8 @@ export async function loadCliConfig(
|
||||||
extensionContextFilePaths,
|
extensionContextFilePaths,
|
||||||
);
|
);
|
||||||
|
|
||||||
let mcpServers = mergeMcpServers(settings, extensions);
|
let mcpServers = mergeMcpServers(settings, activeExtensions);
|
||||||
const excludeTools = mergeExcludeTools(settings, extensions);
|
const excludeTools = mergeExcludeTools(settings, activeExtensions);
|
||||||
|
|
||||||
if (argv['allowed-mcp-server-names']) {
|
if (argv['allowed-mcp-server-names']) {
|
||||||
const allowedNames = new Set(
|
const allowedNames = new Set(
|
||||||
|
@ -262,6 +283,11 @@ export async function loadCliConfig(
|
||||||
bugCommand: settings.bugCommand,
|
bugCommand: settings.bugCommand,
|
||||||
model: argv.model!,
|
model: argv.model!,
|
||||||
extensionContextFilePaths,
|
extensionContextFilePaths,
|
||||||
|
listExtensions: argv.listExtensions || false,
|
||||||
|
activeExtensions: activeExtensions.map((e) => ({
|
||||||
|
name: e.config.name,
|
||||||
|
version: e.config.version,
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,7 @@ import * as path from 'path';
|
||||||
import {
|
import {
|
||||||
EXTENSIONS_CONFIG_FILENAME,
|
EXTENSIONS_CONFIG_FILENAME,
|
||||||
EXTENSIONS_DIRECTORY_NAME,
|
EXTENSIONS_DIRECTORY_NAME,
|
||||||
|
filterActiveExtensions,
|
||||||
loadExtensions,
|
loadExtensions,
|
||||||
} from './extension.js';
|
} 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(
|
function createExtension(
|
||||||
extensionsDir: string,
|
extensionsDir: string,
|
||||||
name: string,
|
name: string,
|
||||||
|
|
|
@ -31,19 +31,17 @@ export function loadExtensions(workspaceDir: string): Extension[] {
|
||||||
...loadExtensionsFromDir(os.homedir()),
|
...loadExtensionsFromDir(os.homedir()),
|
||||||
];
|
];
|
||||||
|
|
||||||
const uniqueExtensions: Extension[] = [];
|
const uniqueExtensions = new Map<string, Extension>();
|
||||||
const seenNames = new Set<string>();
|
|
||||||
for (const extension of allExtensions) {
|
for (const extension of allExtensions) {
|
||||||
if (!seenNames.has(extension.config.name)) {
|
if (!uniqueExtensions.has(extension.config.name)) {
|
||||||
console.log(
|
console.log(
|
||||||
`Loading extension: ${extension.config.name} (version: ${extension.config.version})`,
|
`Loading extension: ${extension.config.name} (version: ${extension.config.version})`,
|
||||||
);
|
);
|
||||||
uniqueExtensions.push(extension);
|
uniqueExtensions.set(extension.config.name, extension);
|
||||||
seenNames.add(extension.config.name);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return uniqueExtensions;
|
return Array.from(uniqueExtensions.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadExtensionsFromDir(dir: string): Extension[] {
|
function loadExtensionsFromDir(dir: string): Extension[] {
|
||||||
|
@ -114,3 +112,48 @@ function getContextFileNames(config: ExtensionConfig): string[] {
|
||||||
}
|
}
|
||||||
return config.contextFileName;
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -103,6 +103,14 @@ export async function main() {
|
||||||
const extensions = loadExtensions(workspaceRoot);
|
const extensions = loadExtensions(workspaceRoot);
|
||||||
const config = await loadCliConfig(settings.merged, extensions, sessionId);
|
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.
|
// Set a default auth type if one isn't set for a couple of known cases.
|
||||||
if (!settings.merged.selectedAuthType) {
|
if (!settings.merged.selectedAuthType) {
|
||||||
if (process.env.GEMINI_API_KEY) {
|
if (process.env.GEMINI_API_KEY) {
|
||||||
|
|
|
@ -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',
|
name: 'tools',
|
||||||
description: 'list available Gemini CLI tools',
|
description: 'list available Gemini CLI tools',
|
||||||
|
|
|
@ -66,6 +66,11 @@ export interface TelemetrySettings {
|
||||||
logPrompts?: boolean;
|
logPrompts?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ActiveExtension {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class MCPServerConfig {
|
export class MCPServerConfig {
|
||||||
constructor(
|
constructor(
|
||||||
// For stdio transport
|
// For stdio transport
|
||||||
|
@ -133,6 +138,8 @@ export interface ConfigParameters {
|
||||||
bugCommand?: BugCommandSettings;
|
bugCommand?: BugCommandSettings;
|
||||||
model: string;
|
model: string;
|
||||||
extensionContextFilePaths?: string[];
|
extensionContextFilePaths?: string[];
|
||||||
|
listExtensions?: boolean;
|
||||||
|
activeExtensions?: ActiveExtension[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Config {
|
export class Config {
|
||||||
|
@ -172,6 +179,8 @@ export class Config {
|
||||||
private readonly model: string;
|
private readonly model: string;
|
||||||
private readonly extensionContextFilePaths: string[];
|
private readonly extensionContextFilePaths: string[];
|
||||||
private modelSwitchedDuringSession: boolean = false;
|
private modelSwitchedDuringSession: boolean = false;
|
||||||
|
private readonly listExtensions: boolean;
|
||||||
|
private readonly _activeExtensions: ActiveExtension[];
|
||||||
flashFallbackHandler?: FlashFallbackHandler;
|
flashFallbackHandler?: FlashFallbackHandler;
|
||||||
|
|
||||||
constructor(params: ConfigParameters) {
|
constructor(params: ConfigParameters) {
|
||||||
|
@ -214,6 +223,8 @@ export class Config {
|
||||||
this.bugCommand = params.bugCommand;
|
this.bugCommand = params.bugCommand;
|
||||||
this.model = params.model;
|
this.model = params.model;
|
||||||
this.extensionContextFilePaths = params.extensionContextFilePaths ?? [];
|
this.extensionContextFilePaths = params.extensionContextFilePaths ?? [];
|
||||||
|
this.listExtensions = params.listExtensions ?? false;
|
||||||
|
this._activeExtensions = params.activeExtensions ?? [];
|
||||||
|
|
||||||
if (params.contextFileName) {
|
if (params.contextFileName) {
|
||||||
setGeminiMdFilename(params.contextFileName);
|
setGeminiMdFilename(params.contextFileName);
|
||||||
|
@ -446,6 +457,14 @@ export class Config {
|
||||||
return this.extensionContextFilePaths;
|
return this.extensionContextFilePaths;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getListExtensions(): boolean {
|
||||||
|
return this.listExtensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveExtensions(): ActiveExtension[] {
|
||||||
|
return this._activeExtensions;
|
||||||
|
}
|
||||||
|
|
||||||
async getGitService(): Promise<GitService> {
|
async getGitService(): Promise<GitService> {
|
||||||
if (!this.gitService) {
|
if (!this.gitService) {
|
||||||
this.gitService = new GitService(this.targetDir);
|
this.gitService = new GitService(this.targetDir);
|
||||||
|
|
Loading…
Reference in New Issue