mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-29 06:25:16 -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:
|
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:
|
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
|
- `--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 { linkCommand } from './extensions/link.js';
|
||||||
import { newCommand } from './extensions/new.js';
|
import { newCommand } from './extensions/new.js';
|
||||||
import { validateCommand } from './extensions/validate.js';
|
import { validateCommand } from './extensions/validate.js';
|
||||||
import { settingsCommand } from './extensions/settings.js';
|
import { configureCommand } from './extensions/configure.js';
|
||||||
import { initializeOutputListenersAndFlush } from '../gemini.js';
|
import { initializeOutputListenersAndFlush } from '../gemini.js';
|
||||||
|
|
||||||
export const extensionsCommand: CommandModule = {
|
export const extensionsCommand: CommandModule = {
|
||||||
@@ -33,7 +33,7 @@ export const extensionsCommand: CommandModule = {
|
|||||||
.command(linkCommand)
|
.command(linkCommand)
|
||||||
.command(newCommand)
|
.command(newCommand)
|
||||||
.command(validateCommand)
|
.command(validateCommand)
|
||||||
.command(settingsCommand)
|
.command(configureCommand)
|
||||||
.demandCommand(1, 'You need at least one command before continuing.')
|
.demandCommand(1, 'You need at least one command before continuing.')
|
||||||
.version(false),
|
.version(false),
|
||||||
handler: () => {
|
handler: () => {
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||||
import { debugLogger } from '@google/gemini-cli-core';
|
import { debugLogger } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
export async function getExtensionAndManager(name: string) {
|
export async function getExtensionManager() {
|
||||||
const workspaceDir = process.cwd();
|
const workspaceDir = process.cwd();
|
||||||
const extensionManager = new ExtensionManager({
|
const extensionManager = new ExtensionManager({
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
@@ -19,6 +19,11 @@ export async function getExtensionAndManager(name: string) {
|
|||||||
settings: loadSettings(workspaceDir).merged,
|
settings: loadSettings(workspaceDir).merged,
|
||||||
});
|
});
|
||||||
await extensionManager.loadExtensions();
|
await extensionManager.loadExtensions();
|
||||||
|
return extensionManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExtensionAndManager(name: string) {
|
||||||
|
const extensionManager = await getExtensionManager();
|
||||||
const extension = extensionManager
|
const extension = extensionManager
|
||||||
.getExtensions()
|
.getExtensions()
|
||||||
.find((ext) => ext.name === name);
|
.find((ext) => ext.name === name);
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
|||||||
.map((s) => s.name)
|
.map((s) => s.name)
|
||||||
.join(
|
.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);
|
debugLogger.warn(message);
|
||||||
coreEvents.emitFeedback('warning', message);
|
coreEvents.emitFeedback('warning', message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,26 +266,6 @@ describe('extensionUpdates', () => {
|
|||||||
vi.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
|
vi.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
|
||||||
vi.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
|
vi.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
|
||||||
vi.mocked(fs.existsSync).mockReturnValue(false); // No hooks
|
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 {
|
try {
|
||||||
await manager.installOrUpdateExtension(installMetadata, previousConfig);
|
await manager.installOrUpdateExtension(installMetadata, previousConfig);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -300,7 +280,7 @@ describe('extensionUpdates', () => {
|
|||||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||||
'warning',
|
'warning',
|
||||||
expect.stringContaining(
|
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