From e81130b6058fd1dddd91fb042b4e9149d7540034 Mon Sep 17 00:00:00 2001 From: christine betts Date: Thu, 6 Nov 2025 12:22:58 -0500 Subject: [PATCH 1/9] Update keychain storage name to be more user-friendly --- .../cli/src/config/extensions/extensionSettings.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/config/extensions/extensionSettings.ts b/packages/cli/src/config/extensions/extensionSettings.ts index f625ef5ea83..89209e58ec8 100644 --- a/packages/cli/src/config/extensions/extensionSettings.ts +++ b/packages/cli/src/config/extensions/extensionSettings.ts @@ -22,6 +22,11 @@ export interface ExtensionSetting { sensitive?: boolean; } +const getKeychainStorageName = ( + extensionName: string, + extensionId: string, +): string => `Gemini CLI Extensions ${extensionName} ${extensionId}`; + export async function maybePromptForSettings( extensionConfig: ExtensionConfig, extensionId: string, @@ -38,7 +43,9 @@ export async function maybePromptForSettings( return; } const envFilePath = new ExtensionStorage(extensionName).getEnvFilePath(); - const keychain = new KeychainTokenStorage(extensionId); + const keychain = new KeychainTokenStorage( + getKeychainStorageName(extensionName, extensionId), + ); if (!settings || settings.length === 0) { await clearSettings(envFilePath, keychain); @@ -107,7 +114,9 @@ export async function getEnvContents( return Promise.resolve({}); } const extensionStorage = new ExtensionStorage(extensionConfig.name); - const keychain = new KeychainTokenStorage(extensionId); + const keychain = new KeychainTokenStorage( + getKeychainStorageName(extensionConfig.name, extensionId), + ); let customEnv: Record = {}; if (fsSync.existsSync(extensionStorage.getEnvFilePath())) { const envFile = fsSync.readFileSync( From 0ee47998644563e08f95786c8b96258d45066e81 Mon Sep 17 00:00:00 2001 From: christine betts Date: Thu, 6 Nov 2025 12:26:13 -0500 Subject: [PATCH 2/9] Also include docs update --- docs/extensions/index.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/extensions/index.md b/docs/extensions/index.md index 2c8bc9b22e2..af886026d70 100644 --- a/docs/extensions/index.md +++ b/docs/extensions/index.md @@ -187,6 +187,9 @@ precedence. ### Settings +_Note: This is an experimental feature. We do not yet recommend extension +authors introduce settings as part of their core flows._ + Extensions can define settings that the user will be prompted to provide upon installation. This is useful for things like API keys, URLs, or other configuration that the extension needs to function. From c23c08b88dfab28f9127a4b6775634cfb1891563 Mon Sep 17 00:00:00 2001 From: christine betts Date: Thu, 6 Nov 2025 15:49:32 -0500 Subject: [PATCH 3/9] Add the ability to make changes to the config --- packages/cli/src/commands/extensions.tsx | 2 + .../cli/src/commands/extensions/configure.ts | 149 ++++++++++++++++++ .../extensions/extensionSettings.test.ts | 118 ++++++++++++++ .../config/extensions/extensionSettings.ts | 62 +++++++- 4 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/extensions/configure.ts diff --git a/packages/cli/src/commands/extensions.tsx b/packages/cli/src/commands/extensions.tsx index 42516dcea32..4655a1a3db0 100644 --- a/packages/cli/src/commands/extensions.tsx +++ b/packages/cli/src/commands/extensions.tsx @@ -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 { configureCommand } from './extensions/configure.js'; export const extensionsCommand: CommandModule = { command: 'extensions ', @@ -30,6 +31,7 @@ export const extensionsCommand: CommandModule = { .command(linkCommand) .command(newCommand) .command(validateCommand) + .command(configureCommand) .demandCommand(1, 'You need at least one command before continuing.') .version(false), handler: () => { diff --git a/packages/cli/src/commands/extensions/configure.ts b/packages/cli/src/commands/extensions/configure.ts new file mode 100644 index 00000000000..60488cf619f --- /dev/null +++ b/packages/cli/src/commands/extensions/configure.ts @@ -0,0 +1,149 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { ExtensionManager } from '../../config/extension-manager.js'; +import { + getEnvContents, + updateSetting, + promptForSetting, +} from '../../config/extensions/extensionSettings.js'; +import { loadSettings } from '../../config/settings.js'; +import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; + +// --- SET COMMAND --- +interface SetArgs { + name: string; + setting: string; +} + +const setCommand: CommandModule = { + command: 'set ', + 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 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) { + console.error(`Extension "${name}" is not installed.`); + return; + } + const extensionConfig = extensionManager.loadExtensionConfig( + extension.path, + ); + if (!extensionConfig) { + console.error(`Could not find configuration for extension "${name}".`); + return; + } + await updateSetting( + extensionConfig, + extension.id, + setting, + promptForSetting, + ); + }, +}; + +// --- LIST COMMAND --- +interface ListArgs { + name: string; +} + +const listCommand: CommandModule = { + command: 'list ', + 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 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) { + console.error(`Extension "${name}" is not installed.`); + return; + } + const extensionConfig = extensionManager.loadExtensionConfig( + extension.path, + ); + if ( + !extensionConfig || + !extensionConfig.settings || + extensionConfig.settings.length === 0 + ) { + console.log(`Extension "${name}" has no settings to configure.`); + return; + } + + const currentSettings = await getEnvContents(extensionConfig, extension.id); + + console.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; + } + console.log(` +- ${setting.name} (${setting.envVar})`); + console.log(` Description: ${setting.description}`); + console.log(` Value: ${displayValue}`); + } + }, +}; + +// --- CONFIGURE COMMAND --- +export const configureCommand: CommandModule = { + command: 'configure ', + 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. + }, +}; diff --git a/packages/cli/src/config/extensions/extensionSettings.test.ts b/packages/cli/src/config/extensions/extensionSettings.test.ts index 09aba0b80bf..54182d735a1 100644 --- a/packages/cli/src/config/extensions/extensionSettings.test.ts +++ b/packages/cli/src/config/extensions/extensionSettings.test.ts @@ -11,6 +11,7 @@ import { maybePromptForSettings, promptForSetting, type ExtensionSetting, + updateSetting, } from './extensionSettings.js'; import type { ExtensionConfig } from '../extension.js'; import { ExtensionStorage } from './storage.js'; @@ -293,6 +294,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', () => { @@ -344,4 +366,100 @@ describe('extensionSettings', () => { expect(result).toBe(promptValue); }); }); + + 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"'); + }); + }); }); diff --git a/packages/cli/src/config/extensions/extensionSettings.ts b/packages/cli/src/config/extensions/extensionSettings.ts index 89209e58ec8..e784e46017a 100644 --- a/packages/cli/src/config/extensions/extensionSettings.ts +++ b/packages/cli/src/config/extensions/extensionSettings.ts @@ -89,7 +89,8 @@ export async function maybePromptForSettings( let envContent = ''; for (const [key, value] of Object.entries(nonSensitiveSettings)) { - envContent += `${key}=${value}\n`; + const formattedValue = value.includes(' ') ? `"${value}"` : value; + envContent += `${key}=${formattedValue}\n`; } await fs.writeFile(envFilePath, envContent); @@ -139,6 +140,65 @@ export async function getEnvContents( return customEnv; } +export async function updateSetting( + extensionConfig: ExtensionConfig, + extensionId: string, + settingKey: string, + requestSetting: (setting: ExtensionSetting) => Promise, +): Promise { + const { name: extensionName, settings } = extensionConfig; + if (!settings || settings.length === 0) { + console.log('This extension does not have any settings.'); + return; + } + + const settingToUpdate = settings.find( + (s) => s.name === settingKey || s.envVar === settingKey, + ); + + if (!settingToUpdate) { + console.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 = {}; + 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; + } + } + + let envContent = ''; + for (const [key, value] of Object.entries(nonSensitiveSettings)) { + const formattedValue = value.includes(' ') ? `"${value}"` : value; + envContent += `${key}=${formattedValue}\n`; + } + + await fs.writeFile(envFilePath, envContent); +} + interface settingsChanges { promptForSensitive: ExtensionSetting[]; removeSensitive: ExtensionSetting[]; From 174ff5cc58560be09b5dc3b6da67f30840b10943 Mon Sep 17 00:00:00 2001 From: christine betts Date: Thu, 6 Nov 2025 15:51:52 -0500 Subject: [PATCH 4/9] Add to docs --- docs/extensions/index.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/extensions/index.md b/docs/extensions/index.md index af886026d70..4caf243a8e6 100644 --- a/docs/extensions/index.md +++ b/docs/extensions/index.md @@ -222,6 +222,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., `/.gemini/extensions/my-api-extension/.env`). +You can view a list of an extension's settings by running: + +``` +gemini extensions configure list +``` + +and you can update a given setting using: + +``` +gemini extensions configure set +``` + ### Custom commands Extensions can provide [custom commands](../cli/custom-commands.md) by placing From 85474e52c6ced94ab56b9093256d2611f106e23d Mon Sep 17 00:00:00 2001 From: christine betts Date: Thu, 6 Nov 2025 16:23:53 -0500 Subject: [PATCH 5/9] Address comments --- .../cli/src/commands/extensions/configure.ts | 51 +++++++++---------- .../config/extensions/extensionSettings.ts | 17 ++++--- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/packages/cli/src/commands/extensions/configure.ts b/packages/cli/src/commands/extensions/configure.ts index 60488cf619f..26a6be8352d 100644 --- a/packages/cli/src/commands/extensions/configure.ts +++ b/packages/cli/src/commands/extensions/configure.ts @@ -37,19 +37,8 @@ const setCommand: CommandModule = { }), handler: async (args) => { const { name, setting } = args; - 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) { - console.error(`Extension "${name}" is not installed.`); + const { extension, extensionManager } = await getExtensionAndManager(name); + if (!extension || !extensionManager) { return; } const extensionConfig = extensionManager.loadExtensionConfig( @@ -68,6 +57,27 @@ const setCommand: CommandModule = { }, }; +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) { + console.error(`Extension "${name}" is not installed.`); + return { extension: null, extensionManager: null }; + } + + return { extension, extensionManager }; +} + // --- LIST COMMAND --- interface ListArgs { name: string; @@ -84,19 +94,8 @@ const listCommand: CommandModule = { }), handler: async (args) => { const { name } = args; - 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) { - console.error(`Extension "${name}" is not installed.`); + const { extension, extensionManager } = await getExtensionAndManager(name); + if (!extension || !extensionManager) { return; } const extensionConfig = extensionManager.loadExtensionConfig( diff --git a/packages/cli/src/config/extensions/extensionSettings.ts b/packages/cli/src/config/extensions/extensionSettings.ts index e784e46017a..0588d8e7640 100644 --- a/packages/cli/src/config/extensions/extensionSettings.ts +++ b/packages/cli/src/config/extensions/extensionSettings.ts @@ -87,13 +87,18 @@ export async function maybePromptForSettings( } } + const envContent = formatEnvContent(nonSensitiveSettings); + + await fs.writeFile(envFilePath, envContent); +} + +function formatEnvContent(settings: Record): string { let envContent = ''; - for (const [key, value] of Object.entries(nonSensitiveSettings)) { + for (const [key, value] of Object.entries(settings)) { const formattedValue = value.includes(' ') ? `"${value}"` : value; envContent += `${key}=${formattedValue}\n`; } - - await fs.writeFile(envFilePath, envContent); + return envContent; } export async function promptForSetting( @@ -190,11 +195,7 @@ export async function updateSetting( } } - let envContent = ''; - for (const [key, value] of Object.entries(nonSensitiveSettings)) { - const formattedValue = value.includes(' ') ? `"${value}"` : value; - envContent += `${key}=${formattedValue}\n`; - } + const envContent = formatEnvContent(nonSensitiveSettings); await fs.writeFile(envFilePath, envContent); } From 772dab520cda6d4e25d5c569916c66792079455e Mon Sep 17 00:00:00 2001 From: christine betts Date: Thu, 20 Nov 2025 14:51:39 -0500 Subject: [PATCH 6/9] feat(extensions): address PR comments for extension settings - Refactor set and list commands to remove code duplication - Rename configure command to settings for consistency - Replace console.log with debugLogger - Update documentation --- docs/extensions/index.md | 10 ++-- package-lock.json | 41 ++++++----------- packages/cli/src/commands/extensions.tsx | 4 +- .../extensions/{configure.ts => settings.ts} | 46 ++++++------------- packages/cli/src/commands/extensions/utils.ts | 32 +++++++++++++ .../config/extensions/extensionSettings.ts | 8 ++-- 6 files changed, 71 insertions(+), 70 deletions(-) rename packages/cli/src/commands/extensions/{configure.ts => settings.ts} (69%) create mode 100644 packages/cli/src/commands/extensions/utils.ts diff --git a/docs/extensions/index.md b/docs/extensions/index.md index 4caf243a8e6..c0609516687 100644 --- a/docs/extensions/index.md +++ b/docs/extensions/index.md @@ -162,11 +162,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`. @@ -225,13 +225,13 @@ key. The value will be saved to a `.env` file in the extension's directory You can view a list of an extension's settings by running: ``` -gemini extensions configure list +gemini extensions settings list ``` and you can update a given setting using: ``` -gemini extensions configure set +gemini extensions settings set ``` ### Custom commands diff --git a/package-lock.json b/package-lock.json index adc9a953af3..3392e449c13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2403,7 +2403,6 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2584,7 +2583,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2618,7 +2616,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2987,7 +2984,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -3021,7 +3017,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz", "integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" @@ -3074,7 +3069,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz", "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", @@ -4285,7 +4279,6 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4573,7 +4566,6 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -5497,7 +5489,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5933,7 +5924,8 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/array-includes": { "version": "3.1.9", @@ -7197,6 +7189,7 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "5.2.1" }, @@ -8212,7 +8205,6 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8802,6 +8794,7 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -8811,6 +8804,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8820,6 +8814,7 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -9073,6 +9068,7 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "license": "MIT", + "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -9091,6 +9087,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -9099,13 +9096,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/finalhandler/node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -10316,7 +10315,6 @@ "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.5.tgz", "integrity": "sha512-mIDkZqtJbedL9XDOoqoJt3S8aGQVqEJYnCnSeLlYzkpUWCsSWC0hW40yJ0DLH86lcl8k5R5lv/9C2i/3746nWw==", "license": "MIT", - "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.0.0", @@ -13424,7 +13422,8 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/path-type": { "version": "3.0.0", @@ -13959,7 +13958,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13970,7 +13968,6 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -16189,7 +16186,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16354,8 +16350,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.20.3", @@ -16363,7 +16358,6 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -16548,7 +16542,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16711,6 +16704,7 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4.0" } @@ -16766,7 +16760,6 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -16883,7 +16876,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16897,7 +16889,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -17604,7 +17595,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -18146,7 +18136,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/packages/cli/src/commands/extensions.tsx b/packages/cli/src/commands/extensions.tsx index 4655a1a3db0..95ebdbfb61e 100644 --- a/packages/cli/src/commands/extensions.tsx +++ b/packages/cli/src/commands/extensions.tsx @@ -14,7 +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 { configureCommand } from './extensions/configure.js'; +import { settingsCommand } from './extensions/settings.js'; export const extensionsCommand: CommandModule = { command: 'extensions ', @@ -31,7 +31,7 @@ export const extensionsCommand: CommandModule = { .command(linkCommand) .command(newCommand) .command(validateCommand) - .command(configureCommand) + .command(settingsCommand) .demandCommand(1, 'You need at least one command before continuing.') .version(false), handler: () => { diff --git a/packages/cli/src/commands/extensions/configure.ts b/packages/cli/src/commands/extensions/settings.ts similarity index 69% rename from packages/cli/src/commands/extensions/configure.ts rename to packages/cli/src/commands/extensions/settings.ts index 26a6be8352d..749ebde17c3 100644 --- a/packages/cli/src/commands/extensions/configure.ts +++ b/packages/cli/src/commands/extensions/settings.ts @@ -5,14 +5,13 @@ */ import type { CommandModule } from 'yargs'; -import { ExtensionManager } from '../../config/extension-manager.js'; import { getEnvContents, updateSetting, promptForSetting, } from '../../config/extensions/extensionSettings.js'; -import { loadSettings } from '../../config/settings.js'; -import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; +import { getExtensionAndManager } from './utils.js'; +import { debugLogger } from '@google/gemini-cli-core'; // --- SET COMMAND --- interface SetArgs { @@ -45,7 +44,9 @@ const setCommand: CommandModule = { extension.path, ); if (!extensionConfig) { - console.error(`Could not find configuration for extension "${name}".`); + debugLogger.error( + `Could not find configuration for extension "${name}".`, + ); return; } await updateSetting( @@ -57,27 +58,6 @@ const setCommand: CommandModule = { }, }; -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) { - console.error(`Extension "${name}" is not installed.`); - return { extension: null, extensionManager: null }; - } - - return { extension, extensionManager }; -} - // --- LIST COMMAND --- interface ListArgs { name: string; @@ -106,13 +86,13 @@ const listCommand: CommandModule = { !extensionConfig.settings || extensionConfig.settings.length === 0 ) { - console.log(`Extension "${name}" has no settings to configure.`); + debugLogger.log(`Extension "${name}" has no settings to configure.`); return; } const currentSettings = await getEnvContents(extensionConfig, extension.id); - console.log(`Settings for "${name}":`); + debugLogger.log(`Settings for "${name}":`); for (const setting of extensionConfig.settings) { const value = currentSettings[setting.envVar]; let displayValue: string; @@ -123,17 +103,17 @@ const listCommand: CommandModule = { } else { displayValue = value; } - console.log(` + debugLogger.log(` - ${setting.name} (${setting.envVar})`); - console.log(` Description: ${setting.description}`); - console.log(` Value: ${displayValue}`); + debugLogger.log(` Description: ${setting.description}`); + debugLogger.log(` Value: ${displayValue}`); } }, }; -// --- CONFIGURE COMMAND --- -export const configureCommand: CommandModule = { - command: 'configure ', +// --- SETTINGS COMMAND --- +export const settingsCommand: CommandModule = { + command: 'settings ', describe: 'Manage extension settings.', builder: (yargs) => yargs diff --git a/packages/cli/src/commands/extensions/utils.ts b/packages/cli/src/commands/extensions/utils.ts new file mode 100644 index 00000000000..9e0ee97f40f --- /dev/null +++ b/packages/cli/src/commands/extensions/utils.ts @@ -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 }; +} diff --git a/packages/cli/src/config/extensions/extensionSettings.ts b/packages/cli/src/config/extensions/extensionSettings.ts index 0588d8e7640..8d61f377992 100644 --- a/packages/cli/src/config/extensions/extensionSettings.ts +++ b/packages/cli/src/config/extensions/extensionSettings.ts @@ -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 = { ...(previousSettings ?? {}) }; + const allSettings: Record = { ...previousSettings }; for (const removedEnvSetting of settingsChanges.removeEnv) { delete allSettings[removedEnvSetting.envVar]; @@ -153,7 +153,7 @@ export async function updateSetting( ): Promise { const { name: extensionName, settings } = extensionConfig; if (!settings || settings.length === 0) { - console.log('This extension does not have any settings.'); + debugLogger.log('This extension does not have any settings.'); return; } @@ -162,7 +162,7 @@ export async function updateSetting( ); if (!settingToUpdate) { - console.log(`Setting ${settingKey} not found.`); + debugLogger.log(`Setting ${settingKey} not found.`); return; } From 602e14ea6c2f726c18bb1e647bb1364a05c0e9eb Mon Sep 17 00:00:00 2001 From: christine betts Date: Mon, 24 Nov 2025 12:24:12 -0500 Subject: [PATCH 7/9] updates --- .../cli/src/commands/extensions/settings.ts | 39 ++- .../extensions/extensionSettings.test.ts | 233 ++++++++++++------ .../config/extensions/extensionSettings.ts | 86 +++++-- 3 files changed, 261 insertions(+), 97 deletions(-) diff --git a/packages/cli/src/commands/extensions/settings.ts b/packages/cli/src/commands/extensions/settings.ts index 749ebde17c3..5d894cb3469 100644 --- a/packages/cli/src/commands/extensions/settings.ts +++ b/packages/cli/src/commands/extensions/settings.ts @@ -6,9 +6,10 @@ import type { CommandModule } from 'yargs'; import { - getEnvContents, updateSetting, promptForSetting, + ExtensionSettingScope, + getScopedEnvContents, } from '../../config/extensions/extensionSettings.js'; import { getExtensionAndManager } from './utils.js'; import { debugLogger } from '@google/gemini-cli-core'; @@ -17,10 +18,11 @@ import { debugLogger } from '@google/gemini-cli-core'; interface SetArgs { name: string; setting: string; + scope: string; } const setCommand: CommandModule = { - command: 'set ', + command: 'set [--scope] ', describe: 'Set a specific setting for an extension.', builder: (yargs) => yargs @@ -33,9 +35,15 @@ const setCommand: CommandModule = { describe: 'The setting to configure (name or env var).', type: 'string', demandOption: true, + }) + .option('scope', { + describe: 'The scope to set the setting in.', + type: 'string', + choices: ['user', 'workspace'], + default: 'user', }), handler: async (args) => { - const { name, setting } = args; + const { name, setting, scope } = args; const { extension, extensionManager } = await getExtensionAndManager(name); if (!extension || !extensionManager) { return; @@ -54,6 +62,7 @@ const setCommand: CommandModule = { extension.id, setting, promptForSetting, + scope as ExtensionSettingScope, ); }, }; @@ -90,12 +99,30 @@ const listCommand: CommandModule = { return; } - const currentSettings = await getEnvContents(extensionConfig, extension.id); + const userSettings = await getScopedEnvContents( + extensionConfig, + extension.id, + ExtensionSettingScope.USER, + ); + const workspaceSettings = await getScopedEnvContents( + extensionConfig, + extension.id, + ExtensionSettingScope.WORKSPACE, + ); + const mergedSettings = { ...userSettings, ...workspaceSettings }; debugLogger.log(`Settings for "${name}":`); for (const setting of extensionConfig.settings) { - const value = currentSettings[setting.envVar]; + const value = mergedSettings[setting.envVar]; let displayValue: string; + let scopeInfo = ''; + + if (workspaceSettings[setting.envVar] !== undefined) { + scopeInfo = ' (workspace)'; + } else if (userSettings[setting.envVar] !== undefined) { + scopeInfo = ' (user)'; + } + if (value === undefined) { displayValue = '[not set]'; } else if (setting.sensitive) { @@ -106,7 +133,7 @@ const listCommand: CommandModule = { debugLogger.log(` - ${setting.name} (${setting.envVar})`); debugLogger.log(` Description: ${setting.description}`); - debugLogger.log(` Value: ${displayValue}`); + debugLogger.log(` Value: ${displayValue}${scopeInfo}`); } }, }; diff --git a/packages/cli/src/config/extensions/extensionSettings.test.ts b/packages/cli/src/config/extensions/extensionSettings.test.ts index 55af343c0fd..8081b2bb144 100644 --- a/packages/cli/src/config/extensions/extensionSettings.test.ts +++ b/packages/cli/src/config/extensions/extensionSettings.test.ts @@ -13,6 +13,8 @@ import { promptForSetting, type ExtensionSetting, updateSetting, + ExtensionSettingScope, + getScopedEnvContents, } from './extensionSettings.js'; import type { ExtensionConfig } from '../extension.js'; import { ExtensionStorage } from './storage.js'; @@ -20,6 +22,7 @@ import prompts from 'prompts'; import * as fsPromises from 'node:fs/promises'; import * as fs from 'node:fs'; import { KeychainTokenStorage } from '@google/gemini-cli-core'; +import { EXTENSION_SETTINGS_FILENAME } from './variables.js'; vi.mock('prompts'); vi.mock('os', async (importOriginal) => { @@ -55,6 +58,7 @@ interface MockKeychainStorage { describe('extensionSettings', () => { let tempHomeDir: string; + let tempWorkspaceDir: string; let extensionDir: string; let mockKeychainStorage: MockKeychainStorage; let keychainData: Record; @@ -84,18 +88,25 @@ describe('extensionSettings', () => { ).mockImplementation(() => mockKeychainStorage); tempHomeDir = os.tmpdir() + path.sep + `gemini-cli-test-home-${Date.now()}`; + tempWorkspaceDir = path.join( + os.tmpdir(), + `gemini-cli-test-workspace-${Date.now()}`, + ); extensionDir = path.join(tempHomeDir, '.gemini', 'extensions', 'test-ext'); // Spy and mock the method, but also create the directory so we can write to it. vi.spyOn(ExtensionStorage.prototype, 'getExtensionDir').mockReturnValue( extensionDir, ); fs.mkdirSync(extensionDir, { recursive: true }); + fs.mkdirSync(tempWorkspaceDir, { recursive: true }); vi.mocked(os.homedir).mockReturnValue(tempHomeDir); + vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); vi.mocked(prompts).mockClear(); }); afterEach(() => { fs.rmSync(tempHomeDir, { recursive: true, force: true }); + fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); @@ -455,7 +466,7 @@ describe('extensionSettings', () => { }); }); - describe('getEnvContents', () => { + describe('getScopedEnvContents', () => { const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0', @@ -469,39 +480,88 @@ describe('extensionSettings', () => { }, ], }; + const extensionId = '12345'; - it('should return combined contents from .env and keychain', async () => { - const envPath = path.join(extensionDir, '.env'); - await fsPromises.writeFile(envPath, 'VAR1=value1'); - keychainData['SENSITIVE_VAR'] = 'secret'; + it('should return combined contents from user .env and keychain for USER scope', async () => { + const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME); + await fsPromises.writeFile(userEnvPath, 'VAR1=user-value1'); + await mockKeychainStorage.setSecret('SENSITIVE_VAR', 'user-secret'); - const contents = await getEnvContents(config, '12345'); + const contents = await getScopedEnvContents( + config, + extensionId, + ExtensionSettingScope.USER, + ); expect(contents).toEqual({ - VAR1: 'value1', - SENSITIVE_VAR: 'secret', + VAR1: 'user-value1', + SENSITIVE_VAR: 'user-secret', }); }); - it('should return an empty object if no settings are defined', async () => { - const contents = await getEnvContents( - { name: 'test-ext', version: '1.0.0' }, - '12345', + it('should return combined contents from workspace .env and keychain for WORKSPACE scope', async () => { + const workspaceEnvPath = path.join( + tempWorkspaceDir, + EXTENSION_SETTINGS_FILENAME, ); - expect(contents).toEqual({}); - }); + await fsPromises.writeFile(workspaceEnvPath, 'VAR1=workspace-value1'); + await mockKeychainStorage.setSecret('SENSITIVE_VAR', 'workspace-secret'); - it('should return only keychain contents if .env file does not exist', async () => { - keychainData['SENSITIVE_VAR'] = 'secret'; - const contents = await getEnvContents(config, '12345'); - expect(contents).toEqual({ SENSITIVE_VAR: 'secret' }); + const contents = await getScopedEnvContents( + config, + extensionId, + ExtensionSettingScope.WORKSPACE, + ); + + expect(contents).toEqual({ + VAR1: 'workspace-value1', + SENSITIVE_VAR: 'workspace-secret', + }); }); + }); - it('should return only .env contents if keychain is empty', async () => { - const envPath = path.join(extensionDir, '.env'); - await fsPromises.writeFile(envPath, 'VAR1=value1'); - const contents = await getEnvContents(config, '12345'); - expect(contents).toEqual({ VAR1: 'value1' }); + describe('getEnvContents (merged)', () => { + 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 }, + { name: 's3', description: 'd3', envVar: 'VAR3' }, + ], + }; + const extensionId = '12345'; + + it('should merge user and workspace settings, with workspace taking precedence', async () => { + // User settings + const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME); + await fsPromises.writeFile( + userEnvPath, + 'VAR1=user-value1\nVAR3=user-value3', + ); + const userKeychain = new KeychainTokenStorage( + `Gemini CLI Extensions test-ext ${extensionId}`, + ); + await userKeychain.setSecret('VAR2', 'user-secret2'); + + // Workspace settings + const workspaceEnvPath = path.join( + tempWorkspaceDir, + EXTENSION_SETTINGS_FILENAME, + ); + await fsPromises.writeFile(workspaceEnvPath, 'VAR1=workspace-value1'); + const workspaceKeychain = new KeychainTokenStorage( + `Gemini CLI Extensions test-ext ${extensionId} ${tempWorkspaceDir}`, + ); + await workspaceKeychain.setSecret('VAR2', 'workspace-secret2'); + + const contents = await getEnvContents(config, extensionId); + + expect(contents).toEqual({ + VAR1: 'workspace-value1', + VAR2: 'workspace-secret2', + VAR3: 'user-value3', + }); }); }); @@ -517,87 +577,114 @@ describe('extensionSettings', () => { 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'; + const userEnvPath = path.join(extensionDir, '.env'); + await fsPromises.writeFile(userEnvPath, 'VAR1=value1\n'); + const userKeychain = new KeychainTokenStorage( + `Gemini CLI Extensions test-ext 12345`, + ); + await userKeychain.setSecret('VAR2', 'value2'); mockRequestSetting.mockClear(); }); - it('should update a non-sensitive setting', async () => { + it('should update a non-sensitive setting in USER scope', async () => { mockRequestSetting.mockResolvedValue('new-value1'); - await updateSetting(config, '12345', 'VAR1', mockRequestSetting); + await updateSetting( + config, + '12345', + 'VAR1', + mockRequestSetting, + ExtensionSettingScope.USER, + ); - 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'); + it('should update a non-sensitive setting in WORKSPACE scope', async () => { + mockRequestSetting.mockResolvedValue('new-workspace-value'); - await updateSetting(config, '12345', 's1', mockRequestSetting); + await updateSetting( + config, + '12345', + 'VAR1', + mockRequestSetting, + ExtensionSettingScope.WORKSPACE, + ); - expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![0]); - const expectedEnvPath = path.join(extensionDir, '.env'); + const expectedEnvPath = path.join(tempWorkspaceDir, '.env'); const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); - expect(actualContent).toContain('VAR1=new-value-by-name'); - expect(mockKeychainStorage.setSecret).not.toHaveBeenCalled(); + expect(actualContent).toContain('VAR1=new-workspace-value'); }); - it('should update a sensitive setting', async () => { + it('should update a sensitive setting in USER scope', async () => { mockRequestSetting.mockResolvedValue('new-value2'); - await updateSetting(config, '12345', 'VAR2', mockRequestSetting); - - expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![1]); - expect(mockKeychainStorage.setSecret).toHaveBeenCalledWith( + await updateSetting( + config, + '12345', 'VAR2', - 'new-value2', + mockRequestSetting, + ExtensionSettingScope.USER, ); - 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'); + const userKeychain = new KeychainTokenStorage( + `Gemini CLI Extensions test-ext 12345`, + ); + expect(await userKeychain.getSecret('VAR2')).toBe('new-value2'); + }); - await updateSetting(config, '12345', 's2', mockRequestSetting); + it('should update a sensitive setting in WORKSPACE scope', async () => { + mockRequestSetting.mockResolvedValue('new-workspace-secret'); - expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![1]); - expect(mockKeychainStorage.setSecret).toHaveBeenCalledWith( + await updateSetting( + config, + '12345', 'VAR2', - 'new-sensitive-by-name', + mockRequestSetting, + ExtensionSettingScope.WORKSPACE, ); - }); - it('should do nothing if the setting does not exist', async () => { - await updateSetting(config, '12345', 'VAR3', mockRequestSetting); - expect(mockRequestSetting).not.toHaveBeenCalled(); + const workspaceKeychain = new KeychainTokenStorage( + `Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`, + ); + expect(await workspaceKeychain.getSecret('VAR2')).toBe( + 'new-workspace-secret', + ); }); - 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 leave existing, unmanaged .env variables intact when updating in WORKSPACE scope', async () => { + // Setup a pre-existing .env file in the workspace with unmanaged variables + const workspaceEnvPath = path.join(tempWorkspaceDir, '.env'); + const originalEnvContent = + 'PROJECT_VAR_1=value_1\nPROJECT_VAR_2=value_2\nVAR1=original-value'; // VAR1 is managed by extension + await fsPromises.writeFile(workspaceEnvPath, originalEnvContent); - it('should wrap values with spaces in quotes', async () => { - mockRequestSetting.mockResolvedValue('a value with spaces'); + // Simulate updating an extension-managed non-sensitive setting + mockRequestSetting.mockResolvedValue('updated-value'); + await updateSetting( + config, + '12345', + 'VAR1', + mockRequestSetting, + ExtensionSettingScope.WORKSPACE, + ); - await updateSetting(config, '12345', 'VAR1', mockRequestSetting); + // Read the .env file after update + const actualContent = await fsPromises.readFile( + workspaceEnvPath, + 'utf-8', + ); - const expectedEnvPath = path.join(extensionDir, '.env'); - const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); - expect(actualContent).toContain('VAR1="a value with spaces"'); + // Assert that original variables are intact and extension variable is updated + expect(actualContent).toContain('PROJECT_VAR_1=value_1'); + expect(actualContent).toContain('PROJECT_VAR_2=value_2'); + expect(actualContent).toContain('VAR1=updated-value'); + + // Ensure no other unexpected changes or deletions + const lines = actualContent.split('\n').filter((line) => line.length > 0); + expect(lines).toHaveLength(3); // Should only have the three variables }); }); }); diff --git a/packages/cli/src/config/extensions/extensionSettings.ts b/packages/cli/src/config/extensions/extensionSettings.ts index 8d61f377992..0aa12d66fab 100644 --- a/packages/cli/src/config/extensions/extensionSettings.ts +++ b/packages/cli/src/config/extensions/extensionSettings.ts @@ -7,12 +7,19 @@ import * as fs from 'node:fs/promises'; import * as fsSync from 'node:fs'; import * as dotenv from 'dotenv'; +import * as path from 'node:path'; import { ExtensionStorage } from './storage.js'; import type { ExtensionConfig } from '../extension.js'; import prompts from 'prompts'; import { debugLogger, KeychainTokenStorage } from '@google/gemini-cli-core'; +import { EXTENSION_SETTINGS_FILENAME } from './variables.js'; + +export enum ExtensionSettingScope { + USER = 'user', + WORKSPACE = 'workspace', +} export interface ExtensionSetting { name: string; @@ -25,7 +32,24 @@ export interface ExtensionSetting { const getKeychainStorageName = ( extensionName: string, extensionId: string, -): string => `Gemini CLI Extensions ${extensionName} ${extensionId}`; + scope: ExtensionSettingScope, +): string => { + const base = `Gemini CLI Extensions ${extensionName} ${extensionId}`; + if (scope === ExtensionSettingScope.WORKSPACE) { + return `${base} ${process.cwd()}`; + } + return base; +}; + +const getEnvFilePath = ( + extensionName: string, + scope: ExtensionSettingScope, +): string => { + if (scope === ExtensionSettingScope.WORKSPACE) { + return path.join(process.cwd(), EXTENSION_SETTINGS_FILENAME); + } + return new ExtensionStorage(extensionName).getEnvFilePath(); +}; export async function maybePromptForSettings( extensionConfig: ExtensionConfig, @@ -42,9 +66,12 @@ export async function maybePromptForSettings( ) { return; } - const envFilePath = new ExtensionStorage(extensionName).getEnvFilePath(); + // We assume user scope here because we don't have a way to ask the user for scope during the initial setup. + // The user can change the scope later using the `settings set` command. + const scope = ExtensionSettingScope.USER; + const envFilePath = getEnvFilePath(extensionName, scope); const keychain = new KeychainTokenStorage( - getKeychainStorageName(extensionName, extensionId), + getKeychainStorageName(extensionName, extensionId, scope), ); if (!settings || settings.length === 0) { @@ -112,23 +139,19 @@ export async function promptForSetting( return response.value; } -export async function getEnvContents( +export async function getScopedEnvContents( extensionConfig: ExtensionConfig, extensionId: string, + scope: ExtensionSettingScope, ): Promise> { - if (!extensionConfig.settings || extensionConfig.settings.length === 0) { - return Promise.resolve({}); - } - const extensionStorage = new ExtensionStorage(extensionConfig.name); + const { name: extensionName } = extensionConfig; const keychain = new KeychainTokenStorage( - getKeychainStorageName(extensionConfig.name, extensionId), + getKeychainStorageName(extensionName, extensionId, scope), ); + const envFilePath = getEnvFilePath(extensionName, scope); let customEnv: Record = {}; - if (fsSync.existsSync(extensionStorage.getEnvFilePath())) { - const envFile = fsSync.readFileSync( - extensionStorage.getEnvFilePath(), - 'utf-8', - ); + if (fsSync.existsSync(envFilePath)) { + const envFile = fsSync.readFileSync(envFilePath, 'utf-8'); customEnv = dotenv.parse(envFile); } @@ -145,11 +168,34 @@ export async function getEnvContents( return customEnv; } +export async function getEnvContents( + extensionConfig: ExtensionConfig, + extensionId: string, +): Promise> { + if (!extensionConfig.settings || extensionConfig.settings.length === 0) { + return Promise.resolve({}); + } + + const userSettings = await getScopedEnvContents( + extensionConfig, + extensionId, + ExtensionSettingScope.USER, + ); + const workspaceSettings = await getScopedEnvContents( + extensionConfig, + extensionId, + ExtensionSettingScope.WORKSPACE, + ); + + return { ...userSettings, ...workspaceSettings }; +} + export async function updateSetting( extensionConfig: ExtensionConfig, extensionId: string, settingKey: string, requestSetting: (setting: ExtensionSetting) => Promise, + scope: ExtensionSettingScope, ): Promise { const { name: extensionName, settings } = extensionConfig; if (!settings || settings.length === 0) { @@ -168,7 +214,7 @@ export async function updateSetting( const newValue = await requestSetting(settingToUpdate); const keychain = new KeychainTokenStorage( - getKeychainStorageName(extensionName, extensionId), + getKeychainStorageName(extensionName, extensionId, scope), ); if (settingToUpdate.sensitive) { @@ -176,12 +222,16 @@ export async function updateSetting( return; } - // For non-sensitive settings, we need to read the existing .env file, + // For non-sensitive settings, we need to read the existing .env file for the given scope, // update the value, and write it back. - const allSettings = await getEnvContents(extensionConfig, extensionId); + const allSettings = await getScopedEnvContents( + extensionConfig, + extensionId, + scope, + ); allSettings[settingToUpdate.envVar] = newValue; - const envFilePath = new ExtensionStorage(extensionName).getEnvFilePath(); + const envFilePath = getEnvFilePath(extensionName, scope); const nonSensitiveSettings: Record = {}; for (const setting of settings) { From 6c83826573b18e6758b0d3fc2fc6d7a747cec0a2 Mon Sep 17 00:00:00 2001 From: christine betts Date: Mon, 24 Nov 2025 12:27:25 -0500 Subject: [PATCH 8/9] update docs --- docs/extensions/index.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/extensions/index.md b/docs/extensions/index.md index 8d3ca7f6ace..9946ea7f4d2 100644 --- a/docs/extensions/index.md +++ b/docs/extensions/index.md @@ -232,9 +232,12 @@ gemini extensions settings list and you can update a given setting using: ``` -gemini extensions settings set +gemini extensions settings set [--scope ] ``` +- `--scope`: The scope to set the setting in (`user` or `workspace`). This is + optional and will default to `user`. + ### Custom commands Extensions can provide [custom commands](../cli/custom-commands.md) by placing From d5c916aab13a3de2713865b488e29ee9eec1d594 Mon Sep 17 00:00:00 2001 From: christine betts Date: Mon, 24 Nov 2025 14:45:36 -0500 Subject: [PATCH 9/9] add exit --- packages/cli/src/commands/extensions/settings.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cli/src/commands/extensions/settings.ts b/packages/cli/src/commands/extensions/settings.ts index 749ebde17c3..7006dd76f59 100644 --- a/packages/cli/src/commands/extensions/settings.ts +++ b/packages/cli/src/commands/extensions/settings.ts @@ -12,6 +12,7 @@ import { } 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 { @@ -55,6 +56,7 @@ const setCommand: CommandModule = { setting, promptForSetting, ); + await exitCli(); }, }; @@ -108,6 +110,7 @@ const listCommand: CommandModule = { debugLogger.log(` Description: ${setting.description}`); debugLogger.log(` Value: ${displayValue}`); } + await exitCli(); }, };