Add support for /extensions config command (#17895)

This commit is contained in:
christine betts
2026-02-05 11:04:24 -05:00
committed by GitHub
parent e3b8490edf
commit ee58e1e3c1
8 changed files with 908 additions and 211 deletions
@@ -17,32 +17,26 @@ 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';
import * as fs from 'node:fs';
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: {} }),
};
});
const { mockExtensionManager, mockGetExtensionManager, mockLoadSettings } =
vi.hoisted(() => {
const extensionManager = {
loadExtensionConfig: vi.fn(),
getExtensions: vi.fn(),
loadExtensions: vi.fn(),
getSettings: vi.fn(),
};
return {
mockExtensionManager: extensionManager,
mockGetExtensionManager: vi.fn(),
mockLoadSettings: vi.fn().mockReturnValue({ merged: {} }),
};
});
vi.mock('../../config/extension-manager.js', () => ({
ExtensionManager: vi.fn().mockImplementation(() => mockExtensionManager),
@@ -62,10 +56,13 @@ vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
vi.mock('./utils.js', () => ({
getExtensionAndManager: mockGetExtensionAndManager,
getExtensionManager: mockGetExtensionManager,
}));
vi.mock('./utils.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./utils.js')>();
return {
...actual,
getExtensionManager: mockGetExtensionManager,
};
});
vi.mock('prompts');
@@ -91,10 +88,6 @@ describe('extensions configure command', () => {
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
// Default behaviors
mockLoadSettings.mockReturnValue({ merged: {} });
mockGetExtensionAndManager.mockResolvedValue({
extension: null,
extensionManager: null,
});
mockGetExtensionManager.mockResolvedValue(mockExtensionManager);
(ExtensionManager as unknown as Mock).mockImplementation(
() => mockExtensionManager,
@@ -117,11 +110,6 @@ describe('extensions configure command', () => {
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({
@@ -144,17 +132,14 @@ describe('extensions configure command', () => {
expect.objectContaining({ name: 'test-ext' }),
'test-id',
'TEST_VAR',
promptForSetting,
expect.any(Function),
'user',
tempWorkspaceDir,
);
});
it('should handle missing extension', async () => {
mockGetExtensionAndManager.mockResolvedValue({
extension: null,
extensionManager: null,
});
mockExtensionManager.getExtensions.mockReturnValue([]);
await runCommand('config missing-ext TEST_VAR');
@@ -190,7 +175,7 @@ describe('extensions configure command', () => {
expect.objectContaining({ name: 'test-ext' }),
'test-id',
'VAR_1',
promptForSetting,
expect.any(Function),
'user',
tempWorkspaceDir,
);
@@ -205,7 +190,7 @@ describe('extensions configure command', () => {
return {};
},
);
(prompts as unknown as Mock).mockResolvedValue({ overwrite: true });
(prompts as unknown as Mock).mockResolvedValue({ confirm: true });
(updateSetting as Mock).mockResolvedValue(undefined);
await runCommand('config test-ext');
@@ -241,7 +226,7 @@ describe('extensions configure command', () => {
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 });
(prompts as unknown as Mock).mockResolvedValue({ confirm: false });
await runCommand('config test-ext');
+20 -150
View File
@@ -5,18 +5,17 @@
*/
import type { CommandModule } from 'yargs';
import type { ExtensionSettingScope } from '../../config/extensions/extensionSettings.js';
import {
updateSetting,
promptForSetting,
ExtensionSettingScope,
getScopedEnvContents,
} from '../../config/extensions/extensionSettings.js';
import { getExtensionAndManager, getExtensionManager } from './utils.js';
configureAllExtensions,
configureExtension,
configureSpecificSetting,
getExtensionManager,
} from './utils.js';
import { loadSettings } from '../../config/settings.js';
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
import { coreEvents, 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;
@@ -64,9 +63,12 @@ export const configureCommand: CommandModule<object, ConfigureArgs> = {
}
}
const extensionManager = await getExtensionManager();
// Case 1: Configure specific setting for an extension
if (name && setting) {
await configureSpecificSetting(
extensionManager,
name,
setting,
scope as ExtensionSettingScope,
@@ -74,152 +76,20 @@ export const configureCommand: CommandModule<object, ConfigureArgs> = {
}
// Case 2: Configure all settings for an extension
else if (name) {
await configureExtension(name, scope as ExtensionSettingScope);
await configureExtension(
extensionManager,
name,
scope as ExtensionSettingScope,
);
}
// Case 3: Configure all extensions
else {
await configureAllExtensions(scope as ExtensionSettingScope);
await configureAllExtensions(
extensionManager,
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,
process.cwd(),
);
}
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,
process.cwd(),
);
let workspaceSettings: Record<string, string> = {};
if (scope === ExtensionSettingScope.USER) {
workspaceSettings = await getScopedEnvContents(
extensionConfig,
extensionId,
ExtensionSettingScope.WORKSPACE,
process.cwd(),
);
}
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,
process.cwd(),
);
}
}
+219 -8
View File
@@ -1,17 +1,54 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 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,
type ResolvedExtensionSetting,
} from '@google/gemini-cli-core';
import type { ExtensionConfig } from '../../config/extension.js';
import prompts from 'prompts';
import {
promptForSetting,
updateSetting,
type ExtensionSetting,
getScopedEnvContents,
ExtensionSettingScope,
} from '../../config/extensions/extensionSettings.js';
export interface ConfigLogger {
log(message: string): void;
error(message: string): void;
}
export type RequestSettingCallback = (
setting: ExtensionSetting,
) => Promise<string>;
export type RequestConfirmationCallback = (message: string) => Promise<boolean>;
const defaultLogger: ConfigLogger = {
log: (message: string) => debugLogger.log(message),
error: (message: string) => debugLogger.error(message),
};
const defaultRequestSetting: RequestSettingCallback = async (setting) =>
promptForSetting(setting);
const defaultRequestConfirmation: RequestConfirmationCallback = async (
message,
) => {
const response = await prompts({
type: 'confirm',
name: 'confirm',
message,
initial: false,
});
return response.confirm;
};
export async function getExtensionManager() {
const workspaceDir = process.cwd();
@@ -25,18 +62,192 @@ export async function getExtensionManager() {
return extensionManager;
}
export async function getExtensionAndManager(name: string) {
const extensionManager = await getExtensionManager();
export async function getExtensionAndManager(
extensionManager: ExtensionManager,
name: string,
logger: ConfigLogger = defaultLogger,
) {
const extension = extensionManager
.getExtensions()
.find((ext) => ext.name === name);
if (!extension) {
debugLogger.error(`Extension "${name}" is not installed.`);
return { extension: null, extensionManager: null };
logger.error(`Extension "${name}" is not installed.`);
return { extension: null };
}
return { extension, extensionManager };
return { extension };
}
export async function configureSpecificSetting(
extensionManager: ExtensionManager,
extensionName: string,
settingKey: string,
scope: ExtensionSettingScope,
logger: ConfigLogger = defaultLogger,
requestSetting: RequestSettingCallback = defaultRequestSetting,
) {
const { extension } = await getExtensionAndManager(
extensionManager,
extensionName,
logger,
);
if (!extension) {
return;
}
const extensionConfig = await extensionManager.loadExtensionConfig(
extension.path,
);
if (!extensionConfig) {
logger.error(
`Could not find configuration for extension "${extensionName}".`,
);
return;
}
await updateSetting(
extensionConfig,
extension.id,
settingKey,
requestSetting,
scope,
process.cwd(),
);
logger.log(`Setting "${settingKey}" updated.`);
}
export async function configureExtension(
extensionManager: ExtensionManager,
extensionName: string,
scope: ExtensionSettingScope,
logger: ConfigLogger = defaultLogger,
requestSetting: RequestSettingCallback = defaultRequestSetting,
requestConfirmation: RequestConfirmationCallback = defaultRequestConfirmation,
) {
const { extension } = await getExtensionAndManager(
extensionManager,
extensionName,
logger,
);
if (!extension) {
return;
}
const extensionConfig = await extensionManager.loadExtensionConfig(
extension.path,
);
if (
!extensionConfig ||
!extensionConfig.settings ||
extensionConfig.settings.length === 0
) {
logger.log(`Extension "${extensionName}" has no settings to configure.`);
return;
}
logger.log(`Configuring settings for "${extensionName}"...`);
await configureExtensionSettings(
extensionConfig,
extension.id,
scope,
logger,
requestSetting,
requestConfirmation,
);
}
export async function configureAllExtensions(
extensionManager: ExtensionManager,
scope: ExtensionSettingScope,
logger: ConfigLogger = defaultLogger,
requestSetting: RequestSettingCallback = defaultRequestSetting,
requestConfirmation: RequestConfirmationCallback = defaultRequestConfirmation,
) {
const extensions = extensionManager.getExtensions();
if (extensions.length === 0) {
logger.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
) {
logger.log(`\nConfiguring settings for "${extension.name}"...`);
await configureExtensionSettings(
extensionConfig,
extension.id,
scope,
logger,
requestSetting,
requestConfirmation,
);
}
}
}
export async function configureExtensionSettings(
extensionConfig: ExtensionConfig,
extensionId: string,
scope: ExtensionSettingScope,
logger: ConfigLogger = defaultLogger,
requestSetting: RequestSettingCallback = defaultRequestSetting,
requestConfirmation: RequestConfirmationCallback = defaultRequestConfirmation,
) {
const currentScopedSettings = await getScopedEnvContents(
extensionConfig,
extensionId,
scope,
process.cwd(),
);
let workspaceSettings: Record<string, string> = {};
if (scope === ExtensionSettingScope.USER) {
workspaceSettings = await getScopedEnvContents(
extensionConfig,
extensionId,
ExtensionSettingScope.WORKSPACE,
process.cwd(),
);
}
if (!extensionConfig.settings) return;
for (const setting of extensionConfig.settings) {
const currentValue = currentScopedSettings[setting.envVar];
const workspaceValue = workspaceSettings[setting.envVar];
if (workspaceValue !== undefined) {
logger.log(
`Note: Setting "${setting.name}" is already configured in the workspace scope.`,
);
}
if (currentValue !== undefined) {
const confirmed = await requestConfirmation(
`Setting "${setting.name}" (${setting.envVar}) is already set. Overwrite?`,
);
if (!confirmed) {
continue;
}
}
await updateSetting(
extensionConfig,
extensionId,
setting.envVar,
requestSetting,
scope,
process.cwd(),
);
}
}
export function getFormattedSettingValue(