mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-16 09:01:17 -07:00
Add commands for listing and updating per-extension settings (#12664)
This commit is contained in:
@@ -163,11 +163,11 @@ The file has the following structure:
|
||||
your extension in the CLI. Note that we expect this name to match the
|
||||
extension directory name.
|
||||
- `version`: The version of the extension.
|
||||
- `mcpServers`: A map of MCP servers to configure. The key is the name of the
|
||||
- `mcpServers`: A map of MCP servers to settings. The key is the name of the
|
||||
server, and the value is the server configuration. These servers will be
|
||||
loaded on startup just like MCP servers configured in a
|
||||
loaded on startup just like MCP servers settingsd in a
|
||||
[`settings.json` file](../get-started/configuration.md). If both an extension
|
||||
and a `settings.json` file configure an MCP server with the same name, the
|
||||
and a `settings.json` file settings an MCP server with the same name, the
|
||||
server defined in the `settings.json` file takes precedence.
|
||||
- Note that all MCP server configuration options are supported except for
|
||||
`trust`.
|
||||
@@ -223,6 +223,18 @@ When a user installs this extension, they will be prompted to enter their API
|
||||
key. The value will be saved to a `.env` file in the extension's directory
|
||||
(e.g., `<home>/.gemini/extensions/my-api-extension/.env`).
|
||||
|
||||
You can view a list of an extension's settings by running:
|
||||
|
||||
```
|
||||
gemini extensions settings list <extension name>
|
||||
```
|
||||
|
||||
and you can update a given setting using:
|
||||
|
||||
```
|
||||
gemini extensions settings set <extension name> <setting name>
|
||||
```
|
||||
|
||||
### Custom commands
|
||||
|
||||
Extensions can provide [custom commands](../cli/custom-commands.md) by placing
|
||||
|
||||
@@ -14,6 +14,7 @@ import { enableCommand } from './extensions/enable.js';
|
||||
import { linkCommand } from './extensions/link.js';
|
||||
import { newCommand } from './extensions/new.js';
|
||||
import { validateCommand } from './extensions/validate.js';
|
||||
import { settingsCommand } from './extensions/settings.js';
|
||||
import { initializeOutputListenersAndFlush } from '../gemini.js';
|
||||
|
||||
export const extensionsCommand: CommandModule = {
|
||||
@@ -32,6 +33,7 @@ export const extensionsCommand: CommandModule = {
|
||||
.command(linkCommand)
|
||||
.command(newCommand)
|
||||
.command(validateCommand)
|
||||
.command(settingsCommand)
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
handler: () => {
|
||||
|
||||
131
packages/cli/src/commands/extensions/settings.ts
Normal file
131
packages/cli/src/commands/extensions/settings.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import {
|
||||
getEnvContents,
|
||||
updateSetting,
|
||||
promptForSetting,
|
||||
} from '../../config/extensions/extensionSettings.js';
|
||||
import { getExtensionAndManager } from './utils.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
// --- SET COMMAND ---
|
||||
interface SetArgs {
|
||||
name: string;
|
||||
setting: string;
|
||||
}
|
||||
|
||||
const setCommand: CommandModule<object, SetArgs> = {
|
||||
command: 'set <name> <setting>',
|
||||
describe: 'Set a specific setting for an extension.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('name', {
|
||||
describe: 'Name of the extension to configure.',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.positional('setting', {
|
||||
describe: 'The setting to configure (name or env var).',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: async (args) => {
|
||||
const { name, setting } = args;
|
||||
const { extension, extensionManager } = await getExtensionAndManager(name);
|
||||
if (!extension || !extensionManager) {
|
||||
return;
|
||||
}
|
||||
const extensionConfig = extensionManager.loadExtensionConfig(
|
||||
extension.path,
|
||||
);
|
||||
if (!extensionConfig) {
|
||||
debugLogger.error(
|
||||
`Could not find configuration for extension "${name}".`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await updateSetting(
|
||||
extensionConfig,
|
||||
extension.id,
|
||||
setting,
|
||||
promptForSetting,
|
||||
);
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
|
||||
// --- LIST COMMAND ---
|
||||
interface ListArgs {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const listCommand: CommandModule<object, ListArgs> = {
|
||||
command: 'list <name>',
|
||||
describe: 'List all settings for an extension.',
|
||||
builder: (yargs) =>
|
||||
yargs.positional('name', {
|
||||
describe: 'Name of the extension.',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: async (args) => {
|
||||
const { name } = args;
|
||||
const { extension, extensionManager } = await getExtensionAndManager(name);
|
||||
if (!extension || !extensionManager) {
|
||||
return;
|
||||
}
|
||||
const extensionConfig = extensionManager.loadExtensionConfig(
|
||||
extension.path,
|
||||
);
|
||||
if (
|
||||
!extensionConfig ||
|
||||
!extensionConfig.settings ||
|
||||
extensionConfig.settings.length === 0
|
||||
) {
|
||||
debugLogger.log(`Extension "${name}" has no settings to configure.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSettings = await getEnvContents(extensionConfig, extension.id);
|
||||
|
||||
debugLogger.log(`Settings for "${name}":`);
|
||||
for (const setting of extensionConfig.settings) {
|
||||
const value = currentSettings[setting.envVar];
|
||||
let displayValue: string;
|
||||
if (value === undefined) {
|
||||
displayValue = '[not set]';
|
||||
} else if (setting.sensitive) {
|
||||
displayValue = '[value stored in keychain]';
|
||||
} else {
|
||||
displayValue = value;
|
||||
}
|
||||
debugLogger.log(`
|
||||
- ${setting.name} (${setting.envVar})`);
|
||||
debugLogger.log(` Description: ${setting.description}`);
|
||||
debugLogger.log(` Value: ${displayValue}`);
|
||||
}
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
|
||||
// --- SETTINGS COMMAND ---
|
||||
export const settingsCommand: CommandModule = {
|
||||
command: 'settings <command>',
|
||||
describe: 'Manage extension settings.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command(setCommand)
|
||||
.command(listCommand)
|
||||
.demandCommand(1, 'You need to specify a command (set or list).')
|
||||
.version(false),
|
||||
handler: () => {
|
||||
// This handler is not called when a subcommand is provided.
|
||||
// Yargs will show the help menu.
|
||||
},
|
||||
};
|
||||
32
packages/cli/src/commands/extensions/utils.ts
Normal file
32
packages/cli/src/commands/extensions/utils.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
export async function getExtensionAndManager(name: string) {
|
||||
const workspaceDir = process.cwd();
|
||||
const extensionManager = new ExtensionManager({
|
||||
workspaceDir,
|
||||
requestConsent: requestConsentNonInteractive,
|
||||
requestSetting: promptForSetting,
|
||||
settings: loadSettings(workspaceDir).merged,
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
const extension = extensionManager
|
||||
.getExtensions()
|
||||
.find((ext) => ext.name === name);
|
||||
|
||||
if (!extension) {
|
||||
debugLogger.error(`Extension "${name}" is not installed.`);
|
||||
return { extension: null, extensionManager: null };
|
||||
}
|
||||
|
||||
return { extension, extensionManager };
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
maybePromptForSettings,
|
||||
promptForSetting,
|
||||
type ExtensionSetting,
|
||||
updateSetting,
|
||||
} from './extensionSettings.js';
|
||||
import type { ExtensionConfig } from '../extension.js';
|
||||
import { ExtensionStorage } from './storage.js';
|
||||
@@ -371,6 +372,27 @@ describe('extensionSettings', () => {
|
||||
const expectedContent = 'VAR1=previous-VAR1\nVAR2=previous-VAR2\n';
|
||||
expect(actualContent).toBe(expectedContent);
|
||||
});
|
||||
|
||||
it('should wrap values with spaces in quotes', async () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
|
||||
};
|
||||
mockRequestSetting.mockResolvedValue('a value with spaces');
|
||||
|
||||
await maybePromptForSettings(
|
||||
config,
|
||||
'12345',
|
||||
mockRequestSetting,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
const expectedEnvPath = path.join(extensionDir, '.env');
|
||||
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
|
||||
expect(actualContent).toBe('VAR1="a value with spaces"\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptForSetting', () => {
|
||||
@@ -482,4 +504,100 @@ describe('extensionSettings', () => {
|
||||
expect(contents).toEqual({ VAR1: 'value1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSetting', () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{ name: 's1', description: 'd1', envVar: 'VAR1' },
|
||||
{ name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true },
|
||||
],
|
||||
};
|
||||
const mockRequestSetting = vi.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
// Pre-populate settings
|
||||
const envContent = 'VAR1=value1\n';
|
||||
const envPath = path.join(extensionDir, '.env');
|
||||
await fsPromises.writeFile(envPath, envContent);
|
||||
keychainData['VAR2'] = 'value2';
|
||||
mockRequestSetting.mockClear();
|
||||
});
|
||||
|
||||
it('should update a non-sensitive setting', async () => {
|
||||
mockRequestSetting.mockResolvedValue('new-value1');
|
||||
|
||||
await updateSetting(config, '12345', 'VAR1', mockRequestSetting);
|
||||
|
||||
expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![0]);
|
||||
const expectedEnvPath = path.join(extensionDir, '.env');
|
||||
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
|
||||
expect(actualContent).toContain('VAR1=new-value1');
|
||||
expect(mockKeychainStorage.setSecret).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update a non-sensitive setting by name', async () => {
|
||||
mockRequestSetting.mockResolvedValue('new-value-by-name');
|
||||
|
||||
await updateSetting(config, '12345', 's1', mockRequestSetting);
|
||||
|
||||
expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![0]);
|
||||
const expectedEnvPath = path.join(extensionDir, '.env');
|
||||
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
|
||||
expect(actualContent).toContain('VAR1=new-value-by-name');
|
||||
expect(mockKeychainStorage.setSecret).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update a sensitive setting', async () => {
|
||||
mockRequestSetting.mockResolvedValue('new-value2');
|
||||
|
||||
await updateSetting(config, '12345', 'VAR2', mockRequestSetting);
|
||||
|
||||
expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![1]);
|
||||
expect(mockKeychainStorage.setSecret).toHaveBeenCalledWith(
|
||||
'VAR2',
|
||||
'new-value2',
|
||||
);
|
||||
const expectedEnvPath = path.join(extensionDir, '.env');
|
||||
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
|
||||
expect(actualContent).not.toContain('VAR2=new-value2');
|
||||
});
|
||||
|
||||
it('should update a sensitive setting by name', async () => {
|
||||
mockRequestSetting.mockResolvedValue('new-sensitive-by-name');
|
||||
|
||||
await updateSetting(config, '12345', 's2', mockRequestSetting);
|
||||
|
||||
expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![1]);
|
||||
expect(mockKeychainStorage.setSecret).toHaveBeenCalledWith(
|
||||
'VAR2',
|
||||
'new-sensitive-by-name',
|
||||
);
|
||||
});
|
||||
|
||||
it('should do nothing if the setting does not exist', async () => {
|
||||
await updateSetting(config, '12345', 'VAR3', mockRequestSetting);
|
||||
expect(mockRequestSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should do nothing if there are no settings', async () => {
|
||||
const emptyConfig: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
};
|
||||
await updateSetting(emptyConfig, '12345', 'VAR1', mockRequestSetting);
|
||||
expect(mockRequestSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should wrap values with spaces in quotes', async () => {
|
||||
mockRequestSetting.mockResolvedValue('a value with spaces');
|
||||
|
||||
await updateSetting(config, '12345', 'VAR1', mockRequestSetting);
|
||||
|
||||
const expectedEnvPath = path.join(extensionDir, '.env');
|
||||
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
|
||||
expect(actualContent).toContain('VAR1="a value with spaces"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ExtensionStorage } from './storage.js';
|
||||
import type { ExtensionConfig } from '../extension.js';
|
||||
|
||||
import prompts from 'prompts';
|
||||
import { KeychainTokenStorage } from '@google/gemini-cli-core';
|
||||
import { debugLogger, KeychainTokenStorage } from '@google/gemini-cli-core';
|
||||
|
||||
export interface ExtensionSetting {
|
||||
name: string;
|
||||
@@ -57,7 +57,7 @@ export async function maybePromptForSettings(
|
||||
previousExtensionConfig?.settings ?? [],
|
||||
);
|
||||
|
||||
const allSettings: Record<string, string> = { ...(previousSettings ?? {}) };
|
||||
const allSettings: Record<string, string> = { ...previousSettings };
|
||||
|
||||
for (const removedEnvSetting of settingsChanges.removeEnv) {
|
||||
delete allSettings[removedEnvSetting.envVar];
|
||||
@@ -87,14 +87,20 @@ export async function maybePromptForSettings(
|
||||
}
|
||||
}
|
||||
|
||||
let envContent = '';
|
||||
for (const [key, value] of Object.entries(nonSensitiveSettings)) {
|
||||
envContent += `${key}=${value}\n`;
|
||||
}
|
||||
const envContent = formatEnvContent(nonSensitiveSettings);
|
||||
|
||||
await fs.writeFile(envFilePath, envContent);
|
||||
}
|
||||
|
||||
function formatEnvContent(settings: Record<string, string>): string {
|
||||
let envContent = '';
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
const formattedValue = value.includes(' ') ? `"${value}"` : value;
|
||||
envContent += `${key}=${formattedValue}\n`;
|
||||
}
|
||||
return envContent;
|
||||
}
|
||||
|
||||
export async function promptForSetting(
|
||||
setting: ExtensionSetting,
|
||||
): Promise<string> {
|
||||
@@ -139,6 +145,61 @@ export async function getEnvContents(
|
||||
return customEnv;
|
||||
}
|
||||
|
||||
export async function updateSetting(
|
||||
extensionConfig: ExtensionConfig,
|
||||
extensionId: string,
|
||||
settingKey: string,
|
||||
requestSetting: (setting: ExtensionSetting) => Promise<string>,
|
||||
): Promise<void> {
|
||||
const { name: extensionName, settings } = extensionConfig;
|
||||
if (!settings || settings.length === 0) {
|
||||
debugLogger.log('This extension does not have any settings.');
|
||||
return;
|
||||
}
|
||||
|
||||
const settingToUpdate = settings.find(
|
||||
(s) => s.name === settingKey || s.envVar === settingKey,
|
||||
);
|
||||
|
||||
if (!settingToUpdate) {
|
||||
debugLogger.log(`Setting ${settingKey} not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = await requestSetting(settingToUpdate);
|
||||
const keychain = new KeychainTokenStorage(
|
||||
getKeychainStorageName(extensionName, extensionId),
|
||||
);
|
||||
|
||||
if (settingToUpdate.sensitive) {
|
||||
await keychain.setSecret(settingToUpdate.envVar, newValue);
|
||||
return;
|
||||
}
|
||||
|
||||
// For non-sensitive settings, we need to read the existing .env file,
|
||||
// update the value, and write it back.
|
||||
const allSettings = await getEnvContents(extensionConfig, extensionId);
|
||||
allSettings[settingToUpdate.envVar] = newValue;
|
||||
|
||||
const envFilePath = new ExtensionStorage(extensionName).getEnvFilePath();
|
||||
|
||||
const nonSensitiveSettings: Record<string, string> = {};
|
||||
for (const setting of settings) {
|
||||
// We only care about non-sensitive settings for the .env file.
|
||||
if (setting.sensitive) {
|
||||
continue;
|
||||
}
|
||||
const value = allSettings[setting.envVar];
|
||||
if (value !== undefined) {
|
||||
nonSensitiveSettings[setting.envVar] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const envContent = formatEnvContent(nonSensitiveSettings);
|
||||
|
||||
await fs.writeFile(envFilePath, envContent);
|
||||
}
|
||||
|
||||
interface settingsChanges {
|
||||
promptForSensitive: ExtensionSetting[];
|
||||
removeSensitive: ExtensionSetting[];
|
||||
|
||||
Reference in New Issue
Block a user