mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-13 10:45:27 +00:00
feat(cli): add /config key=value slash command to set any setting from the prompt (#5773)
* feat(cli): add /config key=value slash command for settings management Add a /config slash command that lets users get or set any setting by dot-path key directly from the prompt, without opening the settings UI or editing settings.json manually. Supports: - /config general.vimMode=true — set boolean - /config general.vimMode — toggle boolean - /config tools.approvalMode=auto — set enum with validation - /config --help — list all settable keys with current values - Tab completion for setting key names - Levenshtein-based 'Did you mean?' suggestions for typos - Type coercion (boolean, number, string, enum) based on schema - Restart warning for settings that require it - Array/object types rejected with settings.json redirect Works in interactive, non-interactive (headless), and ACP modes. Closes #5748 * fix(i18n): add zh-CN/zh-TW translations for /config command description * chore: trigger CI re-run * fix(i18n): add missing en.js key for /config command description * fix(cli): address PR #5773 review comments for /config command - Fix coerceValue: reject empty/Infinity for numbers, call validateSettingValue - Non-boolean toggle now shows current value instead of error - Add try/catch around setValue with user-friendly error - Mask sensitive values (apiKey, proxy, baseUrl) in --help listing - Add security-sensitive warning when setting proxy/credentials - Case-insensitive boolean parsing (True/TRUE/1/0) - Extract SETTABLE_TYPES as module-level constant - Add as const to supportedModes - Fix padRight overflow with ellipsis truncation - Combine duplicate getSettingDefinition calls in completion - Wrap all user-facing strings in t() with en/zh translations - Add 9 new test cases (34 total) * fix(cli): address remaining PR #5773 review comments - Mask sensitive values in write-confirmation message (Critical) - Trim rawValue after = sign for consistent enum/string matching - Wrap formatValue/maskValue (not set)/(empty) in t() for i18n - Add all config command translations to zh-TW.js - Add (empty) translations to en.js, zh.js, zh-TW.js - Add 2 new tests: sensitive write masking, whitespace trimming (36 total) * fix(cli): address PR #5773 review comments for /config command - Use setValues with throwOnWriteFailure instead of setValue to propagate disk write failures - Remove dead isToggle branches in coerceValue, simplify signature - Filter findClosestKey by SETTABLE_TYPES to avoid suggesting non-settable keys - Use formatValue consistently for write confirmation display - Narrow /token/i regex to path-segment boundary to avoid false positives * fix(cli): block tools.approvalMode=yolo via /config for security Address remaining Critical review comment from PR #5773: - Block setting tools.approvalMode to 'yolo' via /config command - yolo disables all tool-execution confirmation prompts, posing a security risk especially in ACP mode where commands can be sent programmatically by a compromised client - Other sensitive keys (proxy, baseUrl, apiKey) retain existing warning mechanism as they are legitimate configuration targets - Add i18n translations for the block message (en/zh/zh-TW) - Add 2 test cases: yolo blocked, non-yolo values allowed --------- Co-authored-by: 易良 <1204183885@qq.com>
This commit is contained in:
parent
c9e1546afd
commit
afb4b07069
6 changed files with 1095 additions and 0 deletions
|
|
@ -2295,6 +2295,36 @@ export default {
|
|||
'Failed to authenticate with Coding Plan: {{error}}',
|
||||
'中国 (China)': '中国 (China)',
|
||||
'阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)',
|
||||
'Get or set any setting by dot-path key':
|
||||
'Get or set any setting by dot-path key',
|
||||
'Invalid boolean value: "{{value}}". Use "true" or "false".':
|
||||
'Invalid boolean value: "{{value}}". Use "true" or "false".',
|
||||
'Cannot toggle a number setting. Provide a value: key=<number>.':
|
||||
'Cannot toggle a number setting. Provide a value: key=<number>.',
|
||||
'Invalid number value: "{{value}}".': 'Invalid number value: "{{value}}".',
|
||||
'Cannot toggle a string setting. Provide a value: key=<value>.':
|
||||
'Cannot toggle a string setting. Provide a value: key=<value>.',
|
||||
'Cannot toggle an enum setting. Provide one of: {{options}}.':
|
||||
'Cannot toggle an enum setting. Provide one of: {{options}}.',
|
||||
'Invalid enum value: "{{value}}". Valid values: {{options}}.':
|
||||
'Invalid enum value: "{{value}}". Valid values: {{options}}.',
|
||||
'Setting "{{type}}" type cannot be set via /config. Edit settings.json directly.':
|
||||
'Setting "{{type}}" type cannot be set via /config. Edit settings.json directly.',
|
||||
'Unsupported setting type: "{{type}}".':
|
||||
'Unsupported setting type: "{{type}}".',
|
||||
'Available settings:': 'Available settings:',
|
||||
'Unknown setting key: "{{key}}". Did you mean "{{suggestion}}"?':
|
||||
'Unknown setting key: "{{key}}". Did you mean "{{suggestion}}"?',
|
||||
'Unknown setting key: "{{key}}".': 'Unknown setting key: "{{key}}".',
|
||||
'Failed to set "{{key}}": {{error}}': 'Failed to set "{{key}}": {{error}}',
|
||||
'Set {{key}} = {{value}}': 'Set {{key}} = {{value}}',
|
||||
'(This setting requires a restart to take effect.)':
|
||||
'(This setting requires a restart to take effect.)',
|
||||
'(Security-sensitive setting — verify you are not exposing credentials.)':
|
||||
'(Security-sensitive setting — verify you are not exposing credentials.)',
|
||||
'Setting tools.approvalMode to "yolo" is blocked via /config for security reasons. Edit settings.json directly if you understand the risks.':
|
||||
'Setting tools.approvalMode to "yolo" is blocked via /config for security reasons. Edit settings.json directly if you understand the risks.',
|
||||
'(empty)': '(empty)',
|
||||
Global: 'Global',
|
||||
'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)',
|
||||
'Select region for Coding Plan:': 'Select region for Coding Plan:',
|
||||
|
|
|
|||
|
|
@ -590,6 +590,35 @@ export default {
|
|||
'checking...': '檢查中...',
|
||||
'not updatable': '不可更新',
|
||||
error: '錯誤',
|
||||
'Get or set any setting by dot-path key':
|
||||
'透過點號路徑鍵查看或設定任意設定項',
|
||||
'Invalid boolean value: "{{value}}". Use "true" or "false".':
|
||||
'無效的布林值:"{{value}}"。請使用 "true" 或 "false"。',
|
||||
'Cannot toggle a number setting. Provide a value: key=<number>.':
|
||||
'無法切換數字類型的設定。請提供值:key=<number>。',
|
||||
'Invalid number value: "{{value}}".': '無效的數字值:"{{value}}"。',
|
||||
'Cannot toggle a string setting. Provide a value: key=<value>.':
|
||||
'無法切換字串類型的設定。請提供值:key=<value>。',
|
||||
'Cannot toggle an enum setting. Provide one of: {{options}}.':
|
||||
'無法切換列舉類型的設定。請提供以下選項之一:{{options}}。',
|
||||
'Invalid enum value: "{{value}}". Valid values: {{options}}.':
|
||||
'無效的列舉值:"{{value}}"。有效值:{{options}}。',
|
||||
'Setting "{{type}}" type cannot be set via /config. Edit settings.json directly.':
|
||||
'"{{type}}" 類型的設定無法透過 /config 修改。請直接編輯 settings.json。',
|
||||
'Unsupported setting type: "{{type}}".': '不支援的設定類型:"{{type}}"。',
|
||||
'Available settings:': '可用設定:',
|
||||
'Unknown setting key: "{{key}}". Did you mean "{{suggestion}}"?':
|
||||
'未知的設定鍵:"{{key}}"。您是不是想設定 "{{suggestion}}"?',
|
||||
'Unknown setting key: "{{key}}".': '未知的設定鍵:"{{key}}"。',
|
||||
'Failed to set "{{key}}": {{error}}': '設定 "{{key}}" 失敗:{{error}}',
|
||||
'Set {{key}} = {{value}}': '已設定 {{key}} = {{value}}',
|
||||
'(This setting requires a restart to take effect.)':
|
||||
'(此設定需要重新啟動才能生效。)',
|
||||
'(Security-sensitive setting — verify you are not exposing credentials.)':
|
||||
'(安全敏感設定 — 請確認您沒有洩露憑證。)',
|
||||
'Setting tools.approvalMode to "yolo" is blocked via /config for security reasons. Edit settings.json directly if you understand the risks.':
|
||||
'出於安全原因,禁止透過 /config 將 tools.approvalMode 設定為 "yolo"。如果您了解相關風險,請直接編輯 settings.json。',
|
||||
'(empty)': '(空)',
|
||||
'View and edit Qwen Code settings': '查看和編輯 Qwen Code 設置',
|
||||
Settings: '設置',
|
||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
|
||||
|
|
|
|||
|
|
@ -629,6 +629,35 @@ export default {
|
|||
// ============================================================================
|
||||
// Commands - General (continued)
|
||||
// ============================================================================
|
||||
'Get or set any setting by dot-path key':
|
||||
'通过点号路径键查看或设置任意配置项',
|
||||
'Invalid boolean value: "{{value}}". Use "true" or "false".':
|
||||
'无效的布尔值:"{{value}}"。请使用 "true" 或 "false"。',
|
||||
'Cannot toggle a number setting. Provide a value: key=<number>.':
|
||||
'无法切换数字类型的设置。请提供值:key=<number>。',
|
||||
'Invalid number value: "{{value}}".': '无效的数字值:"{{value}}"。',
|
||||
'Cannot toggle a string setting. Provide a value: key=<value>.':
|
||||
'无法切换字符串类型的设置。请提供值:key=<value>。',
|
||||
'Cannot toggle an enum setting. Provide one of: {{options}}.':
|
||||
'无法切换枚举类型的设置。请提供以下选项之一:{{options}}。',
|
||||
'Invalid enum value: "{{value}}". Valid values: {{options}}.':
|
||||
'无效的枚举值:"{{value}}"。有效值:{{options}}。',
|
||||
'Setting "{{type}}" type cannot be set via /config. Edit settings.json directly.':
|
||||
'"{{type}}" 类型的设置无法通过 /config 修改。请直接编辑 settings.json。',
|
||||
'Unsupported setting type: "{{type}}".': '不支持的设置类型:"{{type}}"。',
|
||||
'Available settings:': '可用设置:',
|
||||
'Unknown setting key: "{{key}}". Did you mean "{{suggestion}}"?':
|
||||
'未知的设置键:"{{key}}"。您是不是想设置 "{{suggestion}}"?',
|
||||
'Unknown setting key: "{{key}}".': '未知的设置键:"{{key}}"。',
|
||||
'Failed to set "{{key}}": {{error}}': '设置 "{{key}}" 失败:{{error}}',
|
||||
'Set {{key}} = {{value}}': '已设置 {{key}} = {{value}}',
|
||||
'(This setting requires a restart to take effect.)':
|
||||
'(此设置需要重启才能生效。)',
|
||||
'(Security-sensitive setting — verify you are not exposing credentials.)':
|
||||
'(安全敏感设置 — 请确认您没有泄露凭据。)',
|
||||
'Setting tools.approvalMode to "yolo" is blocked via /config for security reasons. Edit settings.json directly if you understand the risks.':
|
||||
'出于安全原因,禁止通过 /config 将 tools.approvalMode 设置为 "yolo"。如果您了解相关风险,请直接编辑 settings.json。',
|
||||
'(empty)': '(空)',
|
||||
'View and edit Qwen Code settings': '查看和编辑 Qwen Code 设置',
|
||||
Settings: '设置',
|
||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { btwCommand } from '../ui/commands/btwCommand.js';
|
|||
import { bugCommand } from '../ui/commands/bugCommand.js';
|
||||
import { cdCommand } from '../ui/commands/cdCommand.js';
|
||||
import { clearCommand } from '../ui/commands/clearCommand.js';
|
||||
import { configCommand } from '../ui/commands/config-command.js';
|
||||
import { deleteCommand } from '../ui/commands/deleteCommand.js';
|
||||
import { compressCommand } from '../ui/commands/compressCommand.js';
|
||||
import { compressFastCommand } from '../ui/commands/compressFastCommand.js';
|
||||
|
|
@ -121,6 +122,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||
clearCommand,
|
||||
compressCommand,
|
||||
compressFastCommand,
|
||||
configCommand,
|
||||
contextCommand,
|
||||
copyCommand,
|
||||
diffCommand,
|
||||
|
|
|
|||
599
packages/cli/src/ui/commands/config-command.test.ts
Normal file
599
packages/cli/src/ui/commands/config-command.test.ts
Normal file
|
|
@ -0,0 +1,599 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { configCommand } from './config-command.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import type { CommandContext } from './types.js';
|
||||
|
||||
function createMockContext(mergedSettings: Record<string, unknown> = {}) {
|
||||
const setValuesMock = vi.fn();
|
||||
const mockSettings = {
|
||||
merged: mergedSettings,
|
||||
setValues: setValuesMock,
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
services: {
|
||||
settings: mockSettings,
|
||||
config: null,
|
||||
logger: null,
|
||||
},
|
||||
} as unknown as CommandContext;
|
||||
|
||||
return { ctx, setValuesMock };
|
||||
}
|
||||
|
||||
describe('configCommand', () => {
|
||||
it('is a built-in command available in all execution modes', () => {
|
||||
expect(configCommand.name).toBe('config');
|
||||
expect(configCommand.kind).toBe(CommandKind.BUILT_IN);
|
||||
expect(configCommand.supportedModes).toEqual([
|
||||
'interactive',
|
||||
'non_interactive',
|
||||
'acp',
|
||||
]);
|
||||
});
|
||||
|
||||
it('has correct metadata', () => {
|
||||
expect(configCommand.argumentHint).toBe('<key>[=<value>] or --help');
|
||||
expect(configCommand.description).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('set boolean value', () => {
|
||||
it('sets a boolean setting to true', async () => {
|
||||
const { ctx, setValuesMock } = createMockContext({
|
||||
general: { vimMode: false },
|
||||
});
|
||||
const result = await configCommand.action!(ctx, 'general.vimMode=true');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('general.vimMode'),
|
||||
});
|
||||
expect(setValuesMock).toHaveBeenCalledWith([
|
||||
{ scope: 'User', key: 'general.vimMode', value: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('sets a boolean setting to false', async () => {
|
||||
const { ctx, setValuesMock } = createMockContext({
|
||||
general: { vimMode: true },
|
||||
});
|
||||
const result = await configCommand.action!(ctx, 'general.vimMode=false');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('false'),
|
||||
});
|
||||
expect(setValuesMock).toHaveBeenCalledWith([
|
||||
{ scope: 'User', key: 'general.vimMode', value: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts case-insensitive boolean values', async () => {
|
||||
const { ctx, setValuesMock } = createMockContext({});
|
||||
const result = await configCommand.action!(ctx, 'general.vimMode=True');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('general.vimMode'),
|
||||
});
|
||||
expect(setValuesMock).toHaveBeenCalledWith([
|
||||
{ scope: 'User', key: 'general.vimMode', value: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts 1 and 0 as boolean values', async () => {
|
||||
const { ctx, setValuesMock } = createMockContext({});
|
||||
|
||||
const result1 = await configCommand.action!(ctx, 'general.vimMode=1');
|
||||
expect(result1).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('general.vimMode'),
|
||||
});
|
||||
expect(setValuesMock).toHaveBeenCalledWith([
|
||||
{ scope: 'User', key: 'general.vimMode', value: true },
|
||||
]);
|
||||
|
||||
const result0 = await configCommand.action!(ctx, 'general.vimMode=0');
|
||||
expect(result0).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('general.vimMode'),
|
||||
});
|
||||
expect(setValuesMock).toHaveBeenCalledWith([
|
||||
{ scope: 'User', key: 'general.vimMode', value: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggle boolean', () => {
|
||||
it('toggles a boolean from false to true', async () => {
|
||||
const { ctx, setValuesMock } = createMockContext({
|
||||
general: { vimMode: false },
|
||||
});
|
||||
const result = await configCommand.action!(ctx, 'general.vimMode');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('true'),
|
||||
});
|
||||
expect(setValuesMock).toHaveBeenCalledWith([
|
||||
{ scope: 'User', key: 'general.vimMode', value: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('toggles a boolean from true to false', async () => {
|
||||
const { ctx, setValuesMock } = createMockContext({
|
||||
general: { vimMode: true },
|
||||
});
|
||||
const result = await configCommand.action!(ctx, 'general.vimMode');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('false'),
|
||||
});
|
||||
expect(setValuesMock).toHaveBeenCalledWith([
|
||||
{ scope: 'User', key: 'general.vimMode', value: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('toggles undefined boolean to true', async () => {
|
||||
const { ctx } = createMockContext({});
|
||||
const result = await configCommand.action!(ctx, 'general.vimMode');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('true'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid boolean value', () => {
|
||||
it('returns error for invalid boolean value', async () => {
|
||||
const { ctx } = createMockContext({});
|
||||
const result = await configCommand.action!(ctx, 'general.vimMode=yes');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining('Invalid boolean'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('enum settings', () => {
|
||||
it('sets a valid enum value', async () => {
|
||||
const { ctx, setValuesMock } = createMockContext({});
|
||||
const result = await configCommand.action!(
|
||||
ctx,
|
||||
'tools.approvalMode=auto',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('auto'),
|
||||
});
|
||||
expect(setValuesMock).toHaveBeenCalledWith([
|
||||
{ scope: 'User', key: 'tools.approvalMode', value: 'auto' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns error for invalid enum value', async () => {
|
||||
const { ctx } = createMockContext({});
|
||||
const result = await configCommand.action!(
|
||||
ctx,
|
||||
'tools.approvalMode=invalid_mode',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining('Invalid enum value'),
|
||||
});
|
||||
});
|
||||
|
||||
it('shows current value when toggling enum', async () => {
|
||||
const { ctx } = createMockContext({});
|
||||
const result = await configCommand.action!(ctx, 'tools.approvalMode');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('tools.approvalMode ='),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('string settings', () => {
|
||||
it('sets a string value', async () => {
|
||||
const { ctx, setValuesMock } = createMockContext({});
|
||||
const result = await configCommand.action!(
|
||||
ctx,
|
||||
'general.preferredEditor=vim',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('vim'),
|
||||
});
|
||||
expect(setValuesMock).toHaveBeenCalledWith([
|
||||
{ scope: 'User', key: 'general.preferredEditor', value: 'vim' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows current value when toggling string', async () => {
|
||||
const { ctx } = createMockContext({
|
||||
general: { preferredEditor: 'vscode' },
|
||||
});
|
||||
const result = await configCommand.action!(
|
||||
ctx,
|
||||
'general.preferredEditor',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('general.preferredEditor = vscode'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('number settings', () => {
|
||||
it('sets a number value', async () => {
|
||||
const { ctx, setValuesMock } = createMockContext({});
|
||||
const result = await configCommand.action!(
|
||||
ctx,
|
||||
'general.sessionRecapAwayThresholdMinutes=10',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('10'),
|
||||
});
|
||||
expect(setValuesMock).toHaveBeenCalledWith([
|
||||
{
|
||||
scope: 'User',
|
||||
key: 'general.sessionRecapAwayThresholdMinutes',
|
||||
value: 10,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns error for invalid number value', async () => {
|
||||
const { ctx } = createMockContext({});
|
||||
const result = await configCommand.action!(
|
||||
ctx,
|
||||
'general.sessionRecapAwayThresholdMinutes=abc',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining('Invalid number'),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for empty number value', async () => {
|
||||
const { ctx } = createMockContext({});
|
||||
const result = await configCommand.action!(
|
||||
ctx,
|
||||
'general.sessionRecapAwayThresholdMinutes=',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining('Invalid number'),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for Infinity', async () => {
|
||||
const { ctx } = createMockContext({});
|
||||
const result = await configCommand.action!(
|
||||
ctx,
|
||||
'general.sessionRecapAwayThresholdMinutes=Infinity',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining('Invalid number'),
|
||||
});
|
||||
});
|
||||
|
||||
it('shows current value when toggling number', async () => {
|
||||
const { ctx } = createMockContext({
|
||||
general: { sessionRecapAwayThresholdMinutes: 30 },
|
||||
});
|
||||
const result = await configCommand.action!(
|
||||
ctx,
|
||||
'general.sessionRecapAwayThresholdMinutes',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining(
|
||||
'general.sessionRecapAwayThresholdMinutes = 30',
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('array/object settings', () => {
|
||||
it('returns error for object settings', async () => {
|
||||
const { ctx } = createMockContext({});
|
||||
const result = await configCommand.action!(ctx, 'mcpServers={"test":{}}');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining('settings.json'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown keys', () => {
|
||||
it('returns error for unknown key', async () => {
|
||||
const { ctx } = createMockContext({});
|
||||
const result = await configCommand.action!(ctx, 'nonexistent.key=value');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining('Unknown setting key'),
|
||||
});
|
||||
});
|
||||
|
||||
it('suggests closest key for typo', async () => {
|
||||
const { ctx } = createMockContext({});
|
||||
const result = await configCommand.action!(ctx, 'general.vimMod=true');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining('Did you mean'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('--help', () => {
|
||||
it('lists all settings with --help', async () => {
|
||||
const { ctx } = createMockContext({});
|
||||
const result = await configCommand.action!(ctx, '--help');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('Available settings'),
|
||||
});
|
||||
});
|
||||
|
||||
it('lists all settings with -h', async () => {
|
||||
const { ctx } = createMockContext({});
|
||||
const result = await configCommand.action!(ctx, '-h');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('Available settings'),
|
||||
});
|
||||
});
|
||||
|
||||
it('lists all settings when no args provided', async () => {
|
||||
const { ctx } = createMockContext({});
|
||||
const result = await configCommand.action!(ctx, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('Available settings'),
|
||||
});
|
||||
});
|
||||
|
||||
it('masks sensitive values in listing', async () => {
|
||||
const { ctx } = createMockContext({
|
||||
proxy: 'http://my-secret-proxy:8080',
|
||||
});
|
||||
const result = await configCommand.action!(ctx, '--help');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.not.stringContaining('my-secret-proxy'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('restart warning', () => {
|
||||
it('shows restart warning for settings that require restart', async () => {
|
||||
const { ctx } = createMockContext({});
|
||||
const result = await configCommand.action!(
|
||||
ctx,
|
||||
'proxy=http://localhost:8080',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('requires a restart'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('security-sensitive settings', () => {
|
||||
it('shows sensitive warning when setting proxy', async () => {
|
||||
const { ctx } = createMockContext({});
|
||||
const result = await configCommand.action!(
|
||||
ctx,
|
||||
'proxy=http://localhost:8080',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('Security-sensitive'),
|
||||
});
|
||||
});
|
||||
|
||||
it('masks sensitive value when getting proxy without =', async () => {
|
||||
const { ctx } = createMockContext({
|
||||
proxy: 'http://secret-proxy:8080',
|
||||
});
|
||||
const result = await configCommand.action!(ctx, 'proxy');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.not.stringContaining('secret-proxy'),
|
||||
});
|
||||
});
|
||||
|
||||
it('masks sensitive value in write confirmation message', async () => {
|
||||
const { ctx } = createMockContext({});
|
||||
const result = await configCommand.action!(
|
||||
ctx,
|
||||
'proxy=http://secret-proxy:8080',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.not.stringContaining('secret-proxy'),
|
||||
});
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('http****'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('whitespace handling', () => {
|
||||
it('trims whitespace from value after = sign', async () => {
|
||||
const { ctx, setValuesMock } = createMockContext({});
|
||||
const result = await configCommand.action!(
|
||||
ctx,
|
||||
'tools.approvalMode= auto ',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('auto'),
|
||||
});
|
||||
expect(setValuesMock).toHaveBeenCalledWith([
|
||||
{ scope: 'User', key: 'tools.approvalMode', value: 'auto' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('security blocklist', () => {
|
||||
it('blocks setting tools.approvalMode to yolo', async () => {
|
||||
const { ctx, setValuesMock } = createMockContext({});
|
||||
const result = await configCommand.action!(
|
||||
ctx,
|
||||
'tools.approvalMode=yolo',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining('blocked'),
|
||||
});
|
||||
expect(setValuesMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows setting tools.approvalMode to non-yolo values', async () => {
|
||||
const { ctx, setValuesMock } = createMockContext({});
|
||||
const result = await configCommand.action!(
|
||||
ctx,
|
||||
'tools.approvalMode=auto',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('auto'),
|
||||
});
|
||||
expect(setValuesMock).toHaveBeenCalledWith([
|
||||
{ scope: 'User', key: 'tools.approvalMode', value: 'auto' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setValue error handling', () => {
|
||||
it('returns error message when setValues throws', async () => {
|
||||
const { ctx } = createMockContext({});
|
||||
const settings = ctx.services.settings as unknown as {
|
||||
setValues: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
settings.setValues.mockImplementation(() => {
|
||||
throw new Error('Permission denied');
|
||||
});
|
||||
|
||||
const result = await configCommand.action!(ctx, 'general.vimMode=true');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining('Failed to set'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('completion', () => {
|
||||
it('provides completions for partial key', async () => {
|
||||
const { ctx } = createMockContext({});
|
||||
const completions = await configCommand.completion!(ctx, 'general.vim');
|
||||
|
||||
expect(completions).toBeTruthy();
|
||||
expect(completions!.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
completions!.some((c) =>
|
||||
typeof c === 'string'
|
||||
? c.includes('vimMode')
|
||||
: c.value.includes('vimMode'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns null when completing after = sign', async () => {
|
||||
const { ctx } = createMockContext({});
|
||||
const completions = await configCommand.completion!(
|
||||
ctx,
|
||||
'general.vimMode=',
|
||||
);
|
||||
|
||||
expect(completions).toBeNull();
|
||||
});
|
||||
|
||||
it('excludes non-settable types from completions', async () => {
|
||||
const { ctx } = createMockContext({});
|
||||
const completions = await configCommand.completion!(ctx, 'mcp');
|
||||
|
||||
if (completions) {
|
||||
const hasObjectOrArray = completions.some((c) => {
|
||||
const value = typeof c === 'string' ? c : c.value;
|
||||
return value === 'mcpServers';
|
||||
});
|
||||
expect(hasObjectOrArray).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
406
packages/cli/src/ui/commands/config-command.ts
Normal file
406
packages/cli/src/ui/commands/config-command.ts
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
CommandContext,
|
||||
MessageActionReturn,
|
||||
SlashCommand,
|
||||
} from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import type { SettingDefinition } from '../../config/settingsSchema.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
getAllSettingKeys,
|
||||
getFlattenedSchema,
|
||||
getNestedProperty,
|
||||
getSettingDefinition,
|
||||
validateSettingValue,
|
||||
} from '../../utils/settingsUtils.js';
|
||||
|
||||
const SETTABLE_TYPES = new Set(['boolean', 'string', 'number', 'enum']);
|
||||
|
||||
const SENSITIVE_KEY_PATTERNS = [
|
||||
/apikey/i,
|
||||
/api[_-]?key/i,
|
||||
/secret/i,
|
||||
/(?:^|\.)token(?:$|\.)/i,
|
||||
/password/i,
|
||||
/credential/i,
|
||||
/private[_-]?key/i,
|
||||
];
|
||||
|
||||
const SENSITIVE_URL_PATTERNS = [/baseurl/i, /base_url/i, /proxy/i];
|
||||
|
||||
function isSensitiveKey(key: string): boolean {
|
||||
return (
|
||||
SENSITIVE_KEY_PATTERNS.some((p) => p.test(key)) ||
|
||||
SENSITIVE_URL_PATTERNS.some((p) => p.test(key))
|
||||
);
|
||||
}
|
||||
|
||||
function maskValue(value: unknown): string {
|
||||
if (value === undefined) return t('(not set)');
|
||||
if (typeof value === 'string') {
|
||||
if (!value) return t('(empty)');
|
||||
if (value.length <= 4) return '****';
|
||||
return `${value.slice(0, 4)}****`;
|
||||
}
|
||||
return '****';
|
||||
}
|
||||
|
||||
function findClosestKey(input: string): string | undefined {
|
||||
const allKeys = getAllSettingKeys();
|
||||
let bestMatch: string | undefined;
|
||||
let bestDistance = Infinity;
|
||||
|
||||
for (const key of allKeys) {
|
||||
const def = getSettingDefinition(key);
|
||||
if (!def || !SETTABLE_TYPES.has(def.type)) continue;
|
||||
const distance = levenshteinDistance(
|
||||
input.toLowerCase(),
|
||||
key.toLowerCase(),
|
||||
);
|
||||
if (distance < bestDistance && distance <= 3) {
|
||||
bestDistance = distance;
|
||||
bestMatch = key;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
function levenshteinDistance(a: string, b: string): number {
|
||||
const matrix: number[][] = [];
|
||||
|
||||
for (let i = 0; i <= a.length; i++) {
|
||||
matrix[i] = [i];
|
||||
}
|
||||
for (let j = 0; j <= b.length; j++) {
|
||||
matrix[0]![j] = j;
|
||||
}
|
||||
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
||||
matrix[i]![j] = Math.min(
|
||||
matrix[i - 1]![j]! + 1,
|
||||
matrix[i]![j - 1]! + 1,
|
||||
matrix[i - 1]![j - 1]! + cost,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[a.length]![b.length]!;
|
||||
}
|
||||
|
||||
function coerceValue(
|
||||
def: SettingDefinition,
|
||||
rawValue: string | undefined,
|
||||
): { value: unknown; error?: string } {
|
||||
switch (def.type) {
|
||||
case 'boolean': {
|
||||
const normalised = rawValue?.toLowerCase().trim();
|
||||
if (normalised === 'true' || normalised === '1') return { value: true };
|
||||
if (normalised === 'false' || normalised === '0') return { value: false };
|
||||
return {
|
||||
value: undefined,
|
||||
error: t('Invalid boolean value: "{{value}}". Use "true" or "false".', {
|
||||
value: String(rawValue),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
case 'number': {
|
||||
if (!rawValue || rawValue.trim() === '') {
|
||||
return {
|
||||
value: undefined,
|
||||
error: t('Invalid number value: "{{value}}".', {
|
||||
value: String(rawValue),
|
||||
}),
|
||||
};
|
||||
}
|
||||
const parsed = Number(rawValue);
|
||||
if (Number.isNaN(parsed) || !Number.isFinite(parsed)) {
|
||||
return {
|
||||
value: undefined,
|
||||
error: t('Invalid number value: "{{value}}".', {
|
||||
value: String(rawValue),
|
||||
}),
|
||||
};
|
||||
}
|
||||
const validationError = validateSettingValue(def, parsed);
|
||||
if (validationError) {
|
||||
return { value: undefined, error: validationError };
|
||||
}
|
||||
return { value: parsed };
|
||||
}
|
||||
|
||||
case 'string': {
|
||||
const strValue = rawValue ?? '';
|
||||
const validationError = validateSettingValue(def, strValue);
|
||||
if (validationError) {
|
||||
return { value: undefined, error: validationError };
|
||||
}
|
||||
return { value: strValue };
|
||||
}
|
||||
|
||||
case 'enum': {
|
||||
const validValues = def.options?.map((o) => o.value) ?? [];
|
||||
if (!validValues.includes(rawValue as never)) {
|
||||
return {
|
||||
value: undefined,
|
||||
error: t(
|
||||
'Invalid enum value: "{{value}}". Valid values: {{options}}.',
|
||||
{ value: String(rawValue), options: validValues.join(', ') },
|
||||
),
|
||||
};
|
||||
}
|
||||
return { value: rawValue };
|
||||
}
|
||||
|
||||
case 'array':
|
||||
case 'object':
|
||||
return {
|
||||
value: undefined,
|
||||
error: t(
|
||||
'Setting "{{type}}" type cannot be set via /config. Edit settings.json directly.',
|
||||
{ type: def.type },
|
||||
),
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
value: undefined,
|
||||
error: t('Unsupported setting type: "{{type}}".', {
|
||||
type: String(def.type),
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === undefined) return t('(not set)');
|
||||
if (typeof value === 'string') return value || t('(empty)');
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function padRight(str: string, len: number): string {
|
||||
if (str.length > len) return str.slice(0, len - 3) + '... ';
|
||||
if (str.length === len) return str + ' ';
|
||||
return str + ' '.repeat(len - str.length);
|
||||
}
|
||||
|
||||
function listAllSettings(context: CommandContext): MessageActionReturn {
|
||||
const flattened = getFlattenedSchema();
|
||||
const merged = context.services.settings.merged;
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(t('Available settings:'));
|
||||
lines.push('');
|
||||
lines.push(
|
||||
padRight('Key', 40) +
|
||||
padRight('Type', 10) +
|
||||
padRight('Current', 15) +
|
||||
'Description',
|
||||
);
|
||||
lines.push('-'.repeat(90));
|
||||
|
||||
const keys = Object.keys(flattened).sort();
|
||||
for (const key of keys) {
|
||||
const def = flattened[key]!;
|
||||
if (!SETTABLE_TYPES.has(def.type)) continue;
|
||||
|
||||
const current = getNestedProperty(merged as Record<string, unknown>, key);
|
||||
let displayCurrent: string;
|
||||
if (isSensitiveKey(key)) {
|
||||
displayCurrent = maskValue(current ?? def.default);
|
||||
} else {
|
||||
displayCurrent =
|
||||
current !== undefined ? formatValue(current) : formatValue(def.default);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
padRight(key, 40) +
|
||||
padRight(def.type, 10) +
|
||||
padRight(displayCurrent, 15) +
|
||||
(def.description ?? def.label),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: lines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
export const configCommand: SlashCommand = {
|
||||
name: 'config',
|
||||
get description() {
|
||||
return t('Get or set any setting by dot-path key');
|
||||
},
|
||||
argumentHint: '<key>[=<value>] or --help',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
|
||||
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<MessageActionReturn> => {
|
||||
const trimmed = args.trim();
|
||||
|
||||
if (!trimmed || trimmed === '--help' || trimmed === '-h') {
|
||||
return listAllSettings(context);
|
||||
}
|
||||
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
const isToggle = eqIndex === -1;
|
||||
const key = isToggle ? trimmed : trimmed.slice(0, eqIndex).trim();
|
||||
const rawValue = isToggle ? undefined : trimmed.slice(eqIndex + 1).trim();
|
||||
|
||||
const def = getSettingDefinition(key);
|
||||
if (!def) {
|
||||
const suggestion = findClosestKey(key);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: suggestion
|
||||
? t(
|
||||
'Unknown setting key: "{{key}}". Did you mean "{{suggestion}}"?',
|
||||
{ key, suggestion },
|
||||
)
|
||||
: t('Unknown setting key: "{{key}}".', { key }),
|
||||
};
|
||||
}
|
||||
|
||||
const currentValue = getNestedProperty(
|
||||
context.services.settings.merged as Record<string, unknown>,
|
||||
key,
|
||||
);
|
||||
|
||||
if (isToggle && def.type !== 'boolean') {
|
||||
const display = isSensitiveKey(key)
|
||||
? maskValue(currentValue)
|
||||
: formatValue(currentValue);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `${key} = ${display}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (isToggle && def.type === 'boolean') {
|
||||
const newValue = !currentValue;
|
||||
try {
|
||||
context.services.settings.setValues([
|
||||
{ scope: SettingScope.User, key, value: newValue },
|
||||
]);
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Failed to set "{{key}}": {{error}}', {
|
||||
key,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
};
|
||||
}
|
||||
let message = t('Set {{key}} = {{value}}', {
|
||||
key,
|
||||
value: String(newValue),
|
||||
});
|
||||
if (def.requiresRestart) {
|
||||
message +=
|
||||
'\n' + t('(This setting requires a restart to take effect.)');
|
||||
}
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: message,
|
||||
};
|
||||
}
|
||||
|
||||
const result = coerceValue(def, rawValue);
|
||||
if (result.error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
if (key === 'tools.approvalMode' && result.value === 'yolo') {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t(
|
||||
'Setting tools.approvalMode to "yolo" is blocked via /config for security reasons. Edit settings.json directly if you understand the risks.',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
context.services.settings.setValues([
|
||||
{ scope: SettingScope.User, key, value: result.value },
|
||||
]);
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Failed to set "{{key}}": {{error}}', {
|
||||
key,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const displayValue = isSensitiveKey(key)
|
||||
? maskValue(result.value)
|
||||
: formatValue(result.value);
|
||||
let message = t('Set {{key}} = {{value}}', {
|
||||
key,
|
||||
value: displayValue,
|
||||
});
|
||||
if (def.requiresRestart) {
|
||||
message += '\n' + t('(This setting requires a restart to take effect.)');
|
||||
}
|
||||
if (isSensitiveKey(key)) {
|
||||
message +=
|
||||
'\n' +
|
||||
t(
|
||||
'(Security-sensitive setting — verify you are not exposing credentials.)',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: message,
|
||||
};
|
||||
},
|
||||
|
||||
completion: async (_context, partialArg) => {
|
||||
const current = partialArg.trimStart();
|
||||
if (current.includes('=')) return null;
|
||||
|
||||
const allKeys = getAllSettingKeys();
|
||||
return allKeys
|
||||
.filter((k) => {
|
||||
if (!k.startsWith(current)) return false;
|
||||
const def = getSettingDefinition(k);
|
||||
return def && SETTABLE_TYPES.has(def.type);
|
||||
})
|
||||
.map((k) => {
|
||||
const def = getSettingDefinition(k);
|
||||
return {
|
||||
value: k,
|
||||
description: def?.description ?? '',
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue