clawt 2.13.0 → 2.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,85 +1,164 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { Command } from 'commander';
3
3
 
4
+ // mock enquirer(必须在所有 import 之前)
5
+ const { mockSelectRun, mockInputRun, mockSelectConstructorArgs } = vi.hoisted(() => {
6
+ const mockSelectRun = vi.fn();
7
+ const mockInputRun = vi.fn();
8
+ /** 用于捕获 Select 构造时传入的参数 */
9
+ const mockSelectConstructorArgs: unknown[] = [];
10
+ return { mockSelectRun, mockInputRun, mockSelectConstructorArgs };
11
+ });
12
+
13
+ vi.mock('enquirer', () => ({
14
+ default: {
15
+ Select: function MockSelect(opts: unknown) { mockSelectConstructorArgs.push(opts); return { run: mockSelectRun }; },
16
+ Input: function MockInput() { return { run: mockInputRun }; },
17
+ },
18
+ }));
19
+
4
20
  // mock 依赖模块
5
21
  vi.mock('../../../src/logger/index.js', () => ({
6
22
  logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
7
23
  }));
8
24
 
9
- vi.mock('../../../src/utils/index.js', () => ({
10
- loadConfig: vi.fn(),
11
- writeDefaultConfig: vi.fn(),
12
- printInfo: vi.fn(),
13
- printSuccess: vi.fn(),
14
- printSeparator: vi.fn(),
15
- confirmDestructiveAction: vi.fn(),
16
- }));
25
+ vi.mock('../../../src/utils/index.js', async (importOriginal) => {
26
+ const original = await importOriginal<typeof import('../../../src/utils/index.js')>();
27
+ return {
28
+ loadConfig: vi.fn(),
29
+ writeDefaultConfig: vi.fn(),
30
+ saveConfig: vi.fn(),
31
+ printInfo: vi.fn(),
32
+ printSuccess: vi.fn(),
33
+ printError: vi.fn(),
34
+ confirmDestructiveAction: vi.fn(),
35
+ // 策略工具函数透传真实实现(因为常量已被 mock,工具函数可以正常工作)
36
+ isValidConfigKey: original.isValidConfigKey,
37
+ getValidConfigKeys: original.getValidConfigKeys,
38
+ parseConfigValue: original.parseConfigValue,
39
+ promptConfigValue: original.promptConfigValue,
40
+ formatConfigValue: original.formatConfigValue,
41
+ };
42
+ });
17
43
 
