clawt 3.1.1 → 3.1.3

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.
@@ -73,14 +73,16 @@ export function parseConfigValue(
73
73
  * - string + 有 allowedValues → Select(枚举列表)
74
74
  * - string + 无 allowedValues → Input(自由输入)
75
75
  *
76
- * @param {keyof ClawtConfig} key - 配置项名称
77
- * @param {ClawtConfig[keyof ClawtConfig]} currentValue - 当前值
78
- * @returns {Promise<ClawtConfig[keyof ClawtConfig]>} 用户输入/选择的新值
76
+ * @param {string} key - 配置项名称
77
+ * @param {unknown} currentValue - 当前值
78
+ * @param {readonly string[]} [allowedValues] - 可选的枚举值列表
79
+ * @returns {Promise<unknown>} 用户输入/选择的新值
79
80
  */
80
81
  export async function promptConfigValue(
81
- key: keyof ClawtConfig,
82
- currentValue: ClawtConfig[keyof ClawtConfig],
83
- ): Promise<ClawtConfig[keyof ClawtConfig]> {
82
+ key: string,
83
+ currentValue: unknown,
84
+ allowedValues?: readonly string[],
85
+ ): Promise<unknown> {
84
86
  const expectedType = typeof currentValue;
85
87
 
86
88
  // 布尔类型策略
@@ -94,9 +96,8 @@ export async function promptConfigValue(
94
96
  }
95
97
 
96
98
  // 字符串类型:根据 allowedValues 自动选择提示策略
97
- const definition = CONFIG_DEFINITIONS[key];
98
- if (definition.allowedValues) {
99
- return promptEnumValue(key, currentValue as string, definition.allowedValues);
99
+ if (allowedValues) {
100
+ return promptEnumValue(key, currentValue as string, allowedValues);
100
101
  }
101
102
 
102
103
  return promptStringValue(key, currentValue as string);
@@ -104,23 +105,70 @@ export async function promptConfigValue(
104
105
 
105
106
  /**
106
107
  * 格式化配置值的显示样式
107
- * @param {ClawtConfig[keyof ClawtConfig]} value - 配置值
108
+ * @param {unknown} value - 配置值
108
109
  * @returns {string} 格式化后的字符串
109
110
  */
110
- export function formatConfigValue(value: ClawtConfig[keyof ClawtConfig]): string {
111
+ export function formatConfigValue(value: unknown): string {
112
+ if (value === undefined || value === null) {
113
+ return chalk.dim('(未设置)');
114
+ }
111
115
  if (typeof value === 'boolean') {
112
116
  return value ? chalk.green('true') : chalk.yellow('false');
113
117
  }
114
118
  return chalk.cyan(String(value));
115
119
  }
116
120
 
121
+ /**
122
+ * 通用交互式配置编辑器
123
+ * @param {T} config - 当前配置对象
124
+ * @param {Record<string, { description: string; allowedValues?: readonly string[] }>} definitions - 配置项定义(含 description、allowedValues)
125
+ * @param {object} [options] - 可选配置
126
+ * @param {string} [options.selectPrompt] - 选择配置项的提示语
127
+ * @param {Record<string, string>} [options.disabledKeys] - 不可编辑的键及其禁用提示
128
+ * @returns {Promise<{ key: string; newValue: unknown }>} 修改后的 key 和 newValue
129
+ */
130
+ export async function interactiveConfigEditor<T extends object>(
131
+ config: T,
132
+ definitions: Record<string, { description: string; allowedValues?: readonly string[] }>,
133
+ options?: { selectPrompt?: string; disabledKeys?: Record<string, string> },
134
+ ): Promise<{ key: keyof T; newValue: unknown }> {
135
+ const keys = Object.keys(definitions);
136
+ const disabledKeys = options?.disabledKeys ?? {};
137
+ const configRecord = config as Record<string, unknown>;
138
+
139
+ // 构建选择列表,显示配置项名称、当前值和描述
140
+ const choices = keys.map((k) => {
141
+ const isDisabled = k in disabledKeys;
142
+ const value = configRecord[k];
143
+ const isObject = typeof value === 'object' && value !== null;
144
+ return {
145
+ name: k,
146
+ message: `${k}: ${isObject || isDisabled ? chalk.dim(isObject ? JSON.stringify(value) : String(value ?? '')) : formatConfigValue(value)} ${chalk.dim(`— ${definitions[k].description}`)}`,
147
+ ...(isDisabled && { disabled: disabledKeys[k] }),
148
+ };
149
+ });
150
+
151
+ // @ts-expect-error enquirer 类型声明未导出 Select 类,但运行时存在
152
+ const selectedKey: string = await new Enquirer.Select({
153
+ message: options?.selectPrompt ?? MESSAGES.CONFIG_SELECT_PROMPT,
154
+ choices,
155
+ }).run();
156
+
157
+ // 根据类型和 allowedValues 自动选择提示策略
158
+ const currentValue = configRecord[selectedKey];
159
+ const definition = definitions[selectedKey];
160
+ const newValue = await promptConfigValue(selectedKey, currentValue, definition.allowedValues);
161
+
162
+ return { key: selectedKey as keyof T, newValue };
163
+ }
164
+
117
165
  /**
118
166
  * 交互式布尔值选择(内部辅助函数)
119
- * @param {keyof ClawtConfig} key - 配置项名称
167
+ * @param {string} key - 配置项名称
120
168
  * @param {boolean} currentValue - 当前值
121
169
  * @returns {Promise<boolean>} 用户选择的布尔值
122
170
  */
123
- async function promptBooleanValue(key: keyof ClawtConfig, currentValue: boolean): Promise<boolean> {
171
+ async function promptBooleanValue(key: string, currentValue: boolean): Promise<boolean> {
124
172
  const choices = [
125
173
  { name: 'true', message: 'true' },
126
174
  { name: 'false', message: 'false' },
@@ -138,11 +186,11 @@ async function promptBooleanValue(key: keyof ClawtConfig, currentValue: boolean)
138
186
 
139
187
  /**
140
188
  * 交互式数字输入(内部辅助函数)
141
- * @param {keyof ClawtConfig} key - 配置项名称
189
+ * @param {string} key - 配置项名称
142
190
  * @param {number} currentValue - 当前值
143
191
  * @returns {Promise<number>} 用户输入的数字值
144
192
  */
145
- async function promptNumberValue(key: keyof ClawtConfig, currentValue: number): Promise<number> {
193
+ async function promptNumberValue(key: string, currentValue: number): Promise<number> {
146
194
  // @ts-expect-error enquirer 类型声明未导出 Input 类,但运行时存在
147
195
  const input: string = await new Enquirer.Input({
148
196
  message: MESSAGES.CONFIG_INPUT_PROMPT(key),
@@ -158,13 +206,13 @@ async function promptNumberValue(key: keyof ClawtConfig, currentValue: number):
158
206
 
159
207
  /**
160
208
  * 交互式枚举值选择(内部辅助函数,用于有 allowedValues 的 string 配置项)
161
- * @param {keyof ClawtConfig} key - 配置项名称
209
+ * @param {string} key - 配置项名称
162
210
  * @param {string} currentValue - 当前值
163
211
  * @param {readonly string[]} allowedValues - 允许的枚举值列表
164
212
  * @returns {Promise<string>} 用户选择的枚举值
165
213
  */
166
214
  async function promptEnumValue(
167
- key: keyof ClawtConfig,
215
+ key: string,
168
216
  currentValue: string,
169
217
  allowedValues: readonly string[],
170
218
  ): Promise<string> {
@@ -183,14 +231,14 @@ async function promptEnumValue(
183
231
 
184
232
  /**
185
233
  * 交互式字符串自由输入(内部辅助函数,用于无 allowedValues 的 string 配置项)
186
- * @param {keyof ClawtConfig} key - 配置项名称
234
+ * @param {string} key - 配置项名称
187
235
  * @param {string} currentValue - 当前值
188
236
  * @returns {Promise<string>} 用户输入的字符串值
189
237
  */
190
- async function promptStringValue(key: keyof ClawtConfig, currentValue: string): Promise<string> {
238
+ async function promptStringValue(key: string, currentValue: string): Promise<string> {
191
239
  // @ts-expect-error enquirer 类型声明未导出 Input 类,但运行时存在
192
240
  return await new Enquirer.Input({
193
241
  message: MESSAGES.CONFIG_INPUT_PROMPT(key),
194
- initial: currentValue,
242
+ initial: currentValue || '',
195
243
  }).run();
196
244
  }
@@ -68,9 +68,9 @@ export type { ParsedActivity, StreamEvent, LineBuffer } from './stream-parser.js
68
68
  export { detectTerminalApp, openCommandInNewTerminalTab } from './terminal.js';
69
69
  export { truncateTaskDesc, printDryRunPreview } from './dry-run.js';
70
70
  export { applyAliases } from './alias.js';
71
- export { isValidConfigKey, getValidConfigKeys, parseConfigValue, promptConfigValue, formatConfigValue } from './config-strategy.js';
71
+ export { isValidConfigKey, getValidConfigKeys, parseConfigValue, promptConfigValue, formatConfigValue, interactiveConfigEditor } from './config-strategy.js';
72
72
  export { checkForUpdates } from './update-checker.js';
73
- export { getProjectConfigPath, loadProjectConfig, saveProjectConfig, requireProjectConfig, getMainWorkBranch } from './project-config.js';
73
+ export { getProjectConfigPath, loadProjectConfig, saveProjectConfig, requireProjectConfig, getMainWorkBranch, getValidateRunCommand } from './project-config.js';
74
74
  export { getValidateBranchName, createValidateBranch, deleteValidateBranch, rebuildValidateBranch, ensureOnMainWorkBranch, handleDirtyWorkingDir } from './validate-branch.js';
75
75
  export { safeStringify } from './json.js';
76
76
  export { InteractivePanel } from './interactive-panel.js';
@@ -75,3 +75,12 @@ export function getMainWorkBranch(): string {
75
75
  const config = requireProjectConfig();
76
76
  return config.clawtMainWorkBranch;
77
77
  }
78
+
79
+ /**
80
+ * 从项目配置中获取 validate 成功后自动执行的命令
81
+ * @returns {string | undefined} 配置的命令字符串,未配置时返回 undefined
82
+ */
83
+ export function getValidateRunCommand(): string | undefined {
84
+ const config = loadProjectConfig();
85
+ return config?.validateRunCommand || undefined;
86
+ }
@@ -38,6 +38,7 @@ vi.mock('../../../src/utils/index.js', async (importOriginal) => {
38
38
  parseConfigValue: original.parseConfigValue,
39
39
  promptConfigValue: original.promptConfigValue,
40
40
  formatConfigValue: original.formatConfigValue,
41
+ interactiveConfigEditor: original.interactiveConfigEditor,
41
42
  };
42
43
  });
43
44
 
@@ -12,6 +12,12 @@ vi.mock('../../../src/constants/index.js', () => ({
12
12
  INIT_SHOW: (configJson: string) => `当前项目配置:\n${configJson}`,
13
13
  PROJECT_NOT_INITIALIZED: '项目尚未初始化,请先执行 clawt init 设置主工作分支',
14
14
  PROJECT_CONFIG_MISSING_BRANCH: '项目配置缺少主工作分支信息,请重新执行 clawt init 设置主工作分支',
15
+ INIT_SELECT_PROMPT: '选择要修改的项目配置项',
16
+ INIT_SET_SUCCESS: (key: string, value: string) => `✓ 项目配置 ${key} 已设置为 ${value}`,
17
+ },
18
+ PROJECT_CONFIG_DEFINITIONS: {
19
+ clawtMainWorkBranch: { defaultValue: '', description: '主 worktree 的工作分支名' },
20
+ validateRunCommand: { defaultValue: undefined, description: 'validate 成功后自动执行的命令' },
15
21
  },
16
22
  }));
17
23
 
@@ -24,6 +30,7 @@ vi.mock('../../../src/utils/index.js', () => ({
24
30
  printSuccess: vi.fn(),
25
31
  printInfo: vi.fn(),
26
32
  safeStringify: vi.fn((value: unknown, indent: number = 2) => JSON.stringify(value, null, indent)),
33
+ interactiveConfigEditor: vi.fn(),
27
34
  }));
28
35
 
29
36
  import { registerInitCommand } from '../../../src/commands/init.js';
@@ -32,16 +39,16 @@ import {
32
39
  saveProjectConfig,
33
40
  requireProjectConfig,
34
41
  printSuccess,
35
- printInfo,
36
42
  getCurrentBranch,
43
+ interactiveConfigEditor,
37
44
  } from '../../../src/utils/index.js';
38
45
 
39
46
  const mockedLoadProjectConfig = vi.mocked(loadProjectConfig);
40
47
  const mockedSaveProjectConfig = vi.mocked(saveProjectConfig);
41
48
  const mockedRequireProjectConfig = vi.mocked(requireProjectConfig);
42
49
  const mockedPrintSuccess = vi.mocked(printSuccess);
43
- const mockedPrintInfo = vi.mocked(printInfo);
44
50
  const mockedGetCurrentBranch = vi.mocked(getCurrentBranch);
51
+ const mockedInteractiveConfigEditor = vi.mocked(interactiveConfigEditor);
45
52
 
46
53
  beforeEach(() => {
47
54
  mockedLoadProjectConfig.mockReset();
@@ -49,8 +56,8 @@ beforeEach(() => {
49
56
  mockedRequireProjectConfig.mockReset();
50
57
  mockedRequireProjectConfig.mockReturnValue({ clawtMainWorkBranch: 'main' });
51
58
  mockedPrintSuccess.mockReset();
52
- mockedPrintInfo.mockReset();
53
59
  mockedGetCurrentBranch.mockReturnValue('main');
60
+ mockedInteractiveConfigEditor.mockReset();
54
61
  });
55
62
 
56
63
  describe('registerInitCommand', () => {
@@ -132,15 +139,43 @@ describe('handleInit', () => {
132
139
  });
133
140
 
134
141
  describe('handleInitShow (show 子命令)', () => {
135
- it('clawt init show 展示当前配置', async () => {
142
+ it('clawt init show 进入交互式面板并保存修改', async () => {
136
143
  mockedRequireProjectConfig.mockReturnValue({ clawtMainWorkBranch: 'develop' });
144
+ mockedInteractiveConfigEditor.mockResolvedValue({ key: 'validateRunCommand', newValue: 'npm test' });
145
+
146
+ const program = new Command();
147
+ program.exitOverride();
148
+ registerInitCommand(program);
149
+ await program.parseAsync(['init', 'show'], { from: 'user' });
150
+
151
+ // 验证调用了交互式配置编辑器
152
+ expect(mockedInteractiveConfigEditor).toHaveBeenCalled();
153
+ // 验证保存了合并后的配置
154
+ expect(mockedSaveProjectConfig).toHaveBeenCalledWith({
155
+ clawtMainWorkBranch: 'develop',
156
+ validateRunCommand: 'npm test',
157
+ });
158
+ // 验证输出了成功消息
159
+ expect(mockedPrintSuccess).toHaveBeenCalledWith(
160
+ expect.stringContaining('validateRunCommand'),
161
+ );
162
+ });
163
+
164
+ it('clawt init show 修改已有配置项', async () => {
165
+ mockedRequireProjectConfig.mockReturnValue({
166
+ clawtMainWorkBranch: 'main',
167
+ validateRunCommand: 'npm test',
168
+ });
169
+ mockedInteractiveConfigEditor.mockResolvedValue({ key: 'clawtMainWorkBranch', newValue: 'develop' });
137
170
 
138
171
  const program = new Command();
139
172
  program.exitOverride();
140
173
  registerInitCommand(program);
141
174
  await program.parseAsync(['init', 'show'], { from: 'user' });
142
175
 
143
- expect(mockedPrintInfo).toHaveBeenCalled();
144
- expect(mockedSaveProjectConfig).not.toHaveBeenCalled();
176
+ expect(mockedSaveProjectConfig).toHaveBeenCalledWith({
177
+ clawtMainWorkBranch: 'develop',
178
+ validateRunCommand: 'npm test',
179
+ });
145
180
  });
146
181
  });
@@ -11,6 +11,8 @@ vi.mock('../../../src/constants/index.js', () => ({
11
11
  RESUME_SELECT_BRANCH: '选择要恢复的分支',
12
12
  RESUME_MULTIPLE_MATCHES: (keyword: string) => `找到多个匹配 "${keyword}" 的分支`,
13
13
  RESUME_NO_MATCH: (keyword: string, branches: string[]) => `未找到匹配 "${keyword}" 的分支`,
14
+ RESUME_ALL_CONFIRM: (count: number) => `确认恢复 ${count} 个分支?`,
15
+ RESUME_ALL_SUCCESS: (count: number) => `已恢复 ${count} 个分支`,
14
16
  },
15
17
  }));
16
18
 
@@ -19,8 +21,14 @@ vi.mock('../../../src/utils/index.js', () => ({
19
21
  validateClaudeCodeInstalled: vi.fn(),
20
22
  getProjectWorktrees: vi.fn(),
21
23
  launchInteractiveClaude: vi.fn(),
24
+ launchInteractiveClaudeInNewTerminal: vi.fn(),
25
+ hasClaudeSessionHistory: vi.fn(),
22
26
  resolveTargetWorktrees: vi.fn(),
23
27
  promptGroupedMultiSelectBranches: vi.fn(),
28
+ printInfo: vi.fn(),
29
+ printSuccess: vi.fn(),
30
+ confirmAction: vi.fn(),
31
+ getConfigValue: vi.fn(),
24
32
  }));
25
33
 
26
34
  import { registerResumeCommand } from '../../../src/commands/resume.js';
@@ -29,24 +37,36 @@ import {
29
37
  validateClaudeCodeInstalled,
30
38
  getProjectWorktrees,
31
39
  launchInteractiveClaude,
40
+ launchInteractiveClaudeInNewTerminal,
41
+ hasClaudeSessionHistory,
32
42
  resolveTargetWorktrees,
33
43
  promptGroupedMultiSelectBranches,
44
+ confirmAction,
45
+ getConfigValue,
34
46
  } from '../../../src/utils/index.js';
35
47
 
36
48
  const mockedValidateMainWorktree = vi.mocked(validateMainWorktree);
37
49
  const mockedValidateClaudeCodeInstalled = vi.mocked(validateClaudeCodeInstalled);
38
50
  const mockedGetProjectWorktrees = vi.mocked(getProjectWorktrees);
39
51
  const mockedLaunchInteractiveClaude = vi.mocked(launchInteractiveClaude);
52
+ const mockedLaunchInteractiveClaudeInNewTerminal = vi.mocked(launchInteractiveClaudeInNewTerminal);
53
+ const mockedHasClaudeSessionHistory = vi.mocked(hasClaudeSessionHistory);
40
54
  const mockedResolveTargetWorktrees = vi.mocked(resolveTargetWorktrees);
41
55
  const mockedPromptGroupedMultiSelectBranches = vi.mocked(promptGroupedMultiSelectBranches);
56
+ const mockedConfirmAction = vi.mocked(confirmAction);
57
+ const mockedGetConfigValue = vi.mocked(getConfigValue);
42
58
 
43
59
  beforeEach(() => {
44
60
  mockedValidateMainWorktree.mockReset();
45
61
  mockedValidateClaudeCodeInstalled.mockReset();
46
62
  mockedGetProjectWorktrees.mockReset();
47
63
  mockedLaunchInteractiveClaude.mockReset();
64
+ mockedLaunchInteractiveClaudeInNewTerminal.mockReset();
65
+ mockedHasClaudeSessionHistory.mockReset();
48
66
  mockedResolveTargetWorktrees.mockReset();
49
67
  mockedPromptGroupedMultiSelectBranches.mockReset();
68
+ mockedConfirmAction.mockReset();
69
+ mockedGetConfigValue.mockReset();
50
70
  });
51
71
 
52
72
  describe('registerResumeCommand', () => {
@@ -63,6 +83,7 @@ describe('handleResume', () => {
63
83
  const worktree = { path: '/path/feature', branch: 'feature' };
64
84
  mockedGetProjectWorktrees.mockReturnValue([worktree]);
65
85
  mockedResolveTargetWorktrees.mockResolvedValue([worktree]);
86
+ mockedGetConfigValue.mockReturnValue(true);
66
87
 
67
88
  const program = new Command();
68
89
  program.exitOverride();
@@ -83,6 +104,7 @@ describe('handleResume', () => {
83
104
  ];
84
105
  mockedGetProjectWorktrees.mockReturnValue(worktrees);
85
106
  mockedPromptGroupedMultiSelectBranches.mockResolvedValue([worktrees[0]]);
107
+ mockedGetConfigValue.mockReturnValue(true);
86
108
 
87
109
  const program = new Command();
88
110
  program.exitOverride();
@@ -100,6 +122,7 @@ describe('handleResume', () => {
100
122
  const worktree = { path: '/path/feature', branch: 'feature' };
101
123
  mockedGetProjectWorktrees.mockReturnValue([worktree]);
102
124
  mockedResolveTargetWorktrees.mockResolvedValue([worktree]);
125
+ mockedGetConfigValue.mockReturnValue(true);
103
126
 
104
127
  const program = new Command();
105
128
  program.exitOverride();
@@ -110,3 +133,93 @@ describe('handleResume', () => {
110
133
  expect(mockedPromptGroupedMultiSelectBranches).not.toHaveBeenCalled();
111
134
  });
112
135
  });
136
+
137
+ describe('handleResume — resumeInPlace 配置', () => {
138
+ it('resumeInPlace 为 true 时,单选在当前终端就地恢复', async () => {
139
+ const worktree = { path: '/path/feature', branch: 'feature' };
140
+ mockedGetProjectWorktrees.mockReturnValue([worktree]);
141
+ mockedResolveTargetWorktrees.mockResolvedValue([worktree]);
142
+ mockedGetConfigValue.mockReturnValue(true);
143
+
144
+ const program = new Command();
145
+ program.exitOverride();
146
+ registerResumeCommand(program);
147
+ await program.parseAsync(['resume', '-b', 'feature'], { from: 'user' });
148
+
149
+ expect(mockedGetConfigValue).toHaveBeenCalledWith('resumeInPlace');
150
+ expect(mockedLaunchInteractiveClaude).toHaveBeenCalledWith(worktree, { autoContinue: true });
151
+ expect(mockedLaunchInteractiveClaudeInNewTerminal).not.toHaveBeenCalled();
152
+ });
153
+
154
+ it('resumeInPlace 为 false 时,单选在新终端 Tab 中恢复', async () => {
155
+ const worktree = { path: '/path/feature', branch: 'feature' };
156
+ mockedGetProjectWorktrees.mockReturnValue([worktree]);
157
+ mockedResolveTargetWorktrees.mockResolvedValue([worktree]);
158
+ mockedGetConfigValue.mockReturnValue(false);
159
+ mockedHasClaudeSessionHistory.mockReturnValue(true);
160
+
161
+ const program = new Command();
162
+ program.exitOverride();
163
+ registerResumeCommand(program);
164
+ await program.parseAsync(['resume', '-b', 'feature'], { from: 'user' });
165
+
166
+ expect(mockedGetConfigValue).toHaveBeenCalledWith('resumeInPlace');
167
+ expect(mockedHasClaudeSessionHistory).toHaveBeenCalledWith(worktree.path);
168
+ expect(mockedLaunchInteractiveClaudeInNewTerminal).toHaveBeenCalledWith(worktree, true);
169
+ expect(mockedLaunchInteractiveClaude).not.toHaveBeenCalled();
170
+ });
171
+
172
+ it('resumeInPlace 为 false 且无历史会话时,传 false 给新终端启动', async () => {
173
+ const worktree = { path: '/path/feature', branch: 'feature' };
174
+ mockedGetProjectWorktrees.mockReturnValue([worktree]);
175
+ mockedResolveTargetWorktrees.mockResolvedValue([worktree]);
176
+ mockedGetConfigValue.mockReturnValue(false);
177
+ mockedHasClaudeSessionHistory.mockReturnValue(false);
178
+
179
+ const program = new Command();
180
+ program.exitOverride();
181
+ registerResumeCommand(program);
182
+ await program.parseAsync(['resume', '-b', 'feature'], { from: 'user' });
183
+
184
+ expect(mockedLaunchInteractiveClaudeInNewTerminal).toHaveBeenCalledWith(worktree, false);
185
+ });
186
+
187
+ it('多选时不受 resumeInPlace 影响,始终在新 Tab 中打开', async () => {
188
+ const worktrees = [
189
+ { path: '/path/feature-a', branch: 'feature-a' },
190
+ { path: '/path/feature-b', branch: 'feature-b' },
191
+ ];
192
+ mockedGetProjectWorktrees.mockReturnValue(worktrees);
193
+ mockedPromptGroupedMultiSelectBranches.mockResolvedValue(worktrees);
194
+ mockedConfirmAction.mockResolvedValue(true);
195
+ mockedHasClaudeSessionHistory.mockReturnValue(false);
196
+ mockedGetConfigValue.mockReturnValue(true);
197
+
198
+ const program = new Command();
199
+ program.exitOverride();
200
+ registerResumeCommand(program);
201
+ await program.parseAsync(['resume'], { from: 'user' });
202
+
203
+ // 多选走 handleBatchResume,不读取 resumeInPlace
204
+ expect(mockedLaunchInteractiveClaude).not.toHaveBeenCalled();
205
+ expect(mockedLaunchInteractiveClaudeInNewTerminal).toHaveBeenCalledTimes(2);
206
+ });
207
+
208
+ it('用户未选择任何分支时直接退出', async () => {
209
+ const worktrees = [
210
+ { path: '/path/feature-a', branch: 'feature-a' },
211
+ { path: '/path/feature-b', branch: 'feature-b' },
212
+ ];
213
+ mockedGetProjectWorktrees.mockReturnValue(worktrees);
214
+ mockedPromptGroupedMultiSelectBranches.mockResolvedValue([]);
215
+
216
+ const program = new Command();
217
+ program.exitOverride();
218
+ registerResumeCommand(program);
219
+ await program.parseAsync(['resume'], { from: 'user' });
220
+
221
+ expect(mockedLaunchInteractiveClaude).not.toHaveBeenCalled();
222
+ expect(mockedLaunchInteractiveClaudeInNewTerminal).not.toHaveBeenCalled();
223
+ expect(mockedGetConfigValue).not.toHaveBeenCalled();
224
+ });
225
+ });
@@ -101,6 +101,7 @@ vi.mock('../../../src/utils/index.js', () => ({
101
101
  handleDirtyWorkingDir: vi.fn(),
102
102
  checkBranchExists: vi.fn().mockReturnValue(true),
103
103
  getCurrentBranch: vi.fn().mockReturnValue('main'),
104
+ getValidateRunCommand: vi.fn(),
104
105
  }));
105
106
 
106
107
  import { registerValidateCommand } from '../../../src/commands/validate.js';
@@ -140,6 +141,7 @@ import {
140
141
  printSeparator,
141
142
  parseParallelCommands,
142
143
  runParallelCommands,
144
+ getValidateRunCommand,
143
145
  } from '../../../src/utils/index.js';
144
146
 
145
147
  const mockedGetProjectName = vi.mocked(getProjectName);
@@ -177,6 +179,7 @@ const mockedPrintError = vi.mocked(printError);
177
179
  const mockedPrintSeparator = vi.mocked(printSeparator);
178
180
  const mockedParseParallelCommands = vi.mocked(parseParallelCommands);
179
181
  const mockedRunParallelCommands = vi.mocked(runParallelCommands);
182
+ const mockedGetValidateRunCommand = vi.mocked(getValidateRunCommand);
180
183
 
181
184
  const worktree = { path: '/path/feature', branch: 'feature' };
182
185
 
@@ -216,6 +219,7 @@ beforeEach(() => {
216
219
  mockedPrintSeparator.mockReset();
217
220
  mockedParseParallelCommands.mockReset();
218
221
  mockedRunParallelCommands.mockReset();
222
+ mockedGetValidateRunCommand.mockReset();
219
223
  // 默认让 parseParallelCommands 返回单命令数组,保持旧测试兼容
220
224
  mockedParseParallelCommands.mockImplementation((cmd: string) => [cmd]);
221
225
  });
@@ -632,3 +636,62 @@ describe('--run 并行命令', () => {
632
636
  expect(mockedRunParallelCommands).not.toHaveBeenCalled();
633
637
  });
634
638
  });
639
+
640
+ describe('配置读取 fallback(resolveRunCommand)', () => {
641
+ /** 设置首次 validate 成功的公共 mock */
642
+ function setupSuccessfulFirstValidate(): void {
643
+ mockedIsWorkingDirClean.mockReturnValue(true);
644
+ mockedHasLocalCommits.mockReturnValue(true);
645
+ mockedHasSnapshot.mockReturnValue(false);
646
+ mockedGitDiffBinaryAgainstBranch.mockReturnValue(Buffer.from('diff'));
647
+ mockedGitWriteTree.mockReturnValue('treehash');
648
+ mockedGetHeadCommitHash.mockReturnValue('headhash');
649
+ }
650
+
651
+ it('未传 -r 但项目配置有 validateRunCommand 时从配置读取执行', async () => {
652
+ setupSuccessfulFirstValidate();
653
+ mockedGetValidateRunCommand.mockReturnValue('pnpm test');
654
+ mockedRunCommandInherited.mockReturnValue({
655
+ pid: 0, output: [], stdout: Buffer.alloc(0), stderr: Buffer.alloc(0),
656
+ status: 0, signal: null, error: undefined,
657
+ });
658
+
659
+ const program = new Command();
660
+ program.exitOverride();
661
+ registerValidateCommand(program);
662
+ await program.parseAsync(['validate', '-b', 'feature'], { from: 'user' });
663
+
664
+ // 应该从配置读取命令并执行
665
+ expect(mockedRunCommandInherited).toHaveBeenCalledWith('pnpm test', { cwd: '/repo' });
666
+ });
667
+
668
+ it('传了 -r 时以用户参数为准,忽略项目配置', async () => {
669
+ setupSuccessfulFirstValidate();
670
+ mockedGetValidateRunCommand.mockReturnValue('pnpm test');
671
+ mockedRunCommandInherited.mockReturnValue({
672
+ pid: 0, output: [], stdout: Buffer.alloc(0), stderr: Buffer.alloc(0),
673
+ status: 0, signal: null, error: undefined,
674
+ });
675
+
676
+ const program = new Command();
677
+ program.exitOverride();
678
+ registerValidateCommand(program);
679
+ await program.parseAsync(['validate', '-b', 'feature', '-r', 'pnpm build'], { from: 'user' });
680
+
681
+ // 应该使用用户传入的命令,而非配置中的
682
+ expect(mockedRunCommandInherited).toHaveBeenCalledWith('pnpm build', { cwd: '/repo' });
683
+ });
684
+
685
+ it('未传 -r 且项目配置无 validateRunCommand 时不执行命令', async () => {
686
+ setupSuccessfulFirstValidate();
687
+ mockedGetValidateRunCommand.mockReturnValue(undefined);
688
+
689
+ const program = new Command();
690
+ program.exitOverride();
691
+ registerValidateCommand(program);
692
+ await program.parseAsync(['validate', '-b', 'feature'], { from: 'user' });
693
+
694
+ // 没有命令可执行
695
+ expect(mockedRunCommandInherited).not.toHaveBeenCalled();
696
+ });
697
+ });
@@ -33,6 +33,7 @@ describe('DEFAULT_CONFIG', () => {
33
33
  expect(DEFAULT_CONFIG.confirmDestructiveOps).toBe(true);
34
34
  expect(DEFAULT_CONFIG.maxConcurrency).toBe(0);
35
35
  expect(DEFAULT_CONFIG.aliases).toEqual({});
36
+ expect(DEFAULT_CONFIG.resumeInPlace).toBe(false);
36
37
  });
37
38
  });
38
39
 
@@ -48,6 +48,7 @@ vi.mock('../../../src/constants/index.js', async (importOriginal) => {
48
48
  CONFIG_INVALID_ENUM: (key: string, validValues: readonly string[]) =>
49
49
  `配置项 ${key} 仅接受以下值: ${validValues.join(', ')}`,
50
50
  CONFIG_INPUT_PROMPT: (key: string) => `输入 ${key} 的新值`,
51
+ CONFIG_SELECT_PROMPT: '选择要修改的配置项',
51
52
  },
52
53
  };
53
54
  });
@@ -58,6 +59,7 @@ import {
58
59
  parseConfigValue,
59
60
  promptConfigValue,
60
61
  formatConfigValue,
62
+ interactiveConfigEditor,
61
63
  } from '../../../src/utils/config-strategy.js';
62
64
 
63
65
  beforeEach(() => {
@@ -184,7 +186,7 @@ describe('promptConfigValue', () => {
184
186
 
185
187
  it('字符串 + 有 allowedValues 使用 Select 提示', async () => {
186
188
  mockSelectRun.mockResolvedValueOnce('iterm2');
187
- const result = await promptConfigValue('terminalApp', 'auto');
189
+ const result = await promptConfigValue('terminalApp', 'auto', ['auto', 'iterm2', 'terminal']);
188
190
  expect(result).toBe('iterm2');
189
191
  expect(mockSelectRun).toHaveBeenCalledTimes(1);
190
192
  });
@@ -217,4 +219,78 @@ describe('formatConfigValue', () => {
217
219
  const result = formatConfigValue('hello');
218
220
  expect(result).toContain('hello');
219
221
  });
222
+
223
+ it('undefined 显示为 (未设置)', () => {
224
+ const result = formatConfigValue(undefined);
225
+ expect(result).toContain('未设置');
226
+ });
227
+
228
+ it('null 显示为 (未设置)', () => {
229
+ const result = formatConfigValue(null);
230
+ expect(result).toContain('未设置');
231
+ });
232
+ });
233
+
234
+ describe('interactiveConfigEditor', () => {
235
+ it('选择配置项并返回新值(布尔类型)', async () => {
236
+ // 第一次 Select 选择配置项,第二次 Select 选择布尔值
237
+ mockSelectRun.mockResolvedValueOnce('enabled');
238
+ mockSelectRun.mockResolvedValueOnce('true');
239
+
240
+ const config = { enabled: false, name: 'test' };
241
+ const definitions = {
242
+ enabled: { description: '是否启用' },
243
+ name: { description: '名称' },
244
+ };
245
+
246
+ const result = await interactiveConfigEditor(config, definitions);
247
+ expect(result.key).toBe('enabled');
248
+ expect(result.newValue).toBe(true);
249
+ });
250
+
251
+ it('选择配置项并返回新值(字符串类型)', async () => {
252
+ mockSelectRun.mockResolvedValueOnce('name');
253
+ mockInputRun.mockResolvedValueOnce('new-name');
254
+
255
+ const config = { enabled: false, name: 'test' };
256
+ const definitions = {
257
+ enabled: { description: '是否启用' },
258
+ name: { description: '名称' },
259
+ };
260
+
261
+ const result = await interactiveConfigEditor(config, definitions);
262
+ expect(result.key).toBe('name');
263
+ expect(result.newValue).toBe('new-name');
264
+ });
265
+
266
+ it('支持自定义 selectPrompt', async () => {
267
+ mockSelectRun.mockResolvedValueOnce('name');
268
+ mockInputRun.mockResolvedValueOnce('value');
269
+
270
+ const config = { name: 'test' };
271
+ const definitions = { name: { description: '名称' } };
272
+
273
+ await interactiveConfigEditor(config, definitions, {
274
+ selectPrompt: '自定义提示',
275
+ });
276
+
277
+ // 验证 Select 被调用(无需检查具体参数,因为 mock 不保留构造参数)
278
+ expect(mockSelectRun).toHaveBeenCalledTimes(1);
279
+ });
280
+
281
+ it('有 allowedValues 的字符串配置项使用 Select 提示', async () => {
282
+ mockSelectRun.mockResolvedValueOnce('mode');
283
+ mockSelectRun.mockResolvedValueOnce('fast');
284
+
285
+ const config = { mode: 'normal' };
286
+ const definitions = {
287
+ mode: { description: '模式', allowedValues: ['normal', 'fast', 'slow'] as const },
288
+ };
289
+
290
+ const result = await interactiveConfigEditor(config, definitions);
291
+ expect(result.key).toBe('mode');
292
+ expect(result.newValue).toBe('fast');
293
+ // 两次 Select:一次选择配置项,一次选择枚举值
294
+ expect(mockSelectRun).toHaveBeenCalledTimes(2);
295
+ });
220
296
  });