clawt 2.11.1 → 2.12.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.
- package/.claude/agent-memory/docs-sync-updater/MEMORY.md +14 -7
- package/README.md +12 -9
- package/dist/index.js +194 -26
- package/dist/postinstall.js +17 -5
- package/docs/spec.md +39 -10
- package/package.json +1 -1
- package/src/commands/merge.ts +1 -1
- package/src/commands/resume.ts +81 -11
- package/src/constants/config.ts +4 -0
- package/src/constants/index.ts +2 -1
- package/src/constants/messages/merge.ts +2 -1
- package/src/constants/messages/resume.ts +13 -4
- package/src/constants/messages.ts +2 -1
- package/src/constants/prompt.ts +5 -0
- package/src/constants/terminal.ts +6 -0
- package/src/types/config.ts +2 -0
- package/src/utils/claude.ts +42 -0
- package/src/utils/index.ts +2 -1
- package/src/utils/terminal.ts +134 -0
- package/src/utils/worktree-matcher.ts +68 -5
- package/tests/unit/commands/merge.test.ts +2 -1
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
- run 命令对应 `5.2 批量创建 Worktree + 执行 Claude Code 任务`,流程按步骤编号描述
|
|
9
9
|
- merge 命令对应 `5.6 合并验证过的分支`,-b 可选,支持模糊匹配(与 resume/validate 共享匹配逻辑),流程按步骤编号描述
|
|
10
10
|
- config 命令对应 `5.10 查看和管理全局配置`,包含查看配置和 config reset 子命令两部分(使用 `####` 子标题区分)
|
|
11
|
-
- resume 命令对应 `5.11 在已有 Worktree
|
|
11
|
+
- resume 命令对应 `5.11 在已有 Worktree 中恢复会话`,统一使用多选交互(resolveTargetWorktrees),选 1 个当前终端恢复,选多个在独立终端 Tab 批量恢复(-b 可选)
|
|
12
12
|
- validate 命令对应 `5.4 在主 Worktree 验证其他分支`,-b 可选,支持模糊匹配(与 resume 共享匹配逻辑)
|
|
13
13
|
- sync 命令对应 `5.12 将主分支代码同步到目标 Worktree`,-b 可选,支持模糊匹配(与 resume/validate/merge 共享匹配逻辑)
|
|
14
14
|
- status 命令对应 `5.14 项目全局状态总览`,支持 `--json` 格式输出,展示主 worktree 状态、各 worktree 详细状态、未清理快照
|
|
@@ -35,7 +35,11 @@
|
|
|
35
35
|
- remove 批量操作时收集错误继续处理,最后汇总报告
|
|
36
36
|
- 文档中文风格,技术术语保留英文(worktree, merge, branch, SIGINT 等)
|
|
37
37
|
- cleanupWorktrees 是 merge 和 run 共用的公共清理函数(在 src/utils/worktree.ts)
|
|
38
|
-
- `launchInteractiveClaude` 是 run(交互式模式)和 resume
|
|
38
|
+
- `launchInteractiveClaude` 是 run(交互式模式)和 resume(单选模式)共用的公共函数(在 src/utils/claude.ts),启动前自动检测会话历史并追加 `--continue`
|
|
39
|
+
- `launchInteractiveClaudeInNewTerminal` 是 resume 批量模式使用的函数(在 src/utils/claude.ts),通过 AppleScript 在新终端 Tab 中启动
|
|
40
|
+
- `buildClaudeCommand` 构建完整 shell 命令字符串(在 src/utils/claude.ts),被 `launchInteractiveClaudeInNewTerminal` 调用
|
|
41
|
+
- `openCommandInNewTerminalTab` 终端 Tab 管理函数(在 src/utils/terminal.ts),支持 iTerm2 和 Terminal.app
|
|
42
|
+
- `detectTerminalApp` 终端类型检测函数(在 src/utils/terminal.ts),读取 `terminalApp` 配置项
|
|
39
43
|
- `hasClaudeSessionHistory` 检测 `~/.claude/projects/<encoded-path>/` 下是否有 `.jsonl` 文件(在 src/utils/claude.ts)
|
|
40
44
|
- `CLAUDE_PROJECTS_DIR` 常量(`~/.claude/projects/`)定义在 `src/constants/paths.ts`
|
|
41
45
|
- killAllChildProcesses 是 run 专用的子进程终止函数(在 src/utils/shell.ts)
|
|
@@ -76,14 +80,17 @@ Notes:
|
|
|
76
80
|
- resume 和 run(交互式模式)共用 `launchInteractiveClaude()`,该函数从 run.ts 提取到 src/utils/claude.ts
|
|
77
81
|
- `claudeCodeCommand` 配置项同时影响 run 交互式模式和 resume 命令
|
|
78
82
|
- reset 命令与 validate --clean 的区别:reset 不删除快照文件,validate --clean 会删除快照
|
|
79
|
-
- `resolveTargetWorktree()` 是
|
|
80
|
-
- `resolveTargetWorktrees()` 是多选分支匹配函数(在 src/utils/worktree-matcher.ts
|
|
83
|
+
- `resolveTargetWorktree()` 是 validate、merge 和 sync 共用的单选分支匹配函数(在 src/utils/worktree-matcher.ts)
|
|
84
|
+
- `resolveTargetWorktrees()` 是多选分支匹配函数(在 src/utils/worktree-matcher.ts),被 remove 和 resume 命令使用
|
|
81
85
|
- `WorktreeResolveMessages` 接口用于单选命令的消息解耦,`WorktreeMultiResolveMessages` 接口用于多选命令的消息解耦
|
|
82
86
|
- `promptSelectBranch()`(Enquirer.Select)用于单选交互,`promptMultiSelectBranches()`(Enquirer.MultiSelect)用于多选交互
|
|
83
|
-
- resume 的消息常量在 `MESSAGES.RESUME_
|
|
84
|
-
- resume
|
|
87
|
+
- resume 的消息常量在 `MESSAGES.RESUME_*`(含 RESUME_ALL_CONFIRM / RESUME_ALL_SUCCESS 等批量恢复消息),validate 的消息常量在 `MESSAGES.VALIDATE_*`,merge 的消息常量在 `MESSAGES.MERGE_*`,sync 的消息常量在 `MESSAGES.SYNC_*`,status 的消息常量在 `MESSAGES.STATUS_*`,remove 的 fuzzy search 消息在 `MESSAGES.REMOVE_*`
|
|
88
|
+
- resume 使用多选匹配策略:精确→模糊→交互多选(与 remove 一致),validate、merge 和 sync 使用单选匹配策略:精确→模糊→交互单选
|
|
85
89
|
- remove 的 `-b` 参数可选,匹配策略:精确→模糊→交互多选;不传 `-b` 时列出所有分支供多选
|
|
86
|
-
- validate
|
|
90
|
+
- validate 的交互式选择使用 `promptSelectBranch()`(Enquirer.Select);resume 和 remove 使用 `promptMultiSelectBranches()`(Enquirer.MultiSelect)
|
|
91
|
+
- `promptMultiSelectBranches` 支持「全选」选项(顶部 [select-all]),通过扩展 MultiSelect 覆写 space() 实现全选 toggle
|
|
92
|
+
- `SELECT_ALL_NAME` 和 `SELECT_ALL_LABEL` 常量定义在 `src/constants/prompt.ts`
|
|
93
|
+
- `VALID_TERMINAL_APPS` 和 `ITERM2_APP_PATH` 常量定义在 `src/constants/terminal.ts`
|
|
87
94
|
|
|
88
95
|
## validate 快照机制
|
|
89
96
|
|
package/README.md
CHANGED
|
@@ -19,22 +19,19 @@ npm i -g clawt
|
|
|
19
19
|
```bash
|
|
20
20
|
# 1. 在项目根目录(包含 .git 的目录)下执行
|
|
21
21
|
# 2. 并行执行 3 个任务,每个任务在独立的 worktree 中运行
|
|
22
|
-
clawt run -b
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
--tasks "实现密码重置功能"
|
|
22
|
+
clawt run -b <branch-1>
|
|
23
|
+
clawt run -b <branch-2>
|
|
24
|
+
clawt run -b <branch-3>
|
|
26
25
|
|
|
27
26
|
# 3. 查看所有 worktree 状态
|
|
28
27
|
clawt status
|
|
29
28
|
|
|
30
29
|
# 4. 验证某个分支的变更(在主 worktree 中测试)
|
|
31
|
-
clawt validate -b
|
|
30
|
+
clawt validate -b branch-1
|
|
32
31
|
|
|
33
32
|
# 5. 确认无误后合并到主分支
|
|
34
|
-
clawt merge -b
|
|
33
|
+
clawt merge -b branch-1 -m "feat: 实现xxx功能"
|
|
35
34
|
|
|
36
|
-
# 6. 清理不需要的 worktree
|
|
37
|
-
clawt remove -b feature-auth
|
|
38
35
|
```
|
|
39
36
|
|
|
40
37
|
## 命令一览
|
|
@@ -80,11 +77,15 @@ clawt run -f tasks.md -b feat
|
|
|
80
77
|
|
|
81
78
|
```bash
|
|
82
79
|
clawt resume -b <branch> # 指定分支
|
|
83
|
-
clawt resume #
|
|
80
|
+
clawt resume # 交互式多选
|
|
84
81
|
```
|
|
85
82
|
|
|
83
|
+
支持多选:选 1 个在当前终端恢复,选多个自动在独立终端 Tab 中批量恢复(仅 macOS)。
|
|
84
|
+
|
|
86
85
|
如果目标 worktree 存在历史会话,会自动继续上次对话(`--continue`)。
|
|
87
86
|
|
|
87
|
+
> **注意:** 使用 Terminal.app 批量恢复时,需要在「系统设置 → 隐私与安全性 → 辅助功能」中授权终端应用。iTerm2 无需额外授权。终端类型可通过配置项 `terminalApp` 指定。
|
|
88
|
+
|
|
88
89
|
### `clawt create` — 仅创建 worktree(不执行任务)
|
|
89
90
|
|
|
90
91
|
```bash
|
|
@@ -159,6 +160,8 @@ clawt config reset # 恢复默认配置
|
|
|
159
160
|
| `claudeCodeCommand` | `"claude"` | Claude Code CLI 启动命令 |
|
|
160
161
|
| `autoPullPush` | `false` | merge 后自动 pull/push |
|
|
161
162
|
| `confirmDestructiveOps` | `true` | 破坏性操作前确认 |
|
|
163
|
+
| `maxConcurrency` | `0` | run 命令最大并发数,`0` 为不限制 |
|
|
164
|
+
| `terminalApp` | `"auto"` | 批量 resume 使用的终端:`auto` / `iterm2` / `terminal` |
|
|
162
165
|
|
|
163
166
|
## 全局选项
|
|
164
167
|
|
package/dist/index.js
CHANGED
|
@@ -119,7 +119,7 @@ var MERGE_MESSAGES = {
|
|
|
119
119
|
/** merge 后清理 worktree 和分支成功 */
|
|
120
120
|
WORKTREE_CLEANED: (branch) => `\u2713 \u5DF2\u6E05\u7406 worktree \u548C\u5206\u652F: ${branch}`,
|
|
121
121
|
/** 目标 worktree 有未提交修改但未指定 -m */
|
|
122
|
-
TARGET_WORKTREE_DIRTY_NO_MESSAGE:
|
|
122
|
+
TARGET_WORKTREE_DIRTY_NO_MESSAGE: (worktreePath) => `${worktreePath} \u6709\u672A\u63D0\u4EA4\u7684\u4FEE\u6539\uFF0C\u8BF7\u901A\u8FC7 -m \u53C2\u6570\u63D0\u4F9B\u63D0\u4EA4\u4FE1\u606F`,
|
|
123
123
|
/** 目标 worktree 既干净又无本地提交 */
|
|
124
124
|
TARGET_WORKTREE_NO_CHANGES: "\u76EE\u6807 worktree \u6CA1\u6709\u4EFB\u4F55\u53EF\u5408\u5E76\u7684\u53D8\u66F4\uFF08\u5DE5\u4F5C\u533A\u5E72\u51C0\u4E14\u65E0\u672C\u5730\u63D0\u4EA4\uFF09",
|
|
125
125
|
/** merge 命令检测到 validate 状态的提示 */
|
|
@@ -209,10 +209,18 @@ var RESUME_MESSAGES = {
|
|
|
209
209
|
RESUME_NO_MATCH: (name, branches) => `\u672A\u627E\u5230\u4E0E "${name}" \u5339\u914D\u7684\u5206\u652F
|
|
210
210
|
\u53EF\u7528\u5206\u652F\uFF1A
|
|
211
211
|
${branches.map((b) => ` - ${b}`).join("\n")}`,
|
|
212
|
-
/** resume
|
|
213
|
-
RESUME_SELECT_BRANCH: "\u8BF7\u9009\u62E9\u8981\u6062\u590D\u7684\u5206\u652F",
|
|
214
|
-
/** resume
|
|
215
|
-
RESUME_MULTIPLE_MATCHES: (
|
|
212
|
+
/** resume 多选交互提示 */
|
|
213
|
+
RESUME_SELECT_BRANCH: "\u8BF7\u9009\u62E9\u8981\u6062\u590D\u7684\u5206\u652F\uFF08\u7A7A\u683C\u9009\u62E9\uFF0C\u56DE\u8F66\u786E\u8BA4\uFF09",
|
|
214
|
+
/** resume 模糊匹配到多个结果的多选提示 */
|
|
215
|
+
RESUME_MULTIPLE_MATCHES: (keyword) => `"${keyword}" \u5339\u914D\u5230\u591A\u4E2A\u5206\u652F\uFF0C\u8BF7\u9009\u62E9\u8981\u6062\u590D\u7684\uFF1A`,
|
|
216
|
+
/** 批量 resume 确认提示 */
|
|
217
|
+
RESUME_ALL_CONFIRM: (count) => `\u5373\u5C06\u5728 ${count} \u4E2A\u72EC\u7ACB\u7EC8\u7AEF Tab \u4E2D\u6062\u590D Claude Code \u4F1A\u8BDD\uFF0C\u662F\u5426\u7EE7\u7EED\uFF1F`,
|
|
218
|
+
/** 批量 resume 完成提示 */
|
|
219
|
+
RESUME_ALL_SUCCESS: (count) => `\u5DF2\u5728 ${count} \u4E2A\u7EC8\u7AEF Tab \u4E2D\u542F\u52A8 Claude Code \u4F1A\u8BDD`,
|
|
220
|
+
/** 批量 resume 非 macOS 平台提示 */
|
|
221
|
+
RESUME_ALL_PLATFORM_UNSUPPORTED: "\u6279\u91CF resume \u76EE\u524D\u4EC5\u652F\u6301 macOS \u5E73\u53F0\uFF08\u901A\u8FC7 AppleScript \u6253\u5F00\u7EC8\u7AEF Tab\uFF09",
|
|
222
|
+
/** 批量 resume 无匹配分支提示 */
|
|
223
|
+
RESUME_ALL_NO_MATCH: (keyword) => `\u672A\u627E\u5230\u4E0E "${keyword}" \u5339\u914D\u7684\u5206\u652F`
|
|
216
224
|
};
|
|
217
225
|
|
|
218
226
|
// src/constants/messages/remove.ts
|
|
@@ -297,6 +305,10 @@ var EXIT_CODES = {
|
|
|
297
305
|
ARGUMENT_ERROR: 2
|
|
298
306
|
};
|
|
299
307
|
|
|
308
|
+
// src/constants/terminal.ts
|
|
309
|
+
var VALID_TERMINAL_APPS = ["auto", "iterm2", "terminal"];
|
|
310
|
+
var ITERM2_APP_PATH = "/Applications/iTerm.app";
|
|
311
|
+
|
|
300
312
|
// src/constants/config.ts
|
|
301
313
|
var APPEND_SYSTEM_PROMPT = "After the code execution is completed, it is prohibited to build the project for verification.";
|
|
302
314
|
var CONFIG_DEFINITIONS = {
|
|
@@ -319,6 +331,10 @@ var CONFIG_DEFINITIONS = {
|
|
|
319
331
|
maxConcurrency: {
|
|
320
332
|
defaultValue: 0,
|
|
321
333
|
description: "run \u547D\u4EE4\u9ED8\u8BA4\u6700\u5927\u5E76\u53D1\u6570\uFF0C0 \u8868\u793A\u4E0D\u9650\u5236"
|
|
334
|
+
},
|
|
335
|
+
terminalApp: {
|
|
336
|
+
defaultValue: "auto",
|
|
337
|
+
description: "\u6279\u91CF resume \u4F7F\u7528\u7684\u7EC8\u7AEF\u5E94\u7528\uFF1Aauto\uFF08\u81EA\u52A8\u68C0\u6D4B\uFF09\u3001iterm2\u3001terminal\uFF08macOS\uFF09"
|
|
322
338
|
}
|
|
323
339
|
};
|
|
324
340
|
function deriveDefaultConfig(definitions) {
|
|
@@ -368,6 +384,10 @@ var TASK_STATUS_LABELS = {
|
|
|
368
384
|
FAILED: "\u5931\u8D25"
|
|
369
385
|
};
|
|
370
386
|
|
|
387
|
+
// src/constants/prompt.ts
|
|
388
|
+
var SELECT_ALL_NAME = "__select_all__";
|
|
389
|
+
var SELECT_ALL_LABEL = "[select-all]";
|
|
390
|
+
|
|
371
391
|
// src/errors/index.ts
|
|
372
392
|
var ClawtError = class extends Error {
|
|
373
393
|
/** 退出码 */
|
|
@@ -905,15 +925,88 @@ import Enquirer from "enquirer";
|
|
|
905
925
|
|
|
906
926
|
// src/utils/claude.ts
|
|
907
927
|
import { spawnSync } from "child_process";
|
|
908
|
-
import { existsSync as
|
|
928
|
+
import { existsSync as existsSync6, readdirSync as readdirSync3 } from "fs";
|
|
909
929
|
import { join as join3 } from "path";
|
|
930
|
+
|
|
931
|
+
// src/utils/terminal.ts
|
|
932
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
933
|
+
import { existsSync as existsSync5 } from "fs";
|
|
934
|
+
function isITerm2Installed() {
|
|
935
|
+
return existsSync5(ITERM2_APP_PATH);
|
|
936
|
+
}
|
|
937
|
+
function detectTerminalApp() {
|
|
938
|
+
const configured = getConfigValue("terminalApp");
|
|
939
|
+
if (configured === "iterm2" || configured === "terminal") {
|
|
940
|
+
return configured;
|
|
941
|
+
}
|
|
942
|
+
if (!VALID_TERMINAL_APPS.includes(configured)) {
|
|
943
|
+
logger.warn(`terminalApp \u914D\u7F6E\u503C "${configured}" \u65E0\u6548\uFF0C\u6709\u6548\u503C: ${VALID_TERMINAL_APPS.join(", ")}\uFF0C\u5C06\u4F7F\u7528\u81EA\u52A8\u68C0\u6D4B`);
|
|
944
|
+
}
|
|
945
|
+
if (isITerm2Installed()) {
|
|
946
|
+
return "iterm2";
|
|
947
|
+
}
|
|
948
|
+
return "terminal";
|
|
949
|
+
}
|
|
950
|
+
function escapeAppleScriptString(str) {
|
|
951
|
+
return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
952
|
+
}
|
|
953
|
+
function buildTerminalAppleScript(command, title) {
|
|
954
|
+
const escapedCommand = escapeAppleScriptString(command);
|
|
955
|
+
const escapedTitle = escapeAppleScriptString(title);
|
|
956
|
+
return `
|
|
957
|
+
tell application "Terminal"
|
|
958
|
+
activate
|
|
959
|
+
tell application "System Events" to tell process "Terminal" to keystroke "t" using command down
|
|
960
|
+
delay 0.3
|
|
961
|
+
do script "${escapedCommand}" in front window's selected tab
|
|
962
|
+
set custom title of front window's selected tab to "${escapedTitle}"
|
|
963
|
+
end tell
|
|
964
|
+
`.trim();
|
|
965
|
+
}
|
|
966
|
+
function buildITermAppleScript(command, title) {
|
|
967
|
+
const escapedCommand = escapeAppleScriptString(command);
|
|
968
|
+
const escapedTitle = escapeAppleScriptString(title);
|
|
969
|
+
return `
|
|
970
|
+
tell application "iTerm"
|
|
971
|
+
activate
|
|
972
|
+
tell current window
|
|
973
|
+
create tab with default profile
|
|
974
|
+
tell current session
|
|
975
|
+
set name to "${escapedTitle}"
|
|
976
|
+
write text "${escapedCommand}"
|
|
977
|
+
end tell
|
|
978
|
+
end tell
|
|
979
|
+
end tell
|
|
980
|
+
`.trim();
|
|
981
|
+
}
|
|
982
|
+
function openCommandInNewTerminalTab(command, tabTitle) {
|
|
983
|
+
if (process.platform !== "darwin") {
|
|
984
|
+
throw new ClawtError("\u6279\u91CF resume \u76EE\u524D\u4EC5\u652F\u6301 macOS \u5E73\u53F0\uFF08\u901A\u8FC7 AppleScript \u6253\u5F00\u7EC8\u7AEF Tab\uFF09");
|
|
985
|
+
}
|
|
986
|
+
const terminalApp = detectTerminalApp();
|
|
987
|
+
const script = terminalApp === "iterm2" ? buildITermAppleScript(command, tabTitle) : buildTerminalAppleScript(command, tabTitle);
|
|
988
|
+
logger.debug(`\u6253\u5F00\u7EC8\u7AEF Tab [${terminalApp}]: ${tabTitle}`);
|
|
989
|
+
logger.debug(`\u6267\u884C\u547D\u4EE4: ${command}`);
|
|
990
|
+
try {
|
|
991
|
+
execFileSync2("osascript", ["-e", script], {
|
|
992
|
+
encoding: "utf-8",
|
|
993
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
994
|
+
});
|
|
995
|
+
} catch (error) {
|
|
996
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
997
|
+
const accessibilityHint = terminalApp === "terminal" ? "\n\u63D0\u793A\uFF1ATerminal.app \u9700\u8981\u8F85\u52A9\u529F\u80FD\u6743\u9650\uFF0C\u8BF7\u5728\u300C\u7CFB\u7EDF\u8BBE\u7F6E \u2192 \u9690\u79C1\u4E0E\u5B89\u5168\u6027 \u2192 \u8F85\u52A9\u529F\u80FD\u300D\u4E2D\u6388\u6743\u7EC8\u7AEF\u5E94\u7528" : "";
|
|
998
|
+
throw new ClawtError(`\u6253\u5F00\u7EC8\u7AEF Tab \u5931\u8D25: ${message}${accessibilityHint}`);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// src/utils/claude.ts
|
|
910
1003
|
function encodeClaudeProjectPath(absolutePath) {
|
|
911
1004
|
return absolutePath.replace(/[^a-zA-Z0-9]/g, "-");
|
|
912
1005
|
}
|
|
913
1006
|
function hasClaudeSessionHistory(worktreePath) {
|
|
914
1007
|
const encodedName = encodeClaudeProjectPath(worktreePath);
|
|
915
1008
|
const projectDir = join3(CLAUDE_PROJECTS_DIR, encodedName);
|
|
916
|
-
if (!
|
|
1009
|
+
if (!existsSync6(projectDir)) {
|
|
917
1010
|
return false;
|
|
918
1011
|
}
|
|
919
1012
|
const entries = readdirSync3(projectDir);
|
|
@@ -951,10 +1044,26 @@ function launchInteractiveClaude(worktree, options = {}) {
|
|
|
951
1044
|
printWarning(`Claude Code \u9000\u51FA\u7801: ${result.status}`);
|
|
952
1045
|
}
|
|
953
1046
|
}
|
|
1047
|
+
function escapeShellSingleQuote(str) {
|
|
1048
|
+
return str.replace(/'/g, "'\\''");
|
|
1049
|
+
}
|
|
1050
|
+
function buildClaudeCommand(worktree, hasPreviousSession) {
|
|
1051
|
+
const commandStr = getConfigValue("claudeCodeCommand");
|
|
1052
|
+
const escapedPath = escapeShellSingleQuote(worktree.path);
|
|
1053
|
+
const escapedPrompt = escapeShellSingleQuote(APPEND_SYSTEM_PROMPT);
|
|
1054
|
+
const continueFlag = hasPreviousSession ? " --continue" : "";
|
|
1055
|
+
return `cd '${escapedPath}' && ${commandStr} --append-system-prompt '${escapedPrompt}'${continueFlag}`;
|
|
1056
|
+
}
|
|
1057
|
+
function launchInteractiveClaudeInNewTerminal(worktree, hasPreviousSession) {
|
|
1058
|
+
const command = buildClaudeCommand(worktree, hasPreviousSession);
|
|
1059
|
+
const modeLabel = hasPreviousSession ? "\u7EE7\u7EED" : "\u65B0\u5BF9\u8BDD";
|
|
1060
|
+
const tabTitle = `clawt: ${worktree.branch}`;
|
|
1061
|
+
openCommandInNewTerminalTab(command, tabTitle);
|
|
1062
|
+
}
|
|
954
1063
|
|
|
955
1064
|
// src/utils/validate-snapshot.ts
|
|
956
1065
|
import { join as join4 } from "path";
|
|
957
|
-
import { existsSync as
|
|
1066
|
+
import { existsSync as existsSync7, readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync, readdirSync as readdirSync4, rmdirSync as rmdirSync2 } from "fs";
|
|
958
1067
|
function getSnapshotPath(projectName, branchName) {
|
|
959
1068
|
return join4(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.tree`);
|
|
960
1069
|
}
|
|
@@ -962,14 +1071,14 @@ function getSnapshotHeadPath(projectName, branchName) {
|
|
|
962
1071
|
return join4(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.head`);
|
|
963
1072
|
}
|
|
964
1073
|
function hasSnapshot(projectName, branchName) {
|
|
965
|
-
return
|
|
1074
|
+
return existsSync7(getSnapshotPath(projectName, branchName));
|
|
966
1075
|
}
|
|
967
1076
|
function readSnapshot(projectName, branchName) {
|
|
968
1077
|
const snapshotPath = getSnapshotPath(projectName, branchName);
|
|
969
1078
|
const headPath = getSnapshotHeadPath(projectName, branchName);
|
|
970
1079
|
logger.debug(`\u8BFB\u53D6 validate \u5FEB\u7167: ${snapshotPath}`);
|
|
971
|
-
const treeHash =
|
|
972
|
-
const headCommitHash =
|
|
1080
|
+
const treeHash = existsSync7(snapshotPath) ? readFileSync2(snapshotPath, "utf-8").trim() : "";
|
|
1081
|
+
const headCommitHash = existsSync7(headPath) ? readFileSync2(headPath, "utf-8").trim() : "";
|
|
973
1082
|
return { treeHash, headCommitHash };
|
|
974
1083
|
}
|
|
975
1084
|
function writeSnapshot(projectName, branchName, treeHash, headCommitHash) {
|
|
@@ -984,18 +1093,18 @@ function writeSnapshot(projectName, branchName, treeHash, headCommitHash) {
|
|
|
984
1093
|
function removeSnapshot(projectName, branchName) {
|
|
985
1094
|
const snapshotPath = getSnapshotPath(projectName, branchName);
|
|
986
1095
|
const headPath = getSnapshotHeadPath(projectName, branchName);
|
|
987
|
-
if (
|
|
1096
|
+
if (existsSync7(snapshotPath)) {
|
|
988
1097
|
unlinkSync(snapshotPath);
|
|
989
1098
|
logger.info(`\u5DF2\u5220\u9664 validate \u5FEB\u7167: ${snapshotPath}`);
|
|
990
1099
|
}
|
|
991
|
-
if (
|
|
1100
|
+
if (existsSync7(headPath)) {
|
|
992
1101
|
unlinkSync(headPath);
|
|
993
1102
|
logger.info(`\u5DF2\u5220\u9664 validate \u5FEB\u7167: ${headPath}`);
|
|
994
1103
|
}
|
|
995
1104
|
}
|
|
996
1105
|
function getProjectSnapshotBranches(projectName) {
|
|
997
1106
|
const projectDir = join4(VALIDATE_SNAPSHOTS_DIR, projectName);
|
|
998
|
-
if (!
|
|
1107
|
+
if (!existsSync7(projectDir)) {
|
|
999
1108
|
return [];
|
|
1000
1109
|
}
|
|
1001
1110
|
const files = readdirSync4(projectDir);
|
|
@@ -1003,7 +1112,7 @@ function getProjectSnapshotBranches(projectName) {
|
|
|
1003
1112
|
}
|
|
1004
1113
|
function removeProjectSnapshots(projectName) {
|
|
1005
1114
|
const projectDir = join4(VALIDATE_SNAPSHOTS_DIR, projectName);
|
|
1006
|
-
if (!
|
|
1115
|
+
if (!existsSync7(projectDir)) {
|
|
1007
1116
|
return;
|
|
1008
1117
|
}
|
|
1009
1118
|
const files = readdirSync4(projectDir);
|
|
@@ -1037,12 +1146,37 @@ async function promptSelectBranch(worktrees, message) {
|
|
|
1037
1146
|
return worktrees.find((wt) => wt.branch === selectedBranch);
|
|
1038
1147
|
}
|
|
1039
1148
|
async function promptMultiSelectBranches(worktrees, message) {
|
|
1040
|
-
const
|
|
1149
|
+
const branchChoices = worktrees.map((wt) => ({
|
|
1150
|
+
name: wt.branch,
|
|
1151
|
+
message: wt.branch
|
|
1152
|
+
}));
|
|
1153
|
+
const choices = [
|
|
1154
|
+
{ name: SELECT_ALL_NAME, message: SELECT_ALL_LABEL },
|
|
1155
|
+
...branchChoices
|
|
1156
|
+
];
|
|
1157
|
+
const MultiSelect = Enquirer2.MultiSelect;
|
|
1158
|
+
class MultiSelectWithSelectAll extends MultiSelect {
|
|
1159
|
+
space() {
|
|
1160
|
+
if (!this.focused) return;
|
|
1161
|
+
if (this.focused.name === SELECT_ALL_NAME) {
|
|
1162
|
+
const willEnable = !this.focused.enabled;
|
|
1163
|
+
for (const ch of this.choices) {
|
|
1164
|
+
ch.enabled = willEnable;
|
|
1165
|
+
}
|
|
1166
|
+
return this.render();
|
|
1167
|
+
}
|
|
1168
|
+
this.toggle(this.focused);
|
|
1169
|
+
const selectAllChoice = this.choices.find((ch) => ch.name === SELECT_ALL_NAME);
|
|
1170
|
+
const branchItems = this.choices.filter((ch) => ch.name !== SELECT_ALL_NAME);
|
|
1171
|
+
if (selectAllChoice) {
|
|
1172
|
+
selectAllChoice.enabled = branchItems.every((ch) => ch.enabled);
|
|
1173
|
+
}
|
|
1174
|
+
return this.render();
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
const selectedBranches = await new MultiSelectWithSelectAll({
|
|
1041
1178
|
message,
|
|
1042
|
-
choices
|
|
1043
|
-
name: wt.branch,
|
|
1044
|
-
message: wt.branch
|
|
1045
|
-
})),
|
|
1179
|
+
choices,
|
|
1046
1180
|
// 使用空心圆/实心圆作为选中指示符
|
|
1047
1181
|
symbols: {
|
|
1048
1182
|
indicator: { on: "\u25CF", off: "\u25CB" }
|
|
@@ -1305,7 +1439,7 @@ var ProgressRenderer = class {
|
|
|
1305
1439
|
|
|
1306
1440
|
// src/utils/task-file.ts
|
|
1307
1441
|
import { resolve } from "path";
|
|
1308
|
-
import { existsSync as
|
|
1442
|
+
import { existsSync as existsSync8, readFileSync as readFileSync3 } from "fs";
|
|
1309
1443
|
var TASK_BLOCK_REGEX = /<!-- CLAWT-TASKS:START -->([\s\S]*?)<!-- CLAWT-TASKS:END -->/g;
|
|
1310
1444
|
var BRANCH_LINE_REGEX = /^#\s*branch:\s*(.+)$/;
|
|
1311
1445
|
function parseTaskFile(content, options) {
|
|
@@ -1343,7 +1477,7 @@ function parseTaskFile(content, options) {
|
|
|
1343
1477
|
}
|
|
1344
1478
|
function loadTaskFile(filePath, options) {
|
|
1345
1479
|
const absolutePath = resolve(filePath);
|
|
1346
|
-
if (!
|
|
1480
|
+
if (!existsSync8(absolutePath)) {
|
|
1347
1481
|
throw new ClawtError(MESSAGES.TASK_FILE_NOT_FOUND(absolutePath));
|
|
1348
1482
|
}
|
|
1349
1483
|
const content = readFileSync3(absolutePath, "utf-8");
|
|
@@ -1783,10 +1917,44 @@ function registerResumeCommand(program2) {
|
|
|
1783
1917
|
async function handleResume(options) {
|
|
1784
1918
|
validateMainWorktree();
|
|
1785
1919
|
validateClaudeCodeInstalled();
|
|
1786
|
-
logger.info(`resume \u547D\u4EE4\u6267\u884C\uFF0C\u5206\u652F: ${options.branch ?? "(\
|
|
1920
|
+
logger.info(`resume \u547D\u4EE4\u6267\u884C\uFF0C\u5206\u652F\u8FC7\u6EE4: ${options.branch ?? "(\u65E0)"}`);
|
|
1787
1921
|
const worktrees = getProjectWorktrees();
|
|
1788
|
-
const
|
|
1789
|
-
|
|
1922
|
+
const targetWorktrees = await resolveTargetWorktrees(worktrees, RESUME_RESOLVE_MESSAGES, options.branch);
|
|
1923
|
+
if (targetWorktrees.length === 0) {
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
if (targetWorktrees.length === 1) {
|
|
1927
|
+
launchInteractiveClaude(targetWorktrees[0], { autoContinue: true });
|
|
1928
|
+
} else {
|
|
1929
|
+
await handleBatchResume(targetWorktrees);
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
function printBatchResumePreview(worktrees, sessionMap) {
|
|
1933
|
+
printInfo("\u5373\u5C06\u6062\u590D\u7684\u5206\u652F\uFF1A");
|
|
1934
|
+
for (const wt of worktrees) {
|
|
1935
|
+
const modeLabel = sessionMap.get(wt.path) ? "\u7EE7\u7EED\u4E0A\u6B21\u5BF9\u8BDD" : "\u65B0\u5BF9\u8BDD";
|
|
1936
|
+
printInfo(` - ${wt.branch} (${modeLabel})`);
|
|
1937
|
+
}
|
|
1938
|
+
printInfo("");
|
|
1939
|
+
}
|
|
1940
|
+
function buildSessionMap(worktrees) {
|
|
1941
|
+
const map = /* @__PURE__ */ new Map();
|
|
1942
|
+
for (const wt of worktrees) {
|
|
1943
|
+
map.set(wt.path, hasClaudeSessionHistory(wt.path));
|
|
1944
|
+
}
|
|
1945
|
+
return map;
|
|
1946
|
+
}
|
|
1947
|
+
async function handleBatchResume(worktrees) {
|
|
1948
|
+
const sessionMap = buildSessionMap(worktrees);
|
|
1949
|
+
printBatchResumePreview(worktrees, sessionMap);
|
|
1950
|
+
const confirmed = await confirmAction(MESSAGES.RESUME_ALL_CONFIRM(worktrees.length));
|
|
1951
|
+
if (!confirmed) {
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
for (const wt of worktrees) {
|
|
1955
|
+
launchInteractiveClaudeInNewTerminal(wt, sessionMap.get(wt.path) ?? false);
|
|
1956
|
+
}
|
|
1957
|
+
printSuccess(MESSAGES.RESUME_ALL_SUCCESS(worktrees.length));
|
|
1790
1958
|
}
|
|
1791
1959
|
|
|
1792
1960
|
// src/commands/validate.ts
|
|
@@ -2037,7 +2205,7 @@ async function handleMerge(options) {
|
|
|
2037
2205
|
const targetClean = isWorkingDirClean(targetWorktreePath);
|
|
2038
2206
|
if (!targetClean) {
|
|
2039
2207
|
if (!options.message) {
|
|
2040
|
-
throw new ClawtError(MESSAGES.TARGET_WORKTREE_DIRTY_NO_MESSAGE);
|
|
2208
|
+
throw new ClawtError(MESSAGES.TARGET_WORKTREE_DIRTY_NO_MESSAGE(targetWorktreePath));
|
|
2041
2209
|
}
|
|
2042
2210
|
gitAddAll(targetWorktreePath);
|
|
2043
2211
|
gitCommit(options.message, targetWorktreePath);
|
package/dist/postinstall.js
CHANGED
|
@@ -111,7 +111,7 @@ var MERGE_MESSAGES = {
|
|
|
111
111
|
/** merge 后清理 worktree 和分支成功 */
|
|
112
112
|
WORKTREE_CLEANED: (branch) => `\u2713 \u5DF2\u6E05\u7406 worktree \u548C\u5206\u652F: ${branch}`,
|
|
113
113
|
/** 目标 worktree 有未提交修改但未指定 -m */
|
|
114
|
-
TARGET_WORKTREE_DIRTY_NO_MESSAGE:
|
|
114
|
+
TARGET_WORKTREE_DIRTY_NO_MESSAGE: (worktreePath) => `${worktreePath} \u6709\u672A\u63D0\u4EA4\u7684\u4FEE\u6539\uFF0C\u8BF7\u901A\u8FC7 -m \u53C2\u6570\u63D0\u4F9B\u63D0\u4EA4\u4FE1\u606F`,
|
|
115
115
|
/** 目标 worktree 既干净又无本地提交 */
|
|
116
116
|
TARGET_WORKTREE_NO_CHANGES: "\u76EE\u6807 worktree \u6CA1\u6709\u4EFB\u4F55\u53EF\u5408\u5E76\u7684\u53D8\u66F4\uFF08\u5DE5\u4F5C\u533A\u5E72\u51C0\u4E14\u65E0\u672C\u5730\u63D0\u4EA4\uFF09",
|
|
117
117
|
/** merge 命令检测到 validate 状态的提示 */
|
|
@@ -201,10 +201,18 @@ var RESUME_MESSAGES = {
|
|
|
201
201
|
RESUME_NO_MATCH: (name, branches) => `\u672A\u627E\u5230\u4E0E "${name}" \u5339\u914D\u7684\u5206\u652F
|
|
202
202
|
\u53EF\u7528\u5206\u652F\uFF1A
|
|
203
203
|
${branches.map((b) => ` - ${b}`).join("\n")}`,
|
|
204
|
-
/** resume
|
|
205
|
-
RESUME_SELECT_BRANCH: "\u8BF7\u9009\u62E9\u8981\u6062\u590D\u7684\u5206\u652F",
|
|
206
|
-
/** resume
|
|
207
|
-
RESUME_MULTIPLE_MATCHES: (
|
|
204
|
+
/** resume 多选交互提示 */
|
|
205
|
+
RESUME_SELECT_BRANCH: "\u8BF7\u9009\u62E9\u8981\u6062\u590D\u7684\u5206\u652F\uFF08\u7A7A\u683C\u9009\u62E9\uFF0C\u56DE\u8F66\u786E\u8BA4\uFF09",
|
|
206
|
+
/** resume 模糊匹配到多个结果的多选提示 */
|
|
207
|
+
RESUME_MULTIPLE_MATCHES: (keyword) => `"${keyword}" \u5339\u914D\u5230\u591A\u4E2A\u5206\u652F\uFF0C\u8BF7\u9009\u62E9\u8981\u6062\u590D\u7684\uFF1A`,
|
|
208
|
+
/** 批量 resume 确认提示 */
|
|
209
|
+
RESUME_ALL_CONFIRM: (count) => `\u5373\u5C06\u5728 ${count} \u4E2A\u72EC\u7ACB\u7EC8\u7AEF Tab \u4E2D\u6062\u590D Claude Code \u4F1A\u8BDD\uFF0C\u662F\u5426\u7EE7\u7EED\uFF1F`,
|
|
210
|
+
/** 批量 resume 完成提示 */
|
|
211
|
+
RESUME_ALL_SUCCESS: (count) => `\u5DF2\u5728 ${count} \u4E2A\u7EC8\u7AEF Tab \u4E2D\u542F\u52A8 Claude Code \u4F1A\u8BDD`,
|
|
212
|
+
/** 批量 resume 非 macOS 平台提示 */
|
|
213
|
+
RESUME_ALL_PLATFORM_UNSUPPORTED: "\u6279\u91CF resume \u76EE\u524D\u4EC5\u652F\u6301 macOS \u5E73\u53F0\uFF08\u901A\u8FC7 AppleScript \u6253\u5F00\u7EC8\u7AEF Tab\uFF09",
|
|
214
|
+
/** 批量 resume 无匹配分支提示 */
|
|
215
|
+
RESUME_ALL_NO_MATCH: (keyword) => `\u672A\u627E\u5230\u4E0E "${keyword}" \u5339\u914D\u7684\u5206\u652F`
|
|
208
216
|
};
|
|
209
217
|
|
|
210
218
|
// src/constants/messages/remove.ts
|
|
@@ -300,6 +308,10 @@ var CONFIG_DEFINITIONS = {
|
|
|
300
308
|
maxConcurrency: {
|
|
301
309
|
defaultValue: 0,
|
|
302
310
|
description: "run \u547D\u4EE4\u9ED8\u8BA4\u6700\u5927\u5E76\u53D1\u6570\uFF0C0 \u8868\u793A\u4E0D\u9650\u5236"
|
|
311
|
+
},
|
|
312
|
+
terminalApp: {
|
|
313
|
+
defaultValue: "auto",
|
|
314
|
+
description: "\u6279\u91CF resume \u4F7F\u7528\u7684\u7EC8\u7AEF\u5E94\u7528\uFF1Aauto\uFF08\u81EA\u52A8\u68C0\u6D4B\uFF09\u3001iterm2\u3001terminal\uFF08macOS\uFF09"
|
|
303
315
|
}
|
|
304
316
|
};
|
|
305
317
|
function deriveDefaultConfig(definitions) {
|
package/docs/spec.md
CHANGED
|
@@ -175,7 +175,7 @@ git show-ref --verify refs/heads/<branchName> 2>/dev/null
|
|
|
175
175
|
| `clawt list` | 列出当前项目所有 worktree(支持 `--json` 格式输出) | 5.8 |
|
|
176
176
|
| `clawt config` | 查看全局配置 | 5.10 |
|
|
177
177
|
| `clawt config reset` | 将配置恢复为默认值 | 5.10 |
|
|
178
|
-
| `clawt resume` | 在已有 worktree 中恢复 Claude Code
|
|
178
|
+
| `clawt resume` | 在已有 worktree 中恢复 Claude Code 会话(支持多选批量恢复) | 5.11 |
|
|
179
179
|
| `clawt sync` | 将主分支最新代码同步到目标 worktree | 5.12 |
|
|
180
180
|
| `clawt reset` | 重置主 worktree 工作区和暂存区 | 5.13 |
|
|
181
181
|
| `clawt status` | 显示项目全局状态总览(支持 `--json` 格式输出) | 5.14 |
|
|
@@ -751,7 +751,7 @@ clawt merge [-m <commitMessage>]
|
|
|
751
751
|
5. **根据目标 worktree 状态决定是否需要提交**
|
|
752
752
|
- 检测目标 worktree 工作区是否干净(`git status --porcelain`)
|
|
753
753
|
- **工作区有未提交修改**:
|
|
754
|
-
- 如果用户未提供 `-m`,提示
|
|
754
|
+
- 如果用户未提供 `-m`,提示 `<worktreePath> 有未提交的修改,请通过 -m 参数提供提交信息`(其中 `<worktreePath>` 为目标 worktree 的完整路径),退出
|
|
755
755
|
- 提供了 `-m` → 执行提交:
|
|
756
756
|
```bash
|
|
757
757
|
cd ~/.clawt/worktrees/<project>/<branchName>
|
|
@@ -840,7 +840,9 @@ clawt merge [-m <commitMessage>]
|
|
|
840
840
|
"autoDeleteBranch": false,
|
|
841
841
|
"claudeCodeCommand": "claude",
|
|
842
842
|
"autoPullPush": false,
|
|
843
|
-
"confirmDestructiveOps": true
|
|
843
|
+
"confirmDestructiveOps": true,
|
|
844
|
+
"maxConcurrency": 0,
|
|
845
|
+
"terminalApp": "auto"
|
|
844
846
|
}
|
|
845
847
|
```
|
|
846
848
|
|
|
@@ -852,6 +854,8 @@ clawt merge [-m <commitMessage>]
|
|
|
852
854
|
| `claudeCodeCommand` | `string` | `"claude"` | Claude Code CLI 启动指令,用于 `clawt run` 不传 `--tasks` 时和 `clawt resume` 在 worktree 中打开交互式界面 |
|
|
853
855
|
| `autoPullPush` | `boolean` | `false` | merge 成功后是否自动执行 git pull 和 git push |
|
|
854
856
|
| `confirmDestructiveOps` | `boolean` | `true` | 执行破坏性操作(reset、validate --clean、config reset)前是否提示确认 |
|
|
857
|
+
| `maxConcurrency` | `number` | `0` | run 命令默认最大并发数,`0` 表示不限制 |
|
|
858
|
+
| `terminalApp` | `string` | `"auto"` | 批量 resume 使用的终端应用:`auto`(自动检测)、`iterm2`、`terminal`(macOS) |
|
|
855
859
|
|
|
856
860
|
---
|
|
857
861
|
|
|
@@ -1054,7 +1058,7 @@ clawt config reset
|
|
|
1054
1058
|
# 指定分支名(支持模糊匹配)
|
|
1055
1059
|
clawt resume -b <branchName>
|
|
1056
1060
|
|
|
1057
|
-
#
|
|
1061
|
+
# 不指定分支名(列出所有分支供多选)
|
|
1058
1062
|
clawt resume
|
|
1059
1063
|
```
|
|
1060
1064
|
|
|
@@ -1062,29 +1066,54 @@ clawt resume
|
|
|
1062
1066
|
|
|
1063
1067
|
| 参数 | 必填 | 说明 |
|
|
1064
1068
|
| ---- | ---- | ----------------------------------------------------- |
|
|
1065
|
-
| `-b` | 否 |
|
|
1069
|
+
| `-b` | 否 | 要恢复的分支名(支持模糊匹配,不传则列出所有分支供多选) |
|
|
1066
1070
|
|
|
1067
1071
|
**使用场景:**
|
|
1068
1072
|
|
|
1069
|
-
当用户之前通过 `clawt run` 或 `clawt create` 创建了 worktree 但会话已结束,希望在该 worktree 中重新打开 Claude Code
|
|
1073
|
+
当用户之前通过 `clawt run` 或 `clawt create` 创建了 worktree 但会话已结束,希望在该 worktree 中重新打开 Claude Code 交互式界面继续工作。支持一次选中多个分支,自动在独立终端 Tab 中批量恢复。
|
|
1070
1074
|
|
|
1071
1075
|
**运行流程:**
|
|
1072
1076
|
|
|
1073
1077
|
1. **主 worktree 校验** (2.1)
|
|
1074
1078
|
2. **Claude Code CLI 校验**:确认 `claude` CLI 可用
|
|
1075
|
-
3. **解析目标 worktree
|
|
1079
|
+
3. **解析目标 worktree(多选模式)**:统一使用 `resolveTargetWorktrees`(多选版本)解析目标 worktree,匹配策略如下:
|
|
1076
1080
|
- **未传 `-b` 参数**:
|
|
1077
1081
|
- 获取当前项目所有 worktree
|
|
1078
1082
|
- 无可用 worktree → 报错退出
|
|
1079
1083
|
- 仅 1 个 worktree → 直接使用,无需选择
|
|
1080
|
-
- 多个 worktree →
|
|
1084
|
+
- 多个 worktree → 通过交互式多选列表(Enquirer.MultiSelect)让用户选择(空格选择,回车确认),顶部提供「全选」选项
|
|
1081
1085
|
- **传了 `-b` 参数**:
|
|
1082
1086
|
1. **精确匹配优先**:在 worktree 列表中查找分支名完全相同的 worktree,找到则直接使用
|
|
1083
1087
|
2. **模糊匹配**(子串匹配,大小写不敏感):
|
|
1084
1088
|
- 唯一匹配 → 直接使用
|
|
1085
|
-
- 多个匹配 →
|
|
1089
|
+
- 多个匹配 → 通过交互式多选列表让用户从匹配结果中选择
|
|
1086
1090
|
3. **无匹配** → 报错退出,并列出所有可用分支名
|
|
1087
|
-
4.
|
|
1091
|
+
4. **根据选中数量自动分发**:
|
|
1092
|
+
- **用户未选择任何分支** → 直接退出
|
|
1093
|
+
- **选中 1 个** → 在当前终端恢复(同原有行为),通过 `launchInteractiveClaude()` 启动(使用 `spawnSync` + `inherit stdio`)
|
|
1094
|
+
- **选中多个** → 进入批量恢复流程(见下文)
|
|
1095
|
+
|
|
1096
|
+
**批量恢复流程:**
|
|
1097
|
+
|
|
1098
|
+
1. **计算会话状态**:一次性遍历所有选中的 worktree,通过 `hasClaudeSessionHistory()` 检测是否存在历史会话,构建 sessionMap 避免重复 I/O
|
|
1099
|
+
2. **输出预览**:列出即将恢复的分支及其会话状态("继续上次对话"或"新对话")
|
|
1100
|
+
3. **用户确认**:提示即将在 N 个独立终端 Tab 中恢复会话,等待用户确认
|
|
1101
|
+
4. **逐个在新终端 Tab 中启动**:通过 `launchInteractiveClaudeInNewTerminal()` 构建 shell 命令并通过 AppleScript 在新终端 Tab 中执行
|
|
1102
|
+
5. **输出完成提示**
|
|
1103
|
+
|
|
1104
|
+
**终端 Tab 管理:**
|
|
1105
|
+
|
|
1106
|
+
批量恢复通过 `openCommandInNewTerminalTab()`(`src/utils/terminal.ts`)在新终端 Tab 中启动 Claude Code。终端类型由配置项 `terminalApp` 控制:
|
|
1107
|
+
|
|
1108
|
+
| 配置值 | 行为 |
|
|
1109
|
+
| ---------- | ------------------------------------------------------------ |
|
|
1110
|
+
| `auto` | 自动检测:优先检测 iTerm2 是否已安装(`/Applications/iTerm.app`),已安装则使用 iTerm2,否则降级到 Terminal.app |
|
|
1111
|
+
| `iterm2` | 强制使用 iTerm2 |
|
|
1112
|
+
| `terminal` | 强制使用 Terminal.app |
|
|
1113
|
+
|
|
1114
|
+
**平台限制:** 批量恢复目前仅支持 macOS 平台(通过 AppleScript 打开终端 Tab)。非 macOS 平台会抛出错误。
|
|
1115
|
+
|
|
1116
|
+
**权限要求:** Terminal.app 通过 System Events 模拟键盘操作(`Cmd+T`)新建 Tab,需要在「系统设置 → 隐私与安全性 → 辅助功能」中授权终端应用。iTerm2 使用原生 AppleScript 接口,无需辅助功能权限。
|
|
1088
1117
|
|
|
1089
1118
|
启动命令通过配置项 `claudeCodeCommand`(默认值 `claude`)指定,与 `clawt run` 不传 `--tasks` 时的交互式界面行为一致。
|
|
1090
1119
|
|
package/package.json
CHANGED
package/src/commands/merge.ts
CHANGED
|
@@ -165,7 +165,7 @@ async function handleMerge(options: MergeOptions): Promise<void> {
|
|
|
165
165
|
if (!targetClean) {
|
|
166
166
|
// 目标 worktree 有未提交修改,必须提供 -m
|
|
167
167
|
if (!options.message) {
|
|
168
|
-
throw new ClawtError(MESSAGES.TARGET_WORKTREE_DIRTY_NO_MESSAGE);
|
|
168
|
+
throw new ClawtError(MESSAGES.TARGET_WORKTREE_DIRTY_NO_MESSAGE(targetWorktreePath));
|
|
169
169
|
}
|
|
170
170
|
gitAddAll(targetWorktreePath);
|
|
171
171
|
gitCommit(options.message, targetWorktreePath);
|
package/src/commands/resume.ts
CHANGED
|
@@ -2,17 +2,23 @@ import type { Command } from 'commander';
|
|
|
2
2
|
import { logger } from '../logger/index.js';
|
|
3
3
|
import { MESSAGES } from '../constants/index.js';
|
|
4
4
|
import type { ResumeOptions } from '../types/index.js';
|
|
5
|
+
import type { WorktreeInfo } from '../types/index.js';
|
|
5
6
|
import {
|
|
6
7
|
validateMainWorktree,
|
|
7
8
|
validateClaudeCodeInstalled,
|
|
8
9
|
getProjectWorktrees,
|
|
9
10
|
launchInteractiveClaude,
|
|
10
|
-
|
|
11
|
+
launchInteractiveClaudeInNewTerminal,
|
|
12
|
+
hasClaudeSessionHistory,
|
|
13
|
+
resolveTargetWorktrees,
|
|
14
|
+
printInfo,
|
|
15
|
+
printSuccess,
|
|
16
|
+
confirmAction,
|
|
11
17
|
} from '../utils/index.js';
|
|
12
|
-
import type {
|
|
18
|
+
import type { WorktreeMultiResolveMessages } from '../utils/index.js';
|
|
13
19
|
|
|
14
|
-
/** resume
|
|
15
|
-
const RESUME_RESOLVE_MESSAGES:
|
|
20
|
+
/** resume 命令的多选分支解析消息配置 */
|
|
21
|
+
const RESUME_RESOLVE_MESSAGES: WorktreeMultiResolveMessages = {
|
|
16
22
|
noWorktrees: MESSAGES.RESUME_NO_WORKTREES,
|
|
17
23
|
selectBranch: MESSAGES.RESUME_SELECT_BRANCH,
|
|
18
24
|
multipleMatches: MESSAGES.RESUME_MULTIPLE_MATCHES,
|
|
@@ -35,19 +41,83 @@ export function registerResumeCommand(program: Command): void {
|
|
|
35
41
|
|
|
36
42
|
/**
|
|
37
43
|
* 执行 resume 命令的核心逻辑
|
|
38
|
-
*
|
|
44
|
+
* 统一走多选交互,根据选中数量自动分发:选 1 个在当前终端恢复,选多个在独立终端 Tab 中批量恢复
|
|
39
45
|
* @param {ResumeOptions} options - 命令选项
|
|
40
46
|
*/
|
|
41
47
|
async function handleResume(options: ResumeOptions): Promise<void> {
|
|
42
48
|
validateMainWorktree();
|
|
43
49
|
validateClaudeCodeInstalled();
|
|
44
50
|
|
|
45
|
-
logger.info(`resume
|
|
46
|
-
|
|
47
|
-
// 解析目标 worktree(精确匹配 / 模糊匹配 / 交互选择)
|
|
51
|
+
logger.info(`resume 命令执行,分支过滤: ${options.branch ?? '(无)'}`);
|
|
48
52
|
const worktrees = getProjectWorktrees();
|
|
49
|
-
const worktree = await resolveTargetWorktree(worktrees, RESUME_RESOLVE_MESSAGES, options.branch);
|
|
50
53
|
|
|
51
|
-
//
|
|
52
|
-
|
|
54
|
+
// 统一走多选解析(精确匹配 / 模糊匹配 / 交互多选)
|
|
55
|
+
const targetWorktrees = await resolveTargetWorktrees(worktrees, RESUME_RESOLVE_MESSAGES, options.branch);
|
|
56
|
+
|
|
57
|
+
// 用户未选择任何分支时直接退出
|
|
58
|
+
if (targetWorktrees.length === 0) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (targetWorktrees.length === 1) {
|
|
63
|
+
// 选中 1 个 → 当前终端恢复(resume 自动续接历史会话)
|
|
64
|
+
launchInteractiveClaude(targetWorktrees[0], { autoContinue: true });
|
|
65
|
+
} else {
|
|
66
|
+
// 选中多个 → 逐个在新终端 Tab 中启动
|
|
67
|
+
await handleBatchResume(targetWorktrees);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 输出即将恢复的分支列表(含会话状态:继续/新对话)
|
|
73
|
+
* @param {WorktreeInfo[]} worktrees - 待恢复的 worktree 列表
|
|
74
|
+
* @param {Map<string, boolean>} sessionMap - worktree 路径 → 是否存在历史会话的映射
|
|
75
|
+
*/
|
|
76
|
+
function printBatchResumePreview(worktrees: WorktreeInfo[], sessionMap: Map<string, boolean>): void {
|
|
77
|
+
printInfo('即将恢复的分支:');
|
|
78
|
+
for (const wt of worktrees) {
|
|
79
|
+
const modeLabel = sessionMap.get(wt.path) ? '继续上次对话' : '新对话';
|
|
80
|
+
printInfo(` - ${wt.branch} (${modeLabel})`);
|
|
81
|
+
}
|
|
82
|
+
printInfo('');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 批量计算 worktree 的会话历史状态
|
|
87
|
+
* 一次性遍历所有 worktree,避免后续流程中重复调用 hasClaudeSessionHistory 产生多余 I/O
|
|
88
|
+
* @param {WorktreeInfo[]} worktrees - worktree 列表
|
|
89
|
+
* @returns {Map<string, boolean>} worktree 路径 → 是否存在历史会话的映射
|
|
90
|
+
*/
|
|
91
|
+
function buildSessionMap(worktrees: WorktreeInfo[]): Map<string, boolean> {
|
|
92
|
+
const map = new Map<string, boolean>();
|
|
93
|
+
for (const wt of worktrees) {
|
|
94
|
+
map.set(wt.path, hasClaudeSessionHistory(wt.path));
|
|
95
|
+
}
|
|
96
|
+
return map;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 批量恢复多个 worktree 的 Claude Code 会话
|
|
101
|
+
* 逐个在新终端 Tab 中启动
|
|
102
|
+
* @param {WorktreeInfo[]} worktrees - 待恢复的 worktree 列表
|
|
103
|
+
*/
|
|
104
|
+
async function handleBatchResume(worktrees: WorktreeInfo[]): Promise<void> {
|
|
105
|
+
// 一次性计算所有 worktree 的会话状态,后续传递使用避免重复 I/O
|
|
106
|
+
const sessionMap = buildSessionMap(worktrees);
|
|
107
|
+
|
|
108
|
+
// 输出即将恢复的分支列表
|
|
109
|
+
printBatchResumePreview(worktrees, sessionMap);
|
|
110
|
+
|
|
111
|
+
// 确认操作
|
|
112
|
+
const confirmed = await confirmAction(MESSAGES.RESUME_ALL_CONFIRM(worktrees.length));
|
|
113
|
+
if (!confirmed) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 逐个在新终端 Tab 中启动 Claude Code
|
|
118
|
+
for (const wt of worktrees) {
|
|
119
|
+
launchInteractiveClaudeInNewTerminal(wt, sessionMap.get(wt.path) ?? false);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
printSuccess(MESSAGES.RESUME_ALL_SUCCESS(worktrees.length));
|
|
53
123
|
}
|
package/src/constants/config.ts
CHANGED
|
@@ -29,6 +29,10 @@ export const CONFIG_DEFINITIONS: ConfigDefinitions = {
|
|
|
29
29
|
defaultValue: 0,
|
|
30
30
|
description: 'run 命令默认最大并发数,0 表示不限制',
|
|
31
31
|
},
|
|
32
|
+
terminalApp: {
|
|
33
|
+
defaultValue: 'auto',
|
|
34
|
+
description: '批量 resume 使用的终端应用:auto(自动检测)、iterm2、terminal(macOS)',
|
|
35
|
+
},
|
|
32
36
|
};
|
|
33
37
|
|
|
34
38
|
/**
|
package/src/constants/index.ts
CHANGED
|
@@ -2,7 +2,7 @@ export { CLAWT_HOME, CONFIG_PATH, LOGS_DIR, WORKTREES_DIR, VALIDATE_SNAPSHOTS_DI
|
|
|
2
2
|
export { INVALID_BRANCH_CHARS } from './branch.js';
|
|
3
3
|
export { MESSAGES } from './messages/index.js';
|
|
4
4
|
export { EXIT_CODES } from './exitCodes.js';
|
|
5
|
-
export { ENABLE_BRACKETED_PASTE, DISABLE_BRACKETED_PASTE, PASTE_THRESHOLD_MS } from './terminal.js';
|
|
5
|
+
export { ENABLE_BRACKETED_PASTE, DISABLE_BRACKETED_PASTE, PASTE_THRESHOLD_MS, VALID_TERMINAL_APPS, ITERM2_APP_PATH } from './terminal.js';
|
|
6
6
|
export { DEFAULT_CONFIG, CONFIG_DESCRIPTIONS, APPEND_SYSTEM_PROMPT } from './config.js';
|
|
7
7
|
export { AUTO_SAVE_COMMIT_MESSAGE } from './git.js';
|
|
8
8
|
export { DEBUG_LOG_PREFIX, DEBUG_TIMESTAMP_FORMAT } from './logger.js';
|
|
@@ -16,3 +16,4 @@ export {
|
|
|
16
16
|
TASK_STATUS_ICONS,
|
|
17
17
|
TASK_STATUS_LABELS,
|
|
18
18
|
} from './progress.js';
|
|
19
|
+
export { SELECT_ALL_NAME, SELECT_ALL_LABEL } from './prompt.js';
|
|
@@ -11,7 +11,8 @@ export const MERGE_MESSAGES = {
|
|
|
11
11
|
/** merge 后清理 worktree 和分支成功 */
|
|
12
12
|
WORKTREE_CLEANED: (branch: string) => `✓ 已清理 worktree 和分支: ${branch}`,
|
|
13
13
|
/** 目标 worktree 有未提交修改但未指定 -m */
|
|
14
|
-
TARGET_WORKTREE_DIRTY_NO_MESSAGE:
|
|
14
|
+
TARGET_WORKTREE_DIRTY_NO_MESSAGE: (worktreePath: string) =>
|
|
15
|
+
`${worktreePath} 有未提交的修改,请通过 -m 参数提供提交信息`,
|
|
15
16
|
/** 目标 worktree 既干净又无本地提交 */
|
|
16
17
|
TARGET_WORKTREE_NO_CHANGES: '目标 worktree 没有任何可合并的变更(工作区干净且无本地提交)',
|
|
17
18
|
/** merge 命令检测到 validate 状态的提示 */
|
|
@@ -5,8 +5,17 @@ export const RESUME_MESSAGES = {
|
|
|
5
5
|
/** resume 模糊匹配无结果,列出可用分支 */
|
|
6
6
|
RESUME_NO_MATCH: (name: string, branches: string[]) =>
|
|
7
7
|
`未找到与 "${name}" 匹配的分支\n 可用分支:\n${branches.map((b) => ` - ${b}`).join('\n')}`,
|
|
8
|
-
/** resume
|
|
9
|
-
RESUME_SELECT_BRANCH: '
|
|
10
|
-
/** resume
|
|
11
|
-
RESUME_MULTIPLE_MATCHES: (
|
|
8
|
+
/** resume 多选交互提示 */
|
|
9
|
+
RESUME_SELECT_BRANCH: '请选择要恢复的分支(空格选择,回车确认)',
|
|
10
|
+
/** resume 模糊匹配到多个结果的多选提示 */
|
|
11
|
+
RESUME_MULTIPLE_MATCHES: (keyword: string) => `"${keyword}" 匹配到多个分支,请选择要恢复的:`,
|
|
12
|
+
|
|
13
|
+
/** 批量 resume 确认提示 */
|
|
14
|
+
RESUME_ALL_CONFIRM: (count: number) => `即将在 ${count} 个独立终端 Tab 中恢复 Claude Code 会话,是否继续?`,
|
|
15
|
+
/** 批量 resume 完成提示 */
|
|
16
|
+
RESUME_ALL_SUCCESS: (count: number) => `已在 ${count} 个终端 Tab 中启动 Claude Code 会话`,
|
|
17
|
+
/** 批量 resume 非 macOS 平台提示 */
|
|
18
|
+
RESUME_ALL_PLATFORM_UNSUPPORTED: '批量 resume 目前仅支持 macOS 平台(通过 AppleScript 打开终端 Tab)',
|
|
19
|
+
/** 批量 resume 无匹配分支提示 */
|
|
20
|
+
RESUME_ALL_NO_MATCH: (keyword: string) => `未找到与 "${keyword}" 匹配的分支`,
|
|
12
21
|
} as const;
|
|
@@ -45,7 +45,8 @@ export const MESSAGES = {
|
|
|
45
45
|
/** 请提供提交信息 */
|
|
46
46
|
COMMIT_MESSAGE_REQUIRED: '请提供提交信息(-m 参数)',
|
|
47
47
|
/** 目标 worktree 有未提交修改但未指定 -m */
|
|
48
|
-
TARGET_WORKTREE_DIRTY_NO_MESSAGE:
|
|
48
|
+
TARGET_WORKTREE_DIRTY_NO_MESSAGE: (worktreePath: string) =>
|
|
49
|
+
`${worktreePath} 有未提交的修改,请通过 -m 参数提供提交信息`,
|
|
49
50
|
/** 目标 worktree 既干净又无本地提交 */
|
|
50
51
|
TARGET_WORKTREE_NO_CHANGES: '目标 worktree 没有任何可合并的变更(工作区干净且无本地提交)',
|
|
51
52
|
/** 检测到用户中断 */
|
|
@@ -11,3 +11,9 @@ export const DISABLE_BRACKETED_PASTE = '\x1b[?2004l';
|
|
|
11
11
|
* 作为 Bracketed Paste Mode 不可用时的降级方案
|
|
12
12
|
*/
|
|
13
13
|
export const PASTE_THRESHOLD_MS = 10;
|
|
14
|
+
|
|
15
|
+
/** 配置项 terminalApp 允许的有效值 */
|
|
16
|
+
export const VALID_TERMINAL_APPS: readonly string[] = ['auto', 'iterm2', 'terminal'];
|
|
17
|
+
|
|
18
|
+
/** iTerm2 应用路径,用于 auto 模式检测是否已安装 */
|
|
19
|
+
export const ITERM2_APP_PATH = '/Applications/iTerm.app';
|
package/src/types/config.ts
CHANGED
package/src/utils/claude.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { ClawtError } from '../errors/index.js';
|
|
|
5
5
|
import { APPEND_SYSTEM_PROMPT, CLAUDE_PROJECTS_DIR } from '../constants/index.js';
|
|
6
6
|
import { getConfigValue } from './config.js';
|
|
7
7
|
import { printInfo, printWarning } from './formatter.js';
|
|
8
|
+
import { openCommandInNewTerminalTab } from './terminal.js';
|
|
8
9
|
import type { WorktreeInfo } from '../types/index.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -87,3 +88,44 @@ export function launchInteractiveClaude(worktree: WorktreeInfo, options: LaunchC
|
|
|
87
88
|
printWarning(`Claude Code 退出码: ${result.status}`);
|
|
88
89
|
}
|
|
89
90
|
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 转义 shell 单引号
|
|
94
|
+
* 将字符串中的单引号替换为 '\'' 以安全嵌入单引号包裹的 shell 字符串
|
|
95
|
+
* @param {string} str - 原始字符串
|
|
96
|
+
* @returns {string} 转义后的字符串
|
|
97
|
+
*/
|
|
98
|
+
function escapeShellSingleQuote(str: string): string {
|
|
99
|
+
return str.replace(/'/g, "'\\''");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 构建在指定 worktree 中启动 Claude Code 的完整 shell 命令
|
|
104
|
+
* 生成格式:cd <path> && <claudeCommand> --append-system-prompt '...' [--continue]
|
|
105
|
+
* @param {WorktreeInfo} worktree - worktree 信息
|
|
106
|
+
* @param {boolean} hasPreviousSession - 是否存在历史会话(由调用方预计算,避免重复 I/O)
|
|
107
|
+
* @returns {string} 完整的 shell 命令字符串
|
|
108
|
+
*/
|
|
109
|
+
export function buildClaudeCommand(worktree: WorktreeInfo, hasPreviousSession: boolean): string {
|
|
110
|
+
const commandStr = getConfigValue('claudeCodeCommand');
|
|
111
|
+
|
|
112
|
+
const escapedPath = escapeShellSingleQuote(worktree.path);
|
|
113
|
+
const escapedPrompt = escapeShellSingleQuote(APPEND_SYSTEM_PROMPT);
|
|
114
|
+
const continueFlag = hasPreviousSession ? ' --continue' : '';
|
|
115
|
+
|
|
116
|
+
return `cd '${escapedPath}' && ${commandStr} --append-system-prompt '${escapedPrompt}'${continueFlag}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 在新终端 Tab 中启动 Claude Code 交互式会话
|
|
121
|
+
* 通过 AppleScript 打开独立终端 Tab,支持 Terminal.app 和 iTerm2
|
|
122
|
+
* @param {WorktreeInfo} worktree - worktree 信息
|
|
123
|
+
* @param {boolean} hasPreviousSession - 是否存在历史会话(由调用方预计算,避免重复 I/O)
|
|
124
|
+
*/
|
|
125
|
+
export function launchInteractiveClaudeInNewTerminal(worktree: WorktreeInfo, hasPreviousSession: boolean): void {
|
|
126
|
+
const command = buildClaudeCommand(worktree, hasPreviousSession);
|
|
127
|
+
const modeLabel = hasPreviousSession ? '继续' : '新对话';
|
|
128
|
+
const tabTitle = `clawt: ${worktree.branch}`;
|
|
129
|
+
|
|
130
|
+
openCommandInNewTerminalTab(command, tabTitle);
|
|
131
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -52,11 +52,12 @@ export { loadConfig, writeDefaultConfig, getConfigValue, ensureClawtDirs } from
|
|
|
52
52
|
export { printSuccess, printError, printWarning, printInfo, printSeparator, printDoubleSeparator, confirmAction, confirmDestructiveAction, formatWorktreeStatus, isWorktreeIdle, formatDuration } from './formatter.js';
|
|
53
53
|
export { ensureDir, removeEmptyDir } from './fs.js';
|
|
54
54
|
export { multilineInput } from './prompt.js';
|
|
55
|
-
export { launchInteractiveClaude, hasClaudeSessionHistory } from './claude.js';
|
|
55
|
+
export { launchInteractiveClaude, hasClaudeSessionHistory, launchInteractiveClaudeInNewTerminal } from './claude.js';
|
|
56
56
|
export { getSnapshotPath, hasSnapshot, readSnapshotTreeHash, readSnapshot, writeSnapshot, removeSnapshot, removeProjectSnapshots, getProjectSnapshotBranches } from './validate-snapshot.js';
|
|
57
57
|
export { findExactMatch, findFuzzyMatches, promptSelectBranch, promptMultiSelectBranches, resolveTargetWorktree, resolveTargetWorktrees } from './worktree-matcher.js';
|
|
58
58
|
export type { WorktreeResolveMessages, WorktreeMultiResolveMessages } from './worktree-matcher.js';
|
|
59
59
|
export { ProgressRenderer } from './progress.js';
|
|
60
60
|
export { parseTaskFile, loadTaskFile } from './task-file.js';
|
|
61
61
|
export { executeBatchTasks } from './task-executor.js';
|
|
62
|
+
export { detectTerminalApp, openCommandInNewTerminalTab } from './terminal.js';
|
|
62
63
|
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { ClawtError } from '../errors/index.js';
|
|
4
|
+
import { logger } from '../logger/index.js';
|
|
5
|
+
import { VALID_TERMINAL_APPS, ITERM2_APP_PATH } from '../constants/index.js';
|
|
6
|
+
import { getConfigValue } from './config.js';
|
|
7
|
+
|
|
8
|
+
/** 终端应用类型 */
|
|
9
|
+
type TerminalApp = 'iterm2' | 'terminal';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 检测系统是否安装了 iTerm2
|
|
13
|
+
* 通过检查 /Applications/iTerm.app 是否存在来判断
|
|
14
|
+
* @returns {boolean} 是否安装了 iTerm2
|
|
15
|
+
*/
|
|
16
|
+
function isITerm2Installed(): boolean {
|
|
17
|
+
return existsSync(ITERM2_APP_PATH);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 检测当前使用的终端应用
|
|
22
|
+
* 优先读取配置项 terminalApp;值为 'auto' 时优先检测 iTerm2 是否已安装,
|
|
23
|
+
* 已安装则使用 iTerm2,否则降级到 Terminal.app
|
|
24
|
+
* @returns {TerminalApp} 终端类型:'iterm2' 或 'terminal'
|
|
25
|
+
*/
|
|
26
|
+
export function detectTerminalApp(): TerminalApp {
|
|
27
|
+
const configured = getConfigValue('terminalApp');
|
|
28
|
+
|
|
29
|
+
// 配置了明确的终端类型,直接使用
|
|
30
|
+
if (configured === 'iterm2' || configured === 'terminal') {
|
|
31
|
+
return configured;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 配置值无效时给出警告(auto 除外)
|
|
35
|
+
if (!VALID_TERMINAL_APPS.includes(configured)) {
|
|
36
|
+
logger.warn(`terminalApp 配置值 "${configured}" 无效,有效值: ${VALID_TERMINAL_APPS.join(', ')},将使用自动检测`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// auto 模式:优先检测 iTerm2 是否已安装
|
|
40
|
+
if (isITerm2Installed()) {
|
|
41
|
+
return 'iterm2';
|
|
42
|
+
}
|
|
43
|
+
return 'terminal';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 转义 AppleScript 字符串中的特殊字符
|
|
48
|
+
* 将反斜杠和双引号进行转义,防止注入
|
|
49
|
+
* @param {string} str - 原始字符串
|
|
50
|
+
* @returns {string} 转义后的字符串
|
|
51
|
+
*/
|
|
52
|
+
function escapeAppleScriptString(str: string): string {
|
|
53
|
+
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 构建 Terminal.app 的 AppleScript 脚本
|
|
58
|
+
* 在当前窗口新建 Tab 并执行命令
|
|
59
|
+
* @param {string} command - 要执行的 shell 命令
|
|
60
|
+
* @param {string} title - Tab 标题
|
|
61
|
+
* @returns {string} AppleScript 脚本内容
|
|
62
|
+
*/
|
|
63
|
+
function buildTerminalAppleScript(command: string, title: string): string {
|
|
64
|
+
const escapedCommand = escapeAppleScriptString(command);
|
|
65
|
+
const escapedTitle = escapeAppleScriptString(title);
|
|
66
|
+
return `
|
|
67
|
+
tell application "Terminal"
|
|
68
|
+
activate
|
|
69
|
+
tell application "System Events" to tell process "Terminal" to keystroke "t" using command down
|
|
70
|
+
delay 0.3
|
|
71
|
+
do script "${escapedCommand}" in front window's selected tab
|
|
72
|
+
set custom title of front window's selected tab to "${escapedTitle}"
|
|
73
|
+
end tell
|
|
74
|
+
`.trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 构建 iTerm2 的 AppleScript 脚本
|
|
79
|
+
* 在当前窗口新建 Tab 并执行命令
|
|
80
|
+
* @param {string} command - 要执行的 shell 命令
|
|
81
|
+
* @param {string} title - Tab 标题
|
|
82
|
+
* @returns {string} AppleScript 脚本内容
|
|
83
|
+
*/
|
|
84
|
+
function buildITermAppleScript(command: string, title: string): string {
|
|
85
|
+
const escapedCommand = escapeAppleScriptString(command);
|
|
86
|
+
const escapedTitle = escapeAppleScriptString(title);
|
|
87
|
+
return `
|
|
88
|
+
tell application "iTerm"
|
|
89
|
+
activate
|
|
90
|
+
tell current window
|
|
91
|
+
create tab with default profile
|
|
92
|
+
tell current session
|
|
93
|
+
set name to "${escapedTitle}"
|
|
94
|
+
write text "${escapedCommand}"
|
|
95
|
+
end tell
|
|
96
|
+
end tell
|
|
97
|
+
end tell
|
|
98
|
+
`.trim();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 在新终端 Tab 中执行命令
|
|
103
|
+
* 自动检测终端类型(iTerm2 / Terminal.app),通过 AppleScript 打开新 Tab
|
|
104
|
+
* @param {string} command - 要执行的 shell 命令
|
|
105
|
+
* @param {string} tabTitle - Tab 标题
|
|
106
|
+
* @throws {ClawtError} 非 macOS 平台或 AppleScript 执行失败时抛出
|
|
107
|
+
*/
|
|
108
|
+
export function openCommandInNewTerminalTab(command: string, tabTitle: string): void {
|
|
109
|
+
if (process.platform !== 'darwin') {
|
|
110
|
+
throw new ClawtError('批量 resume 目前仅支持 macOS 平台(通过 AppleScript 打开终端 Tab)');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const terminalApp = detectTerminalApp();
|
|
114
|
+
const script = terminalApp === 'iterm2'
|
|
115
|
+
? buildITermAppleScript(command, tabTitle)
|
|
116
|
+
: buildTerminalAppleScript(command, tabTitle);
|
|
117
|
+
|
|
118
|
+
logger.debug(`打开终端 Tab [${terminalApp}]: ${tabTitle}`);
|
|
119
|
+
logger.debug(`执行命令: ${command}`);
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
execFileSync('osascript', ['-e', script], {
|
|
123
|
+
encoding: 'utf-8',
|
|
124
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
125
|
+
});
|
|
126
|
+
} catch (error) {
|
|
127
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
128
|
+
// Terminal.app 通过 System Events 模拟键盘操作需要辅助功能权限
|
|
129
|
+
const accessibilityHint = terminalApp === 'terminal'
|
|
130
|
+
? '\n提示:Terminal.app 需要辅助功能权限,请在「系统设置 → 隐私与安全性 → 辅助功能」中授权终端应用'
|
|
131
|
+
: '';
|
|
132
|
+
throw new ClawtError(`打开终端 Tab 失败: ${message}${accessibilityHint}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
import Enquirer from 'enquirer';
|
|
2
2
|
import { ClawtError } from '../errors/index.js';
|
|
3
|
+
import { SELECT_ALL_NAME, SELECT_ALL_LABEL } from '../constants/index.js';
|
|
3
4
|
import type { WorktreeInfo } from '../types/index.js';
|
|
4
5
|
|
|
6
|
+
/** enquirer MultiSelect 选项条目的运行时结构 */
|
|
7
|
+
interface MultiSelectChoice {
|
|
8
|
+
name: string;
|
|
9
|
+
message: string;
|
|
10
|
+
enabled: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* enquirer MultiSelect 实例的运行时接口
|
|
15
|
+
* enquirer 类型声明未导出 MultiSelect,手动声明以消除 TypeScript 类型错误
|
|
16
|
+
*/
|
|
17
|
+
interface MultiSelectInstance {
|
|
18
|
+
focused: MultiSelectChoice | undefined;
|
|
19
|
+
choices: MultiSelectChoice[];
|
|
20
|
+
render(): void;
|
|
21
|
+
toggle(choice: MultiSelectChoice): void;
|
|
22
|
+
run(): Promise<string[]>;
|
|
23
|
+
}
|
|
24
|
+
|
|
5
25
|
/**
|
|
6
26
|
* 分支解析时使用的消息文案配置
|
|
7
27
|
* 通过此接口实现命令间的消息解耦,不同命令可传入各自的提示文案
|
|
@@ -74,25 +94,68 @@ export async function promptSelectBranch(worktrees: WorktreeInfo[], message: str
|
|
|
74
94
|
|
|
75
95
|
/**
|
|
76
96
|
* 通过交互式多选列表让用户从 worktree 列表中选择多个分支
|
|
97
|
+
* 顶部提供「全选」选项,点击可切换全选/全不选
|
|
77
98
|
* 用户可通过空格键选择/取消,回车键确认
|
|
78
99
|
* @param {WorktreeInfo[]} worktrees - 可供选择的 worktree 列表
|
|
79
100
|
* @param {string} message - 选择提示信息
|
|
80
101
|
* @returns {Promise<WorktreeInfo[]>} 用户选择的 worktree 列表
|
|
81
102
|
*/
|
|
82
103
|
export async function promptMultiSelectBranches(worktrees: WorktreeInfo[], message: string): Promise<WorktreeInfo[]> {
|
|
104
|
+
// 构建 choices 列表,顶部插入全选选项
|
|
105
|
+
const branchChoices = worktrees.map((wt) => ({
|
|
106
|
+
name: wt.branch,
|
|
107
|
+
message: wt.branch,
|
|
108
|
+
}));
|
|
109
|
+
|
|
110
|
+
const choices = [
|
|
111
|
+
{ name: SELECT_ALL_NAME, message: SELECT_ALL_LABEL },
|
|
112
|
+
...branchChoices,
|
|
113
|
+
];
|
|
114
|
+
|
|
83
115
|
// @ts-expect-error enquirer 类型声明未导出 MultiSelect 类,但运行时存在
|
|
84
|
-
const
|
|
116
|
+
const MultiSelect: new (options: Record<string, unknown>) => MultiSelectInstance = Enquirer.MultiSelect;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 扩展 MultiSelect,覆写 space() 方法实现全选 toggle
|
|
120
|
+
* 当焦点在「全选」选项上按空格时,切换所有分支选项的选中状态
|
|
121
|
+
*/
|
|
122
|
+
class MultiSelectWithSelectAll extends MultiSelect {
|
|
123
|
+
space(this: MultiSelectInstance) {
|
|
124
|
+
if (!this.focused) return;
|
|
125
|
+
|
|
126
|
+
if (this.focused.name === SELECT_ALL_NAME) {
|
|
127
|
+
// 切换全选:如果全选项当前未选中则全选,否则全不选
|
|
128
|
+
const willEnable = !this.focused.enabled;
|
|
129
|
+
for (const ch of this.choices) {
|
|
130
|
+
ch.enabled = willEnable;
|
|
131
|
+
}
|
|
132
|
+
return this.render();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 非全选选项:执行默认的 toggle 行为
|
|
136
|
+
this.toggle(this.focused);
|
|
137
|
+
|
|
138
|
+
// 同步全选选项状态:所有分支选项都选中时自动勾选全选,否则取消
|
|
139
|
+
const selectAllChoice = this.choices.find((ch) => ch.name === SELECT_ALL_NAME);
|
|
140
|
+
const branchItems = this.choices.filter((ch) => ch.name !== SELECT_ALL_NAME);
|
|
141
|
+
if (selectAllChoice) {
|
|
142
|
+
selectAllChoice.enabled = branchItems.every((ch) => ch.enabled);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return this.render();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const selectedBranches: string[] = await new MultiSelectWithSelectAll({
|
|
85
150
|
message,
|
|
86
|
-
choices
|
|
87
|
-
name: wt.branch,
|
|
88
|
-
message: wt.branch,
|
|
89
|
-
})),
|
|
151
|
+
choices,
|
|
90
152
|
// 使用空心圆/实心圆作为选中指示符
|
|
91
153
|
symbols: {
|
|
92
154
|
indicator: { on: '●', off: '○' },
|
|
93
155
|
},
|
|
94
156
|
}).run();
|
|
95
157
|
|
|
158
|
+
// 过滤掉全选选项,只返回实际的 worktree
|
|
96
159
|
return worktrees.filter((wt) => selectedBranches.includes(wt.branch));
|
|
97
160
|
}
|
|
98
161
|
|
|
@@ -26,7 +26,8 @@ vi.mock('../../../src/constants/index.js', () => ({
|
|
|
26
26
|
MERGE_SQUASH_PENDING: (path: string, branch: string) => `请手动提交: ${path}`,
|
|
27
27
|
MERGE_VALIDATE_STATE_HINT: (branch: string) => `分支 ${branch} 存在 validate 状态`,
|
|
28
28
|
MAIN_WORKTREE_DIRTY: '主 worktree 有未提交的更改',
|
|
29
|
-
TARGET_WORKTREE_DIRTY_NO_MESSAGE:
|
|
29
|
+
TARGET_WORKTREE_DIRTY_NO_MESSAGE: (worktreePath: string) =>
|
|
30
|
+
`${worktreePath} 有未提交修改,请提供 -m 参数`,
|
|
30
31
|
TARGET_WORKTREE_NO_CHANGES: '没有可合并的变更',
|
|
31
32
|
MERGE_CONFLICT: '合并冲突',
|
|
32
33
|
PULL_CONFLICT: 'pull 冲突',
|