18
44
  vi.mock('../../../src/constants/index.js', () => ({
19
45
  CONFIG_PATH: '/mock/.clawt/config.json',
20
46
  DEFAULT_CONFIG: {
21
- claudeCodeCommand: 'claude',
22
47
  autoDeleteBranch: false,
48
+ claudeCodeCommand: 'claude',
23
49
  autoPullPush: false,
24
50
  confirmDestructiveOps: true,
51
+ maxConcurrency: 0,
52
+ terminalApp: 'auto',
53
+ aliases: {},
25
54
  },
26
55
  CONFIG_DESCRIPTIONS: {
27
- claudeCodeCommand: 'Claude Code CLI 命令',
28
56
  autoDeleteBranch: '自动删除分支',
57
+ claudeCodeCommand: 'Claude Code CLI 命令',
29
58
  autoPullPush: '自动 pull/push',
30
59
  confirmDestructiveOps: '破坏性操作确认',
60
+ maxConcurrency: '最大并发数',
61
+ terminalApp: '终端应用',
62
+ aliases: '命令别名映射',
63
+ },
64
+ CONFIG_DEFINITIONS: {
65
+ autoDeleteBranch: { defaultValue: false, description: '自动删除分支' },
66
+ claudeCodeCommand: { defaultValue: 'claude', description: 'Claude Code CLI 命令' },
67
+ autoPullPush: { defaultValue: false, description: '自动 pull/push' },
68
+ confirmDestructiveOps: { defaultValue: true, description: '破坏性操作确认' },
69
+ maxConcurrency: { defaultValue: 0, description: '最大并发数' },
70
+ terminalApp: { defaultValue: 'auto', description: '终端应用', allowedValues: ['auto', 'iterm2', 'terminal'] },
71
+ aliases: { defaultValue: {}, description: '命令别名映射' },
31
72
  },
73
+ CONFIG_ALIAS_DISABLED_HINT: '(通过 clawt alias 命令管理)',
32
74
  MESSAGES: {
33
75
  CONFIG_RESET_SUCCESS: '配置已恢复为默认值',
34
76
  DESTRUCTIVE_OP_CANCELLED: '已取消操作',
77
+ CONFIG_SET_SUCCESS: (key: string, value: string) => `✓ ${key} 已设置为 ${value}`,
78
+ CONFIG_GET_VALUE: (key: string, value: string) => `${key} = ${value}`,
79
+ CONFIG_INVALID_KEY: (key: string, validKeys: string[]) =>
80
+ `无效的配置项: ${key}\n可用的配置项: ${validKeys.join(', ')}`,
81
+ CONFIG_INVALID_BOOLEAN: (key: string) =>
82
+ `配置项 ${key} 为布尔类型,仅接受 true 或 false`,
83
+ CONFIG_INVALID_NUMBER: (key: string) =>
84
+ `配置项 ${key} 为数字类型,请输入有效的数字`,
85
+ CONFIG_INVALID_ENUM: (key: string, validValues: readonly string[]) =>
86
+ `配置项 ${key} 仅接受以下值: ${validValues.join(', ')}`,
87
+ CONFIG_SELECT_PROMPT: '选择要修改的配置项',
88
+ CONFIG_INPUT_PROMPT: (key: string) => `输入 ${key} 的新值`,
89
+ CONFIG_MISSING_VALUE: (key: string) => `缺少配置值,用法: clawt config set ${key} <value>`,
35
90
  },
36
91
  }));
37
92
 
38
93
  import { registerConfigCommand } from '../../../src/commands/config.js';
39
- import { loadConfig, writeDefaultConfig, printInfo, printSuccess, confirmDestructiveAction } from '../../../src/utils/index.js';
94
+ import { loadConfig, writeDefaultConfig, saveConfig, printInfo, printSuccess, printError, confirmDestructiveAction } from '../../../src/utils/index.js';
40
95
 
41
96
  const mockedLoadConfig = vi.mocked(loadConfig);
42
97
  const mockedWriteDefaultConfig = vi.mocked(writeDefaultConfig);
98
+ const mockedSaveConfig = vi.mocked(saveConfig);
43
99
  const mockedPrintInfo = vi.mocked(printInfo);
44
100
  const mockedPrintSuccess = vi.mocked(printSuccess);
101
+ const mockedPrintError = vi.mocked(printError);
45
102
  const mockedConfirmDestructiveAction = vi.mocked(confirmDestructiveAction);
46
103
 
104
+ /** 创建默认配置对象用于 mock */
105
+ function createMockConfig() {
106
+ return {
107
+ autoDeleteBranch: false,
108
+ claudeCodeCommand: 'claude',
109
+ autoPullPush: false,
110
+ confirmDestructiveOps: true,
111
+ maxConcurrency: 0,
112
+ terminalApp: 'auto',
113
+ aliases: {},
114
+ };
115
+ }
116
+
47
117
  beforeEach(() => {
48
118
  mockedLoadConfig.mockReset();
49
119
  mockedWriteDefaultConfig.mockReset();
120
+ mockedSaveConfig.mockReset();
50
121
  mockedPrintInfo.mockReset();
51
122
  mockedPrintSuccess.mockReset();
123
+ mockedPrintError.mockReset();
52
124
  mockedConfirmDestructiveAction.mockReset();
125
+ mockSelectRun.mockReset();
126
+ mockInputRun.mockReset();
127
+ mockSelectConstructorArgs.length = 0;
53
128
  });
54
129
 
