From 97b31c4eefab2a9b7b9dedfc0511c930cf8d85a9 Mon Sep 17 00:00:00 2001 From: christine betts Date: Wed, 7 Jan 2026 11:23:07 -0500 Subject: [PATCH] Simplify extension settings command (#16001) --- docs/extensions/index.md | 4 +- packages/cli/src/commands/extensions.tsx | 4 +- .../src/commands/extensions/configure.test.ts | 292 ++++++++++++++++++ .../cli/src/commands/extensions/configure.ts | 210 +++++++++++++ .../src/commands/extensions/settings.test.ts | 231 -------------- .../cli/src/commands/extensions/settings.ts | 162 ---------- packages/cli/src/commands/extensions/utils.ts | 7 +- packages/cli/src/config/extension-manager.ts | 2 +- .../extensions/extensionUpdates.test.ts | 22 +- 9 files changed, 514 insertions(+), 420 deletions(-) create mode 100644 packages/cli/src/commands/extensions/configure.test.ts create mode 100644 packages/cli/src/commands/extensions/configure.ts delete mode 100644 packages/cli/src/commands/extensions/settings.test.ts delete mode 100644 packages/cli/src/commands/extensions/settings.ts diff --git a/docs/extensions/index.md b/docs/extensions/index.md index 9d4d6c63cc..2c1ab9cd93 100644 --- a/docs/extensions/index.md +++ b/docs/extensions/index.md @@ -226,13 +226,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 settings list +gemini extensions list ``` and you can update a given setting using: ``` -gemini extensions settings set [--scope ] +gemini extensions config [setting name] [--scope ] ``` - `--scope`: The scope to set the setting in (`user` or `workspace`). This is diff --git a/packages/cli/src/commands/extensions.tsx b/packages/cli/src/commands/extensions.tsx index b2cf160e90..8079d67256 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 { settingsCommand } from './extensions/settings.js'; +import { configureCommand } from './extensions/configure.js'; import { initializeOutputListenersAndFlush } from '../gemini.js'; export const extensionsCommand: CommandModule = { @@ -33,7 +33,7 @@ export const extensionsCommand: CommandModule = { .command(linkCommand) .command(newCommand) .command(validateCommand) - .command(settingsCommand) + .command(configureCommand) .demandCommand(1, 'You need at least one command before continuing.') .version(false), handler: () => { diff --git a/packages/cli/src/commands/extensions/configure.test.ts b/packages/cli/src/commands/extensions/configure.test.ts new file mode 100644 index 0000000000..70c30e6945 --- /dev/null +++ b/packages/cli/src/commands/extensions/configure.test.ts @@ -0,0 +1,292 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; +import { configureCommand } from './configure.js'; +import yargs from 'yargs'; +import { debugLogger } from '@google/gemini-cli-core'; +import { + updateSetting, + promptForSetting, + getScopedEnvContents, + type ExtensionSetting, +} from '../../config/extensions/extensionSettings.js'; +import prompts from 'prompts'; + +const { + mockExtensionManager, + mockGetExtensionAndManager, + mockGetExtensionManager, + mockLoadSettings, +} = vi.hoisted(() => { + const extensionManager = { + loadExtensionConfig: vi.fn(), + getExtensions: vi.fn(), + loadExtensions: vi.fn(), + getSettings: vi.fn(), + }; + return { + mockExtensionManager: extensionManager, + mockGetExtensionAndManager: vi.fn(), + mockGetExtensionManager: vi.fn(), + mockLoadSettings: vi.fn().mockReturnValue({ merged: {} }), + }; +}); + +vi.mock('../../config/extension-manager.js', () => ({ + ExtensionManager: vi.fn().mockImplementation(() => mockExtensionManager), +})); + +vi.mock('../../config/extensions/extensionSettings.js', () => ({ + updateSetting: vi.fn(), + promptForSetting: vi.fn(), + getScopedEnvContents: vi.fn(), + ExtensionSettingScope: { + USER: 'user', + WORKSPACE: 'workspace', + }, +})); + +vi.mock('../utils.js', () => ({ + exitCli: vi.fn(), +})); + +vi.mock('./utils.js', () => ({ + getExtensionAndManager: mockGetExtensionAndManager, + getExtensionManager: mockGetExtensionManager, +})); + +vi.mock('prompts'); + +vi.mock('../../config/extensions/consent.js', () => ({ + requestConsentNonInteractive: vi.fn(), +})); + +import { ExtensionManager } from '../../config/extension-manager.js'; + +vi.mock('../../config/settings.js', () => ({ + loadSettings: mockLoadSettings, +})); + +describe('extensions configure command', () => { + beforeEach(() => { + vi.spyOn(debugLogger, 'log'); + vi.spyOn(debugLogger, 'error'); + vi.clearAllMocks(); + + // Default behaviors + mockLoadSettings.mockReturnValue({ merged: {} }); + mockGetExtensionAndManager.mockResolvedValue({ + extension: null, + extensionManager: null, + }); + mockGetExtensionManager.mockResolvedValue(mockExtensionManager); + (ExtensionManager as unknown as Mock).mockImplementation( + () => mockExtensionManager, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const runCommand = async (command: string) => { + const parser = yargs().command(configureCommand).help(false).version(false); + await parser.parse(command); + }; + + const setupExtension = ( + name: string, + settings: Array> = [], + id = 'test-id', + path = '/test/path', + ) => { + const extension = { name, path, id }; + mockGetExtensionAndManager.mockImplementation(async (n) => { + if (n === name) + return { extension, extensionManager: mockExtensionManager }; + return { extension: null, extensionManager: null }; + }); + + mockExtensionManager.getExtensions.mockReturnValue([extension]); + mockExtensionManager.loadExtensionConfig.mockResolvedValue({ + name, + settings, + }); + return extension; + }; + + describe('Specific setting configuration', () => { + it('should configure a specific setting', async () => { + setupExtension('test-ext', [ + { name: 'Test Setting', envVar: 'TEST_VAR' }, + ]); + (updateSetting as Mock).mockResolvedValue(undefined); + + await runCommand('config test-ext TEST_VAR'); + + expect(updateSetting).toHaveBeenCalledWith( + expect.objectContaining({ name: 'test-ext' }), + 'test-id', + 'TEST_VAR', + promptForSetting, + 'user', + ); + }); + + it('should handle missing extension', async () => { + mockGetExtensionAndManager.mockResolvedValue({ + extension: null, + extensionManager: null, + }); + + await runCommand('config missing-ext TEST_VAR'); + + expect(updateSetting).not.toHaveBeenCalled(); + }); + + it('should reject invalid extension names', async () => { + await runCommand('config ../invalid TEST_VAR'); + expect(debugLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Invalid extension name'), + ); + + await runCommand('config ext/with/slash TEST_VAR'); + expect(debugLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Invalid extension name'), + ); + }); + }); + + describe('Extension configuration (all settings)', () => { + it('should configure all settings for an extension', async () => { + const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }]; + setupExtension('test-ext', settings); + (getScopedEnvContents as Mock).mockResolvedValue({}); + (updateSetting as Mock).mockResolvedValue(undefined); + + await runCommand('config test-ext'); + + expect(debugLogger.log).toHaveBeenCalledWith( + 'Configuring settings for "test-ext"...', + ); + expect(updateSetting).toHaveBeenCalledWith( + expect.objectContaining({ name: 'test-ext' }), + 'test-id', + 'VAR_1', + promptForSetting, + 'user', + ); + }); + + it('should verify overwrite if setting is already set', async () => { + const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }]; + setupExtension('test-ext', settings); + (getScopedEnvContents as Mock).mockImplementation( + async (_config, _id, scope) => { + if (scope === 'user') return { VAR_1: 'existing' }; + return {}; + }, + ); + (prompts as unknown as Mock).mockResolvedValue({ overwrite: true }); + (updateSetting as Mock).mockResolvedValue(undefined); + + await runCommand('config test-ext'); + + expect(prompts).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'confirm', + message: expect.stringContaining('is already set. Overwrite?'), + }), + ); + expect(updateSetting).toHaveBeenCalled(); + }); + + it('should note if setting is configured in workspace', async () => { + const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }]; + setupExtension('test-ext', settings); + (getScopedEnvContents as Mock).mockImplementation( + async (_config, _id, scope) => { + if (scope === 'workspace') return { VAR_1: 'workspace_value' }; + return {}; + }, + ); + (updateSetting as Mock).mockResolvedValue(undefined); + + await runCommand('config test-ext'); + + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('is already configured in the workspace scope'), + ); + }); + + it('should skip update if user denies overwrite', async () => { + const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }]; + setupExtension('test-ext', settings); + (getScopedEnvContents as Mock).mockResolvedValue({ VAR_1: 'existing' }); + (prompts as unknown as Mock).mockResolvedValue({ overwrite: false }); + + await runCommand('config test-ext'); + + expect(prompts).toHaveBeenCalled(); + expect(updateSetting).not.toHaveBeenCalled(); + }); + }); + + describe('Configure all extensions', () => { + it('should configure settings for all installed extensions', async () => { + const ext1 = { + name: 'ext1', + path: '/p1', + id: 'id1', + settings: [{ envVar: 'V1' }], + }; + const ext2 = { + name: 'ext2', + path: '/p2', + id: 'id2', + settings: [{ envVar: 'V2' }], + }; + mockExtensionManager.getExtensions.mockReturnValue([ext1, ext2]); + + mockExtensionManager.loadExtensionConfig.mockImplementation( + async (path) => { + if (path === '/p1') + return { name: 'ext1', settings: [{ name: 'S1', envVar: 'V1' }] }; + if (path === '/p2') + return { name: 'ext2', settings: [{ name: 'S2', envVar: 'V2' }] }; + return null; + }, + ); + + (getScopedEnvContents as Mock).mockResolvedValue({}); + (updateSetting as Mock).mockResolvedValue(undefined); + + await runCommand('config'); + + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('Configuring settings for "ext1"'), + ); + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('Configuring settings for "ext2"'), + ); + expect(updateSetting).toHaveBeenCalledTimes(2); + }); + + it('should log if no extensions installed', async () => { + mockExtensionManager.getExtensions.mockReturnValue([]); + await runCommand('config'); + expect(debugLogger.log).toHaveBeenCalledWith('No extensions installed.'); + }); + }); +}); diff --git a/packages/cli/src/commands/extensions/configure.ts b/packages/cli/src/commands/extensions/configure.ts new file mode 100644 index 0000000000..4ea3299610 --- /dev/null +++ b/packages/cli/src/commands/extensions/configure.ts @@ -0,0 +1,210 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { + updateSetting, + promptForSetting, + ExtensionSettingScope, + getScopedEnvContents, +} from '../../config/extensions/extensionSettings.js'; +import { getExtensionAndManager, getExtensionManager } from './utils.js'; +import { debugLogger } from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; +import prompts from 'prompts'; +import type { ExtensionConfig } from '../../config/extension.js'; +interface ConfigureArgs { + name?: string; + setting?: string; + scope: string; +} + +export const configureCommand: CommandModule = { + command: 'config [name] [setting]', + describe: 'Configure extension settings.', + builder: (yargs) => + yargs + .positional('name', { + describe: 'Name of the extension to configure.', + type: 'string', + }) + .positional('setting', { + describe: 'The specific setting to configure (name or env var).', + type: 'string', + }) + .option('scope', { + describe: 'The scope to set the setting in.', + type: 'string', + choices: ['user', 'workspace'], + default: 'user', + }), + handler: async (args) => { + const { name, setting, scope } = args; + + if (name) { + if (name.includes('/') || name.includes('\\') || name.includes('..')) { + debugLogger.error( + 'Invalid extension name. Names cannot contain path separators or "..".', + ); + return; + } + } + + // Case 1: Configure specific setting for an extension + if (name && setting) { + await configureSpecificSetting( + name, + setting, + scope as ExtensionSettingScope, + ); + } + // Case 2: Configure all settings for an extension + else if (name) { + await configureExtension(name, scope as ExtensionSettingScope); + } + // Case 3: Configure all extensions + else { + await configureAllExtensions(scope as ExtensionSettingScope); + } + + await exitCli(); + }, +}; + +async function configureSpecificSetting( + extensionName: string, + settingKey: string, + scope: ExtensionSettingScope, +) { + const { extension, extensionManager } = + await getExtensionAndManager(extensionName); + if (!extension || !extensionManager) { + return; + } + const extensionConfig = await extensionManager.loadExtensionConfig( + extension.path, + ); + if (!extensionConfig) { + debugLogger.error( + `Could not find configuration for extension "${extensionName}".`, + ); + return; + } + + await updateSetting( + extensionConfig, + extension.id, + settingKey, + promptForSetting, + scope, + ); +} + +async function configureExtension( + extensionName: string, + scope: ExtensionSettingScope, +) { + const { extension, extensionManager } = + await getExtensionAndManager(extensionName); + if (!extension || !extensionManager) { + return; + } + const extensionConfig = await extensionManager.loadExtensionConfig( + extension.path, + ); + if ( + !extensionConfig || + !extensionConfig.settings || + extensionConfig.settings.length === 0 + ) { + debugLogger.log( + `Extension "${extensionName}" has no settings to configure.`, + ); + return; + } + + debugLogger.log(`Configuring settings for "${extensionName}"...`); + await configureExtensionSettings(extensionConfig, extension.id, scope); +} + +async function configureAllExtensions(scope: ExtensionSettingScope) { + const extensionManager = await getExtensionManager(); + const extensions = extensionManager.getExtensions(); + + if (extensions.length === 0) { + debugLogger.log('No extensions installed.'); + return; + } + + for (const extension of extensions) { + const extensionConfig = await extensionManager.loadExtensionConfig( + extension.path, + ); + if ( + extensionConfig && + extensionConfig.settings && + extensionConfig.settings.length > 0 + ) { + debugLogger.log(`\nConfiguring settings for "${extension.name}"...`); + await configureExtensionSettings(extensionConfig, extension.id, scope); + } + } +} + +async function configureExtensionSettings( + extensionConfig: ExtensionConfig, + extensionId: string, + scope: ExtensionSettingScope, +) { + const currentScopedSettings = await getScopedEnvContents( + extensionConfig, + extensionId, + scope, + ); + + let workspaceSettings: Record = {}; + if (scope === ExtensionSettingScope.USER) { + workspaceSettings = await getScopedEnvContents( + extensionConfig, + extensionId, + ExtensionSettingScope.WORKSPACE, + ); + } + + if (!extensionConfig.settings) return; + + for (const setting of extensionConfig.settings) { + const currentValue = currentScopedSettings[setting.envVar]; + const workspaceValue = workspaceSettings[setting.envVar]; + + if (workspaceValue !== undefined) { + debugLogger.log( + `Note: Setting "${setting.name}" is already configured in the workspace scope.`, + ); + } + + if (currentValue !== undefined) { + const response = await prompts({ + type: 'confirm', + name: 'overwrite', + message: `Setting "${setting.name}" (${setting.envVar}) is already set. Overwrite?`, + initial: false, + }); + + if (!response.overwrite) { + continue; + } + } + + await updateSetting( + extensionConfig, + extensionId, + setting.envVar, + promptForSetting, + scope, + ); + } +} diff --git a/packages/cli/src/commands/extensions/settings.test.ts b/packages/cli/src/commands/extensions/settings.test.ts deleted file mode 100644 index db8c14a922..0000000000 --- a/packages/cli/src/commands/extensions/settings.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - describe, - it, - expect, - vi, - beforeEach, - afterEach, - type Mock, -} from 'vitest'; -import { settingsCommand } from './settings.js'; -import yargs from 'yargs'; -import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core'; -import type { getExtensionAndManager } from './utils.js'; -import type { - updateSetting, - getScopedEnvContents, -} from '../../config/extensions/extensionSettings.js'; -import { - promptForSetting, - ExtensionSettingScope, -} from '../../config/extensions/extensionSettings.js'; -import type { exitCli } from '../utils.js'; -import type { ExtensionManager } from '../../config/extension-manager.js'; - -const mockGetExtensionAndManager: Mock = - vi.hoisted(() => vi.fn()); -const mockUpdateSetting: Mock = vi.hoisted(() => vi.fn()); -const mockGetScopedEnvContents: Mock = vi.hoisted( - () => vi.fn(), -); -const mockExitCli: Mock = vi.hoisted(() => vi.fn()); - -vi.mock('./utils.js', () => ({ - getExtensionAndManager: mockGetExtensionAndManager, -})); - -vi.mock('../../config/extensions/extensionSettings.js', () => ({ - updateSetting: mockUpdateSetting, - promptForSetting: vi.fn(), - ExtensionSettingScope: { - USER: 'user', - WORKSPACE: 'workspace', - }, - getScopedEnvContents: mockGetScopedEnvContents, -})); - -vi.mock('@google/gemini-cli-core', () => ({ - debugLogger: { - log: vi.fn(), - error: vi.fn(), - }, -})); - -vi.mock('../utils.js', () => ({ - exitCli: mockExitCli, -})); - -describe('settings command', () => { - let debugLogSpy: Mock; - let debugErrorSpy: Mock; - - beforeEach(() => { - debugLogSpy = debugLogger.log as Mock; - debugErrorSpy = debugLogger.error as Mock; - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe('set command', () => { - it('should log error and exit if extension is not found', async () => { - mockGetExtensionAndManager.mockResolvedValue({ - extension: null, - extensionManager: null, - }); - - await yargs([]) - .command(settingsCommand) - .parseAsync('settings set foo bar'); - - expect(mockExitCli).toHaveBeenCalled(); - }); - - it('should log error and exit if extension config is not found', async () => { - const mockExtensionManager = { - loadExtensionConfig: vi.fn().mockResolvedValue(null), - } as unknown as ExtensionManager; - mockGetExtensionAndManager.mockResolvedValue({ - extension: { path: '/path/to/ext' } as unknown as GeminiCLIExtension, - extensionManager: mockExtensionManager, - }); - - await yargs([]) - .command(settingsCommand) - .parseAsync('settings set foo bar'); - - expect(debugErrorSpy).toHaveBeenCalledWith( - 'Could not find configuration for extension "foo".', - ); - expect(mockExitCli).toHaveBeenCalled(); - }); - - it('should call updateSetting with correct arguments', async () => { - const mockExtensionManager = { - loadExtensionConfig: vi.fn().mockResolvedValue({}), - } as unknown as ExtensionManager; - const extension = { path: '/path/to/ext', id: 'ext-id' }; - mockGetExtensionAndManager.mockResolvedValue({ - extension: extension as unknown as GeminiCLIExtension, - extensionManager: mockExtensionManager, - }); - - await yargs([]) - .command(settingsCommand) - .parseAsync('settings set foo bar --scope workspace'); - - expect(mockUpdateSetting).toHaveBeenCalledWith( - {}, - 'ext-id', - 'bar', - promptForSetting, - ExtensionSettingScope.WORKSPACE, - ); - expect(mockExitCli).toHaveBeenCalled(); - }); - }); - - describe('list command', () => { - it('should log error and exit if extension is not found', async () => { - mockGetExtensionAndManager.mockResolvedValue({ - extension: null, - extensionManager: null, - }); - - await yargs([]).command(settingsCommand).parseAsync('settings list foo'); - - expect(mockExitCli).toHaveBeenCalled(); - }); - - it('should log message and exit if extension has no settings', async () => { - const mockExtensionManager = { - loadExtensionConfig: vi.fn().mockResolvedValue({ settings: [] }), - } as unknown as ExtensionManager; - mockGetExtensionAndManager.mockResolvedValue({ - extension: { path: '/path/to/ext' } as unknown as GeminiCLIExtension, - extensionManager: mockExtensionManager, - }); - - await yargs([]).command(settingsCommand).parseAsync('settings list foo'); - - expect(debugLogSpy).toHaveBeenCalledWith( - 'Extension "foo" has no settings to configure.', - ); - expect(mockExitCli).toHaveBeenCalled(); - }); - - it('should list settings correctly', async () => { - const mockExtensionManager = { - loadExtensionConfig: vi.fn().mockResolvedValue({ - settings: [ - { - name: 'Setting 1', - envVar: 'SETTING_1', - description: 'Desc 1', - sensitive: false, - }, - { - name: 'Setting 2', - envVar: 'SETTING_2', - description: 'Desc 2', - sensitive: true, - }, - { - name: 'Setting 3', - envVar: 'SETTING_3', - description: 'Desc 3', - sensitive: false, - }, - ], - }), - } as unknown as ExtensionManager; - const extension = { path: '/path/to/ext', id: 'ext-id' }; - mockGetExtensionAndManager.mockResolvedValue({ - extension: extension as unknown as GeminiCLIExtension, - extensionManager: mockExtensionManager, - }); - - mockGetScopedEnvContents.mockImplementation((_config, _id, scope) => { - if (scope === ExtensionSettingScope.USER) { - return Promise.resolve({ - SETTING_1: 'val1', - SETTING_2: 'val2', - }); - } - if (scope === ExtensionSettingScope.WORKSPACE) { - return Promise.resolve({ - SETTING_3: 'val3', - }); - } - return Promise.resolve({}); - }); - - await yargs([]).command(settingsCommand).parseAsync('settings list foo'); - - expect(debugLogSpy).toHaveBeenCalledWith('Settings for "foo":'); - // Setting 1 (User) - expect(debugLogSpy).toHaveBeenCalledWith('\n- Setting 1 (SETTING_1)'); - expect(debugLogSpy).toHaveBeenCalledWith(' Description: Desc 1'); - expect(debugLogSpy).toHaveBeenCalledWith(' Value: val1 (user)'); - // Setting 2 (Sensitive) - expect(debugLogSpy).toHaveBeenCalledWith('\n- Setting 2 (SETTING_2)'); - expect(debugLogSpy).toHaveBeenCalledWith(' Description: Desc 2'); - expect(debugLogSpy).toHaveBeenCalledWith( - ' Value: [value stored in keychain] (user)', - ); - // Setting 3 (Workspace) - expect(debugLogSpy).toHaveBeenCalledWith('\n- Setting 3 (SETTING_3)'); - expect(debugLogSpy).toHaveBeenCalledWith(' Description: Desc 3'); - expect(debugLogSpy).toHaveBeenCalledWith(' Value: val3 (workspace)'); - - expect(mockExitCli).toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/cli/src/commands/extensions/settings.ts b/packages/cli/src/commands/extensions/settings.ts deleted file mode 100644 index f373534d7a..0000000000 --- a/packages/cli/src/commands/extensions/settings.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { CommandModule } from 'yargs'; -import { - updateSetting, - promptForSetting, - ExtensionSettingScope, - getScopedEnvContents, -} 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; - scope: string; -} - -const setCommand: CommandModule = { - command: 'set [--scope] ', - 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, - }) - .option('scope', { - describe: 'The scope to set the setting in.', - type: 'string', - choices: ['user', 'workspace'], - default: 'user', - }), - handler: async (args) => { - const { name, setting, scope } = args; - const { extension, extensionManager } = await getExtensionAndManager(name); - if (!extension || !extensionManager) { - await exitCli(); - return; - } - const extensionConfig = await extensionManager.loadExtensionConfig( - extension.path, - ); - if (!extensionConfig) { - debugLogger.error( - `Could not find configuration for extension "${name}".`, - ); - await exitCli(); - return; - } - await updateSetting( - extensionConfig, - extension.id, - setting, - promptForSetting, - scope as ExtensionSettingScope, - ); - await exitCli(); - }, -}; - -// --- 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 { extension, extensionManager } = await getExtensionAndManager(name); - if (!extension || !extensionManager) { - await exitCli(); - return; - } - const extensionConfig = await extensionManager.loadExtensionConfig( - extension.path, - ); - if ( - !extensionConfig || - !extensionConfig.settings || - extensionConfig.settings.length === 0 - ) { - debugLogger.log(`Extension "${name}" has no settings to configure.`); - await exitCli(); - return; - } - - 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 = 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) { - displayValue = '[value stored in keychain]'; - } else { - displayValue = value; - } - debugLogger.log(` -- ${setting.name} (${setting.envVar})`); - debugLogger.log(` Description: ${setting.description}`); - debugLogger.log(` Value: ${displayValue}${scopeInfo}`); - } - await exitCli(); - }, -}; - -// --- SETTINGS COMMAND --- -export const settingsCommand: CommandModule = { - command: 'settings ', - 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/commands/extensions/utils.ts b/packages/cli/src/commands/extensions/utils.ts index 9e0ee97f40..1571c56794 100644 --- a/packages/cli/src/commands/extensions/utils.ts +++ b/packages/cli/src/commands/extensions/utils.ts @@ -10,7 +10,7 @@ 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) { +export async function getExtensionManager() { const workspaceDir = process.cwd(); const extensionManager = new ExtensionManager({ workspaceDir, @@ -19,6 +19,11 @@ export async function getExtensionAndManager(name: string) { settings: loadSettings(workspaceDir).merged, }); await extensionManager.loadExtensions(); + return extensionManager; +} + +export async function getExtensionAndManager(name: string) { + const extensionManager = await getExtensionManager(); const extension = extensionManager .getExtensions() .find((ext) => ext.name === name); diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index d5fe2ad2b1..3c4ed226c8 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -309,7 +309,7 @@ Would you like to attempt to install via "git clone" instead?`, .map((s) => s.name) .join( ', ', - )}. Please run "gemini extensions settings ${newExtensionConfig.name} " to configure them.`; + )}. Please run "gemini extensions config ${newExtensionConfig.name} [setting-name]" to configure them.`; debugLogger.warn(message); coreEvents.emitFeedback('warning', message); } diff --git a/packages/cli/src/config/extensions/extensionUpdates.test.ts b/packages/cli/src/config/extensions/extensionUpdates.test.ts index 3ba11981bb..bdd2841ad6 100644 --- a/packages/cli/src/config/extensions/extensionUpdates.test.ts +++ b/packages/cli/src/config/extensions/extensionUpdates.test.ts @@ -266,26 +266,6 @@ describe('extensionUpdates', () => { vi.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined); vi.spyOn(fs.promises, 'rm').mockResolvedValue(undefined); vi.mocked(fs.existsSync).mockReturnValue(false); // No hooks - - // Mock copyExtension? It's imported. - // We can rely on ignoring the failure if we mock enough. - // Actually copyExtension is called. We need to mock it if it does real IO. - // But we can just let it fail or mock fs.cp if it uses it. - // Let's assume the other mocks cover the critical path to the warning. - // Warning happens BEFORE copyExtension? - // No, warning is after copyExtension usually. - // But in my code: - // const missingSettings = await getMissingSettings(...) - // if (missingSettings.length > 0) debugLogger.warn(...) - // ... - // copyExtension(...) - - // Wait, let's check extension-manager.ts order. - // Line 303: getMissingSettings - // Line 317: if (installMetadata.type === 'local' ...) copyExtension - - // So getMissingSettings is called BEFORE copyExtension. - try { await manager.installOrUpdateExtension(installMetadata, previousConfig); } catch (_) { @@ -300,7 +280,7 @@ describe('extensionUpdates', () => { expect(coreEvents.emitFeedback).toHaveBeenCalledWith( 'warning', expect.stringContaining( - 'Please run "gemini extensions settings test-ext "', + 'Please run "gemini extensions config test-ext [setting-name]"', ), ); });