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:
DennisYu07 2026-07-01 11:35:37 +08:00 committed by GitHub
parent c9e1546afd
commit afb4b07069
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1095 additions and 0 deletions

View file

@ -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:',

View file

@ -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.':

View file

@ -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.':

View file

@ -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,

View 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);
}
});
});
});

View 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 ?? '',
};
});
},
};