55
130
  describe('registerConfigCommand', () => {
56
- it('注册 config 命令和 config reset 子命令', () => {
131
+ it('注册 config 命令及所有子命令', () => {
57
132
  const program = new Command();
58
133
  registerConfigCommand(program);
59
134
  const configCmd = program.commands.find((c) => c.name() === 'config');
60
135
  expect(configCmd).toBeDefined();
61
- const resetCmd = configCmd!.commands.find((c) => c.name() === 'reset');
62
- expect(resetCmd).toBeDefined();
136
+
137
+ const subcommandNames = configCmd!.commands.map((c) => c.name());
138
+ expect(subcommandNames).toContain('reset');
139
+ expect(subcommandNames).toContain('set');
140
+ expect(subcommandNames).toContain('get');
63
141
  });
64
142
  });
65
143
 
66
144
  describe('handleConfig(通过 action 间接测试)', () => {
67
- it('展示配置列表', () => {
68
- mockedLoadConfig.mockReturnValue({
69
- claudeCodeCommand: 'claude',
70
- autoDeleteBranch: false,
71
- autoPullPush: false,
72
- confirmDestructiveOps: true,
73
- });
145
+ it('无子命令时进入交互式配置', async () => {
146
+ mockedLoadConfig.mockReturnValue(createMockConfig());
147
+ // 第一次 Select.run 选择配置项
148
+ mockSelectRun.mockResolvedValueOnce('autoDeleteBranch');
149
+ // 第二次 Select.run 选择布尔值
150
+ mockSelectRun.mockResolvedValueOnce('true');
74
151
 
75
152
  const program = new Command();
76
153
  program.exitOverride();
77
154
  registerConfigCommand(program);
78
- program.parse(['config'], { from: 'user' });
155
+ await program.parseAsync(['config'], { from: 'user' });
79
156
 
80
157
  expect(mockedLoadConfig).toHaveBeenCalled();
81
- // 应输出配置信息
82
- expect(mockedPrintInfo).toHaveBeenCalled();
158
+ expect(mockedSaveConfig).toHaveBeenCalledWith(
159
+ expect.objectContaining({ autoDeleteBranch: true }),
160
+ );
161
+ expect(mockedPrintSuccess).toHaveBeenCalled();
83
162
  });
84
163
  });
85
164
 
@@ -108,3 +187,255 @@ describe('handleConfigReset(通过 action 间接测试)', () => {
108
187
  expect(mockedPrintInfo).toHaveBeenCalled();
109
188
  });
110
189
  });
190
+
191
+ describe('handleConfigSet — 直接模式', () => {
192
+ it('设置布尔值 true', async () => {
193
+ mockedLoadConfig.mockReturnValue(createMockConfig());
194
+
195
+ const program = new Command();
196
+ program.exitOverride();
197
+ registerConfigCommand(program);
198
+ await program.parseAsync(['config', 'set', 'autoDeleteBranch', 'true'], { from: 'user' });
199
+
200
+ expect(mockedSaveConfig).toHaveBeenCalledWith(
201
+ expect.objectContaining({ autoDeleteBranch: true }),
202
+ );
203
+ expect(mockedPrintSuccess).toHaveBeenCalled();
204
+ });
205
+
206
+ it('设置布尔值 false', async () => {
207
+ mockedLoadConfig.mockReturnValue({ ...createMockConfig(), confirmDestructiveOps: true });
208
+
209
+ const program = new Command();
210
+ program.exitOverride();
211
+ registerConfigCommand(program);
212
+ await program.parseAsync(['config', 'set', 'confirmDestructiveOps', 'false'], { from: 'user' });
213
+
214
+ expect(mockedSaveConfig).toHaveBeenCalledWith(
215
+ expect.objectContaining({ confirmDestructiveOps: false }),
216
+ );
217
+ expect(mockedPrintSuccess).toHaveBeenCalled();
218
+ });
219
+
220
+ it('布尔值无效时报错', async () => {
221
+ const program = new Command();
222
+ program.exitOverride();
223
+ registerConfigCommand(program);
224
+ await program.parseAsync(['config', 'set', 'autoDeleteBranch', 'abc'], { from: 'user' });
225
+
226
+ expect(mockedSaveConfig).not.toHaveBeenCalled();
227
+ expect(mockedPrintError).toHaveBeenCalled();
228
+ });
229
+
230
+ it('设置数字值', async () => {
231
+ mockedLoadConfig.mockReturnValue(createMockConfig());
232
+
233
+ const program = new Command();
234
+ program.exitOverride();
235
+ registerConfigCommand(program);
236
+ await program.parseAsync(['config', 'set', 'maxConcurrency', '4'], { from: 'user' });
237
+
238
+ expect(mockedSaveConfig).toHaveBeenCalledWith(
239
+ expect.objectContaining({ maxConcurrency: 4 }),
240
+ );
241
+ expect(mockedPrintSuccess).toHaveBeenCalled();
242
+ });
243
+
244
+ it('数字值无效时报错', async () => {
245
+ const program = new Command();
246
+ program.exitOverride();
247
+ registerConfigCommand(program);
248
+ await program.parseAsync(['config', 'set', 'maxConcurrency', 'abc'], { from: 'user' });
249
+
250
+ expect(mockedSaveConfig).not.toHaveBeenCalled();
251
+ expect(mockedPrintError).toHaveBeenCalled();
252
+ });
253
+
254
+ it('设置字符串值', async () => {
255
+ mockedLoadConfig.mockReturnValue(createMockConfig());
256
+
257
+ const program = new Command();
258
+ program.exitOverride();
259
+ registerConfigCommand(program);
260
+ await program.parseAsync(['config', 'set', 'claudeCodeCommand', 'cc'], { from: 'user' });
261
+
262
+ expect(mockedSaveConfig).toHaveBeenCalledWith(
263
+ expect.objectContaining({ claudeCodeCommand: 'cc' }),
264
+ );
265
+ expect(mockedPrintSuccess).toHaveBeenCalled();
266
+ });
267
+
268
+ it('设置 terminalApp 有效值', async () => {
269
+ mockedLoadConfig.mockReturnValue(createMockConfig());
270
+
271
+ const program = new Command();
272
+ program.exitOverride();
273
+ registerConfigCommand(program);
274
+ await program.parseAsync(['config', 'set', 'terminalApp', 'iterm2'], { from: 'user' });
275
+
276
+ expect(mockedSaveConfig).toHaveBeenCalledWith(
277
+ expect.objectContaining({ terminalApp: 'iterm2' }),
278
+ );
279
+ expect(mockedPrintSuccess).toHaveBeenCalled();
280
+ });
281
+
282
+ it('设置 terminalApp 无效值时报错', async () => {
283
+ const program = new Command();
284
+ program.exitOverride();
285
+ registerConfigCommand(program);
286
+ await program.parseAsync(['config', 'set', 'terminalApp', 'invalid'], { from: 'user' });
287
+
288
+ expect(mockedSaveConfig).not.toHaveBeenCalled();
289
+ expect(mockedPrintError).toHaveBeenCalled();
290
+ });
291
+
292
+ it('无效 key 时报错', async () => {
293
+ const program = new Command();
294
+ program.exitOverride();
295
+ registerConfigCommand(program);
296
+ await program.parseAsync(['config', 'set', 'foobar', 'true'], { from: 'user' });
297
+
298
+ expect(mockedSaveConfig).not.toHaveBeenCalled();
299
+ expect(mockedPrintError).toHaveBeenCalled();
300
+ });
301
+
302
+ it('缺少 value 参数时报错', async () => {
303
+ const program = new Command();
304
+ program.exitOverride();
305
+ registerConfigCommand(program);
306
+ await program.parseAsync(['config', 'set', 'autoDeleteBranch'], { from: 'user' });
307
+
308
+ expect(mockedSaveConfig).not.toHaveBeenCalled();
309
+ expect(mockedPrintError).toHaveBeenCalled();
310
+ });
311
+ });
312
+
313
+ describe('handleConfigSet — 交互模式', () => {
314
+ it('交互选择布尔配置项并修改', async () => {
315
+ mockedLoadConfig.mockReturnValue(createMockConfig());
316
+ // 第一次 Select.run 选择配置项
317
+ mockSelectRun.mockResolvedValueOnce('autoDeleteBranch');
318
+ // 第二次 Select.run 选择布尔值
319
+ mockSelectRun.mockResolvedValueOnce('true');
320
+
321
+ const program = new Command();
322
+ program.exitOverride();
323
+ registerConfigCommand(program);
324
+ await program.parseAsync(['config', 'set'], { from: 'user' });
325
+
326
+ expect(mockedSaveConfig).toHaveBeenCalledWith(
327
+ expect.objectContaining({ autoDeleteBranch: true }),
328
+ );
329
+ expect(mockedPrintSuccess).toHaveBeenCalled();
330
+ });
331
+
332
+ it('交互选择数字配置项并修改', async () => {
333
+ mockedLoadConfig.mockReturnValue(createMockConfig());
334
+ // 选择配置项
335
+ mockSelectRun.mockResolvedValueOnce('maxConcurrency');
336
+ // 输入数字
337
+ mockInputRun.mockResolvedValueOnce('8');
338
+
339
+ const program = new Command();
340
+ program.exitOverride();
341
+ registerConfigCommand(program);
342
+ await program.parseAsync(['config', 'set'], { from: 'user' });
343
+
344
+ expect(mockedSaveConfig).toHaveBeenCalledWith(
345
+ expect.objectContaining({ maxConcurrency: 8 }),
346
+ );
347
+ expect(mockedPrintSuccess).toHaveBeenCalled();
348
+ });
349
+
350
+ it('交互选择字符串配置项并修改', async () => {
351
+ mockedLoadConfig.mockReturnValue(createMockConfig());
352
+ // 选择配置项
353
+ mockSelectRun.mockResolvedValueOnce('claudeCodeCommand');
354
+ // 输入字符串
355
+ mockInputRun.mockResolvedValueOnce('cc');
356
+
357
+ const program = new Command();
358
+ program.exitOverride();
359
+ registerConfigCommand(program);
360
+ await program.parseAsync(['config', 'set'], { from: 'user' });
361
+
362
+ expect(mockedSaveConfig).toHaveBeenCalledWith(
363
+ expect.objectContaining({ claudeCodeCommand: 'cc' }),
364
+ );
365
+ expect(mockedPrintSuccess).toHaveBeenCalled();
366
+ });
367
+
368
+ it('交互选择 terminalApp 配置项时使用 Select', async () => {
369
+ mockedLoadConfig.mockReturnValue(createMockConfig());
370
+ // 选择配置项
371
+ mockSelectRun.mockResolvedValueOnce('terminalApp');
372
+ // 选择 terminalApp 值
373
+ mockSelectRun.mockResolvedValueOnce('iterm2');
374
+
375
+ const program = new Command();
376
+ program.exitOverride();
377
+ registerConfigCommand(program);
378
+ await program.parseAsync(['config', 'set'], { from: 'user' });
379
+
380
+ expect(mockedSaveConfig).toHaveBeenCalledWith(
381
+ expect.objectContaining({ terminalApp: 'iterm2' }),
382
+ );
383
+ expect(mockedPrintSuccess).toHaveBeenCalled();
384
+ });
385
+
386
+ it('aliases 选项带 disabled 属性且不可选', async () => {
387
+ mockedLoadConfig.mockReturnValue(createMockConfig());
388
+ // 选择一个普通配置项完成交互流程
389
+ mockSelectRun.mockResolvedValueOnce('autoDeleteBranch');
390
+ mockSelectRun.mockResolvedValueOnce('true');
391
+
392
+ const program = new Command();
393
+ program.exitOverride();
394
+ registerConfigCommand(program);
395
+ await program.parseAsync(['config', 'set'], { from: 'user' });
396
+
397
+ // 捕获第一次 Select 构造参数(配置项选择列表)
398
+ const selectOpts = mockSelectConstructorArgs[0] as { choices: Array<{ name: string; disabled?: string }> };
399
+ const aliasesChoice = selectOpts.choices.find((c) => c.name === 'aliases');
400
+ expect(aliasesChoice).toBeDefined();
401
+ expect(aliasesChoice!.disabled).toBe('(通过 clawt alias 命令管理)');
402
+
403
+ // 普通配置项不应有 disabled 属性
404
+ const normalChoice = selectOpts.choices.find((c) => c.name === 'autoDeleteBranch');
405
+ expect(normalChoice).toBeDefined();
406
+ expect(normalChoice!.disabled).toBeUndefined();
407
+ });
408
+ });
409
+
410
+ describe('handleConfigGet', () => {
411
+ it('获取有效配置项的值', () => {
412
+ mockedLoadConfig.mockReturnValue(createMockConfig());
413
+
414
+ const program = new Command();
415
+ program.exitOverride();
416
+ registerConfigCommand(program);
417
+ program.parse(['config', 'get', 'maxConcurrency'], { from: 'user' });
418
+
419
+ expect(mockedPrintInfo).toHaveBeenCalled();
420
+ });
421
+
422
+ it('获取布尔配置项的值', () => {
423
+ mockedLoadConfig.mockReturnValue({ ...createMockConfig(), autoDeleteBranch: true });
424
+
425
+ const program = new Command();
426
+ program.exitOverride();
427
+ registerConfigCommand(program);
428
+ program.parse(['config', 'get', 'autoDeleteBranch'], { from: 'user' });
429
+
430
+ expect(mockedPrintInfo).toHaveBeenCalled();
431
+ });
432
+
433
+ it('无效 key 时报错', () => {
434
+ const program = new Command();
435
+ program.exitOverride();
436
+ registerConfigCommand(program);
437
+ program.parse(['config', 'get', 'invalidKey'], { from: 'user' });
438
+
439
+ expect(mockedPrintError).toHaveBeenCalled();
440
+ });
441
+ });
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { CONFIG_DEFINITIONS, DEFAULT_CONFIG, CONFIG_DESCRIPTIONS } from '../../../src/constants/config.js';
3
+ import { VALID_TERMINAL_APPS } from '../../../src/constants/terminal.js';
3
4
 
4
5
  describe('CONFIG_DEFINITIONS', () => {
5
6
  it('所有配置项都有 defaultValue 和 description', () => {
@@ -21,7 +22,7 @@ describe('DEFAULT_CONFIG', () => {
21
22
 
22
23
  it('每个 key 的值等于对应 CONFIG_DEFINITIONS 的 defaultValue', () => {
23
24
  for (const [key, def] of Object.entries(CONFIG_DEFINITIONS)) {
24
- expect((DEFAULT_CONFIG as Record<string, unknown>)[key]).toBe(def.defaultValue);
25
+ expect((DEFAULT_CONFIG as unknown as Record<string, unknown>)[key]).toBe(def.defaultValue);
25
26
  }
26
27
  });
27
28
 
@@ -55,3 +56,25 @@ describe('CONFIG_DESCRIPTIONS', () => {
55
56
  }
56
57
  });
57
58
  });
59
+
60
+ describe('CONFIG_DEFINITIONS — allowedValues', () => {
61
+ it('terminalApp 的 allowedValues 与 VALID_TERMINAL_APPS 一致', () => {
62
+ expect(CONFIG_DEFINITIONS.terminalApp.allowedValues).toEqual(VALID_TERMINAL_APPS);
63
+ });
64
+
65
+ it('有 allowedValues 的配置项,defaultValue 必须在其中', () => {
66
+ for (const [, def] of Object.entries(CONFIG_DEFINITIONS)) {
67
+ if (def.allowedValues) {
68
+ expect(def.allowedValues).toContain(def.defaultValue);
69
+ }
70
+ }
71
+ });
72
+
73
+ it('非 string 类型的配置项不应有 allowedValues', () => {
74
+ for (const [, def] of Object.entries(CONFIG_DEFINITIONS)) {
75
+ if (typeof def.defaultValue !== 'string') {
76
+ expect(def.allowedValues).toBeUndefined();
77
+ }
78
+ }
79
+ });
80
+ });