Add commands for listing and updating per-extension settings (#12664)

This commit is contained in:
christine betts
2025-12-03 19:16:16 -05:00
committed by GitHub
parent 470f3b057f
commit e0a2227faf
6 changed files with 365 additions and 9 deletions

View File

@@ -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

View File

@@ -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: () => {

View 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.
},
};

View 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 };
}

View File

@@ -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"');
});
});
});

View File

@@ -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[];