clawt 2.11.0 → 2.11.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 +3 -1
- package/README.md +2 -0
- package/dist/index.js +43 -21
- package/dist/postinstall.js +1 -0
- package/docs/spec.md +2 -0
- package/package.json +1 -1
- package/src/commands/resume.ts +2 -2
- package/src/constants/index.ts +1 -1
- package/src/constants/paths.ts +3 -0
- package/src/utils/claude.ts +50 -2
- package/src/utils/index.ts +1 -1
- package/tests/unit/commands/resume.test.ts +1 -1
- package/tests/unit/utils/claude.test.ts +115 -1
|
@@ -35,7 +35,9 @@
|
|
|
35
35
|
- remove 批量操作时收集错误继续处理,最后汇总报告
|
|
36
36
|
- 文档中文风格,技术术语保留英文(worktree, merge, branch, SIGINT 等)
|
|
37
37
|
- cleanupWorktrees 是 merge 和 run 共用的公共清理函数(在 src/utils/worktree.ts)
|
|
38
|
-
- `launchInteractiveClaude` 是 run(交互式模式)和 resume 共用的公共函数(在 src/utils/claude.ts
|
|
38
|
+
- `launchInteractiveClaude` 是 run(交互式模式)和 resume 共用的公共函数(在 src/utils/claude.ts),启动前自动检测会话历史并追加 `--continue`
|
|
39
|
+
- `hasClaudeSessionHistory` 检测 `~/.claude/projects/<encoded-path>/` 下是否有 `.jsonl` 文件(在 src/utils/claude.ts)
|
|
40
|
+
- `CLAUDE_PROJECTS_DIR` 常量(`~/.claude/projects/`)定义在 `src/constants/paths.ts`
|
|
39
41
|
- killAllChildProcesses 是 run 专用的子进程终止函数(在 src/utils/shell.ts)
|
|
40
42
|
- validate 快照管理函数在 `src/utils/validate-snapshot.ts`,被 validate、merge、remove 和 status 四个命令使用
|
|
41
43
|
- `confirmDestructiveAction` 在 `src/utils/formatter.ts`,被 reset、validate --clean 和 config reset 使用
|
package/README.md
CHANGED
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ var CONFIG_PATH = join(CLAWT_HOME, "config.json");
|
|
|
14
14
|
var LOGS_DIR = join(CLAWT_HOME, "logs");
|
|
15
15
|
var WORKTREES_DIR = join(CLAWT_HOME, "worktrees");
|
|
16
16
|
var VALIDATE_SNAPSHOTS_DIR = join(CLAWT_HOME, "validate-snapshots");
|
|
17
|
+
var CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
|
|
17
18
|
|
|
18
19
|
// src/constants/branch.ts
|
|
19
20
|
var INVALID_BRANCH_CHARS = /[\/\\.\s~:*?[\]^]+/g;
|
|
@@ -904,7 +905,21 @@ import Enquirer from "enquirer";
|
|
|
904
905
|
|
|
905
906
|
// src/utils/claude.ts
|
|
906
907
|
import { spawnSync } from "child_process";
|
|
907
|
-
|
|
908
|
+
import { existsSync as existsSync5, readdirSync as readdirSync3 } from "fs";
|
|
909
|
+
import { join as join3 } from "path";
|
|
910
|
+
function encodeClaudeProjectPath(absolutePath) {
|
|
911
|
+
return absolutePath.replace(/[^a-zA-Z0-9]/g, "-");
|
|
912
|
+
}
|
|
913
|
+
function hasClaudeSessionHistory(worktreePath) {
|
|
914
|
+
const encodedName = encodeClaudeProjectPath(worktreePath);
|
|
915
|
+
const projectDir = join3(CLAUDE_PROJECTS_DIR, encodedName);
|
|
916
|
+
if (!existsSync5(projectDir)) {
|
|
917
|
+
return false;
|
|
918
|
+
}
|
|
919
|
+
const entries = readdirSync3(projectDir);
|
|
920
|
+
return entries.some((entry) => entry.endsWith(".jsonl"));
|
|
921
|
+
}
|
|
922
|
+
function launchInteractiveClaude(worktree, options = {}) {
|
|
908
923
|
const commandStr = getConfigValue("claudeCodeCommand");
|
|
909
924
|
const parts = commandStr.split(/\s+/).filter(Boolean);
|
|
910
925
|
const cmd = parts[0];
|
|
@@ -913,10 +928,17 @@ function launchInteractiveClaude(worktree) {
|
|
|
913
928
|
"--append-system-prompt",
|
|
914
929
|
APPEND_SYSTEM_PROMPT
|
|
915
930
|
];
|
|
931
|
+
const hasPreviousSession = options.autoContinue === true && hasClaudeSessionHistory(worktree.path);
|
|
932
|
+
if (hasPreviousSession) {
|
|
933
|
+
args.push("--continue");
|
|
934
|
+
}
|
|
916
935
|
printInfo(`\u6B63\u5728 worktree \u4E2D\u542F\u52A8 Claude Code \u4EA4\u4E92\u5F0F\u754C\u9762...`);
|
|
917
936
|
printInfo(` \u5206\u652F: ${worktree.branch}`);
|
|
918
937
|
printInfo(` \u8DEF\u5F84: ${worktree.path}`);
|
|
919
938
|
printInfo(` \u6307\u4EE4: ${commandStr}`);
|
|
939
|
+
if (options.autoContinue) {
|
|
940
|
+
printInfo(` \u6A21\u5F0F: ${hasPreviousSession ? "\u7EE7\u7EED\u4E0A\u6B21\u5BF9\u8BDD" : "\u65B0\u5BF9\u8BDD"}`);
|
|
941
|
+
}
|
|
920
942
|
printInfo("");
|
|
921
943
|
const result = spawnSync(cmd, args, {
|
|
922
944
|
cwd: worktree.path,
|
|
@@ -931,29 +953,29 @@ function launchInteractiveClaude(worktree) {
|
|
|
931
953
|
}
|
|
932
954
|
|
|
933
955
|
// src/utils/validate-snapshot.ts
|
|
934
|
-
import { join as
|
|
935
|
-
import { existsSync as
|
|
956
|
+
import { join as join4 } from "path";
|
|
957
|
+
import { existsSync as existsSync6, readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync, readdirSync as readdirSync4, rmdirSync as rmdirSync2 } from "fs";
|
|
936
958
|
function getSnapshotPath(projectName, branchName) {
|
|
937
|
-
return
|
|
959
|
+
return join4(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.tree`);
|
|
938
960
|
}
|
|
939
961
|
function getSnapshotHeadPath(projectName, branchName) {
|
|
940
|
-
return
|
|
962
|
+
return join4(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.head`);
|
|
941
963
|
}
|
|
942
964
|
function hasSnapshot(projectName, branchName) {
|
|
943
|
-
return
|
|
965
|
+
return existsSync6(getSnapshotPath(projectName, branchName));
|
|
944
966
|
}
|
|
945
967
|
function readSnapshot(projectName, branchName) {
|
|
946
968
|
const snapshotPath = getSnapshotPath(projectName, branchName);
|
|
947
969
|
const headPath = getSnapshotHeadPath(projectName, branchName);
|
|
948
970
|
logger.debug(`\u8BFB\u53D6 validate \u5FEB\u7167: ${snapshotPath}`);
|
|
949
|
-
const treeHash =
|
|
950
|
-
const headCommitHash =
|
|
971
|
+
const treeHash = existsSync6(snapshotPath) ? readFileSync2(snapshotPath, "utf-8").trim() : "";
|
|
972
|
+
const headCommitHash = existsSync6(headPath) ? readFileSync2(headPath, "utf-8").trim() : "";
|
|
951
973
|
return { treeHash, headCommitHash };
|
|
952
974
|
}
|
|
953
975
|
function writeSnapshot(projectName, branchName, treeHash, headCommitHash) {
|
|
954
976
|
const snapshotPath = getSnapshotPath(projectName, branchName);
|
|
955
977
|
const headPath = getSnapshotHeadPath(projectName, branchName);
|
|
956
|
-
const snapshotDir =
|
|
978
|
+
const snapshotDir = join4(VALIDATE_SNAPSHOTS_DIR, projectName);
|
|
957
979
|
ensureDir(snapshotDir);
|
|
958
980
|
writeFileSync2(snapshotPath, treeHash, "utf-8");
|
|
959
981
|
writeFileSync2(headPath, headCommitHash, "utf-8");
|
|
@@ -962,31 +984,31 @@ function writeSnapshot(projectName, branchName, treeHash, headCommitHash) {
|
|
|
962
984
|
function removeSnapshot(projectName, branchName) {
|
|
963
985
|
const snapshotPath = getSnapshotPath(projectName, branchName);
|
|
964
986
|
const headPath = getSnapshotHeadPath(projectName, branchName);
|
|
965
|
-
if (
|
|
987
|
+
if (existsSync6(snapshotPath)) {
|
|
966
988
|
unlinkSync(snapshotPath);
|
|
967
989
|
logger.info(`\u5DF2\u5220\u9664 validate \u5FEB\u7167: ${snapshotPath}`);
|
|
968
990
|
}
|
|
969
|
-
if (
|
|
991
|
+
if (existsSync6(headPath)) {
|
|
970
992
|
unlinkSync(headPath);
|
|
971
993
|
logger.info(`\u5DF2\u5220\u9664 validate \u5FEB\u7167: ${headPath}`);
|
|
972
994
|
}
|
|
973
995
|
}
|
|
974
996
|
function getProjectSnapshotBranches(projectName) {
|
|
975
|
-
const projectDir =
|
|
976
|
-
if (!
|
|
997
|
+
const projectDir = join4(VALIDATE_SNAPSHOTS_DIR, projectName);
|
|
998
|
+
if (!existsSync6(projectDir)) {
|
|
977
999
|
return [];
|
|
978
1000
|
}
|
|
979
|
-
const files =
|
|
1001
|
+
const files = readdirSync4(projectDir);
|
|
980
1002
|
return files.filter((f) => f.endsWith(".tree")).map((f) => f.replace(/\.tree$/, ""));
|
|
981
1003
|
}
|
|
982
1004
|
function removeProjectSnapshots(projectName) {
|
|
983
|
-
const projectDir =
|
|
984
|
-
if (!
|
|
1005
|
+
const projectDir = join4(VALIDATE_SNAPSHOTS_DIR, projectName);
|
|
1006
|
+
if (!existsSync6(projectDir)) {
|
|
985
1007
|
return;
|
|
986
1008
|
}
|
|
987
|
-
const files =
|
|
1009
|
+
const files = readdirSync4(projectDir);
|
|
988
1010
|
for (const file of files) {
|
|
989
|
-
unlinkSync(
|
|
1011
|
+
unlinkSync(join4(projectDir, file));
|
|
990
1012
|
}
|
|
991
1013
|
try {
|
|
992
1014
|
rmdirSync2(projectDir);
|
|
@@ -1283,7 +1305,7 @@ var ProgressRenderer = class {
|
|
|
1283
1305
|
|
|
1284
1306
|
// src/utils/task-file.ts
|
|
1285
1307
|
import { resolve } from "path";
|
|
1286
|
-
import { existsSync as
|
|
1308
|
+
import { existsSync as existsSync7, readFileSync as readFileSync3 } from "fs";
|
|
1287
1309
|
var TASK_BLOCK_REGEX = /<!-- CLAWT-TASKS:START -->([\s\S]*?)<!-- CLAWT-TASKS:END -->/g;
|
|
1288
1310
|
var BRANCH_LINE_REGEX = /^#\s*branch:\s*(.+)$/;
|
|
1289
1311
|
function parseTaskFile(content, options) {
|
|
@@ -1321,7 +1343,7 @@ function parseTaskFile(content, options) {
|
|
|
1321
1343
|
}
|
|
1322
1344
|
function loadTaskFile(filePath, options) {
|
|
1323
1345
|
const absolutePath = resolve(filePath);
|
|
1324
|
-
if (!
|
|
1346
|
+
if (!existsSync7(absolutePath)) {
|
|
1325
1347
|
throw new ClawtError(MESSAGES.TASK_FILE_NOT_FOUND(absolutePath));
|
|
1326
1348
|
}
|
|
1327
1349
|
const content = readFileSync3(absolutePath, "utf-8");
|
|
@@ -1764,7 +1786,7 @@ async function handleResume(options) {
|
|
|
1764
1786
|
logger.info(`resume \u547D\u4EE4\u6267\u884C\uFF0C\u5206\u652F: ${options.branch ?? "(\u672A\u6307\u5B9A)"}`);
|
|
1765
1787
|
const worktrees = getProjectWorktrees();
|
|
1766
1788
|
const worktree = await resolveTargetWorktree(worktrees, RESUME_RESOLVE_MESSAGES, options.branch);
|
|
1767
|
-
launchInteractiveClaude(worktree);
|
|
1789
|
+
launchInteractiveClaude(worktree, { autoContinue: true });
|
|
1768
1790
|
}
|
|
1769
1791
|
|
|
1770
1792
|
// src/commands/validate.ts
|
package/dist/postinstall.js
CHANGED
|
@@ -9,6 +9,7 @@ var CONFIG_PATH = join(CLAWT_HOME, "config.json");
|
|
|
9
9
|
var LOGS_DIR = join(CLAWT_HOME, "logs");
|
|
10
10
|
var WORKTREES_DIR = join(CLAWT_HOME, "worktrees");
|
|
11
11
|
var VALIDATE_SNAPSHOTS_DIR = join(CLAWT_HOME, "validate-snapshots");
|
|
12
|
+
var CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
|
|
12
13
|
|
|
13
14
|
// src/constants/messages/common.ts
|
|
14
15
|
var COMMON_MESSAGES = {
|
package/docs/spec.md
CHANGED
|
@@ -1088,6 +1088,8 @@ clawt resume
|
|
|
1088
1088
|
|
|
1089
1089
|
启动命令通过配置项 `claudeCodeCommand`(默认值 `claude`)指定,与 `clawt run` 不传 `--tasks` 时的交互式界面行为一致。
|
|
1090
1090
|
|
|
1091
|
+
**会话自动续接:** 启动前会自动检测该 worktree 是否存在 Claude Code 历史会话(通过检查 `~/.claude/projects/<encoded-path>/` 下是否有 `.jsonl` 文件判断),如果存在则自动追加 `--continue` 参数继续上次对话,否则打开新对话。启动信息中会显示当前模式("继续上次对话"或"新对话")。路径编码规则:将绝对路径中所有非字母数字字符替换为 `-`(与 Claude Code 源码的编码逻辑一致)。
|
|
1092
|
+
|
|
1091
1093
|
---
|
|
1092
1094
|
|
|
1093
1095
|
### 5.12 将主分支代码同步到目标 Worktree
|
package/package.json
CHANGED
package/src/commands/resume.ts
CHANGED
|
@@ -48,6 +48,6 @@ async function handleResume(options: ResumeOptions): Promise<void> {
|
|
|
48
48
|
const worktrees = getProjectWorktrees();
|
|
49
49
|
const worktree = await resolveTargetWorktree(worktrees, RESUME_RESOLVE_MESSAGES, options.branch);
|
|
50
50
|
|
|
51
|
-
// 启动 Claude Code
|
|
52
|
-
launchInteractiveClaude(worktree);
|
|
51
|
+
// 启动 Claude Code 交互式界面(resume 自动续接历史会话)
|
|
52
|
+
launchInteractiveClaude(worktree, { autoContinue: true });
|
|
53
53
|
}
|
package/src/constants/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { CLAWT_HOME, CONFIG_PATH, LOGS_DIR, WORKTREES_DIR, VALIDATE_SNAPSHOTS_DIR } from './paths.js';
|
|
1
|
+
export { CLAWT_HOME, CONFIG_PATH, LOGS_DIR, WORKTREES_DIR, VALIDATE_SNAPSHOTS_DIR, CLAUDE_PROJECTS_DIR } from './paths.js';
|
|
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';
|
package/src/constants/paths.ts
CHANGED
|
@@ -15,3 +15,6 @@ export const WORKTREES_DIR = join(CLAWT_HOME, 'worktrees');
|
|
|
15
15
|
|
|
16
16
|
/** validate 快照目录 ~/.clawt/validate-snapshots/ */
|
|
17
17
|
export const VALIDATE_SNAPSHOTS_DIR = join(CLAWT_HOME, 'validate-snapshots');
|
|
18
|
+
|
|
19
|
+
/** Claude Code 项目会话目录 ~/.claude/projects/ */
|
|
20
|
+
export const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
|
package/src/utils/claude.ts
CHANGED
|
@@ -1,16 +1,55 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
2
4
|
import { ClawtError } from '../errors/index.js';
|
|
3
|
-
import { APPEND_SYSTEM_PROMPT } from '../constants/index.js';
|
|
5
|
+
import { APPEND_SYSTEM_PROMPT, CLAUDE_PROJECTS_DIR } from '../constants/index.js';
|
|
4
6
|
import { getConfigValue } from './config.js';
|
|
5
7
|
import { printInfo, printWarning } from './formatter.js';
|
|
6
8
|
import type { WorktreeInfo } from '../types/index.js';
|
|
7
9
|
|
|
10
|
+
/**
|
|
11
|
+
* 将路径编码为 Claude Code 项目目录名
|
|
12
|
+
* 规则:将所有非字母数字字符替换为 -(与 Claude Code 源码中的编码逻辑一致)
|
|
13
|
+
* @param {string} absolutePath - 绝对路径
|
|
14
|
+
* @returns {string} 编码后的目录名
|
|
15
|
+
*/
|
|
16
|
+
function encodeClaudeProjectPath(absolutePath: string): string {
|
|
17
|
+
return absolutePath.replace(/[^a-zA-Z0-9]/g, '-');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 检测指定 worktree 路径是否存在 Claude Code 会话历史
|
|
22
|
+
* 通过检查 ~/.claude/projects/<encoded-path>/ 下是否有 .jsonl 文件来判断
|
|
23
|
+
* @param {string} worktreePath - worktree 的绝对路径
|
|
24
|
+
* @returns {boolean} 是否存在会话历史
|
|
25
|
+
*/
|
|
26
|
+
export function hasClaudeSessionHistory(worktreePath: string): boolean {
|
|
27
|
+
const encodedName = encodeClaudeProjectPath(worktreePath);
|
|
28
|
+
const projectDir = join(CLAUDE_PROJECTS_DIR, encodedName);
|
|
29
|
+
|
|
30
|
+
if (!existsSync(projectDir)) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const entries = readdirSync(projectDir);
|
|
35
|
+
return entries.some((entry) => entry.endsWith('.jsonl'));
|
|
36
|
+
}
|
|
37
|
+
|
|
8
38
|
/**
|
|
9
39
|
* 在指定 worktree 中启动 Claude Code CLI 交互式界面
|
|
10
40
|
* 使用 spawnSync + inherit stdio,让用户直接与 Claude Code 交互
|
|
11
41
|
* @param {WorktreeInfo} worktree - worktree 信息
|
|
12
42
|
*/
|
|
13
|
-
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {Object} LaunchClaudeOptions
|
|
45
|
+
* @property {boolean} [autoContinue] - 是否自动检测历史会话并续接
|
|
46
|
+
*/
|
|
47
|
+
interface LaunchClaudeOptions {
|
|
48
|
+
/** 是否自动检测历史会话并追加 --continue 参数 */
|
|
49
|
+
autoContinue?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function launchInteractiveClaude(worktree: WorktreeInfo, options: LaunchClaudeOptions = {}): void {
|
|
14
53
|
const commandStr = getConfigValue('claudeCodeCommand');
|
|
15
54
|
const parts = commandStr.split(/\s+/).filter(Boolean);
|
|
16
55
|
const cmd = parts[0];
|
|
@@ -20,10 +59,19 @@ export function launchInteractiveClaude(worktree: WorktreeInfo): void {
|
|
|
20
59
|
APPEND_SYSTEM_PROMPT,
|
|
21
60
|
];
|
|
22
61
|
|
|
62
|
+
// 仅在启用 autoContinue 时检测历史会话并追加 --continue
|
|
63
|
+
const hasPreviousSession = options.autoContinue === true && hasClaudeSessionHistory(worktree.path);
|
|
64
|
+
if (hasPreviousSession) {
|
|
65
|
+
args.push('--continue');
|
|
66
|
+
}
|
|
67
|
+
|
|
23
68
|
printInfo(`正在 worktree 中启动 Claude Code 交互式界面...`);
|
|
24
69
|
printInfo(` 分支: ${worktree.branch}`);
|
|
25
70
|
printInfo(` 路径: ${worktree.path}`);
|
|
26
71
|
printInfo(` 指令: ${commandStr}`);
|
|
72
|
+
if (options.autoContinue) {
|
|
73
|
+
printInfo(` 模式: ${hasPreviousSession ? '继续上次对话' : '新对话'}`);
|
|
74
|
+
}
|
|
27
75
|
printInfo('');
|
|
28
76
|
|
|
29
77
|
const result = spawnSync(cmd, args, {
|
package/src/utils/index.ts
CHANGED
|
@@ -52,7 +52,7 @@ 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 } from './claude.js';
|
|
55
|
+
export { launchInteractiveClaude, hasClaudeSessionHistory } 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';
|
|
@@ -68,7 +68,7 @@ describe('handleResume', () => {
|
|
|
68
68
|
expect(mockedValidateMainWorktree).toHaveBeenCalled();
|
|
69
69
|
expect(mockedValidateClaudeCodeInstalled).toHaveBeenCalled();
|
|
70
70
|
expect(mockedResolveTargetWorktree).toHaveBeenCalled();
|
|
71
|
-
expect(mockedLaunchInteractiveClaude).toHaveBeenCalledWith(worktree);
|
|
71
|
+
expect(mockedLaunchInteractiveClaude).toHaveBeenCalledWith(worktree, { autoContinue: true });
|
|
72
72
|
});
|
|
73
73
|
|
|
74
74
|
it('不传 -b 时也能调用 resolveTargetWorktree', async () => {
|
|
@@ -10,6 +10,12 @@ vi.mock('node:child_process', () => ({
|
|
|
10
10
|
spawnSync: vi.fn(),
|
|
11
11
|
}));
|
|
12
12
|
|
|
13
|
+
// mock node:fs
|
|
14
|
+
vi.mock('node:fs', () => ({
|
|
15
|
+
existsSync: vi.fn(),
|
|
16
|
+
readdirSync: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
13
19
|
// mock config
|
|
14
20
|
vi.mock('../../../src/utils/config.js', () => ({
|
|
15
21
|
getConfigValue: vi.fn(),
|
|
@@ -22,7 +28,8 @@ vi.mock('../../../src/utils/formatter.js', () => ({
|
|
|
22
28
|
}));
|
|
23
29
|
|
|
24
30
|
import { spawnSync } from 'node:child_process';
|
|
25
|
-
import {
|
|
31
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
32
|
+
import { launchInteractiveClaude, hasClaudeSessionHistory } from '../../../src/utils/claude.js';
|
|
26
33
|
import { getConfigValue } from '../../../src/utils/config.js';
|
|
27
34
|
import { printInfo, printWarning } from '../../../src/utils/formatter.js';
|
|
28
35
|
import { ClawtError } from '../../../src/errors/index.js';
|
|
@@ -32,6 +39,45 @@ const mockedSpawnSync = vi.mocked(spawnSync);
|
|
|
32
39
|
const mockedGetConfigValue = vi.mocked(getConfigValue);
|
|
33
40
|
const mockedPrintInfo = vi.mocked(printInfo);
|
|
34
41
|
const mockedPrintWarning = vi.mocked(printWarning);
|
|
42
|
+
const mockedExistsSync = vi.mocked(existsSync);
|
|
43
|
+
const mockedReaddirSync = vi.mocked(readdirSync);
|
|
44
|
+
|
|
45
|
+
describe('hasClaudeSessionHistory', () => {
|
|
46
|
+
it('项目目录不存在时返回 false', () => {
|
|
47
|
+
mockedExistsSync.mockReturnValue(false);
|
|
48
|
+
|
|
49
|
+
expect(hasClaudeSessionHistory('/Users/test/project')).toBe(false);
|
|
50
|
+
expect(mockedExistsSync).toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('项目目录存在但无 .jsonl 文件时返回 false', () => {
|
|
54
|
+
mockedExistsSync.mockReturnValue(true);
|
|
55
|
+
mockedReaddirSync.mockReturnValue(['memory', 'CLAUDE.md'] as unknown as ReturnType<typeof readdirSync>);
|
|
56
|
+
|
|
57
|
+
expect(hasClaudeSessionHistory('/Users/test/project')).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('项目目录存在且有 .jsonl 文件时返回 true', () => {
|
|
61
|
+
mockedExistsSync.mockReturnValue(true);
|
|
62
|
+
mockedReaddirSync.mockReturnValue([
|
|
63
|
+
'abc-123.jsonl',
|
|
64
|
+
'memory',
|
|
65
|
+
] as unknown as ReturnType<typeof readdirSync>);
|
|
66
|
+
|
|
67
|
+
expect(hasClaudeSessionHistory('/Users/test/project')).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('路径编码规则正确(非字母数字字符替换为 -)', () => {
|
|
71
|
+
mockedExistsSync.mockReturnValue(false);
|
|
72
|
+
|
|
73
|
+
hasClaudeSessionHistory('/Users/qihoo/.clawt/worktrees/clawt/resume');
|
|
74
|
+
|
|
75
|
+
// 验证 existsSync 被调用时路径包含编码后的目录名
|
|
76
|
+
// /Users/qihoo/.clawt/worktrees/clawt/resume → -Users-qihoo--clawt-worktrees-clawt-resume
|
|
77
|
+
const calledPath = mockedExistsSync.mock.calls[0][0] as string;
|
|
78
|
+
expect(calledPath).toContain('-Users-qihoo--clawt-worktrees-clawt-resume');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
35
81
|
|
|
36
82
|
describe('launchInteractiveClaude', () => {
|
|
37
83
|
const worktree = createWorktreeInfo({
|
|
@@ -41,6 +87,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
41
87
|
|
|
42
88
|
it('正常启动 Claude Code(退出码为 0)', () => {
|
|
43
89
|
mockedGetConfigValue.mockReturnValue('claude');
|
|
90
|
+
mockedExistsSync.mockReturnValue(false);
|
|
44
91
|
mockedSpawnSync.mockReturnValue({
|
|
45
92
|
status: 0,
|
|
46
93
|
error: undefined,
|
|
@@ -66,6 +113,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
66
113
|
|
|
67
114
|
it('输出分支和路径信息', () => {
|
|
68
115
|
mockedGetConfigValue.mockReturnValue('claude');
|
|
116
|
+
mockedExistsSync.mockReturnValue(false);
|
|
69
117
|
mockedSpawnSync.mockReturnValue({
|
|
70
118
|
status: 0,
|
|
71
119
|
error: undefined,
|
|
@@ -84,6 +132,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
84
132
|
|
|
85
133
|
it('支持带参数的命令(如 npx claude)', () => {
|
|
86
134
|
mockedGetConfigValue.mockReturnValue('npx claude');
|
|
135
|
+
mockedExistsSync.mockReturnValue(false);
|
|
87
136
|
mockedSpawnSync.mockReturnValue({
|
|
88
137
|
status: 0,
|
|
89
138
|
error: undefined,
|
|
@@ -105,6 +154,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
105
154
|
|
|
106
155
|
it('spawnSync 返回 error 时抛出 ClawtError', () => {
|
|
107
156
|
mockedGetConfigValue.mockReturnValue('claude');
|
|
157
|
+
mockedExistsSync.mockReturnValue(false);
|
|
108
158
|
mockedSpawnSync.mockReturnValue({
|
|
109
159
|
status: null,
|
|
110
160
|
error: new Error('命令未找到'),
|
|
@@ -121,6 +171,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
121
171
|
|
|
122
172
|
it('非零退出码时调用 printWarning', () => {
|
|
123
173
|
mockedGetConfigValue.mockReturnValue('claude');
|
|
174
|
+
mockedExistsSync.mockReturnValue(false);
|
|
124
175
|
mockedSpawnSync.mockReturnValue({
|
|
125
176
|
status: 1,
|
|
126
177
|
error: undefined,
|
|
@@ -138,6 +189,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
138
189
|
|
|
139
190
|
it('退出码为 null 时不调用 printWarning', () => {
|
|
140
191
|
mockedGetConfigValue.mockReturnValue('claude');
|
|
192
|
+
mockedExistsSync.mockReturnValue(false);
|
|
141
193
|
mockedSpawnSync.mockReturnValue({
|
|
142
194
|
status: null,
|
|
143
195
|
error: undefined,
|
|
@@ -155,6 +207,7 @@ describe('launchInteractiveClaude', () => {
|
|
|
155
207
|
|
|
156
208
|
it('退出码为 0 时不调用 printWarning', () => {
|
|
157
209
|
mockedGetConfigValue.mockReturnValue('claude');
|
|
210
|
+
mockedExistsSync.mockReturnValue(false);
|
|
158
211
|
mockedSpawnSync.mockReturnValue({
|
|
159
212
|
status: 0,
|
|
160
213
|
error: undefined,
|
|
@@ -169,4 +222,65 @@ describe('launchInteractiveClaude', () => {
|
|
|
169
222
|
|
|
170
223
|
expect(mockedPrintWarning).not.toHaveBeenCalled();
|
|
171
224
|
});
|
|
225
|
+
|
|
226
|
+
it('autoContinue 启用且有会话历史时追加 --continue 参数', () => {
|
|
227
|
+
mockedGetConfigValue.mockReturnValue('claude');
|
|
228
|
+
mockedExistsSync.mockReturnValue(true);
|
|
229
|
+
mockedReaddirSync.mockReturnValue(['session-abc.jsonl'] as unknown as ReturnType<typeof readdirSync>);
|
|
230
|
+
mockedSpawnSync.mockReturnValue({
|
|
231
|
+
status: 0,
|
|
232
|
+
error: undefined,
|
|
233
|
+
stdout: '',
|
|
234
|
+
stderr: '',
|
|
235
|
+
pid: 1234,
|
|
236
|
+
output: [],
|
|
237
|
+
signal: null,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
launchInteractiveClaude(worktree, { autoContinue: true });
|
|
241
|
+
|
|
242
|
+
const callArgs = mockedSpawnSync.mock.calls[0][1] as string[];
|
|
243
|
+
expect(callArgs).toContain('--continue');
|
|
244
|
+
expect(mockedPrintInfo).toHaveBeenCalledWith(expect.stringContaining('继续上次对话'));
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('autoContinue 启用但无会话历史时不追加 --continue 参数', () => {
|
|
248
|
+
mockedGetConfigValue.mockReturnValue('claude');
|
|
249
|
+
mockedExistsSync.mockReturnValue(false);
|
|
250
|
+
mockedSpawnSync.mockReturnValue({
|
|
251
|
+
status: 0,
|
|
252
|
+
error: undefined,
|
|
253
|
+
stdout: '',
|
|
254
|
+
stderr: '',
|
|
255
|
+
pid: 1234,
|
|
256
|
+
output: [],
|
|
257
|
+
signal: null,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
launchInteractiveClaude(worktree, { autoContinue: true });
|
|
261
|
+
|
|
262
|
+
const callArgs = mockedSpawnSync.mock.calls[0][1] as string[];
|
|
263
|
+
expect(callArgs).not.toContain('--continue');
|
|
264
|
+
expect(mockedPrintInfo).toHaveBeenCalledWith(expect.stringContaining('新对话'));
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('不传 autoContinue 时即使有会话历史也不追加 --continue', () => {
|
|
268
|
+
mockedGetConfigValue.mockReturnValue('claude');
|
|
269
|
+
mockedExistsSync.mockReturnValue(true);
|
|
270
|
+
mockedReaddirSync.mockReturnValue(['session-abc.jsonl'] as unknown as ReturnType<typeof readdirSync>);
|
|
271
|
+
mockedSpawnSync.mockReturnValue({
|
|
272
|
+
status: 0,
|
|
273
|
+
error: undefined,
|
|
274
|
+
stdout: '',
|
|
275
|
+
stderr: '',
|
|
276
|
+
pid: 1234,
|
|
277
|
+
output: [],
|
|
278
|
+
signal: null,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
launchInteractiveClaude(worktree);
|
|
282
|
+
|
|
283
|
+
const callArgs = mockedSpawnSync.mock.calls[0][1] as string[];
|
|
284
|
+
expect(callArgs).not.toContain('--continue');
|
|
285
|
+
});
|
|
172
286
|
});
|