diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index e744d6e901..c5c2d6a775 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -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=.': + 'Cannot toggle a number setting. Provide a value: key=.', + 'Invalid number value: "{{value}}".': 'Invalid number value: "{{value}}".', + 'Cannot toggle a string setting. Provide a value: key=.': + 'Cannot toggle a string setting. Provide a value: key=.', + '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:', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 3c961c9e8b..9e28364259 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -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=.': + '無法切換數字類型的設定。請提供值:key=。', + 'Invalid number value: "{{value}}".': '無效的數字值:"{{value}}"。', + 'Cannot toggle a string setting. Provide a value: key=.': + '無法切換字串類型的設定。請提供值:key=。', + '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.': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 7ece3d354f..9e0200576f 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -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=.': + '无法切换数字类型的设置。请提供值:key=。', + 'Invalid number value: "{{value}}".': '无效的数字值:"{{value}}"。', + 'Cannot toggle a string setting. Provide a value: key=.': + '无法切换字符串类型的设置。请提供值:key=。', + '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.': diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index ed0bfc76df..a66d71e0ab 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -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, diff --git a/packages/cli/src/ui/commands/config-command.test.ts b/packages/cli/src/ui/commands/config-command.test.ts new file mode 100644 index 0000000000..aa1d4d69b4 --- /dev/null +++ b/packages/cli/src/ui/commands/config-command.test.ts @@ -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 = {}) { + 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('[=] 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; + }; + 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); + } + }); + }); +}); diff --git a/packages/cli/src/ui/commands/config-command.ts b/packages/cli/src/ui/commands/config-command.ts new file mode 100644 index 0000000000..0feb1d8970 --- /dev/null +++ b/packages/cli/src/ui/commands/config-command.ts @@ -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, 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: '[=] or --help', + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + + action: async ( + context: CommandContext, + args: string, + ): Promise => { + 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, + 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 ?? '', + }; + }); + }, +};