clawt 3.8.10 → 3.9.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/README.md +5 -1
- package/README.zh-CN.md +4 -0
- package/dist/index.js +90 -24
- package/dist/postinstall.js +5 -5
- package/docs/config-file.md +1 -1
- package/docs/cover-validate.md +21 -12
- package/docs/merge.md +1 -1
- package/docs/resume.md +12 -5
- package/docs/run.md +1 -0
- package/docs/spec.md +6 -0
- package/package.json +1 -1
- package/src/commands/cover-validate.ts +17 -24
- package/src/constants/config.ts +5 -2
- package/src/constants/messages/cover-validate.ts +3 -3
- package/src/constants/terminal.ts +1 -1
- package/src/types/config.ts +1 -1
- package/src/utils/git-core.ts +8 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/shell.ts +3 -1
- package/src/utils/terminal.ts +121 -20
- package/tests/unit/commands/cover-validate.test.ts +21 -29
- package/tests/unit/utils/git.test.ts +8 -0
- package/tests/unit/utils/terminal-cmux.test.ts +288 -0
package/README.md
CHANGED
|
@@ -338,7 +338,7 @@ Configuration file is located at `~/.clawt/config.json`, auto-generated after in
|
|
|
338
338
|
| `autoPullPush` | `false` | Auto pull/push after merge |
|
|
339
339
|
| `confirmDestructiveOps` | `true` | Confirm before destructive operations |
|
|
340
340
|
| `maxConcurrency` | `0` | Max concurrency for run command, `0` means unlimited |
|
|
341
|
-
| `terminalApp` | `"auto"` | Terminal for batch resume: `auto` / `iterm2` / `terminal` |
|
|
341
|
+
| `terminalApp` | `"auto"` | Terminal for batch resume: `auto` / `iterm2` / `terminal` / `cmux` |
|
|
342
342
|
| `resumeInPlace` | `false` | Resume single selection in current terminal; `false` opens in new tab |
|
|
343
343
|
| `aliases` | `{}` | Command alias mapping (e.g., `{"l": "list", "r": "run"}`) |
|
|
344
344
|
| `autoUpdate` | `true` | Auto-check for new versions (checks npm registry every 24 hours) |
|
|
@@ -374,6 +374,10 @@ Hooks run asynchronously in the background (fire-and-forget), not blocking the m
|
|
|
374
374
|
|
|
375
375
|
> **Priority:** `--yes` > `CI` > `CLAWT_NON_INTERACTIVE` > default interactive mode
|
|
376
376
|
|
|
377
|
+
**Internally injected environment variables:**
|
|
378
|
+
|
|
379
|
+
All non-interactive Claude Code sessions launched via `claude -p` (task-executor and conflict-resolver) automatically inject the environment variable `CLAUDE_CODE_ENTRYPOINT="cli"`, which enables these sessions to be resumed via `--continue`. This does not apply to interactive Claude Code sessions (e.g., `clawt resume`).
|
|
380
|
+
|
|
377
381
|
## Logs
|
|
378
382
|
|
|
379
383
|
Logs are saved in `~/.clawt/logs/`, rotated by date, retained for 30 days.
|
package/README.zh-CN.md
CHANGED
|
@@ -374,6 +374,10 @@ hook 以 fire-and-forget 模式后台异步并行执行,不阻塞主流程。
|
|
|
374
374
|
|
|
375
375
|
> **优先级:** `--yes` > `CI` > `CLAWT_NON_INTERACTIVE` > 默认交互模式
|
|
376
376
|
|
|
377
|
+
**内部注入的环境变量:**
|
|
378
|
+
|
|
379
|
+
所有通过 `claude -p` 启动的非交互式 Claude Code 子进程(task-executor 和 conflict-resolver)会自动注入环境变量 `CLAUDE_CODE_ENTRYPOINT="cli"`,使这些会话支持通过 `--continue` 恢复。不适用于交互式启动 Claude Code 的场景(如 `clawt resume`)。
|
|
380
|
+
|
|
377
381
|
## 日志
|
|
378
382
|
|
|
379
383
|
日志保存在 `~/.clawt/logs/`,按日期滚动,保留 30 天。
|
package/dist/index.js
CHANGED
|
@@ -545,9 +545,9 @@ var COVER_VALIDATE_MESSAGES = {
|
|
|
545
545
|
/** 无快照,提示先执行 validate */
|
|
546
546
|
COVER_VALIDATE_NO_SNAPSHOT: (branch) => `\u672A\u627E\u5230\u5206\u652F ${branch} \u7684 validate \u5FEB\u7167
|
|
547
547
|
\u8BF7\u5148\u6267\u884C clawt validate -b ${branch} \u521B\u5EFA\u5FEB\u7167`,
|
|
548
|
-
/**
|
|
549
|
-
|
|
550
|
-
\u8BF7\u68C0\u67E5\u76EE\u6807 worktree \
|
|
548
|
+
/** 覆盖失败(tree checkout/clean 失败) */
|
|
549
|
+
COVER_VALIDATE_COVER_FAILED: (branch) => `\u8986\u76D6\u53D8\u66F4\u5230 worktree ${branch} \u5931\u8D25\uFF1Atree checkout \u6216\u6E05\u7406\u64CD\u4F5C\u51FA\u9519
|
|
550
|
+
\u8BF7\u68C0\u67E5\u76EE\u6807 worktree \u72B6\u6001\u540E\u91CD\u8BD5`,
|
|
551
551
|
/** 工作区和暂存区无修改,可能为误操作 */
|
|
552
552
|
COVER_VALIDATE_WORKING_DIR_CLEAN: "\u5F53\u524D\u9A8C\u8BC1\u5206\u652F\u7684\u5DE5\u4F5C\u533A\u548C\u6682\u5B58\u533A\u6CA1\u6709\u4EFB\u4F55\u4FEE\u6539\uFF0C\u53EF\u80FD\u4E3A\u8BEF\u64CD\u4F5C",
|
|
553
553
|
/** 覆盖成功 */
|
|
@@ -703,7 +703,7 @@ var EXIT_CODES = {
|
|
|
703
703
|
};
|
|
704
704
|
|
|
705
705
|
// src/constants/terminal.ts
|
|
706
|
-
var VALID_TERMINAL_APPS = ["auto", "iterm2", "terminal"];
|
|
706
|
+
var VALID_TERMINAL_APPS = ["auto", "iterm2", "terminal", "cmux"];
|
|
707
707
|
var ITERM2_APP_PATH = "/Applications/iTerm.app";
|
|
708
708
|
|
|
709
709
|
// src/constants/config.ts
|
|
@@ -732,7 +732,7 @@ var CONFIG_DEFINITIONS = {
|
|
|
732
732
|
},
|
|
733
733
|
terminalApp: {
|
|
734
734
|
defaultValue: "auto",
|
|
735
|
-
description: "\u6279\u91CF resume \u4F7F\u7528\u7684\u7EC8\u7AEF\u5E94\u7528\uFF1Aauto\uFF08\u81EA\u52A8\u68C0\u6D4B\uFF09\u3001iterm2\u3001terminal\uFF08macOS\uFF09",
|
|
735
|
+
description: "\u6279\u91CF resume \u4F7F\u7528\u7684\u7EC8\u7AEF\u5E94\u7528\uFF1Aauto\uFF08\u81EA\u52A8\u68C0\u6D4B\uFF09\u3001iterm2\u3001terminal\u3001cmux\uFF08macOS\uFF09",
|
|
736
736
|
allowedValues: VALID_TERMINAL_APPS
|
|
737
737
|
},
|
|
738
738
|
resumeInPlace: {
|
|
@@ -1252,6 +1252,9 @@ function gitResetHard(cwd) {
|
|
|
1252
1252
|
function gitCleanForce(cwd) {
|
|
1253
1253
|
execCommand("git clean -fd", { cwd });
|
|
1254
1254
|
}
|
|
1255
|
+
function gitCheckoutIndexForce(cwd) {
|
|
1256
|
+
execCommand("git checkout-index -f -a", { cwd });
|
|
1257
|
+
}
|
|
1255
1258
|
function gitStashPush(message, cwd) {
|
|
1256
1259
|
execCommand(`git stash push -m "${message}"`, { cwd });
|
|
1257
1260
|
}
|
|
@@ -2015,14 +2018,20 @@ import { existsSync as existsSync6 } from "fs";
|
|
|
2015
2018
|
function isITerm2Installed() {
|
|
2016
2019
|
return existsSync6(ITERM2_APP_PATH);
|
|
2017
2020
|
}
|
|
2021
|
+
function isCmuxEnvironment() {
|
|
2022
|
+
return !!process.env.CMUX_WORKSPACE_ID;
|
|
2023
|
+
}
|
|
2018
2024
|
function detectTerminalApp() {
|
|
2019
2025
|
const configured = getConfigValue("terminalApp");
|
|
2020
|
-
if (configured === "iterm2" || configured === "terminal") {
|
|
2026
|
+
if (configured === "iterm2" || configured === "terminal" || configured === "cmux") {
|
|
2021
2027
|
return configured;
|
|
2022
2028
|
}
|
|
2023
2029
|
if (!VALID_TERMINAL_APPS.includes(configured)) {
|
|
2024
2030
|
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`);
|
|
2025
2031
|
}
|
|
2032
|
+
if (isCmuxEnvironment()) {
|
|
2033
|
+
return "cmux";
|
|
2034
|
+
}
|
|
2026
2035
|
if (isITerm2Installed()) {
|
|
2027
2036
|
return "iterm2";
|
|
2028
2037
|
}
|
|
@@ -2060,14 +2069,52 @@ tell application "iTerm"
|
|
|
2060
2069
|
end tell
|
|
2061
2070
|
`.trim();
|
|
2062
2071
|
}
|
|
2063
|
-
function
|
|
2064
|
-
if (
|
|
2065
|
-
throw new ClawtError(
|
|
2072
|
+
function openCommandInCmuxSurface(command, title) {
|
|
2073
|
+
if (!isCmuxEnvironment()) {
|
|
2074
|
+
throw new ClawtError(
|
|
2075
|
+
"\u5F53\u524D\u4E0D\u5728 cmux \u73AF\u5883\u4E2D\uFF0C\u65E0\u6CD5\u521B\u5EFA surface\n\u8BF7\u786E\u4FDD\u5728 cmux \u7EC8\u7AEF\u4E2D\u6267\u884C clawt resume \u547D\u4EE4\uFF0C\u6216\u4FEE\u6539 terminalApp \u914D\u7F6E"
|
|
2076
|
+
);
|
|
2066
2077
|
}
|
|
2067
|
-
|
|
2068
|
-
const script = terminalApp === "iterm2" ? buildITermAppleScript(command, tabTitle) : buildTerminalAppleScript(command, tabTitle);
|
|
2069
|
-
logger.debug(`\u6253\u5F00\u7EC8\u7AEF Tab [${terminalApp}]: ${tabTitle}`);
|
|
2078
|
+
logger.debug(`\u5728 cmux \u4E2D\u521B\u5EFA\u65B0 surface: ${title}`);
|
|
2070
2079
|
logger.debug(`\u6267\u884C\u547D\u4EE4: ${command}`);
|
|
2080
|
+
try {
|
|
2081
|
+
const newSurfaceResult = execFileSync3("cmux", [
|
|
2082
|
+
"new-split",
|
|
2083
|
+
"right"
|
|
2084
|
+
// 在右侧创建新 surface
|
|
2085
|
+
], {
|
|
2086
|
+
encoding: "utf-8",
|
|
2087
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2088
|
+
timeout: 5e3
|
|
2089
|
+
// 5秒超时
|
|
2090
|
+
});
|
|
2091
|
+
logger.debug(`new-split \u8F93\u51FA: ${newSurfaceResult}`);
|
|
2092
|
+
const match = newSurfaceResult.match(/(?:OK\s+)?(surface:\d+)/i);
|
|
2093
|
+
if (!match) {
|
|
2094
|
+
throw new Error(`\u65E0\u6CD5\u89E3\u6790 cmux new-split \u8F93\u51FA: ${newSurfaceResult}`);
|
|
2095
|
+
}
|
|
2096
|
+
const surfaceRef = match[1];
|
|
2097
|
+
logger.debug(`\u5DF2\u521B\u5EFA surface: ${surfaceRef}`);
|
|
2098
|
+
execFileSync3("cmux", [
|
|
2099
|
+
"send",
|
|
2100
|
+
"--surface",
|
|
2101
|
+
surfaceRef,
|
|
2102
|
+
`${command}\\n`
|
|
2103
|
+
// 追加换行符以自动执行命令
|
|
2104
|
+
], {
|
|
2105
|
+
encoding: "utf-8",
|
|
2106
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2107
|
+
timeout: 5e3
|
|
2108
|
+
});
|
|
2109
|
+
logger.debug(`\u5DF2\u5411 ${surfaceRef} \u53D1\u9001\u547D\u4EE4`);
|
|
2110
|
+
} catch (error) {
|
|
2111
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2112
|
+
throw new ClawtError(`\u5728 cmux \u4E2D\u521B\u5EFA surface \u5931\u8D25: ${message}`);
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
function executeAppleScript(script, terminalApp) {
|
|
2116
|
+
logger.debug(`\u6253\u5F00\u7EC8\u7AEF Tab [${terminalApp}]`);
|
|
2117
|
+
logger.debug(`\u6267\u884C AppleScript`);
|
|
2071
2118
|
try {
|
|
2072
2119
|
execFileSync3("osascript", ["-e", script], {
|
|
2073
2120
|
encoding: "utf-8",
|
|
@@ -2079,6 +2126,27 @@ function openCommandInNewTerminalTab(command, tabTitle) {
|
|
|
2079
2126
|
throw new ClawtError(`\u6253\u5F00\u7EC8\u7AEF Tab \u5931\u8D25: ${message}${accessibilityHint}`);
|
|
2080
2127
|
}
|
|
2081
2128
|
}
|
|
2129
|
+
function openCommandInNewTerminalTab(command, tabTitle) {
|
|
2130
|
+
if (process.platform !== "darwin") {
|
|
2131
|
+
throw new ClawtError("\u6279\u91CF resume \u76EE\u524D\u4EC5\u652F\u6301 macOS \u5E73\u53F0");
|
|
2132
|
+
}
|
|
2133
|
+
const terminalApp = detectTerminalApp();
|
|
2134
|
+
switch (terminalApp) {
|
|
2135
|
+
case "cmux":
|
|
2136
|
+
openCommandInCmuxSurface(command, tabTitle);
|
|
2137
|
+
break;
|
|
2138
|
+
case "iterm2":
|
|
2139
|
+
const itermScript = buildITermAppleScript(command, tabTitle);
|
|
2140
|
+
executeAppleScript(itermScript, "iterm2");
|
|
2141
|
+
break;
|
|
2142
|
+
case "terminal":
|
|
2143
|
+
const terminalScript = buildTerminalAppleScript(command, tabTitle);
|
|
2144
|
+
executeAppleScript(terminalScript, "terminal");
|
|
2145
|
+
break;
|
|
2146
|
+
default:
|
|
2147
|
+
throw new ClawtError(`\u4E0D\u652F\u6301\u7684\u7EC8\u7AEF\u7C7B\u578B: ${terminalApp}`);
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2082
2150
|
|
|
2083
2151
|
// src/utils/claude.ts
|
|
2084
2152
|
function encodeClaudeProjectPath(absolutePath) {
|
|
@@ -5085,7 +5153,7 @@ function findTargetWorktreePath(branchName) {
|
|
|
5085
5153
|
}
|
|
5086
5154
|
return match.path;
|
|
5087
5155
|
}
|
|
5088
|
-
function
|
|
5156
|
+
function computeWorktreeTreeHash(mainWorktreePath) {
|
|
5089
5157
|
const savedIndexTreeHash = gitWriteTree(mainWorktreePath);
|
|
5090
5158
|
let currentTreeHash;
|
|
5091
5159
|
try {
|
|
@@ -5094,11 +5162,7 @@ function computeIncrementalPatch(snapshotTreeHash, mainWorktreePath) {
|
|
|
5094
5162
|
} finally {
|
|
5095
5163
|
gitReadTree(savedIndexTreeHash, mainWorktreePath);
|
|
5096
5164
|
}
|
|
5097
|
-
|
|
5098
|
-
return null;
|
|
5099
|
-
}
|
|
5100
|
-
const patch = gitDiffTree(snapshotTreeHash, currentTreeHash, mainWorktreePath);
|
|
5101
|
-
return { patch, currentTreeHash };
|
|
5165
|
+
return currentTreeHash;
|
|
5102
5166
|
}
|
|
5103
5167
|
async function handleCoverValidate() {
|
|
5104
5168
|
await runPreChecks({ requireMainWorktree: true, requireHead: true, requireProjectConfig: true });
|
|
@@ -5120,18 +5184,20 @@ async function handleCoverValidate() {
|
|
|
5120
5184
|
const confirmed = await confirmAction("\u662F\u5426\u7EE7\u7EED\u6267\u884C\u8986\u76D6\uFF1F");
|
|
5121
5185
|
if (!confirmed) return;
|
|
5122
5186
|
}
|
|
5123
|
-
const
|
|
5124
|
-
if (
|
|
5187
|
+
const currentTreeHash = computeWorktreeTreeHash(mainWorktreePath);
|
|
5188
|
+
if (snapshotTreeHash === currentTreeHash) {
|
|
5125
5189
|
printInfo(MESSAGES.COVER_VALIDATE_NO_CHANGES);
|
|
5126
5190
|
return;
|
|
5127
5191
|
}
|
|
5128
5192
|
try {
|
|
5129
|
-
|
|
5193
|
+
gitReadTree(currentTreeHash, targetWorktreePath);
|
|
5194
|
+
gitCheckoutIndexForce(targetWorktreePath);
|
|
5195
|
+
gitCleanForce(targetWorktreePath);
|
|
5130
5196
|
} catch (error) {
|
|
5131
|
-
logger.error(`cover-validate
|
|
5132
|
-
throw new ClawtError(MESSAGES.
|
|
5197
|
+
logger.error(`cover-validate \u8986\u76D6\u5931\u8D25: ${error}`);
|
|
5198
|
+
throw new ClawtError(MESSAGES.COVER_VALIDATE_COVER_FAILED(targetBranchName));
|
|
5133
5199
|
}
|
|
5134
|
-
writeSnapshot(projectName, targetBranchName,
|
|
5200
|
+
writeSnapshot(projectName, targetBranchName, currentTreeHash);
|
|
5135
5201
|
printSuccess(MESSAGES.COVER_VALIDATE_SUCCESS(targetBranchName));
|
|
5136
5202
|
}
|
|
5137
5203
|
|
package/dist/postinstall.js
CHANGED
|
@@ -522,9 +522,9 @@ var COVER_VALIDATE_MESSAGES = {
|
|
|
522
522
|
/** 无快照,提示先执行 validate */
|
|
523
523
|
COVER_VALIDATE_NO_SNAPSHOT: (branch) => `\u672A\u627E\u5230\u5206\u652F ${branch} \u7684 validate \u5FEB\u7167
|
|
524
524
|
\u8BF7\u5148\u6267\u884C clawt validate -b ${branch} \u521B\u5EFA\u5FEB\u7167`,
|
|
525
|
-
/**
|
|
526
|
-
|
|
527
|
-
\u8BF7\u68C0\u67E5\u76EE\u6807 worktree \
|
|
525
|
+
/** 覆盖失败(tree checkout/clean 失败) */
|
|
526
|
+
COVER_VALIDATE_COVER_FAILED: (branch) => `\u8986\u76D6\u53D8\u66F4\u5230 worktree ${branch} \u5931\u8D25\uFF1Atree checkout \u6216\u6E05\u7406\u64CD\u4F5C\u51FA\u9519
|
|
527
|
+
\u8BF7\u68C0\u67E5\u76EE\u6807 worktree \u72B6\u6001\u540E\u91CD\u8BD5`,
|
|
528
528
|
/** 工作区和暂存区无修改,可能为误操作 */
|
|
529
529
|
COVER_VALIDATE_WORKING_DIR_CLEAN: "\u5F53\u524D\u9A8C\u8BC1\u5206\u652F\u7684\u5DE5\u4F5C\u533A\u548C\u6682\u5B58\u533A\u6CA1\u6709\u4EFB\u4F55\u4FEE\u6539\uFF0C\u53EF\u80FD\u4E3A\u8BEF\u64CD\u4F5C",
|
|
530
530
|
/** 覆盖成功 */
|
|
@@ -641,7 +641,7 @@ var MESSAGES = {
|
|
|
641
641
|
};
|
|
642
642
|
|
|
643
643
|
// src/constants/terminal.ts
|
|
644
|
-
var VALID_TERMINAL_APPS = ["auto", "iterm2", "terminal"];
|
|
644
|
+
var VALID_TERMINAL_APPS = ["auto", "iterm2", "terminal", "cmux"];
|
|
645
645
|
|
|
646
646
|
// src/constants/config.ts
|
|
647
647
|
var CONFIG_DEFINITIONS = {
|
|
@@ -667,7 +667,7 @@ var CONFIG_DEFINITIONS = {
|
|
|
667
667
|
},
|
|
668
668
|
terminalApp: {
|
|
669
669
|
defaultValue: "auto",
|
|
670
|
-
description: "\u6279\u91CF resume \u4F7F\u7528\u7684\u7EC8\u7AEF\u5E94\u7528\uFF1Aauto\uFF08\u81EA\u52A8\u68C0\u6D4B\uFF09\u3001iterm2\u3001terminal\uFF08macOS\uFF09",
|
|
670
|
+
description: "\u6279\u91CF resume \u4F7F\u7528\u7684\u7EC8\u7AEF\u5E94\u7528\uFF1Aauto\uFF08\u81EA\u52A8\u68C0\u6D4B\uFF09\u3001iterm2\u3001terminal\u3001cmux\uFF08macOS\uFF09",
|
|
671
671
|
allowedValues: VALID_TERMINAL_APPS
|
|
672
672
|
},
|
|
673
673
|
resumeInPlace: {
|
package/docs/config-file.md
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
| `autoPullPush` | `boolean` | `false` | merge 成功后是否自动执行 git pull 和 git push |
|
|
40
40
|
| `confirmDestructiveOps` | `boolean` | `true` | 执行破坏性操作(reset、validate --clean)前是否提示确认 |
|
|
41
41
|
| `maxConcurrency` | `number` | `0` | run 命令默认最大并发数,`0` 表示不限制 |
|
|
42
|
-
| `terminalApp` | `string` | `"auto"` | 批量 resume 使用的终端应用:`auto`(自动检测)、`iterm2`、`terminal`(macOS) |
|
|
42
|
+
| `terminalApp` | `string` | `"auto"` | 批量 resume 使用的终端应用:`auto`(自动检测)、`iterm2`、`terminal`、`cmux`(macOS) |
|
|
43
43
|
| `resumeInPlace` | `boolean` | `false` | resume 单选时是否在当前终端就地打开,`false` 则通过 `terminalApp` 在新 Tab 中打开 |
|
|
44
44
|
| `aliases` | `Record<string, string>` | `{}` | 命令别名映射,键为别名,值为目标内置命令名 |
|
|
45
45
|
| `autoUpdate` | `boolean` | `true` | 是否启用自动更新检查(每 24 小时通过 npm registry 检查一次新版本) |
|
package/docs/cover-validate.md
CHANGED
|
@@ -44,20 +44,25 @@ clawt cover
|
|
|
44
44
|
|
|
45
45
|
> 工作区干净时通常意味着用户没有在验证分支上做任何修改就执行了 cover,这大概率是误操作。增加确认提示可以避免不必要的覆盖操作。
|
|
46
46
|
|
|
47
|
-
##### 步骤 4
|
|
47
|
+
##### 步骤 4:计算当前 tree hash
|
|
48
48
|
|
|
49
|
-
通过 `
|
|
49
|
+
通过 `computeWorktreeTreeHash()` 计算验证分支当前的完整 tree hash:
|
|
50
50
|
|
|
51
|
-
1. 保存当前暂存区的 tree hash
|
|
52
|
-
2. `git add .` + `git write-tree` 获取当前工作区的完整 tree hash
|
|
53
|
-
3. 通过 `git read-tree`
|
|
54
|
-
4. 比较 `snapshotTreeHash` 与 `currentTreeHash`:
|
|
55
|
-
- **相同** → 无增量变更,输出提示后返回
|
|
56
|
-
- **不同** → 通过 `git diff-tree` 生成 patch
|
|
51
|
+
1. 保存当前暂存区的 tree hash,用于后续恢复
|
|
52
|
+
2. `git add .` + `git write-tree` 获取当前工作区的完整 tree hash
|
|
53
|
+
3. 通过 `git read-tree` 恢复原始暂存区状态
|
|
57
54
|
|
|
58
|
-
|
|
55
|
+
比较 snapshotTreeHash 与 currentTreeHash,如果相同则无变更,输出提示后返回。
|
|
59
56
|
|
|
60
|
-
|
|
57
|
+
##### 步骤 5:直接覆盖目标 worktree
|
|
58
|
+
|
|
59
|
+
采用 **直接 checkout tree** 方式,实现真正的覆盖语义:
|
|
60
|
+
|
|
61
|
+
1. **写入暂存区**:通过 `git read-tree <currentTreeHash>` 将验证分支的完整 tree 写入目标 worktree 的暂存区
|
|
62
|
+
2. **强制检出工作区**:通过 `git checkout-index -f -a` 将暂存区内容强制写入工作区
|
|
63
|
+
3. **清理残留文件**:通过 `git clean -fd` 删除目标 worktree 中未跟踪文件
|
|
64
|
+
|
|
65
|
+
> **关键优势**:无基准依赖,无条件覆盖目标 worktree,符合 cover 的语义。
|
|
61
66
|
|
|
62
67
|
##### 步骤 6:更新快照
|
|
63
68
|
|
|
@@ -78,7 +83,7 @@ clawt cover
|
|
|
78
83
|
| `COVER_VALIDATE_NO_SNAPSHOT` | 无快照 | 提示先执行 `clawt validate -b <branch>` 创建快照 |
|
|
79
84
|
| `COVER_VALIDATE_NO_CHANGES` | 无增量变更 | 提示无需覆盖 |
|
|
80
85
|
| `COVER_VALIDATE_WORKING_DIR_CLEAN` | 工作区干净 | 提示可能为误操作,需确认是否继续 |
|
|
81
|
-
| `
|
|
86
|
+
| `COVER_VALIDATE_COVER_FAILED` | tree checkout/clean 失败 | 提示检查目标 worktree 状态后重试 |
|
|
82
87
|
|
|
83
88
|
**实现要点:**
|
|
84
89
|
|
|
@@ -87,7 +92,11 @@ clawt cover
|
|
|
87
92
|
- 辅助函数:
|
|
88
93
|
- `extractTargetBranchName()`:从验证分支名提取目标分支名
|
|
89
94
|
- `findTargetWorktreePath()`:查找目标 worktree 路径
|
|
90
|
-
- `
|
|
95
|
+
- `computeWorktreeTreeHash()`:计算当前工作区 tree hash(保存并恢复暂存区状态)
|
|
96
|
+
- Git 工具函数:
|
|
97
|
+
- `gitReadTree()`:将 tree 写入暂存区
|
|
98
|
+
- `gitCheckoutIndexForce()`:强制检出暂存区到工作区
|
|
99
|
+
- `gitCleanForce()`:清理未跟踪文件
|
|
91
100
|
- 消息常量:`COVER_VALIDATE_MESSAGES`(`src/constants/messages/cover-validate.ts`)
|
|
92
101
|
- `writeSnapshot` 调用时只传 `treeHash`,利用其可选参数特性保留磁盘上的 `headCommitHash` 和 `stagedTreeHash` 原值
|
|
93
102
|
|
package/docs/merge.md
CHANGED
|
@@ -104,7 +104,7 @@ clawt merge [-m <commitMessage>]
|
|
|
104
104
|
| `auto` | — | 直接调用 AI 解决,不询问 |
|
|
105
105
|
| `manual` | — | 输出冲突提示信息,用户手动解决 |
|
|
106
106
|
|
|
107
|
-
AI 解决冲突时,调用 Claude Code CLI 在主 worktree 中分析并解决冲突文件,超时时间由配置项 `conflictResolveTimeoutMs` 控制(默认 15
|
|
107
|
+
AI 解决冲突时,调用 Claude Code CLI 在主 worktree 中分析并解决冲突文件,超时时间由配置项 `conflictResolveTimeoutMs` 控制(默认 15 分钟)。冲突解决子进程通过 `getEnvWithoutNestedSessionFlag()` 启动,会自动注入环境变量 `CLAUDE_CODE_ENTRYPOINT="cli"` 使会话支持 `--continue` 恢复。AI 解决成功后自动执行 `git add . && git merge --continue` 完成合并。
|
|
108
108
|
10. **推送(受 `autoPullPush` 配置控制)**
|
|
109
109
|
- `autoPullPush` 为 `false` → 输出提示 `已跳过自动 pull/push,请手动执行 git pull && git push`
|
|
110
110
|
- `autoPullPush` 为 `true` → 执行 `git pull` + `git push`:
|
package/docs/resume.md
CHANGED
|
@@ -88,13 +88,20 @@ clawt resume -f tasks.md -c 2
|
|
|
88
88
|
|
|
89
89
|
| 配置值 | 行为 |
|
|
90
90
|
| ---------- | ------------------------------------------------------------ |
|
|
91
|
-
| `auto` | 自动检测:优先检测 iTerm2
|
|
92
|
-
| `
|
|
93
|
-
| `
|
|
91
|
+
| `auto` | 自动检测:优先检测 cmux 环境(通过 `CMUX_WORKSPACE_ID` 环境变量),在 cmux 环境时在当前 workspace 创建新 surface;否则检测 iTerm2 是否已安装,已安装则使用 iTerm2,否则降级到 Terminal.app |
|
|
92
|
+
| `cmux` | 在当前 cmux workspace 中创建新 surface 执行命令 |
|
|
93
|
+
| `iterm2` | 强制使用 iTerm2 创建新 Tab |
|
|
94
|
+
| `terminal` | 强制使用 Terminal.app 创建新 Tab |
|
|
94
95
|
|
|
95
|
-
**平台限制:** 批量恢复目前仅支持 macOS
|
|
96
|
+
**平台限制:** 批量恢复目前仅支持 macOS 平台。非 macOS 平台会抛出错误。
|
|
96
97
|
|
|
97
|
-
|
|
98
|
+
**cmux 集成说明:**
|
|
99
|
+
- 环境检测:通过 `CMUX_WORKSPACE_ID` 环境变量判断是否在 cmux 环境中
|
|
100
|
+
- 创建 surface:使用 `cmux new-split right` 在右侧创建新 surface
|
|
101
|
+
- 执行命令:通过 `cmux send --surface <surface-id> <command>\n` 发送命令(`\n` 触发自动执行)
|
|
102
|
+
- 输出解析:支持简短格式 `surface:24` 和完整格式 `OK surface:24 pane:14 workspace:5`
|
|
103
|
+
|
|
104
|
+
**权限要求:** Terminal.app 通过 System Events 模拟键盘操作(`Cmd+T`)新建 Tab,需要在「系统设置 → 隐私与安全性 → 辅助功能」中授权终端应用。iTerm2 使用原生 AppleScript 接口,无需辅助功能权限。cmux 通过 CLI 命令操作,无需特殊权限。
|
|
98
105
|
|
|
99
106
|
启动命令通过配置项 `claudeCodeCommand`(默认值 `claude`)指定,与 `clawt run` 不传 `--tasks` 时的交互式界面行为一致。
|
|
100
107
|
|
package/docs/run.md
CHANGED
|
@@ -89,6 +89,7 @@ clawt run -b <branchName>
|
|
|
89
89
|
claude -p "<tasks[i]>" --output-format stream-json --verbose --permission-mode bypassPermissions --append-system-prompt "<系统提示>"
|
|
90
90
|
```
|
|
91
91
|
其中 `--append-system-prompt` 使用统一的 `APPEND_SYSTEM_PROMPT` 常量(定义在 `src/constants/config.ts`)。
|
|
92
|
+
子进程通过 `spawnProcess()`(`src/utils/shell.ts`)启动,会自动注入环境变量 `CLAUDE_CODE_ENTRYPOINT="cli"`(通过 `getEnvWithoutNestedSessionFlag()` 函数),使会话支持通过 `--continue` 恢复。
|
|
92
93
|
使用 `stream-json` 格式可实时获取 Claude Code 的流式事件(工具调用、文本输出、最终结果),用于在进度面板中显示每个任务的实时活动描述和结果预览。流式事件解析由 `src/utils/stream-parser.ts` 负责。
|
|
93
94
|
6. 进入**事件监听通知**阶段(见 [5.3](#53-任务完成通知机制))
|
|
94
95
|
7. **中断处理(Ctrl+C / SIGINT)**
|
package/docs/spec.md
CHANGED
|
@@ -316,6 +316,12 @@ async function interactiveConfigEditor<T extends object>(
|
|
|
316
316
|
|
|
317
317
|
> **非交互模式判断优先级:** CLI `--yes` 选项 > `CI` 环境变量 > `CLAWT_NON_INTERACTIVE` 环境变量 > 默认交互模式。实现见 `src/utils/interactive.ts`。
|
|
318
318
|
|
|
319
|
+
**Clawt 内部注入的环境变量:**
|
|
320
|
+
|
|
321
|
+
| 环境变量 | 值 | 说明 |
|
|
322
|
+
| -------- | --- | ---- |
|
|
323
|
+
| `CLAUDE_CODE_ENTRYPOINT` | `cli` | 所有通过 `claude -p` 启动的非交互式 Claude Code 子进程(task-executor 和 conflict-resolver)会自动注入此环境变量,使这些会话支持通过 `--continue` 恢复。常量定义在 `src/constants/config.ts` 的 `CLAUDE_CODE_ENTRYPOINT_VALUE`,注入逻辑在 `src/utils/shell.ts` 的 `getEnvWithoutNestedSessionFlag()` 函数中实现。不适用于交互式启动 Claude Code 的场景(如 `clawt resume`)。 |
|
|
324
|
+
|
|
319
325
|
所有命令执行前,都必须先执行**主 worktree 校验**(见 [2.1](#21-主-worktree-的定义与定位规则))。
|
|
320
326
|
|
|
321
327
|
---
|
package/package.json
CHANGED
|
@@ -15,8 +15,8 @@ import {
|
|
|
15
15
|
gitAddAll,
|
|
16
16
|
gitWriteTree,
|
|
17
17
|
gitReadTree,
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
gitCheckoutIndexForce,
|
|
19
|
+
gitCleanForce,
|
|
20
20
|
printSuccess,
|
|
21
21
|
printInfo,
|
|
22
22
|
isWorkingDirClean,
|
|
@@ -62,15 +62,12 @@ export function findTargetWorktreePath(branchName: string): string {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
|
-
*
|
|
65
|
+
* 计算验证分支当前的 tree hash(保存并恢复暂存区状态)
|
|
66
66
|
* 操作序列:git write-tree(保存暂存区)→ git add . → git write-tree(获取工作区 tree)→ git read-tree(恢复暂存区)
|
|
67
|
-
* 当 snapshotTreeHash 与当前 tree hash 相同时返回 null 表示无变更
|
|
68
|
-
* @param {string} snapshotTreeHash - 快照中记录的 tree hash
|
|
69
67
|
* @param {string} mainWorktreePath - 主 worktree 路径
|
|
70
|
-
* @returns {
|
|
68
|
+
* @returns {string} 当前工作区对应的 tree hash
|
|
71
69
|
*/
|
|
72
|
-
export function
|
|
73
|
-
// 先保存当前暂存区的 tree hash,用于后续恢复
|
|
70
|
+
export function computeWorktreeTreeHash(mainWorktreePath: string): string {
|
|
74
71
|
const savedIndexTreeHash = gitWriteTree(mainWorktreePath);
|
|
75
72
|
let currentTreeHash: string;
|
|
76
73
|
|
|
@@ -78,17 +75,10 @@ export function computeIncrementalPatch(snapshotTreeHash: string, mainWorktreePa
|
|
|
78
75
|
gitAddAll(mainWorktreePath);
|
|
79
76
|
currentTreeHash = gitWriteTree(mainWorktreePath);
|
|
80
77
|
} finally {
|
|
81
|
-
// 无论成功或失败,都通过 git read-tree 恢复原始暂存区状态
|
|
82
78
|
gitReadTree(savedIndexTreeHash, mainWorktreePath);
|
|
83
79
|
}
|
|
84
80
|
|
|
85
|
-
|
|
86
|
-
if (snapshotTreeHash === currentTreeHash) {
|
|
87
|
-
return null;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const patch = gitDiffTree(snapshotTreeHash, currentTreeHash, mainWorktreePath);
|
|
91
|
-
return { patch, currentTreeHash };
|
|
81
|
+
return currentTreeHash;
|
|
92
82
|
}
|
|
93
83
|
|
|
94
84
|
/**
|
|
@@ -127,23 +117,26 @@ async function handleCoverValidate(): Promise<void> {
|
|
|
127
117
|
if (!confirmed) return;
|
|
128
118
|
}
|
|
129
119
|
|
|
130
|
-
// 步骤 4
|
|
131
|
-
const
|
|
132
|
-
|
|
120
|
+
// 步骤 4:计算当前 tree hash
|
|
121
|
+
const currentTreeHash = computeWorktreeTreeHash(mainWorktreePath);
|
|
122
|
+
|
|
123
|
+
if (snapshotTreeHash === currentTreeHash) {
|
|
133
124
|
printInfo(MESSAGES.COVER_VALIDATE_NO_CHANGES);
|
|
134
125
|
return;
|
|
135
126
|
}
|
|
136
127
|
|
|
137
|
-
// 步骤 5
|
|
128
|
+
// 步骤 5:直接将 tree 应用到目标 worktree
|
|
138
129
|
try {
|
|
139
|
-
|
|
130
|
+
gitReadTree(currentTreeHash, targetWorktreePath);
|
|
131
|
+
gitCheckoutIndexForce(targetWorktreePath);
|
|
132
|
+
gitCleanForce(targetWorktreePath);
|
|
140
133
|
} catch (error) {
|
|
141
|
-
logger.error(`cover-validate
|
|
142
|
-
throw new ClawtError(MESSAGES.
|
|
134
|
+
logger.error(`cover-validate 覆盖失败: ${error}`);
|
|
135
|
+
throw new ClawtError(MESSAGES.COVER_VALIDATE_COVER_FAILED(targetBranchName));
|
|
143
136
|
}
|
|
144
137
|
|
|
145
138
|
// 步骤 6:更新快照 treeHash(使后续再次 cover 的基准正确),HEAD 和 stagedTreeHash 不变
|
|
146
|
-
writeSnapshot(projectName, targetBranchName,
|
|
139
|
+
writeSnapshot(projectName, targetBranchName, currentTreeHash);
|
|
147
140
|
|
|
148
141
|
printSuccess(MESSAGES.COVER_VALIDATE_SUCCESS(targetBranchName));
|
|
149
142
|
}
|
package/src/constants/config.ts
CHANGED
|
@@ -5,7 +5,10 @@ import { VALID_TERMINAL_APPS } from './terminal.js';
|
|
|
5
5
|
export const APPEND_SYSTEM_PROMPT =
|
|
6
6
|
'Currently, you are in the git worktree directory.';
|
|
7
7
|
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* 通过 clawt 启动的 Claude Code 非交互式会话(claude -p)的 entrypoint 标识
|
|
10
|
+
* 设置为 'cli' 使 claude -p 启动的会话可以通过 --continue 恢复
|
|
11
|
+
*/
|
|
9
12
|
export const CLAUDE_CODE_ENTRYPOINT_VALUE = 'cli';
|
|
10
13
|
|
|
11
14
|
/**
|
|
@@ -35,7 +38,7 @@ export const CONFIG_DEFINITIONS: ConfigDefinitions = {
|
|
|
35
38
|
},
|
|
36
39
|
terminalApp: {
|
|
37
40
|
defaultValue: 'auto',
|
|
38
|
-
description: '批量 resume 使用的终端应用:auto(自动检测)、iterm2、terminal(macOS)',
|
|
41
|
+
description: '批量 resume 使用的终端应用:auto(自动检测)、iterm2、terminal、cmux(macOS)',
|
|
39
42
|
allowedValues: VALID_TERMINAL_APPS,
|
|
40
43
|
},
|
|
41
44
|
resumeInPlace: {
|
|
@@ -10,9 +10,9 @@ export const COVER_VALIDATE_MESSAGES = {
|
|
|
10
10
|
/** 无快照,提示先执行 validate */
|
|
11
11
|
COVER_VALIDATE_NO_SNAPSHOT: (branch: string) =>
|
|
12
12
|
`未找到分支 ${branch} 的 validate 快照\n 请先执行 clawt validate -b ${branch} 创建快照`,
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
`覆盖变更到 worktree ${branch} 失败:
|
|
13
|
+
/** 覆盖失败(tree checkout/clean 失败) */
|
|
14
|
+
COVER_VALIDATE_COVER_FAILED: (branch: string) =>
|
|
15
|
+
`覆盖变更到 worktree ${branch} 失败:tree checkout 或清理操作出错\n 请检查目标 worktree 状态后重试`,
|
|
16
16
|
/** 工作区和暂存区无修改,可能为误操作 */
|
|
17
17
|
COVER_VALIDATE_WORKING_DIR_CLEAN: '当前验证分支的工作区和暂存区没有任何修改,可能为误操作',
|
|
18
18
|
/** 覆盖成功 */
|
|
@@ -13,7 +13,7 @@ export const DISABLE_BRACKETED_PASTE = '\x1b[?2004l';
|
|
|
13
13
|
export const PASTE_THRESHOLD_MS = 10;
|
|
14
14
|
|
|
15
15
|
/** 配置项 terminalApp 允许的有效值 */
|
|
16
|
-
export const VALID_TERMINAL_APPS: readonly string[] = ['auto', 'iterm2', 'terminal'];
|
|
16
|
+
export const VALID_TERMINAL_APPS: readonly string[] = ['auto', 'iterm2', 'terminal', 'cmux'];
|
|
17
17
|
|
|
18
18
|
/** iTerm2 应用路径,用于 auto 模式检测是否已安装 */
|
|
19
19
|
export const ITERM2_APP_PATH = '/Applications/iTerm.app';
|
package/src/types/config.ts
CHANGED
|
@@ -10,7 +10,7 @@ export interface ClawtConfig {
|
|
|
10
10
|
confirmDestructiveOps: boolean;
|
|
11
11
|
/** run 命令默认最大并发数,0 表示不限制 */
|
|
12
12
|
maxConcurrency: number;
|
|
13
|
-
/** 批量 resume 使用的终端应用:'auto'(自动检测)、'iterm2'、'terminal'(macOS) */
|
|
13
|
+
/** 批量 resume 使用的终端应用:'auto'(自动检测)、'iterm2'、'terminal'、'cmux'(macOS) */
|
|
14
14
|
terminalApp: string;
|
|
15
15
|
/** resume 单选时是否在当前终端就地打开,false 则通过 terminalApp 在新 Tab 中打开 */
|
|
16
16
|
resumeInPlace: boolean;
|
package/src/utils/git-core.ts
CHANGED
|
@@ -159,6 +159,14 @@ export function gitCleanForce(cwd?: string): void {
|
|
|
159
159
|
execCommand('git clean -fd', { cwd });
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/**
|
|
163
|
+
* git checkout-index -f -a,将暂存区内容强制写入工作区(覆盖工作区文件)
|
|
164
|
+
* @param {string} [cwd] - 工作目录
|
|
165
|
+
*/
|
|
166
|
+
export function gitCheckoutIndexForce(cwd?: string): void {
|
|
167
|
+
execCommand('git checkout-index -f -a', { cwd });
|
|
168
|
+
}
|
|
169
|
+
|
|
162
170
|
/**
|
|
163
171
|
* git stash push -m <message>
|
|
164
172
|
* @param {string} message - stash 消息
|
package/src/utils/index.ts
CHANGED
package/src/utils/shell.ts
CHANGED
|
@@ -6,7 +6,9 @@ import { throwIfGitIndexLockError } from './git-lock.js';
|
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* 获取移除了 CLAUDECODE 嵌套会话标记的环境变量副本,并注入 CLAUDE_CODE_ENTRYPOINT 标识
|
|
9
|
-
* 仅用于 claude -p
|
|
9
|
+
* 仅用于 claude -p 等非交互式子进程:
|
|
10
|
+
* - 移除 CLAUDECODE:避免被 Claude Code 误判为嵌套会话而拒绝启动
|
|
11
|
+
* - 注入 CLAUDE_CODE_ENTRYPOINT="cli":使 claude -p 会话支持 --continue 恢复
|
|
10
12
|
* 不适用于交互式启动 Claude Code(如 clawt resume),交互式场景应保留原始环境变量
|
|
11
13
|
* @returns {NodeJS.ProcessEnv} 移除 CLAUDECODE 并注入 CLAUDE_CODE_ENTRYPOINT 后的环境变量
|
|
12
14
|
*/
|
package/src/utils/terminal.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { VALID_TERMINAL_APPS, ITERM2_APP_PATH } from '../constants/index.js';
|
|
|
6
6
|
import { getConfigValue } from './config.js';
|
|
7
7
|
|
|
8
8
|
/** 终端应用类型 */
|
|
9
|
-
type TerminalApp = 'iterm2' | 'terminal';
|
|
9
|
+
type TerminalApp = 'iterm2' | 'terminal' | 'cmux';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* 检测系统是否安装了 iTerm2
|
|
@@ -17,17 +17,28 @@ function isITerm2Installed(): boolean {
|
|
|
17
17
|
return existsSync(ITERM2_APP_PATH);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* 检测当前是否在 cmux 环境中运行
|
|
22
|
+
* 通过检查环境变量 CMUX_WORKSPACE_ID 是否存在来判断
|
|
23
|
+
* @returns {boolean} 是否在 cmux 环境中
|
|
24
|
+
*/
|
|
25
|
+
export function isCmuxEnvironment(): boolean {
|
|
26
|
+
return !!process.env.CMUX_WORKSPACE_ID;
|
|
27
|
+
}
|
|
28
|
+
|
|
20
29
|
/**
|
|
21
30
|
* 检测当前使用的终端应用
|
|
22
|
-
* 优先读取配置项 terminalApp;值为 'auto'
|
|
23
|
-
*
|
|
24
|
-
*
|
|
31
|
+
* 优先读取配置项 terminalApp;值为 'auto' 时按以下顺序检测:
|
|
32
|
+
* 1. cmux 环境(通过 CMUX_WORKSPACE_ID 环境变量)
|
|
33
|
+
* 2. iTerm2 是否已安装
|
|
34
|
+
* 3. 降级到 Terminal.app
|
|
35
|
+
* @returns {TerminalApp} 终端类型:'iterm2'、'terminal' 或 'cmux'
|
|
25
36
|
*/
|
|
26
37
|
export function detectTerminalApp(): TerminalApp {
|
|
27
38
|
const configured = getConfigValue('terminalApp');
|
|
28
39
|
|
|
29
40
|
// 配置了明确的终端类型,直接使用
|
|
30
|
-
if (configured === 'iterm2' || configured === 'terminal') {
|
|
41
|
+
if (configured === 'iterm2' || configured === 'terminal' || configured === 'cmux') {
|
|
31
42
|
return configured;
|
|
32
43
|
}
|
|
33
44
|
|
|
@@ -36,10 +47,15 @@ export function detectTerminalApp(): TerminalApp {
|
|
|
36
47
|
logger.warn(`terminalApp 配置值 "${configured}" 无效,有效值: ${VALID_TERMINAL_APPS.join(', ')},将使用自动检测`);
|
|
37
48
|
}
|
|
38
49
|
|
|
39
|
-
// auto 模式:优先检测
|
|
50
|
+
// auto 模式:优先检测 cmux 环境
|
|
51
|
+
if (isCmuxEnvironment()) {
|
|
52
|
+
return 'cmux';
|
|
53
|
+
}
|
|
54
|
+
|
|
40
55
|
if (isITerm2Installed()) {
|
|
41
56
|
return 'iterm2';
|
|
42
57
|
}
|
|
58
|
+
|
|
43
59
|
return 'terminal';
|
|
44
60
|
}
|
|
45
61
|
|
|
@@ -99,25 +115,78 @@ end tell
|
|
|
99
115
|
}
|
|
100
116
|
|
|
101
117
|
/**
|
|
102
|
-
*
|
|
103
|
-
* 自动检测终端类型(iTerm2 / Terminal.app),通过 AppleScript 打开新 Tab
|
|
118
|
+
* 在当前 cmux workspace 中分割创建新 surface 并执行命令
|
|
104
119
|
* @param {string} command - 要执行的 shell 命令
|
|
105
|
-
* @param {string}
|
|
106
|
-
* @throws {ClawtError}
|
|
120
|
+
* @param {string} title - surface 标题(用于日志)
|
|
121
|
+
* @throws {ClawtError} 不在 cmux 环境中或 CLI 执行失败时抛出
|
|
107
122
|
*/
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
123
|
+
function openCommandInCmuxSurface(command: string, title: string): void {
|
|
124
|
+
// 环境检查:只需要检查 WORKSPACE_ID
|
|
125
|
+
if (!isCmuxEnvironment()) {
|
|
126
|
+
throw new ClawtError(
|
|
127
|
+
'当前不在 cmux 环境中,无法创建 surface\n' +
|
|
128
|
+
'请确保在 cmux 终端中执行 clawt resume 命令,或修改 terminalApp 配置'
|
|
129
|
+
);
|
|
111
130
|
}
|
|
112
131
|
|
|
113
|
-
|
|
114
|
-
const script = terminalApp === 'iterm2'
|
|
115
|
-
? buildITermAppleScript(command, tabTitle)
|
|
116
|
-
: buildTerminalAppleScript(command, tabTitle);
|
|
117
|
-
|
|
118
|
-
logger.debug(`打开终端 Tab [${terminalApp}]: ${tabTitle}`);
|
|
132
|
+
logger.debug(`在 cmux 中创建新 surface: ${title}`);
|
|
119
133
|
logger.debug(`执行命令: ${command}`);
|
|
120
134
|
|
|
135
|
+
try {
|
|
136
|
+
// 步骤 1:分割创建新 surface(利用默认值机制)
|
|
137
|
+
const newSurfaceResult = execFileSync('cmux', [
|
|
138
|
+
'new-split',
|
|
139
|
+
'right', // 在右侧创建新 surface
|
|
140
|
+
], {
|
|
141
|
+
encoding: 'utf-8',
|
|
142
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
143
|
+
timeout: 5000, // 5秒超时
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
logger.debug(`new-split 输出: ${newSurfaceResult}`);
|
|
147
|
+
|
|
148
|
+
// 步骤 2:解析输出获取新 surface ID
|
|
149
|
+
// 输出格式可能是:
|
|
150
|
+
// - "surface:24"(简短引用)
|
|
151
|
+
// - "OK surface:24 pane:14 workspace:5"(带 OK 前缀)
|
|
152
|
+
// 需要灵活匹配
|
|
153
|
+
const match = newSurfaceResult.match(/(?:OK\s+)?(surface:\d+)/i);
|
|
154
|
+
if (!match) {
|
|
155
|
+
throw new Error(`无法解析 cmux new-split 输出: ${newSurfaceResult}`);
|
|
156
|
+
}
|
|
157
|
+
const surfaceRef = match[1];
|
|
158
|
+
|
|
159
|
+
logger.debug(`已创建 surface: ${surfaceRef}`);
|
|
160
|
+
|
|
161
|
+
// 步骤 3:向新 surface 发送命令(追加 \n 自动执行)
|
|
162
|
+
execFileSync('cmux', [
|
|
163
|
+
'send',
|
|
164
|
+
'--surface', surfaceRef,
|
|
165
|
+
`${command}\\n`, // 追加换行符以自动执行命令
|
|
166
|
+
], {
|
|
167
|
+
encoding: 'utf-8',
|
|
168
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
169
|
+
timeout: 5000,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
logger.debug(`已向 ${surfaceRef} 发送命令`);
|
|
173
|
+
|
|
174
|
+
} catch (error) {
|
|
175
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
176
|
+
throw new ClawtError(`在 cmux 中创建 surface 失败: ${message}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 执行 AppleScript 脚本
|
|
182
|
+
* @param {string} script - AppleScript 内容
|
|
183
|
+
* @param {TerminalApp} terminalApp - 终端类型('iterm2' 或 'terminal')
|
|
184
|
+
* @throws {ClawtError} AppleScript 执行失败时抛出
|
|
185
|
+
*/
|
|
186
|
+
function executeAppleScript(script: string, terminalApp: 'iterm2' | 'terminal'): void {
|
|
187
|
+
logger.debug(`打开终端 Tab [${terminalApp}]`);
|
|
188
|
+
logger.debug(`执行 AppleScript`);
|
|
189
|
+
|
|
121
190
|
try {
|
|
122
191
|
execFileSync('osascript', ['-e', script], {
|
|
123
192
|
encoding: 'utf-8',
|
|
@@ -125,10 +194,42 @@ export function openCommandInNewTerminalTab(command: string, tabTitle: string):
|
|
|
125
194
|
});
|
|
126
195
|
} catch (error) {
|
|
127
196
|
const message = error instanceof Error ? error.message : String(error);
|
|
128
|
-
// Terminal.app 通过 System Events 模拟键盘操作需要辅助功能权限
|
|
129
197
|
const accessibilityHint = terminalApp === 'terminal'
|
|
130
198
|
? '\n提示:Terminal.app 需要辅助功能权限,请在「系统设置 → 隐私与安全性 → 辅助功能」中授权终端应用'
|
|
131
199
|
: '';
|
|
132
200
|
throw new ClawtError(`打开终端 Tab 失败: ${message}${accessibilityHint}`);
|
|
133
201
|
}
|
|
134
202
|
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 在新终端 Tab 或 cmux Surface 中执行命令
|
|
206
|
+
* 自动检测终端类型(cmux / iTerm2 / Terminal.app)
|
|
207
|
+
* - cmux:在当前 pane 创建新 surface
|
|
208
|
+
* - iTerm2 / Terminal.app:通过 AppleScript 打开新 Tab
|
|
209
|
+
* @param {string} command - 要执行的 shell 命令
|
|
210
|
+
* @param {string} tabTitle - Tab 或 surface 标题
|
|
211
|
+
* @throws {ClawtError} 非 macOS 平台或终端打开失败时抛出
|
|
212
|
+
*/
|
|
213
|
+
export function openCommandInNewTerminalTab(command: string, tabTitle: string): void {
|
|
214
|
+
if (process.platform !== 'darwin') {
|
|
215
|
+
throw new ClawtError('批量 resume 目前仅支持 macOS 平台');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const terminalApp = detectTerminalApp();
|
|
219
|
+
|
|
220
|
+
switch (terminalApp) {
|
|
221
|
+
case 'cmux':
|
|
222
|
+
openCommandInCmuxSurface(command, tabTitle);
|
|
223
|
+
break;
|
|
224
|
+
case 'iterm2':
|
|
225
|
+
const itermScript = buildITermAppleScript(command, tabTitle);
|
|
226
|
+
executeAppleScript(itermScript, 'iterm2');
|
|
227
|
+
break;
|
|
228
|
+
case 'terminal':
|
|
229
|
+
const terminalScript = buildTerminalAppleScript(command, tabTitle);
|
|
230
|
+
executeAppleScript(terminalScript, 'terminal');
|
|
231
|
+
break;
|
|
232
|
+
default:
|
|
233
|
+
throw new ClawtError(`不支持的终端类型: ${terminalApp}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -21,7 +21,7 @@ vi.mock('../../../src/constants/index.js', () => ({
|
|
|
21
21
|
COVER_VALIDATE_NO_CHANGES: '验证分支上没有相对于快照的增量修改,无需覆盖',
|
|
22
22
|
COVER_VALIDATE_TARGET_NOT_FOUND: (branch: string) => `未找到分支 ${branch} 对应的 worktree`,
|
|
23
23
|
COVER_VALIDATE_NO_SNAPSHOT: (branch: string) => `未找到分支 ${branch} 的 validate 快照`,
|
|
24
|
-
|
|
24
|
+
COVER_VALIDATE_COVER_FAILED: (branch: string) => `覆盖变更到 worktree ${branch} 失败`,
|
|
25
25
|
COVER_VALIDATE_SUCCESS: (branch: string) => `✓ 已将验证分支上的修改覆盖到 worktree => ${branch}`,
|
|
26
26
|
COVER_VALIDATE_WORKING_DIR_CLEAN: '当前验证分支的工作区和暂存区没有任何修改,可能为误操作',
|
|
27
27
|
},
|
|
@@ -42,8 +42,8 @@ vi.mock('../../../src/utils/index.js', () => ({
|
|
|
42
42
|
gitAddAll: vi.fn(),
|
|
43
43
|
gitWriteTree: vi.fn().mockReturnValue('current-tree-hash'),
|
|
44
44
|
gitReadTree: vi.fn(),
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
gitCheckoutIndexForce: vi.fn(),
|
|
46
|
+
gitCleanForce: vi.fn(),
|
|
47
47
|
printSuccess: vi.fn(),
|
|
48
48
|
printInfo: vi.fn(),
|
|
49
49
|
isWorkingDirClean: vi.fn().mockReturnValue(false),
|
|
@@ -53,7 +53,7 @@ vi.mock('../../../src/utils/index.js', () => ({
|
|
|
53
53
|
isNonInteractive: vi.fn().mockReturnValue(false),
|
|
54
54
|
}));
|
|
55
55
|
|
|
56
|
-
import { registerCoverValidateCommand, extractTargetBranchName, findTargetWorktreePath,
|
|
56
|
+
import { registerCoverValidateCommand, extractTargetBranchName, findTargetWorktreePath, computeWorktreeTreeHash } from '../../../src/commands/cover-validate.js';
|
|
57
57
|
import {
|
|
58
58
|
getCurrentBranch,
|
|
59
59
|
getProjectWorktrees,
|
|
@@ -64,8 +64,8 @@ import {
|
|
|
64
64
|
gitAddAll,
|
|
65
65
|
gitWriteTree,
|
|
66
66
|
gitReadTree,
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
gitCheckoutIndexForce,
|
|
68
|
+
gitCleanForce,
|
|
69
69
|
printSuccess,
|
|
70
70
|
printInfo,
|
|
71
71
|
isWorkingDirClean,
|
|
@@ -81,8 +81,8 @@ const mockedWriteSnapshot = vi.mocked(writeSnapshot);
|
|
|
81
81
|
const mockedGitAddAll = vi.mocked(gitAddAll);
|
|
82
82
|
const mockedGitWriteTree = vi.mocked(gitWriteTree);
|
|
83
83
|
const mockedGitReadTree = vi.mocked(gitReadTree);
|
|
84
|
-
const
|
|
85
|
-
const
|
|
84
|
+
const mockedGitCheckoutIndexForce = vi.mocked(gitCheckoutIndexForce);
|
|
85
|
+
const mockedGitCleanForce = vi.mocked(gitCleanForce);
|
|
86
86
|
const mockedPrintSuccess = vi.mocked(printSuccess);
|
|
87
87
|
const mockedPrintInfo = vi.mocked(printInfo);
|
|
88
88
|
const mockedIsWorkingDirClean = vi.mocked(isWorkingDirClean);
|
|
@@ -99,7 +99,6 @@ beforeEach(() => {
|
|
|
99
99
|
mockedIsWorkingDirClean.mockReturnValue(false);
|
|
100
100
|
mockedConfirmAction.mockResolvedValue(true);
|
|
101
101
|
mockedGitWriteTree.mockReturnValue('current-tree-hash');
|
|
102
|
-
mockedGitDiffTree.mockReturnValue(Buffer.from('fake-patch'));
|
|
103
102
|
});
|
|
104
103
|
|
|
105
104
|
describe('registerCoverValidateCommand', () => {
|
|
@@ -130,27 +129,16 @@ describe('findTargetWorktreePath', () => {
|
|
|
130
129
|
});
|
|
131
130
|
});
|
|
132
131
|
|
|
133
|
-
describe('
|
|
134
|
-
it('
|
|
132
|
+
describe('computeWorktreeTreeHash', () => {
|
|
133
|
+
it('返回当前工作区的 tree hash', () => {
|
|
135
134
|
mockedGitWriteTree
|
|
136
135
|
.mockReturnValueOnce('saved-index-tree') // 保存暂存区
|
|
137
136
|
.mockReturnValueOnce('new-tree-hash'); // git add . 后的 tree
|
|
138
137
|
|
|
139
|
-
const result =
|
|
140
|
-
expect(result).
|
|
141
|
-
expect(result!.currentTreeHash).toBe('new-tree-hash');
|
|
138
|
+
const result = computeWorktreeTreeHash('/repo');
|
|
139
|
+
expect(result).toBe('new-tree-hash');
|
|
142
140
|
expect(mockedGitAddAll).toHaveBeenCalledWith('/repo');
|
|
143
141
|
expect(mockedGitReadTree).toHaveBeenCalledWith('saved-index-tree', '/repo');
|
|
144
|
-
expect(mockedGitDiffTree).toHaveBeenCalledWith('snapshot-tree-hash', 'new-tree-hash', '/repo');
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it('无增量变更时返回 null', () => {
|
|
148
|
-
mockedGitWriteTree
|
|
149
|
-
.mockReturnValueOnce('saved-index-tree')
|
|
150
|
-
.mockReturnValueOnce('snapshot-tree-hash'); // 与快照相同
|
|
151
|
-
|
|
152
|
-
const result = computeIncrementalPatch('snapshot-tree-hash', '/repo');
|
|
153
|
-
expect(result).toBeNull();
|
|
154
142
|
});
|
|
155
143
|
});
|
|
156
144
|
|
|
@@ -163,7 +151,7 @@ describe('handleCoverValidate - 工作区干净检查', () => {
|
|
|
163
151
|
await program.parseAsync(['cover'], { from: 'user' });
|
|
164
152
|
}
|
|
165
153
|
|
|
166
|
-
it('
|
|
154
|
+
it('工作区干净且用户取消时不执行覆盖', async () => {
|
|
167
155
|
mockedIsWorkingDirClean.mockReturnValue(true);
|
|
168
156
|
mockedConfirmAction.mockResolvedValue(false);
|
|
169
157
|
|
|
@@ -173,14 +161,14 @@ describe('handleCoverValidate - 工作区干净检查', () => {
|
|
|
173
161
|
expect(mockedConfirmAction).toHaveBeenCalledWith('是否继续执行覆盖?');
|
|
174
162
|
// 用户取消后不应继续执行后续逻辑
|
|
175
163
|
expect(mockedGitAddAll).not.toHaveBeenCalled();
|
|
176
|
-
expect(
|
|
164
|
+
expect(mockedGitCheckoutIndexForce).not.toHaveBeenCalled();
|
|
165
|
+
expect(mockedGitCleanForce).not.toHaveBeenCalled();
|
|
177
166
|
expect(mockedPrintSuccess).not.toHaveBeenCalled();
|
|
178
167
|
});
|
|
179
168
|
|
|
180
169
|
it('工作区干净且用户确认继续时正常执行', async () => {
|
|
181
170
|
mockedIsWorkingDirClean.mockReturnValue(true);
|
|
182
171
|
mockedConfirmAction.mockResolvedValue(true);
|
|
183
|
-
// computeIncrementalPatch 返回有 patch 的结果
|
|
184
172
|
mockedGitWriteTree
|
|
185
173
|
.mockReturnValueOnce('saved-index-tree')
|
|
186
174
|
.mockReturnValueOnce('new-tree-hash');
|
|
@@ -188,7 +176,9 @@ describe('handleCoverValidate - 工作区干净检查', () => {
|
|
|
188
176
|
await runCover();
|
|
189
177
|
|
|
190
178
|
expect(mockedConfirmAction).toHaveBeenCalledWith('是否继续执行覆盖?');
|
|
191
|
-
expect(
|
|
179
|
+
expect(mockedGitReadTree).toHaveBeenCalledWith('new-tree-hash', '/path/feature');
|
|
180
|
+
expect(mockedGitCheckoutIndexForce).toHaveBeenCalledWith('/path/feature');
|
|
181
|
+
expect(mockedGitCleanForce).toHaveBeenCalledWith('/path/feature');
|
|
192
182
|
expect(mockedPrintSuccess).toHaveBeenCalled();
|
|
193
183
|
});
|
|
194
184
|
|
|
@@ -201,7 +191,9 @@ describe('handleCoverValidate - 工作区干净检查', () => {
|
|
|
201
191
|
await runCover();
|
|
202
192
|
|
|
203
193
|
expect(mockedConfirmAction).not.toHaveBeenCalled();
|
|
204
|
-
expect(
|
|
194
|
+
expect(mockedGitReadTree).toHaveBeenCalledWith('new-tree-hash', '/path/feature');
|
|
195
|
+
expect(mockedGitCheckoutIndexForce).toHaveBeenCalledWith('/path/feature');
|
|
196
|
+
expect(mockedGitCleanForce).toHaveBeenCalledWith('/path/feature');
|
|
205
197
|
expect(mockedPrintSuccess).toHaveBeenCalled();
|
|
206
198
|
});
|
|
207
199
|
});
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
gitPush,
|
|
37
37
|
gitResetHard,
|
|
38
38
|
gitCleanForce,
|
|
39
|
+
gitCheckoutIndexForce,
|
|
39
40
|
gitStashPush,
|
|
40
41
|
gitStashApply,
|
|
41
42
|
gitStashPop,
|
|
@@ -251,6 +252,13 @@ describe('gitCleanForce', () => {
|
|
|
251
252
|
});
|
|
252
253
|
});
|
|
253
254
|
|
|
255
|
+
describe('gitCheckoutIndexForce', () => {
|
|
256
|
+
it('执行 git checkout-index -f -a', () => {
|
|
257
|
+
gitCheckoutIndexForce('/repo');
|
|
258
|
+
expect(mockedExecCommand).toHaveBeenCalledWith('git checkout-index -f -a', { cwd: '/repo' });
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
254
262
|
describe('gitStashPush', () => {
|
|
255
263
|
it('执行 git stash push -m', () => {
|
|
256
264
|
gitStashPush('auto-stash', '/repo');
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// mock node:child_process
|
|
4
|
+
vi.mock('node:child_process', () => ({
|
|
5
|
+
execFileSync: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
// mock node:fs
|
|
9
|
+
vi.mock('node:fs', () => ({
|
|
10
|
+
existsSync: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
// mock logger
|
|
14
|
+
vi.mock('../../../src/logger/index.js', () => ({
|
|
15
|
+
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
// mock config
|
|
19
|
+
vi.mock('../../../src/utils/config.js', () => ({
|
|
20
|
+
getConfigValue: vi.fn(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
import { execFileSync } from 'node:child_process';
|
|
24
|
+
import { existsSync } from 'node:fs';
|
|
25
|
+
import {
|
|
26
|
+
isCmuxEnvironment,
|
|
27
|
+
detectTerminalApp,
|
|
28
|
+
openCommandInNewTerminalTab,
|
|
29
|
+
} from '../../../src/utils/terminal.js';
|
|
30
|
+
import { getConfigValue } from '../../../src/utils/config.js';
|
|
31
|
+
|
|
32
|
+
const mockedExecFileSync = vi.mocked(execFileSync);
|
|
33
|
+
const mockedExistsSync = vi.mocked(existsSync);
|
|
34
|
+
const mockedGetConfigValue = vi.mocked(getConfigValue);
|
|
35
|
+
|
|
36
|
+
describe('cmux 环境检测', () => {
|
|
37
|
+
const originalEnv = process.env;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
// 每个测试前重置环境变量
|
|
41
|
+
vi.resetModules();
|
|
42
|
+
process.env = { ...originalEnv };
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
// 测试后恢复原始环境变量
|
|
47
|
+
process.env = originalEnv;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('isCmuxEnvironment', () => {
|
|
51
|
+
it('CMUX_WORKSPACE_ID 存在时返回 true', () => {
|
|
52
|
+
process.env.CMUX_WORKSPACE_ID = '6E83B1B3-5617-43F0-82FB-75F55E9F3F28';
|
|
53
|
+
expect(isCmuxEnvironment()).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('CMUX_WORKSPACE_ID 不存在时返回 false', () => {
|
|
57
|
+
delete process.env.CMUX_WORKSPACE_ID;
|
|
58
|
+
expect(isCmuxEnvironment()).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('终端检测优先级', () => {
|
|
64
|
+
const originalEnv = process.env;
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
vi.clearAllMocks();
|
|
68
|
+
process.env = { ...originalEnv };
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
process.env = originalEnv;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('明确配置 cmux 时返回 cmux', () => {
|
|
76
|
+
mockedGetConfigValue.mockReturnValue('cmux');
|
|
77
|
+
expect(detectTerminalApp()).toBe('cmux');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('明确配置 iterm2 时返回 iterm2', () => {
|
|
81
|
+
mockedGetConfigValue.mockReturnValue('iterm2');
|
|
82
|
+
expect(detectTerminalApp()).toBe('iterm2');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('明确配置 terminal 时返回 terminal', () => {
|
|
86
|
+
mockedGetConfigValue.mockReturnValue('terminal');
|
|
87
|
+
expect(detectTerminalApp()).toBe('terminal');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('auto 模式下 cmux 环境优先级最高', () => {
|
|
91
|
+
mockedGetConfigValue.mockReturnValue('auto');
|
|
92
|
+
process.env.CMUX_WORKSPACE_ID = '6E83B1B3-5617-43F0-82FB-75F55E9F3F28'; // 在 cmux 环境中
|
|
93
|
+
|
|
94
|
+
expect(detectTerminalApp()).toBe('cmux');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('auto 模式下非 cmux 环境降级到 iTerm2', () => {
|
|
98
|
+
mockedGetConfigValue.mockReturnValue('auto');
|
|
99
|
+
mockedExistsSync.mockReturnValue(true); // iTerm2 已安装
|
|
100
|
+
delete process.env.CMUX_WORKSPACE_ID; // 不在 cmux 环境中
|
|
101
|
+
|
|
102
|
+
expect(detectTerminalApp()).toBe('iterm2');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('auto 模式下无 iTerm2 时降级到 terminal', () => {
|
|
106
|
+
mockedGetConfigValue.mockReturnValue('auto');
|
|
107
|
+
mockedExistsSync.mockReturnValue(false); // iTerm2 未安装
|
|
108
|
+
delete process.env.CMUX_WORKSPACE_ID; // 不在 cmux 环境中
|
|
109
|
+
|
|
110
|
+
expect(detectTerminalApp()).toBe('terminal');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('cmux surface 创建', () => {
|
|
115
|
+
const originalEnv = process.env;
|
|
116
|
+
const originalPlatform = process.platform;
|
|
117
|
+
|
|
118
|
+
beforeEach(() => {
|
|
119
|
+
vi.clearAllMocks();
|
|
120
|
+
process.env = { ...originalEnv };
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
afterEach(() => {
|
|
124
|
+
process.env = originalEnv;
|
|
125
|
+
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('成功创建 surface 并发送命令(简短格式输出)', () => {
|
|
129
|
+
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
|
130
|
+
process.env.CMUX_WORKSPACE_ID = '6E83B1B3-5617-43F0-82FB-75F55E9F3F28';
|
|
131
|
+
|
|
132
|
+
mockedGetConfigValue.mockReturnValue('cmux');
|
|
133
|
+
mockedExecFileSync
|
|
134
|
+
.mockReturnValueOnce('surface:24') // new-split 返回简短格式
|
|
135
|
+
.mockReturnValueOnce(''); // send 返回
|
|
136
|
+
|
|
137
|
+
expect(() => openCommandInNewTerminalTab('claude', 'test-title')).not.toThrow();
|
|
138
|
+
|
|
139
|
+
// 验证 new-split 调用
|
|
140
|
+
expect(mockedExecFileSync).toHaveBeenNthCalledWith(
|
|
141
|
+
1,
|
|
142
|
+
'cmux',
|
|
143
|
+
['new-split', 'right'],
|
|
144
|
+
expect.objectContaining({ timeout: 5000 })
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// 验证 send 调用(包含 \n 以自动执行)
|
|
148
|
+
expect(mockedExecFileSync).toHaveBeenNthCalledWith(
|
|
149
|
+
2,
|
|
150
|
+
'cmux',
|
|
151
|
+
['send', '--surface', 'surface:24', 'claude\\n'],
|
|
152
|
+
expect.objectContaining({ timeout: 5000 })
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('成功创建 surface 并发送命令(带 OK 前缀输出)', () => {
|
|
157
|
+
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
|
158
|
+
process.env.CMUX_WORKSPACE_ID = '6E83B1B3-5617-43F0-82FB-75F55E9F3F28';
|
|
159
|
+
|
|
160
|
+
mockedGetConfigValue.mockReturnValue('cmux');
|
|
161
|
+
mockedExecFileSync
|
|
162
|
+
.mockReturnValueOnce('OK surface:24 pane:14 workspace:5') // new-split 返回带前缀格式
|
|
163
|
+
.mockReturnValueOnce(''); // send 返回
|
|
164
|
+
|
|
165
|
+
expect(() => openCommandInNewTerminalTab('claude', 'test-title')).not.toThrow();
|
|
166
|
+
|
|
167
|
+
// 验证解析正确(包含 \n 以自动执行)
|
|
168
|
+
expect(mockedExecFileSync).toHaveBeenNthCalledWith(
|
|
169
|
+
2,
|
|
170
|
+
'cmux',
|
|
171
|
+
['send', '--surface', 'surface:24', 'claude\\n'],
|
|
172
|
+
expect.objectContaining({ timeout: 5000 })
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('不在 cmux 环境中时抛出友好错误', () => {
|
|
177
|
+
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
|
178
|
+
delete process.env.CMUX_WORKSPACE_ID;
|
|
179
|
+
|
|
180
|
+
mockedGetConfigValue.mockReturnValue('cmux');
|
|
181
|
+
|
|
182
|
+
expect(() => openCommandInNewTerminalTab('claude', 'test-title')).toThrow(
|
|
183
|
+
/当前不在 cmux 环境中/
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('new-split 输出格式无法解析时抛出错误', () => {
|
|
188
|
+
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
|
189
|
+
process.env.CMUX_WORKSPACE_ID = '6E83B1B3-5617-43F0-82FB-75F55E9F3F28';
|
|
190
|
+
|
|
191
|
+
mockedGetConfigValue.mockReturnValue('cmux');
|
|
192
|
+
mockedExecFileSync.mockReturnValueOnce('invalid output format');
|
|
193
|
+
|
|
194
|
+
expect(() => openCommandInNewTerminalTab('claude', 'test-title')).toThrow(
|
|
195
|
+
/无法解析 cmux new-split 输出/
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('cmux CLI 执行失败时捕获并抛出错误', () => {
|
|
200
|
+
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
|
201
|
+
process.env.CMUX_WORKSPACE_ID = '6E83B1B3-5617-43F0-82FB-75F55E9F3F28';
|
|
202
|
+
|
|
203
|
+
mockedGetConfigValue.mockReturnValue('cmux');
|
|
204
|
+
mockedExecFileSync.mockImplementation(() => {
|
|
205
|
+
const error = new Error('spawn cmux ENOENT');
|
|
206
|
+
throw error;
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(() => openCommandInNewTerminalTab('claude', 'test-title')).toThrow(
|
|
210
|
+
/在 cmux 中创建 surface 失败/
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('非 macOS 平台抛出错误', () => {
|
|
215
|
+
Object.defineProperty(process, 'platform', { value: 'linux' });
|
|
216
|
+
|
|
217
|
+
expect(() => openCommandInNewTerminalTab('claude', 'test-title')).toThrow(
|
|
218
|
+
/仅支持 macOS 平台/
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe('向后兼容性', () => {
|
|
224
|
+
const originalEnv = process.env;
|
|
225
|
+
const originalPlatform = process.platform;
|
|
226
|
+
|
|
227
|
+
beforeEach(() => {
|
|
228
|
+
vi.clearAllMocks();
|
|
229
|
+
process.env = { ...originalEnv };
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
afterEach(() => {
|
|
233
|
+
process.env = originalEnv;
|
|
234
|
+
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('iTerm2 用户不受影响', () => {
|
|
238
|
+
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
|
239
|
+
delete process.env.CMUX_WORKSPACE_ID;
|
|
240
|
+
|
|
241
|
+
mockedGetConfigValue.mockReturnValue('iterm2');
|
|
242
|
+
mockedExecFileSync.mockReturnValue('');
|
|
243
|
+
|
|
244
|
+
expect(() => openCommandInNewTerminalTab('claude', 'test-title')).not.toThrow();
|
|
245
|
+
|
|
246
|
+
// 验证使用 osascript 执行 AppleScript
|
|
247
|
+
expect(mockedExecFileSync).toHaveBeenCalledWith(
|
|
248
|
+
'osascript',
|
|
249
|
+
expect.arrayContaining([expect.stringContaining('-e')]),
|
|
250
|
+
expect.any(Object)
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('Terminal.app 用户不受影响', () => {
|
|
255
|
+
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
|
256
|
+
delete process.env.CMUX_WORKSPACE_ID;
|
|
257
|
+
|
|
258
|
+
mockedGetConfigValue.mockReturnValue('terminal');
|
|
259
|
+
mockedExecFileSync.mockReturnValue('');
|
|
260
|
+
|
|
261
|
+
expect(() => openCommandInNewTerminalTab('claude', 'test-title')).not.toThrow();
|
|
262
|
+
|
|
263
|
+
// 验证使用 osascript 执行 AppleScript
|
|
264
|
+
expect(mockedExecFileSync).toHaveBeenCalledWith(
|
|
265
|
+
'osascript',
|
|
266
|
+
expect.arrayContaining([expect.stringContaining('-e')]),
|
|
267
|
+
expect.any(Object)
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('auto 模式下原有行为不变(无 cmux 环境时)', () => {
|
|
272
|
+
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
|
273
|
+
delete process.env.CMUX_WORKSPACE_ID;
|
|
274
|
+
|
|
275
|
+
mockedGetConfigValue.mockReturnValue('auto');
|
|
276
|
+
mockedExistsSync.mockReturnValue(true); // iTerm2 已安装
|
|
277
|
+
mockedExecFileSync.mockReturnValue('');
|
|
278
|
+
|
|
279
|
+
expect(() => openCommandInNewTerminalTab('claude', 'test-title')).not.toThrow();
|
|
280
|
+
|
|
281
|
+
// 验证使用 iTerm2
|
|
282
|
+
expect(mockedExecFileSync).toHaveBeenCalledWith(
|
|
283
|
+
'osascript',
|
|
284
|
+
expect.arrayContaining([expect.stringContaining('-e')]),
|
|
285
|
+
expect.any(Object)
|
|
286
|
+
);
|
|
287
|
+
});
|
|
288
|
+
});
|