clawt 3.9.13 → 3.10.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.
Files changed (106) hide show
  1. package/README.md +80 -1
  2. package/README.zh-CN.md +81 -0
  3. package/dist/index.js +1935 -592
  4. package/dist/postinstall.js +1626 -283
  5. package/docs/config-file.md +2 -0
  6. package/docs/config.md +2 -1
  7. package/docs/init.md +3 -2
  8. package/docs/project-config.md +10 -1
  9. package/docs/spec.md +69 -2
  10. package/package.json +1 -1
  11. package/src/commands/alias.ts +5 -4
  12. package/src/commands/completion.ts +2 -1
  13. package/src/commands/config.ts +25 -7
  14. package/src/commands/cover-validate.ts +3 -2
  15. package/src/commands/create.ts +8 -7
  16. package/src/commands/home.ts +2 -1
  17. package/src/commands/init.ts +13 -6
  18. package/src/commands/list.ts +6 -4
  19. package/src/commands/merge.ts +8 -7
  20. package/src/commands/projects.ts +5 -3
  21. package/src/commands/remove.ts +7 -6
  22. package/src/commands/reset.ts +3 -2
  23. package/src/commands/resume.ts +10 -7
  24. package/src/commands/run.ts +8 -7
  25. package/src/commands/status.ts +16 -11
  26. package/src/commands/sync.ts +4 -3
  27. package/src/commands/tasks.ts +8 -6
  28. package/src/commands/validate.ts +7 -6
  29. package/src/constants/ai-prompts.ts +11 -11
  30. package/src/constants/config.ts +30 -0
  31. package/src/constants/index.ts +3 -2
  32. package/src/constants/messages/alias.ts +44 -14
  33. package/src/constants/messages/cli-descriptions.ts +91 -0
  34. package/src/constants/messages/common.ts +221 -36
  35. package/src/constants/messages/completion.ts +43 -14
  36. package/src/constants/messages/config.ts +61 -18
  37. package/src/constants/messages/cover-validate.ts +43 -14
  38. package/src/constants/messages/create.ts +16 -5
  39. package/src/constants/messages/home.ts +19 -6
  40. package/src/constants/messages/index.ts +2 -0
  41. package/src/constants/messages/init.ts +45 -14
  42. package/src/constants/messages/interactive-panel.ts +183 -29
  43. package/src/constants/messages/merge.ts +140 -38
  44. package/src/constants/messages/post-create.ts +59 -19
  45. package/src/constants/messages/projects.ts +51 -14
  46. package/src/constants/messages/remove.ts +50 -15
  47. package/src/constants/messages/reset.ts +14 -4
  48. package/src/constants/messages/resume.ts +116 -19
  49. package/src/constants/messages/run.ts +165 -35
  50. package/src/constants/messages/status.ts +84 -23
  51. package/src/constants/messages/sync.ts +54 -17
  52. package/src/constants/messages/tasks.ts +21 -7
  53. package/src/constants/messages/update.ts +35 -11
  54. package/src/constants/messages/validate.ts +218 -57
  55. package/src/constants/progress.ts +17 -6
  56. package/src/constants/project-config.ts +17 -0
  57. package/src/constants/prompt.ts +18 -2
  58. package/src/constants/tasks-template.ts +56 -2
  59. package/src/hooks/post-create.ts +5 -2
  60. package/src/index.ts +6 -5
  61. package/src/types/config.ts +2 -0
  62. package/src/utils/alias.ts +2 -1
  63. package/src/utils/claude.ts +10 -9
  64. package/src/utils/config-strategy.ts +3 -3
  65. package/src/utils/dry-run.ts +2 -2
  66. package/src/utils/formatter.ts +18 -11
  67. package/src/utils/i18n.ts +63 -0
  68. package/src/utils/index.ts +2 -0
  69. package/src/utils/interactive-panel-render.ts +6 -3
  70. package/src/utils/progress-render.ts +11 -9
  71. package/src/utils/prompt.ts +2 -1
  72. package/src/utils/task-executor.ts +10 -7
  73. package/src/utils/task-file.ts +2 -1
  74. package/src/utils/terminal.ts +9 -9
  75. package/src/utils/ui-prompts.ts +4 -3
  76. package/src/utils/update-checker.ts +1 -1
  77. package/src/utils/validate-branch.ts +16 -9
  78. package/src/utils/validate-core.ts +2 -1
  79. package/src/utils/validate-runner.ts +2 -2
  80. package/src/utils/worktree-matcher.ts +9 -7
  81. package/tests/unit/commands/alias.test.ts +4 -0
  82. package/tests/unit/commands/completion.test.ts +14 -0
  83. package/tests/unit/commands/config.test.ts +61 -28
  84. package/tests/unit/commands/cover-validate.test.ts +13 -2
  85. package/tests/unit/commands/init.test.ts +6 -2
  86. package/tests/unit/commands/merge.test.ts +14 -0
  87. package/tests/unit/commands/run.test.ts +17 -0
  88. package/tests/unit/commands/tasks.test.ts +39 -9
  89. package/tests/unit/constants/config.test.ts +16 -1
  90. package/tests/unit/constants/messages-post-create.test.ts +93 -1
  91. package/tests/unit/constants/messages.test.ts +85 -1
  92. package/tests/unit/hooks/post-create.test.ts +32 -0
  93. package/tests/unit/utils/alias.test.ts +14 -0
  94. package/tests/unit/utils/claude.test.ts +24 -4
  95. package/tests/unit/utils/config-strategy.test.ts +21 -0
  96. package/tests/unit/utils/conflict-resolver.test.ts +24 -4
  97. package/tests/unit/utils/formatter.test.ts +21 -0
  98. package/tests/unit/utils/i18n.test.ts +91 -0
  99. package/tests/unit/utils/progress.test.ts +39 -18
  100. package/tests/unit/utils/prompt.test.ts +25 -2
  101. package/tests/unit/utils/task-file.test.ts +73 -10
  102. package/tests/unit/utils/terminal-cmux.test.ts +19 -4
  103. package/tests/unit/utils/update-checker.test.ts +2 -0
  104. package/tests/unit/utils/validate-branch.test.ts +26 -1
  105. package/tests/unit/utils/validation.test.ts +2 -2
  106. package/tests/unit/utils/worktree-matcher.test.ts +2 -0
