mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 14:10:37 -07:00
Simplify extension settings command (#16001)
This commit is contained in:
@@ -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 <extension name>
|
||||
gemini extensions list
|
||||
```
|
||||
|
||||
and you can update a given setting using:
|
||||
|
||||
```
|
||||
gemini extensions settings set <extension name> <setting name> [--scope <scope>]
|
||||
gemini extensions config <extension name> [setting name] [--scope <scope>]
|
||||
```
|
||||
|
||||
- `--scope`: The scope to set the setting in (`user` or `workspace`). This is
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
292
packages/cli/src/commands/extensions/configure.test.ts
Normal file
292
packages/cli/src/commands/extensions/configure.test.ts
Normal file
@@ -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<Partial<ExtensionSetting>> = [],
|
||||
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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
210
packages/cli/src/commands/extensions/configure.ts
Normal file
210
packages/cli/src/commands/extensions/configure.ts
Normal file
@@ -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<object, ConfigureArgs> = {
|
||||
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<string, string> = {};
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<typeof getExtensionAndManager> =
|
||||
vi.hoisted(() => vi.fn());
|
||||
const mockUpdateSetting: Mock<typeof updateSetting> = vi.hoisted(() => vi.fn());
|
||||
const mockGetScopedEnvContents: Mock<typeof getScopedEnvContents> = vi.hoisted(
|
||||
() => vi.fn(),
|
||||
);
|
||||
const mockExitCli: Mock<typeof exitCli> = 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<object, SetArgs> = {
|
||||
command: 'set [--scope] <name> <setting>',
|
||||
describe: 'Set a specific setting for an extension.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('name', {
|
||||
describe: 'Name of the extension to configure.',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.positional('setting', {
|
||||
describe: 'The setting to configure (name or env var).',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.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<object, ListArgs> = {
|
||||
command: 'list <name>',
|
||||
describe: 'List all settings for an extension.',
|
||||
builder: (yargs) =>
|
||||
yargs.positional('name', {
|
||||
describe: 'Name of the extension.',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: async (args) => {
|
||||
const { name } = args;
|
||||
const { extension, extensionManager } = await getExtensionAndManager(name);
|
||||
if (!extension || !extensionManager) {
|
||||
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 <command>',
|
||||
describe: 'Manage extension settings.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command(setCommand)
|
||||
.command(listCommand)
|
||||
.demandCommand(1, 'You need to specify a command (set or list).')
|
||||
.version(false),
|
||||
handler: () => {
|
||||
// This handler is not called when a subcommand is provided.
|
||||
// Yargs will show the help menu.
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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} <setting-name>" to configure them.`;
|
||||
)}. Please run "gemini extensions config ${newExtensionConfig.name} [setting-name]" to configure them.`;
|
||||
debugLogger.warn(message);
|
||||
coreEvents.emitFeedback('warning', message);
|
||||
}
|
||||
|
||||
@@ -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 <setting-name>"',
|
||||
'Please run "gemini extensions config test-ext [setting-name]"',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user