@@ -5,6 +5,7 @@ import { checkBranchExists, createBranch, deleteBranch, getCurrentBranch, gitChe
5
5
  import { getMainWorkBranch } from './project-config.js';
6
6
  import { printWarning, confirmAction } from './formatter.js';
7
7
  import { ClawtError } from '../errors/index.js';
8
+ import { getCurrentLanguage } from './i18n.js';
8
9
  import { isNonInteractive } from './interactive.js';
9
10
 
10
11
  /**
@@ -94,35 +95,41 @@ export async function handleDirtyWorkingDir(cwd?: string): Promise<void> {
94
95
  gitAddAll(cwd);
95
96
  gitStashPush('clawt:auto-stash', cwd);
96
97
  if (!isWorkingDirClean(cwd)) {
97
- throw new ClawtError('工作区仍然不干净,请手动处理');
98
+ throw new ClawtError(MESSAGES.WORKSPACE_STILL_DIRTY);
98
99
  }
99
100
  return;
100
101
  }
101
102
 
102
- printWarning('当前分支有未提交的更改,请选择处理方式:\n');
103
+ printWarning(MESSAGES.UNCOMMITTED_CHANGES_ON_BRANCH);
103
104
 
104
105
  // @ts-expect-error enquirer 类型声明未导出 Select 类,但运行时存在
105
106
  const choice = await new Enquirer.Select({
106
- message: '选择处理方式',
107
+ message: MESSAGES.SELECT_ACTION,
107
108
  choices: [
108
109
  {
109
110
  name: 'reset',
110
- message: 'reset - 丢弃所有更改 (git reset --hard HEAD && git clean -fd)',
111
+ message: getCurrentLanguage() === 'en'
112
+ ? 'reset - Discard all changes (git reset --hard HEAD && git clean -fd)'
113
+ : 'reset - 丢弃所有更改 (git reset --hard HEAD && git clean -fd)',
111
114
  },
112
115
  {
113
116
  name: 'stash',
114
- message: 'stash - 暂存更改 (git add . && git stash)',
117
+ message: getCurrentLanguage() === 'en'
118
+ ? 'stash - Stash changes (git add . && git stash)'
119
+ : 'stash - 暂存更改 (git add . && git stash)',
115
120
  },
116
121
  {
117
122
  name: 'exit',
118
- message: 'exit - 退出,手动处理',
123
+ message: getCurrentLanguage() === 'en'
124
+ ? 'exit - Exit, handle manually'
125
+ : 'exit - 退出,手动处理',
119
126
  },
120
127
  ],
121
128
  initial: 0,
122
129
  }).run();
123
130
 
124
131
  if (choice === 'exit') {
125
- throw new ClawtError('用户选择退出,请手动处理工作区更改后重试');
132
+ throw new ClawtError(MESSAGES.USER_CHOSE_EXIT);
126
133
  }
127
134
 
128
135
  if (choice === 'reset') {
@@ -135,7 +142,7 @@ export async function handleDirtyWorkingDir(cwd?: string): Promise<void> {
135
142
 
136
143
  // 再次检查是否干净
137
144
  if (!isWorkingDirClean(cwd)) {
138
- throw new ClawtError('工作区仍然不干净,请手动处理');
145
+ throw new ClawtError(MESSAGES.WORKSPACE_STILL_DIRTY);
139
146
  }
140
147
  }
141
148
 
@@ -171,7 +178,7 @@ export async function ensureOnMainWorkBranch(cwd?: string): Promise<void> {
171
178
  // 当前在其他分支上,警告并确认后处理脏工作区再切换
172
179
  printWarning(MESSAGES.GUARD_BRANCH_MISMATCH(mainBranch, currentBranch));
173
180
  // 非交互模式下自动确认继续
174
- const confirmed = isNonInteractive() ? true : await confirmAction('是否继续执行?');
181
+ const confirmed = isNonInteractive() ? true : await confirmAction(MESSAGES.CONFIRM_CONTINUE_VALIDATE);
175
182
  if (!confirmed) {
176
183
  throw new ClawtError(MESSAGES.DESTRUCTIVE_OP_CANCELLED);
177
184
  }
@@ -1,6 +1,7 @@
1
1
  import { logger } from '../logger/index.js';
2
2
  import { ClawtError } from '../errors/index.js';
3
3
  import { MESSAGES } from '../constants/index.js';
4
+ import { getCurrentLanguage } from './i18n.js';
4
5
  import {
5
6
  gitAddAll,
6
7
  gitCommit,
@@ -137,7 +138,7 @@ export function loadOldSnapshotToStage(oldTreeHash: string, oldHeadCommitHash: s
137
138
  return { success: true, stagedTreeHash };
138
139
  } else if (oldChangePatch.length > 0) {
139
140
  // 有冲突:降级为全量模式(暂存区保持为空)
140
- logger.warn('旧变更 patch 与当前 HEAD 冲突,降级为全量模式');
141
+ logger.warn(getCurrentLanguage() === 'en' ? 'Old changes patch conflicts with current HEAD, falling back to full mode' : '旧变更 patch 与当前 HEAD 冲突,降级为全量模式');
141
142
  return { success: false, stagedTreeHash: '' };
142
143
  }
143
144
  // oldChangePatch 为空表示旧变更为空,暂存区保持干净即可
@@ -36,7 +36,7 @@ function buildSingleErrorClipboard(command: string, stderr: string, exitCode: nu
36
36
  if (stderr.trim()) {
37
37
  return MESSAGES.VALIDATE_CLIPBOARD_SINGLE_ERROR(command, stderr.trim());
38
38
  }
39
- return `${command} 指令执行出错,退出码: ${exitCode}`;
39
+ return MESSAGES.COMMAND_EXEC_ERROR(command, exitCode);
40
40
  }
41
41
 
42
42
  /**
@@ -94,7 +94,7 @@ function reportParallelResults(results: ParallelCommandResultWithStderr[]): void
94
94
  // 收集错误信息用于剪贴板
95
95
  const errorContent = result.stderr.trim()
96
96
  ? result.stderr.trim()
97
- : `退出码: ${result.exitCode}`;
97
+ : MESSAGES.EXIT_CODE_LABEL(result.exitCode);
98
98
  errorClipboardParts.push(
99
99
  MESSAGES.VALIDATE_CLIPBOARD_PARALLEL_ERROR(result.command, errorContent),
100
100
  );
@@ -12,6 +12,7 @@ import {
12
12
  import type { WorktreeInfo } from '../types/index.js';
13
13
  import { promptSelectBranch, promptGroupedMultiSelectBranches } from './ui-prompts.js';
14
14
  import type { GroupedChoice } from './ui-prompts.js';
15
+ import { getCurrentLanguage } from './i18n.js';
15
16
 
16
17
  /**
17
18
  * 分支解析时使用的消息文案配置
@@ -213,26 +214,27 @@ export function getWorktreeCreatedTime(dirPath: string): string | null {
213
214
  }
214
215
 
215
216
  /**
216
- * 将 YYYY-MM-DD 日期字符串格式化为中文相对日期描述
217
+ * 将 YYYY-MM-DD 日期字符串格式化为相对日期描述
217
218
  * 基于自然日计算,适用于日期分组场景
218
219
  * @param {string} dateStr - YYYY-MM-DD 格式的日期字符串
219
- * @returns {string} 中文相对日期描述,如"今天"、"昨天"、"3 天前"
220
+ * @returns {string} 相对日期描述,如"今天"/"Today"、"昨天"/"Yesterday"、"3 天前"/"3 days ago"
220
221
  */
221
222
  export function formatRelativeDate(dateStr: string): string {
223
+ const lang = getCurrentLanguage();
222
224
  const today = formatLocalDate(new Date());
223
225
  const todayMs = new Date(today).getTime();
224
226
  const targetMs = new Date(dateStr).getTime();
225
227
  const diffDays = Math.round((todayMs - targetMs) / (1000 * 60 * 60 * 24));
226
228
 
227
- if (diffDays === 0) return '今天';
228
- if (diffDays === 1) return '昨天';
229
- if (diffDays < 30) return `${diffDays} 天前`;
229
+ if (diffDays === 0) return lang === 'en' ? 'Today' : '今天';
230
+ if (diffDays === 1) return lang === 'en' ? 'Yesterday' : '昨天';
231
+ if (diffDays < 30) return lang === 'en' ? `${diffDays} day${diffDays > 1 ? 's' : ''} ago` : `${diffDays} 天前`;
230
232
  if (diffDays < 365) {
231
233
  const months = Math.floor(diffDays / 30);
232
- return `${months} 个月前`;
234
+ return lang === 'en' ? `${months} month${months > 1 ? 's' : ''} ago` : `${months} 个月前`;
233
235
  }
234
236
  const years = Math.floor(diffDays / 365);
235
- return `${years} 年前`;
237
+ return lang === 'en' ? `${years} year${years > 1 ? 's' : ''} ago` : `${years} 年前`;
236
238
  }
237
239
 
238
240
  /**
@@ -43,14 +43,18 @@ beforeEach(() => {
43
43
  /** 构造默认配置 mock 数据 */
44
44
  function mockDefaultConfig(aliases: Record<string, string> = {}) {
45
45
  return {
46
+ language: 'en' as const,
46
47
  autoDeleteBranch: false,
47
48
  claudeCodeCommand: 'claude',
48
49
  autoPullPush: false,
49
50
  confirmDestructiveOps: true,
50
51
  maxConcurrency: 0,
51
52
  terminalApp: 'auto',
53
+ resumeInPlace: false,
52
54
  aliases,
53
55
  autoUpdate: true,
56
+ conflictResolveMode: 'ask' as const,
57
+ conflictResolveTimeoutMs: 900000,
54
58
  };
55
59
  }
56
60
 
@@ -28,6 +28,20 @@ vi.mock('../../../src/logger/index.js', () => ({
28
28
  }));
29
29
  vi.mock('../../../src/utils/fs.js', () => ({}));
30
30
 
31
+ // mock i18n 模块,避免循环依赖导致 currentLanguage 未初始化
32
+ vi.mock('../../../src/utils/i18n.js', () => ({
33
+ getCurrentLanguage: vi.fn().mockReturnValue('en'),
34
+ resetLanguageCache: vi.fn(),
35
+ setCurrentLanguage: vi.fn(),
36
+ createMessages: vi.fn((i18nMap: Record<string, { en: any; 'zh-CN': any }>) => {
37
+ const result: any = {};
38
+ for (const key of Object.keys(i18nMap)) {
39
+ result[key] = i18nMap[key]['en'];
40
+ }
41
+ return result;
42
+ }),
43
+ }));
44
+
31
45
  /**
32
46
  * 创建 statSync 的 mock 返回值
33
47
  * @param {boolean} isDir - 是否为目录
@@ -45,52 +45,81 @@ vi.mock('../../../src/utils/index.js', async (importOriginal) => {
45
45
  vi.mock('../../../src/constants/index.js', () => ({
46
46
  CONFIG_PATH: '/mock/.clawt/config.json',
47
47
  DEFAULT_CONFIG: {
48
+ language: 'en',
48
49
  autoDeleteBranch: false,
49
50
  claudeCodeCommand: 'claude',
50
51
  autoPullPush: false,
51
52
  confirmDestructiveOps: true,
52
53
  maxConcurrency: 0,
53
54
  terminalApp: 'auto',
55
+ resumeInPlace: false,
54
56
  aliases: {},
55
57
  autoUpdate: true,
58
+ conflictResolveMode: 'ask',
59
+ conflictResolveTimeoutMs: 900000,
56
60
  },
57
61
  CONFIG_DESCRIPTIONS: {
58
- autoDeleteBranch: '自动删除分支',
59
- claudeCodeCommand: 'Claude Code CLI 命令',
60
- autoPullPush: '自动 pull/push',
61
- confirmDestructiveOps: '破坏性操作确认',
62
- maxConcurrency: '最大并发数',
63
- terminalApp: '终端应用',
64
- aliases: '命令别名映射',
65
- autoUpdate: '自动更新',
62
+ language: 'Interface language',
63
+ autoDeleteBranch: 'Whether to auto-delete the local branch when removing a worktree',
64
+ claudeCodeCommand: 'Claude Code CLI launch command',
65
+ autoPullPush: 'Whether to auto-run git pull and git push after merge',
66
+ confirmDestructiveOps: 'Whether to prompt for confirmation before destructive operations',
67
+ maxConcurrency: 'Default max concurrency for run command, 0 means unlimited',
68
+ terminalApp: 'Terminal app for batch resume',
69
+ resumeInPlace: 'Whether to resume in current terminal',
70
+ aliases: 'Command alias mapping',
71
+ autoUpdate: 'Whether to enable auto-update checks',
72
+ conflictResolveMode: 'Merge conflict resolution mode',
73
+ conflictResolveTimeoutMs: 'Claude Code conflict resolution timeout',
66
74
  },
67
75
  CONFIG_DEFINITIONS: {
68
- autoDeleteBranch: { defaultValue: false, description: '自动删除分支' },
69
- claudeCodeCommand: { defaultValue: 'claude', description: 'Claude Code CLI 命令' },
70
- autoPullPush: { defaultValue: false, description: '自动 pull/push' },
71
- confirmDestructiveOps: { defaultValue: true, description: '破坏性操作确认' },
72
- maxConcurrency: { defaultValue: 0, description: '最大并发数' },
73
- terminalApp: { defaultValue: 'auto', description: '终端应用', allowedValues: ['auto', 'iterm2', 'terminal'] },
74
- aliases: { defaultValue: {}, description: '命令别名映射' },
75
- autoUpdate: { defaultValue: true, description: '自动更新' },
76
+ language: { defaultValue: 'en', description: 'Interface language', allowedValues: ['en', 'zh-CN'] },
77
+ autoDeleteBranch: { defaultValue: false, description: 'Whether to auto-delete the local branch when removing a worktree' },
78
+ claudeCodeCommand: { defaultValue: 'claude', description: 'Claude Code CLI launch command' },
79
+ autoPullPush: { defaultValue: false, description: 'Whether to auto-run git pull and git push after merge' },
80
+ confirmDestructiveOps: { defaultValue: true, description: 'Whether to prompt for confirmation before destructive operations' },
81
+ maxConcurrency: { defaultValue: 0, description: 'Default max concurrency for run command, 0 means unlimited' },
82
+ terminalApp: { defaultValue: 'auto', description: 'Terminal app for batch resume', allowedValues: ['auto', 'iterm2', 'terminal'] },
83
+ resumeInPlace: { defaultValue: false, description: 'Whether to resume in current terminal' },
84
+ aliases: { defaultValue: {}, description: 'Command alias mapping' },
85
+ autoUpdate: { defaultValue: true, description: 'Whether to enable auto-update checks' },
86
+ conflictResolveMode: { defaultValue: 'ask', description: 'Merge conflict resolution mode', allowedValues: ['ask', 'auto', 'manual'] },
87
+ conflictResolveTimeoutMs: { defaultValue: 900000, description: 'Claude Code conflict resolution timeout' },
76
88
  },
77
- CONFIG_ALIAS_DISABLED_HINT: '(通过 clawt alias 命令管理)',
89
+ CONFIG_ALIAS_DISABLED_HINT: '(Manage via clawt alias command)',
90
+ getI18nConfigDescriptions: () => ({
91
+ autoDeleteBranch: 'Whether to auto-delete the local branch when removing a worktree',
92
+ claudeCodeCommand: 'Claude Code CLI launch command',
93
+ autoPullPush: 'Whether to auto-run git pull and git push after merge',
94
+ confirmDestructiveOps: 'Whether to prompt for confirmation before destructive operations',
95
+ maxConcurrency: 'Default max concurrency for run command, 0 means unlimited',
96
+ terminalApp: 'Terminal app for batch resume',
97
+ aliases: 'Command alias mapping',
98
+ autoUpdate: 'Whether to enable auto-update checks',
99
+ resumeInPlace: 'Whether to resume in current terminal',
100
+ conflictResolveMode: 'Merge conflict resolution mode',
101
+ conflictResolveTimeoutMs: 'Claude Code conflict resolution timeout',
102
+ language: 'Interface language',
103
+ }),
78
104
  MESSAGES: {
79
- CONFIG_RESET_SUCCESS: '配置已恢复为默认值',
80
- DESTRUCTIVE_OP_CANCELLED: '已取消操作',
81
- CONFIG_SET_SUCCESS: (key: string, value: string) => `✓ ${key} 已设置为 ${value}`,
105
+ CONFIG_RESET_SUCCESS: 'Configuration reset to defaults',
106
+ DESTRUCTIVE_OP_CANCELLED: 'Operation cancelled',
107
+ CONFIG_SET_SUCCESS: (key: string, value: string) => `✓ ${key} set to ${value}`,
82
108
  CONFIG_GET_VALUE: (key: string, value: string) => `${key} = ${value}`,
83
109
  CONFIG_INVALID_KEY: (key: string, validKeys: string[]) =>
84
- `无效的配置项: ${key}\n可用的配置项: ${validKeys.join(', ')}`,
110
+ `Invalid config key: ${key}\nAvailable keys: ${validKeys.join(', ')}`,
85
111
  CONFIG_INVALID_BOOLEAN: (key: string) =>
86
- `配置项 ${key} 为布尔类型,仅接受 true false`,
112
+ `Config key ${key} is boolean, only true or false accepted`,
87
113
  CONFIG_INVALID_NUMBER: (key: string) =>
88
- `配置项 ${key} 为数字类型,请输入有效的数字`,
114
+ `Config key ${key} is number, please enter a valid number`,
89
115
  CONFIG_INVALID_ENUM: (key: string, validValues: readonly string[]) =>
90
- `配置项 ${key} 仅接受以下值: ${validValues.join(', ')}`,
91
- CONFIG_SELECT_PROMPT: '选择要修改的配置项',
92
- CONFIG_INPUT_PROMPT: (key: string) => `输入 ${key} 的新值`,
93
- CONFIG_MISSING_VALUE: (key: string) => `缺少配置值,用法: clawt config set ${key} <value>`,
116
+ `Config key ${key} only accepts: ${validValues.join(', ')}`,
117
+ CONFIG_SELECT_PROMPT: 'Select a config key to modify',
118
+ CONFIG_INPUT_PROMPT: (key: string) => `Enter new value for ${key}`,
119
+ CONFIG_MISSING_VALUE: (key: string) => `Missing value, usage: clawt config set ${key} <value>`,
120
+ CONFIG_RESET_WARNING: 'This will reset all configuration to defaults',
121
+ NON_INTERACTIVE_CONFIG_EDITOR: 'Cannot edit config in non-interactive mode',
122
+ NOT_SET: '(not set)',
94
123
  },
95
124
  }));
96
125
 
@@ -108,14 +137,18 @@ const mockedConfirmDestructiveAction = vi.mocked(confirmDestructiveAction);
108
137
  /** 创建默认配置对象用于 mock */
109
138
  function createMockConfig() {
110
139
  return {
140
+ language: 'en' as const,
111
141
  autoDeleteBranch: false,
112
142
  claudeCodeCommand: 'claude',
113
143
  autoPullPush: false,
114
144
  confirmDestructiveOps: true,
115
145
  maxConcurrency: 0,
116
146
  terminalApp: 'auto',
147
+ resumeInPlace: false,
117
148
  aliases: {},
118
149
  autoUpdate: true,
150
+ conflictResolveMode: 'ask' as const,
151
+ conflictResolveTimeoutMs: 900000,
119
152
  };
120
153
  }
121
154
 
@@ -403,7 +436,7 @@ describe('handleConfigSet — 交互模式', () => {
403
436
  const selectOpts = mockSelectConstructorArgs[0] as { choices: Array<{ name: string; disabled?: string }> };
404
437
  const aliasesChoice = selectOpts.choices.find((c) => c.name === 'aliases');
405
438
  expect(aliasesChoice).toBeDefined();
406
- expect(aliasesChoice!.disabled).toBe('(通过 clawt alias 命令管理)');
439
+ expect(aliasesChoice!.disabled).toBe('(Manage via clawt alias command)');
407
440
 
408
441
  // 普通配置项不应有 disabled 属性
409
442
  const normalChoice = selectOpts.choices.find((c) => c.name === 'autoDeleteBranch');
@@ -5,6 +5,17 @@ vi.mock('../../../src/logger/index.js', () => ({
5
5
  logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
6
6
  }));
7
7
 
8
+ // mock i18n 模块,使 getCurrentLanguage 返回 'zh-CN' 以匹配中文断言
9
+ // 同时导出 createMessages 供 constants 模块使用
10
+ vi.mock('../../../src/utils/i18n.js', async (importOriginal) => {
11
+ const actual = await importOriginal<typeof import('../../../src/utils/i18n.js')>();
12
+ return {
13
+ ...actual,
14
+ getCurrentLanguage: vi.fn().mockReturnValue('zh-CN'),
15
+ resetLanguageCache: vi.fn(),
16
+ };
17
+ });
18
+
8
19
  vi.mock('../../../src/errors/index.js', () => ({
9
20
  ClawtError: class ClawtError extends Error {
10
21
  exitCode: number;
@@ -37,7 +48,7 @@ vi.mock('../../../src/utils/index.js', () => ({
37
48
  getProjectWorktrees: vi.fn().mockReturnValue([{ path: '/path/feature', branch: 'feature' }]),
38
49
  findExactMatch: vi.fn().mockReturnValue({ path: '/path/feature', branch: 'feature' }),
39
50
  hasSnapshot: vi.fn().mockReturnValue(true),
40
- readSnapshot: vi.fn().mockReturnValue({ treeHash: 'snapshot-tree-hash' }),
51
+ readSnapshot: vi.fn().mockReturnValue({ treeHash: 'snapshot-tree-hash', headCommitHash: '', stagedTreeHash: '' }),
41
52
  writeSnapshot: vi.fn(),
42
53
  gitAddAll: vi.fn(),
43
54
  gitWriteTree: vi.fn().mockReturnValue('current-tree-hash'),
@@ -95,7 +106,7 @@ beforeEach(() => {
95
106
  mockedGetProjectWorktrees.mockReturnValue([{ path: '/path/feature', branch: 'feature' }]);
96
107
  mockedFindExactMatch.mockReturnValue({ path: '/path/feature', branch: 'feature' });
97
108
  mockedHasSnapshot.mockReturnValue(true);
98
- mockedReadSnapshot.mockReturnValue({ treeHash: 'snapshot-tree-hash' });
109
+ mockedReadSnapshot.mockReturnValue({ treeHash: 'snapshot-tree-hash', headCommitHash: '', stagedTreeHash: '' });
99
110
  mockedIsWorkingDirClean.mockReturnValue(false);
100
111
  mockedConfirmAction.mockResolvedValue(true);
101
112
  mockedGitWriteTree.mockReturnValue('current-tree-hash');
@@ -16,9 +16,13 @@ vi.mock('../../../src/constants/index.js', () => ({
16
16
  INIT_SET_SUCCESS: (key: string, value: string) => `✓ 项目配置 ${key} 已设置为 ${value}`,
17
17
  },
18
18
  PROJECT_CONFIG_DEFINITIONS: {
19
- clawtMainWorkBranch: { defaultValue: '', description: ' worktree 的工作分支名' },
20
- validateRunCommand: { defaultValue: undefined, description: 'validate 成功后自动执行的命令' },
19
+ clawtMainWorkBranch: { defaultValue: '', description: 'Main worktree branch name' },
20
+ validateRunCommand: { defaultValue: undefined, description: 'Command to auto-run after validate succeeds' },
21
21
  },
22
+ getI18nProjectConfigDescriptions: () => ({
23
+ clawtMainWorkBranch: 'Main worktree branch name',
24
+ validateRunCommand: 'Command to auto-run after validate succeeds',
25
+ }),
22
26
  }));
23
27
 
24
28
  vi.mock('../../../src/utils/index.js', () => ({
@@ -5,6 +5,17 @@ vi.mock('../../../src/logger/index.js', () => ({
5
5
  logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
6
6
  }));
7
7
 
8
+ // mock i18n 模块,使 getCurrentLanguage 返回 'zh-CN' 以匹配中文断言
9
+ // 同时导出 createMessages 供 constants 模块使用
10
+ vi.mock('../../../src/utils/i18n.js', async (importOriginal) => {
11
+ const actual = await importOriginal<typeof import('../../../src/utils/i18n.js')>();
12
+ return {
13
+ ...actual,
14
+ getCurrentLanguage: vi.fn().mockReturnValue('zh-CN'),
15
+ resetLanguageCache: vi.fn(),
16
+ };
17
+ });
18
+
8
19
  vi.mock('../../../src/errors/index.js', () => ({
9
20
  ClawtError: class ClawtError extends Error {
10
21
  exitCode: number;
@@ -40,6 +51,9 @@ vi.mock('../../../src/constants/index.js', async (importOriginal) => {
40
51
  MERGE_SUCCESS: (branch: string, message: string, autoPullPush: boolean) => `合并成功: ${branch}`,
41
52
  MERGE_SUCCESS_NO_MESSAGE: (branch: string, autoPullPush: boolean) => `合并成功: ${branch}`,
42
53
  WORKTREE_CLEANED: (branch: string) => `已清理: ${branch}`,
54
+ // i18n 新增的消息键(merge.ts shouldCleanupAfterMerge 中使用)
55
+ AUTO_DELETE_CONFIGURED: (branch: string) => `已配置自动删除,merge 成功后将自动清理 worktree 和分支: ${branch}`,
56
+ CONFIRM_DELETE_WORKTREE_BRANCH: (branch: string) => `是否删除对应的 worktree 和分支 (${branch})?`,
43
57
  },
44
58
  AUTO_SAVE_COMMIT_MESSAGE_PREFIX: 'clawt: auto-save before merging',
45
59
  };
@@ -6,6 +6,17 @@ vi.mock('../../../src/logger/index.js', () => ({
6
6
  logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
7
7
  }));
8
8
 
9
+ // mock i18n 模块,使 getCurrentLanguage 返回 'zh-CN' 以匹配中文断言
10
+ // 同时导出 createMessages 供 constants 模块使用
11
+ vi.mock('../../../src/utils/i18n.js', async (importOriginal) => {
12
+ const actual = await importOriginal<typeof import('../../../src/utils/i18n.js')>();
13
+ return {
14
+ ...actual,
15
+ getCurrentLanguage: vi.fn().mockReturnValue('zh-CN'),
16
+ resetLanguageCache: vi.fn(),
17
+ };
18
+ });
19
+
9
20
  vi.mock('../../../src/errors/index.js', () => ({
10
21
  ClawtError: class ClawtError extends Error {
11
22
  exitCode: number;
@@ -42,6 +53,12 @@ vi.mock('../../../src/constants/index.js', async (importOriginal) => {
42
53
  DRY_RUN_INTERACTIVE_MODE: '模式: 交互式(无预设任务)',
43
54
  DRY_RUN_READY: '预览完成,无冲突。移除 --dry-run 即可正式执行。',
44
55
  DRY_RUN_HAS_CONFLICT: '存在分支冲突,实际执行时将会报错。请先处理冲突的分支。',
56
+ // i18n 新增的消息键(task-executor.ts 中使用)
57
+ ALL_TASKS_COMPLETED: (total: number) => `全部任务已完成 (${total}/${total})`,
58
+ SUCCESS_LABEL: '成功:',
59
+ FAILURE_LABEL: '失败:',
60
+ TOTAL_DURATION_LABEL: '总耗时:',
61
+ TOTAL_COST_LABEL: '总花费:',
45
62
  },
46
63
  };
47
64
  });
@@ -1,19 +1,49 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { Command } from 'commander';
3
3
 
4
+ // mock i18n 模块,使 getCurrentLanguage 返回 'en' 以匹配英文断言
5
+ vi.mock('../../../src/utils/i18n.js', async (importOriginal) => {
6
+ const actual = await importOriginal<typeof import('../../../src/utils/i18n.js')>();
7
+ return {
8
+ ...actual,
9
+ getCurrentLanguage: vi.fn().mockReturnValue('en'),
10
+ resetLanguageCache: vi.fn(),
11
+ createMessages: <T extends Record<string, { en: any; 'zh-CN': any }>>(
12
+ i18nMap: T,
13
+ ) => {
14
+ const result: any = {};
15
+ for (const key of Object.keys(i18nMap)) {
16
+ result[key] = i18nMap[key]['en'];
17
+ }
18
+ return result;
19
+ },
20
+ };
21
+ });
22
+
4
23
  vi.mock('../../../src/logger/index.js', () => ({
5
24
  logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
6
25
  }));
7
26
 
8
- vi.mock('../../../src/constants/index.js', () => ({
9
- MESSAGES: {
10
- TASK_INIT_FILE_EXISTS: (path: string) => `文件已存在: ${path},如需覆盖请先删除`,
11
- TASK_INIT_SUCCESS: (path: string) => `✓ 任务模板已生成: ${path}`,
12
- TASK_INIT_HINT: (path: string) => `使用 clawt run -f ${path} 执行任务`,
13
- },
27
+ vi.mock('../../../src/constants/index.js', async (importOriginal) => {
28
+ const actual = await importOriginal<typeof import('../../../src/constants/index.js')>();
29
+ return {
30
+ ...actual,
31
+ MESSAGES: {
32
+ ...actual.MESSAGES,
33
+ TASK_INIT_FILE_EXISTS: (path: string) => `File already exists: ${path}, delete it first to overwrite`,
34
+ TASK_INIT_SUCCESS: (path: string) => `✓ Task template generated: ${path}`,
35
+ TASK_INIT_HINT: (path: string) =>
36
+ `Run task:\n clawt run -f ${path} # Create worktree and execute\n clawt resume -f ${path} # Resume in existing worktree`,
37
+ },
38
+ };
39
+ });
40
+
41
+ // mock tasks-template 模块,使 getTaskTemplateContent 返回英文模板
42
+ vi.mock('../../../src/constants/tasks-template.js', () => ({
43
+ getTaskTemplateContent: vi.fn().mockReturnValue('# Template content'),
44
+ TASK_TEMPLATE_CONTENT: '# Template content',
14
45
  TASK_TEMPLATE_OUTPUT_DIR: '.clawt/tasks',
15
46
  TASK_TEMPLATE_FILENAME_PREFIX: 'clawt-tasks',
16
- TASK_TEMPLATE_CONTENT: '# 模板内容',
17
47
  }));
18
48
 
19
49
  const mockExistsSync = vi.fn();
@@ -93,7 +123,7 @@ describe('handleTasksInit', () => {
93
123
  // 默认路径应输出到 .clawt/tasks/ 目录下
94
124
  expect(mockWriteFileSync).toHaveBeenCalledWith(
95
125
  expect.stringContaining('.clawt/tasks/clawt-tasks-2026-01-01-00-00-00.md'),
96
- '# 模板内容',
126
+ '# Template content',
97
127
  'utf-8',
98
128
  );
99
129
  expect(mockedPrintSuccess).toHaveBeenCalledWith(
@@ -115,7 +145,7 @@ describe('handleTasksInit', () => {
115
145
  expect(mockGenerateTaskFilename).not.toHaveBeenCalled();
116
146
  expect(mockWriteFileSync).toHaveBeenCalledWith(
117
147
  expect.stringContaining('my-tasks.md'),
118
- '# 模板内容',
148
+ '# Template content',
119
149
  'utf-8',
120
150
  );
121
151
  expect(mockedPrintSuccess).toHaveBeenCalledWith(
@@ -1,4 +1,19 @@
1
- import { describe, it, expect } from 'vitest';
1
+ import { describe, it, expect, vi } from 'vitest';
2
+
3
+ // mock i18n 模块,避免循环依赖导致 currentLanguage 未初始化
4
+ vi.mock('../../../src/utils/i18n.js', () => ({
5
+ getCurrentLanguage: vi.fn().mockReturnValue('zh-CN'),
6
+ resetLanguageCache: vi.fn(),
7
+ setCurrentLanguage: vi.fn(),
8
+ createMessages: vi.fn((i18nMap: Record<string, { en: any; 'zh-CN': any }>) => {
9
+ const result: any = {};
10
+ for (const key of Object.keys(i18nMap)) {
11
+ result[key] = i18nMap[key]['zh-CN'];
12
+ }
13
+ return result;
14
+ }),
15
+ }));
16
+
2
17
  import { CONFIG_DEFINITIONS, DEFAULT_CONFIG, CONFIG_DESCRIPTIONS } from '../../../src/constants/config.js';
3
18
  import { VALID_TERMINAL_APPS } from '../../../src/constants/terminal.js';
4
19
 
@@ -1,4 +1,19 @@
1
- import { describe, it, expect } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // mock i18n 模块,使 getCurrentLanguage 返回 'zh-CN' 以匹配中文断言
4
+ vi.mock('../../../src/utils/i18n.js', () => ({
5
+ getCurrentLanguage: vi.fn().mockReturnValue('zh-CN'),
6
+ resetLanguageCache: vi.fn(),
7
+ setCurrentLanguage: vi.fn(),
8
+ createMessages: vi.fn((i18nMap: Record<string, { en: any; 'zh-CN': any }>) => {
9
+ const result: any = {};
10
+ for (const key of Object.keys(i18nMap)) {
11
+ result[key] = i18nMap[key]['zh-CN'];
12
+ }
13
+ return result;
14
+ }),
15
+ }));
16
+
2
17
  import { POST_CREATE_MESSAGES } from '../../../src/constants/messages/post-create.js';
3
18
 
4
19
  describe('POST_CREATE_MESSAGES', () => {
@@ -110,3 +125,80 @@ describe('POST_CREATE_MESSAGES', () => {
110
125
  });
111
126
  });
112
127
  });
128
+
129
+ /**
130
+ * 英文版 post-create 消息测试
131
+ * 使用 vi.resetModules + vi.doMock 动态切换语言为 en,
132
+ * 然后重新加载 POST_CREATE_MESSAGES 模块验证英文版消息内容
133
+ */
134
+ describe('POST_CREATE_MESSAGES — 英文版', () => {
135
+ beforeEach(() => {
136
+ vi.resetModules();
137
+ });
138
+
139
+ it('纯字符串消息在英文版下返回英文文本', async () => {
140
+ vi.doMock('../../../src/utils/i18n.js', () => ({
141
+ getCurrentLanguage: () => 'en',
142
+ resetLanguageCache: vi.fn(),
143
+ setCurrentLanguage: vi.fn(),
144
+ createMessages: (i18nMap: Record<string, { en: any; 'zh-CN': any }>) => {
145
+ const result: any = {};
146
+ for (const key of Object.keys(i18nMap)) {
147
+ result[key] = i18nMap[key]['en'];
148
+ }
149
+ return result;
150
+ },
151
+ }));
152
+
153
+ const { POST_CREATE_MESSAGES: EN_MSGS } = await import('../../../src/constants/messages/post-create.js');
154
+
155
+ // HOOK_SKIPPED 英文版包含 Skipped
156
+ expect(EN_MSGS.HOOK_SKIPPED).toContain('Skipped');
157
+
158
+ // HOOK_NOT_CONFIGURED 英文版包含 "not configured" 和 "skipping"
159
+ expect(EN_MSGS.HOOK_NOT_CONFIGURED).toContain('not configured');
160
+ expect(EN_MSGS.HOOK_NOT_CONFIGURED).toContain('skipping');
161
+ });
162
+
163
+ it('模板函数消息在英文版下返回英文文本', async () => {
164
+ vi.doMock('../../../src/utils/i18n.js', () => ({
165
+ getCurrentLanguage: () => 'en',
166
+ resetLanguageCache: vi.fn(),
167
+ setCurrentLanguage: vi.fn(),
168
+ createMessages: (i18nMap: Record<string, { en: any; 'zh-CN': any }>) => {
169
+ const result: any = {};
170
+ for (const key of Object.keys(i18nMap)) {
171
+ result[key] = i18nMap[key]['en'];
172
+ }
173
+ return result;
174
+ },
175
+ }));
176
+
177
+ const { POST_CREATE_MESSAGES: EN_MSGS } = await import('../../../src/constants/messages/post-create.js');
178
+
179
+ // HOOK_SOURCE_INFO 英文版包含 "postCreate hook source"
180
+ expect(EN_MSGS.HOOK_SOURCE_INFO('project config')).toContain('postCreate hook source');
181
+
182
+ // HOOK_SUCCESS 英文版包含 "successfully" 而非 "成功"
183
+ expect(EN_MSGS.HOOK_SUCCESS('feat-login')).toContain('successfully');
184
+ expect(EN_MSGS.HOOK_SUCCESS('feat-login')).not.toContain('成功');
185
+
186
+ // HOOK_FAILED 英文版包含 "failed" 而非 "失败"
187
+ expect(EN_MSGS.HOOK_FAILED('feat-login', 'error')).toContain('failed');
188
+ expect(EN_MSGS.HOOK_FAILED('feat-login', 'error')).not.toContain('失败');
189
+
190
+ // HOOK_SUMMARY 英文版包含 "succeeded"/"failed" 而非 "成功"/"失败"
191
+ const summary = EN_MSGS.HOOK_SUMMARY(5, 0);
192
+ expect(summary).toContain('5 succeeded');
193
+ expect(summary).toContain('0 failed');
194
+
195
+ // POST_CREATE_SCRIPT_NOT_EXECUTABLE 英文版包含 "not executable"
196
+ expect(EN_MSGS.POST_CREATE_SCRIPT_NOT_EXECUTABLE('/repo/.clawt/postCreate.sh')).toContain('not executable');
197
+
198
+ // POST_CREATE_SCRIPT_AUTO_CHMOD 英文版包含 "auto-added execute permission"
199
+ expect(EN_MSGS.POST_CREATE_SCRIPT_AUTO_CHMOD('/repo/.clawt/postCreate.sh')).toContain('auto-added execute permission');
200
+
201
+ // HOOK_BACKGROUND_START 英文版包含 "running in background"
202
+ expect(EN_MSGS.HOOK_BACKGROUND_START(3, 'npm install')).toContain('running in background');
203
+ });
204
+ });