clawt 2.14.1 → 2.15.0

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 CHANGED
@@ -52,6 +52,29 @@ clawt run -f tasks.md
52
52
 
53
53
  # 从任务文件读取任务,但用 -b 自动编号分支(文件中分支名可省略)
54
54
  clawt run -f tasks.md -b feat
55
+
56
+ # 试运行:仅预览将要创建的 worktree 和任务,不实际执行
57
+ clawt run -b <branch> --tasks "任务1" --tasks "任务2" --dry-run
58
+ ```
59
+
60
+ **`--dry-run` 预览示例:**
61
+
62
+ ```
63
+ ════════════════════════════════════════
64
+ Dry Run 预览
65
+ ════════════════════════════════════════
66
+ 任务数: 2 │ 并发数: 不限制 │ Worktree: ~/.clawt/worktrees/project
67
+ ────────────────────────────────────────
68
+ ✓ [1/2] feat-1
69
+ 路径: ~/.clawt/worktrees/project/feat-1
70
+ 任务: 任务1
71
+
72
+ ✓ [2/2] feat-2
73
+ 路径: ~/.clawt/worktrees/project/feat-2
74
+ 任务: 任务2
75
+
76
+ ════════════════════════════════════════
77
+ ✓ 预览完成,无冲突。移除 --dry-run 即可正式执行。
55
78
  ```
56
79
 
57
80
  **任务文件格式:**
package/dist/index.js CHANGED
@@ -98,7 +98,23 @@ var RUN_MESSAGES = {
98
98
  /** 任务文件加载成功 */
99
99
  TASK_FILE_LOADED: (count, path) => `\u2713 \u4ECE ${path} \u52A0\u8F7D\u4E86 ${count} \u4E2A\u4EFB\u52A1`,
100
100
  /** 未指定 -b 或 -f */
101
- BRANCH_OR_FILE_REQUIRED: "\u8BF7\u6307\u5B9A -b \u5206\u652F\u540D\u6216 -f \u4EFB\u52A1\u6587\u4EF6"
101
+ BRANCH_OR_FILE_REQUIRED: "\u8BF7\u6307\u5B9A -b \u5206\u652F\u540D\u6216 -f \u4EFB\u52A1\u6587\u4EF6",
102
+ /** dry-run 预览标题 */
103
+ DRY_RUN_TITLE: "Dry Run \u9884\u89C8",
104
+ /** dry-run 任务数量 */
105
+ DRY_RUN_TASK_COUNT: (count) => `\u4EFB\u52A1\u6570: ${count}`,
106
+ /** dry-run 并发数 */
107
+ DRY_RUN_CONCURRENCY: (concurrency) => `\u5E76\u53D1\u6570: ${concurrency === 0 ? "\u4E0D\u9650\u5236" : concurrency}`,
108
+ /** dry-run worktree 目录 */
109
+ DRY_RUN_WORKTREE_DIR: (dir) => `Worktree: ${dir}`,
110
+ /** dry-run 分支已存在警告 */
111
+ DRY_RUN_BRANCH_EXISTS_WARNING: (name) => `\u5206\u652F ${name} \u5DF2\u5B58\u5728`,
112
+ /** dry-run 交互式模式提示(无任务描述) */
113
+ DRY_RUN_INTERACTIVE_MODE: "\u6A21\u5F0F: \u4EA4\u4E92\u5F0F\uFF08\u65E0\u9884\u8BBE\u4EFB\u52A1\uFF09",
114
+ /** dry-run 预览完成且无冲突 */
115
+ DRY_RUN_READY: "\u9884\u89C8\u5B8C\u6210\uFF0C\u65E0\u51B2\u7A81\u3002\u79FB\u9664 --dry-run \u5373\u53EF\u6B63\u5F0F\u6267\u884C\u3002",
116
+ /** dry-run 存在分支冲突 */
117
+ DRY_RUN_HAS_CONFLICT: "\u5B58\u5728\u5206\u652F\u51B2\u7A81\uFF0C\u5B9E\u9645\u6267\u884C\u65F6\u5C06\u4F1A\u62A5\u9519\u3002\u8BF7\u5148\u5904\u7406\u51B2\u7A81\u7684\u5206\u652F\u3002"
102
118
  };
103
119
 
104
120
  // src/constants/messages/create.ts
@@ -969,6 +985,16 @@ function ensureClawtDirs() {
969
985
  ensureDir(LOGS_DIR);
970
986
  ensureDir(WORKTREES_DIR);
971
987
  }
988
+ function parseConcurrency(optionValue, configValue) {
989
+ if (optionValue === void 0) {
990
+ return configValue;
991
+ }
992
+ const parsed = parseInt(optionValue, 10);
993
+ if (Number.isNaN(parsed) || parsed < 0) {
994
+ throw new ClawtError(MESSAGES.CONCURRENCY_INVALID);
995
+ }
996
+ return parsed;
997
+ }
972
998
 
973
999
  // src/utils/prompt.ts
974
1000
  import Enquirer from "enquirer";
@@ -1492,6 +1518,14 @@ import { resolve } from "path";
1492
1518
  import { existsSync as existsSync8, readFileSync as readFileSync3 } from "fs";
1493
1519
  var TASK_BLOCK_REGEX = /<!-- CLAWT-TASKS:START -->([\s\S]*?)<!-- CLAWT-TASKS:END -->/g;
1494
1520
  var BRANCH_LINE_REGEX = /^#\s*branch:\s*(.+)$/;
1521
+ var EMPTY_TASKS_MESSAGE = "\u4EFB\u52A1\u5217\u8868\u4E0D\u80FD\u4E3A\u7A7A";
1522
+ function parseTasksFromOptions(rawTasks) {
1523
+ const tasks = rawTasks.map((t) => t.trim()).filter(Boolean);
1524
+ if (tasks.length === 0) {
1525
+ throw new ClawtError(EMPTY_TASKS_MESSAGE);
1526
+ }
1527
+ return tasks;
1528
+ }
1495
1529
  function parseTaskFile(content, options) {
1496
1530
  const branchRequired = options?.branchRequired ?? true;
1497
1531
  const entries = [];
@@ -1742,6 +1776,59 @@ async function executeBatchTasks(worktrees, tasks, concurrency) {
1742
1776
  printTaskSummary(summary);
1743
1777
  }
1744
1778
 
1779
+ // src/utils/dry-run.ts
1780
+ import chalk4 from "chalk";
1781
+ import { join as join5 } from "path";
1782
+ var DRY_RUN_TASK_DESC_MAX_LENGTH = 70;
1783
+ function truncateTaskDesc(task) {
1784
+ const oneLine = task.replace(/\n/g, " ").trim();
1785
+ if (oneLine.length <= DRY_RUN_TASK_DESC_MAX_LENGTH) {
1786
+ return oneLine;
1787
+ }
1788
+ return oneLine.slice(0, DRY_RUN_TASK_DESC_MAX_LENGTH) + "...";
1789
+ }
1790
+ function printDryRunPreview(branchNames, tasks, concurrency) {
1791
+ const projectDir = getProjectWorktreeDir();
1792
+ const isInteractive = tasks.length === 0;
1793
+ printDoubleSeparator();
1794
+ printInfo(` ${chalk4.bold(MESSAGES.DRY_RUN_TITLE)}`);
1795
+ printDoubleSeparator();
1796
+ const summaryParts = [
1797
+ MESSAGES.DRY_RUN_TASK_COUNT(branchNames.length),
1798
+ MESSAGES.DRY_RUN_CONCURRENCY(concurrency),
1799
+ MESSAGES.DRY_RUN_WORKTREE_DIR(projectDir)
1800
+ ];
1801
+ if (isInteractive) {
1802
+ summaryParts.push(MESSAGES.DRY_RUN_INTERACTIVE_MODE);
1803
+ }
1804
+ printInfo(summaryParts.join(chalk4.gray(" \u2502 ")));
1805
+ printSeparator();
1806
+ let hasConflict = false;
1807
+ for (let i = 0; i < branchNames.length; i++) {
1808
+ const branch = branchNames[i];
1809
+ const worktreePath = join5(projectDir, branch);
1810
+ const exists = checkBranchExists(branch);
1811
+ if (exists) hasConflict = true;
1812
+ const indexLabel = `[${i + 1}/${branchNames.length}]`;
1813
+ if (exists) {
1814
+ printInfo(`${chalk4.yellow("\u26A0")} ${indexLabel} ${chalk4.yellow(branch)} ${chalk4.gray("\u2014")} ${chalk4.yellow(MESSAGES.DRY_RUN_BRANCH_EXISTS_WARNING(branch))}`);
1815
+ } else {
1816
+ printInfo(`${chalk4.green("\u2713")} ${indexLabel} ${chalk4.cyan(branch)}`);
1817
+ }
1818
+ printInfo(` ${chalk4.gray("\u8DEF\u5F84:")} ${worktreePath}`);
1819
+ if (!isInteractive) {
1820
+ printInfo(` ${chalk4.gray("\u4EFB\u52A1:")} ${truncateTaskDesc(tasks[i])}`);
1821
+ }
1822
+ printInfo("");
1823
+ }
1824
+ printDoubleSeparator();
1825
+ if (hasConflict) {
1826
+ printInfo(chalk4.yellow(`\u26A0 ${MESSAGES.DRY_RUN_HAS_CONFLICT}`));
1827
+ } else {
1828
+ printInfo(chalk4.green(`\u2713 ${MESSAGES.DRY_RUN_READY}`));
1829
+ }
1830
+ }
1831
+
1745
1832
  // src/utils/alias.ts
1746
1833
  function applyAliases(program2, aliases) {
1747
1834
  for (const [alias, commandName] of Object.entries(aliases)) {
@@ -1756,7 +1843,7 @@ function applyAliases(program2, aliases) {
1756
1843
  }
1757
1844
 
1758
1845
  // src/utils/config-strategy.ts
1759
- import chalk4 from "chalk";
1846
+ import chalk5 from "chalk";
1760
1847
  import Enquirer3 from "enquirer";
1761
1848
  function isValidConfigKey(key) {
1762
1849
  return key in DEFAULT_CONFIG;
@@ -1800,9 +1887,9 @@ async function promptConfigValue(key, currentValue) {
1800
1887
  }
1801
1888
  function formatConfigValue(value) {
1802
1889
  if (typeof value === "boolean") {
1803
- return value ? chalk4.green("true") : chalk4.yellow("false");
1890
+ return value ? chalk5.green("true") : chalk5.yellow("false");
1804
1891
  }
1805
- return chalk4.cyan(String(value));
1892
+ return chalk5.cyan(String(value));
1806
1893
  }
1807
1894
  async function promptBooleanValue(key, currentValue) {
1808
1895
  const choices = [
@@ -1846,7 +1933,7 @@ async function promptStringValue(key, currentValue) {
1846
1933
  }
1847
1934
 
1848
1935
  // src/commands/list.ts
1849
- import chalk5 from "chalk";
1936
+ import chalk6 from "chalk";
1850
1937
  function registerListCommand(program2) {
1851
1938
  program2.command("list").description("\u5217\u51FA\u5F53\u524D\u9879\u76EE\u6240\u6709 worktree").option("--json", "\u4EE5 JSON \u683C\u5F0F\u8F93\u51FA").action((options) => {
1852
1939
  handleList(options);
@@ -1883,12 +1970,12 @@ function printListAsText(projectName, worktrees) {
1883
1970
  for (const wt of worktrees) {
1884
1971
  const status = getWorktreeStatus(wt);
1885
1972
  const isIdle = status ? isWorktreeIdle(status) : false;
1886
- const pathDisplay = isIdle ? chalk5.hex("#FF8C00")(wt.path) : wt.path;
1973
+ const pathDisplay = isIdle ? chalk6.hex("#FF8C00")(wt.path) : wt.path;
1887
1974
  printInfo(` ${pathDisplay} [${wt.branch}]`);
1888
1975
  if (status) {
1889
1976
  printInfo(` ${formatWorktreeStatus(status)}`);
1890
1977
  } else {
1891
- printInfo(` ${chalk5.yellow(MESSAGES.WORKTREE_STATUS_UNAVAILABLE)}`);
1978
+ printInfo(` ${chalk6.yellow(MESSAGES.WORKTREE_STATUS_UNAVAILABLE)}`);
1892
1979
  }
1893
1980
  printInfo("");
1894
1981
  }
@@ -1991,19 +2078,16 @@ async function handleRemove(options) {
1991
2078
 
1992
2079
  // src/commands/run.ts
1993
2080
  function registerRunCommand(program2) {
1994
- program2.command("run").description("\u6279\u91CF\u521B\u5EFA worktree \u5E76\u542F\u52A8 Claude Code \u6267\u884C\u4EFB\u52A1").option("-b, --branch <branchName>", "\u5206\u652F\u540D").option("--tasks <task...>", "\u4EFB\u52A1\u5217\u8868\uFF08\u53EF\u591A\u6B21\u6307\u5B9A\uFF09\uFF0C\u4E0D\u4F20\u5219\u5728 worktree \u4E2D\u6253\u5F00 Claude Code \u4EA4\u4E92\u5F0F\u754C\u9762").option("-c, --concurrency <n>", "\u6700\u5927\u5E76\u53D1\u6570\uFF0C0 \u8868\u793A\u4E0D\u9650\u5236").option("-f, --file <path>", "\u4ECE\u4EFB\u52A1\u6587\u4EF6\u8BFB\u53D6\u4EFB\u52A1\u5217\u8868\uFF08\u4E0E --tasks \u4E92\u65A5\uFF09").action(async (options) => {
2081
+ program2.command("run").description("\u6279\u91CF\u521B\u5EFA worktree \u5E76\u542F\u52A8 Claude Code \u6267\u884C\u4EFB\u52A1").option("-b, --branch <branchName>", "\u5206\u652F\u540D").option("--tasks <task...>", "\u4EFB\u52A1\u5217\u8868\uFF08\u53EF\u591A\u6B21\u6307\u5B9A\uFF09\uFF0C\u4E0D\u4F20\u5219\u5728 worktree \u4E2D\u6253\u5F00 Claude Code \u4EA4\u4E92\u5F0F\u754C\u9762").option("-c, --concurrency <n>", "\u6700\u5927\u5E76\u53D1\u6570\uFF0C0 \u8868\u793A\u4E0D\u9650\u5236").option("-f, --file <path>", "\u4ECE\u4EFB\u52A1\u6587\u4EF6\u8BFB\u53D6\u4EFB\u52A1\u5217\u8868\uFF08\u4E0E --tasks \u4E92\u65A5\uFF09").option("-d, --dry-run", "\u9884\u89C8\u6A21\u5F0F\uFF0C\u4EC5\u5C55\u793A\u4EFB\u52A1\u8BA1\u5212\u4E0D\u5B9E\u9645\u6267\u884C").action(async (options) => {
1995
2082
  await handleRun(options);
1996
2083
  });
1997
2084
  }
1998
- function parseConcurrency(optionValue, configValue) {
1999
- if (optionValue === void 0) {
2000
- return configValue;
2001
- }
2002
- const parsed = parseInt(optionValue, 10);
2003
- if (Number.isNaN(parsed) || parsed < 0) {
2004
- throw new ClawtError(MESSAGES.CONCURRENCY_INVALID);
2085
+ function resolveBranchNamesFromFile(options, entryCount, entries) {
2086
+ if (options.branch) {
2087
+ const sanitized = sanitizeBranchName(options.branch);
2088
+ return generateBranchNames(sanitized, entryCount);
2005
2089
  }
2006
- return parsed;
2090
+ return entries.map((e) => sanitizeBranchName(e.branch));
2007
2091
  }
2008
2092
  async function handleRunFromFile(options) {
2009
2093
  const branchRequired = !options.branch;
@@ -2021,12 +2105,39 @@ async function handleRunFromFile(options) {
2021
2105
  logger.info(`run \u547D\u4EE4\uFF08\u6587\u4EF6\u6A21\u5F0F\uFF09\u6267\u884C\uFF0C\u4EFB\u52A1\u6570: ${entries.length}\uFF0C\u5E76\u53D1\u6570: ${concurrency || "\u4E0D\u9650\u5236"}`);
2022
2106
  await executeBatchTasks(worktrees, tasks, concurrency);
2023
2107
  }
2108
+ function handleDryRunFromFile(options) {
2109
+ const branchRequired = !options.branch;
2110
+ const entries = loadTaskFile(options.file, { branchRequired });
2111
+ printSuccess(MESSAGES.TASK_FILE_LOADED(entries.length, options.file));
2112
+ const tasks = entries.map((e) => e.task);
2113
+ const branchNames = resolveBranchNamesFromFile(options, entries.length, entries);
2114
+ const concurrency = parseConcurrency(options.concurrency, getConfigValue("maxConcurrency"));
2115
+ printDryRunPreview(branchNames, tasks, concurrency);
2116
+ }
2024
2117
  async function handleRun(options) {
2025
2118
  validateMainWorktree();
2026
- validateClaudeCodeInstalled();
2027
2119
  if (options.file && options.tasks) {
2028
2120
  throw new ClawtError(MESSAGES.FILE_AND_TASKS_CONFLICT);
2029
2121
  }
2122
+ if (options.dryRun) {
2123
+ if (options.file) {
2124
+ return handleDryRunFromFile(options);
2125
+ }
2126
+ if (!options.branch) {
2127
+ throw new ClawtError(MESSAGES.BRANCH_OR_FILE_REQUIRED);
2128
+ }
2129
+ const sanitized = sanitizeBranchName(options.branch);
2130
+ if (!options.tasks || options.tasks.length === 0) {
2131
+ printDryRunPreview([sanitized], [], 0);
2132
+ return;
2133
+ }
2134
+ const tasks2 = parseTasksFromOptions(options.tasks);
2135
+ const branchNames = generateBranchNames(sanitized, tasks2.length);
2136
+ const concurrency2 = parseConcurrency(options.concurrency, getConfigValue("maxConcurrency"));
2137
+ printDryRunPreview(branchNames, tasks2, concurrency2);
2138
+ return;
2139
+ }
2140
+ validateClaudeCodeInstalled();
2030
2141
  if (options.file) {
2031
2142
  return handleRunFromFile(options);
2032
2143
  }
@@ -2044,10 +2155,7 @@ async function handleRun(options) {
2044
2155
  launchInteractiveClaude(worktree);
2045
2156
  return;
2046
2157
  }
2047
- const tasks = options.tasks.map((t) => t.trim()).filter(Boolean);
2048
- if (tasks.length === 0) {
2049
- throw new ClawtError("\u4EFB\u52A1\u5217\u8868\u4E0D\u80FD\u4E3A\u7A7A");
2050
- }
2158
+ const tasks = parseTasksFromOptions(options.tasks);
2051
2159
  const count = tasks.length;
2052
2160
  const concurrency = parseConcurrency(options.concurrency, getConfigValue("maxConcurrency"));
2053
2161
  logger.info(`run \u547D\u4EE4\u6267\u884C\uFF0C\u5206\u652F: ${options.branch}\uFF0C\u4EFB\u52A1\u6570: ${count}\uFF0C\u5E76\u53D1\u6570: ${concurrency || "\u4E0D\u9650\u5236"}`);
@@ -2413,7 +2521,7 @@ async function handleMerge(options) {
2413
2521
  }
2414
2522
 
2415
2523
  // src/commands/config.ts
2416
- import chalk6 from "chalk";
2524
+ import chalk7 from "chalk";
2417
2525
  import Enquirer5 from "enquirer";
2418
2526
  function registerConfigCommand(program2) {
2419
2527
  const configCmd = program2.command("config").description("\u4EA4\u4E92\u5F0F\u67E5\u770B\u548C\u4FEE\u6539\u5168\u5C40\u914D\u7F6E").action(async () => {
@@ -2474,7 +2582,7 @@ async function handleInteractiveConfigSet() {
2474
2582
  const isObject = typeof DEFAULT_CONFIG[k] === "object";
2475
2583
  return {
2476
2584
  name: k,
2477
- message: `${k}: ${isObject ? chalk6.dim(JSON.stringify(config2[k])) : formatConfigValue(config2[k])} ${chalk6.dim(`\u2014 ${CONFIG_DESCRIPTIONS[k]}`)}`,
2585
+ message: `${k}: ${isObject ? chalk7.dim(JSON.stringify(config2[k])) : formatConfigValue(config2[k])} ${chalk7.dim(`\u2014 ${CONFIG_DESCRIPTIONS[k]}`)}`,
2478
2586
  ...isObject && { disabled: CONFIG_ALIAS_DISABLED_HINT }
2479
2587
  };
2480
2588
  });
@@ -2583,7 +2691,7 @@ async function handleReset() {
2583
2691
  }
2584
2692
 
2585
2693
  // src/commands/status.ts
2586
- import chalk7 from "chalk";
2694
+ import chalk8 from "chalk";
2587
2695
  function registerStatusCommand(program2) {
2588
2696
  program2.command("status").description("\u663E\u793A\u9879\u76EE\u5168\u5C40\u72B6\u6001\u603B\u89C8").option("--json", "\u4EE5 JSON \u683C\u5F0F\u8F93\u51FA").action((options) => {
2589
2697
  handleStatus(options);
@@ -2679,7 +2787,7 @@ function printStatusAsJson(result) {
2679
2787
  }
2680
2788
  function printStatusAsText(result) {
2681
2789
  printDoubleSeparator();
2682
- printInfo(` ${chalk7.bold.cyan(MESSAGES.STATUS_TITLE(result.main.projectName))}`);
2790
+ printInfo(` ${chalk8.bold.cyan(MESSAGES.STATUS_TITLE(result.main.projectName))}`);
2683
2791
  printDoubleSeparator();
2684
2792
  printInfo("");
2685
2793
  printMainSection(result.main);
@@ -2692,17 +2800,17 @@ function printStatusAsText(result) {
2692
2800
  printDoubleSeparator();
2693
2801
  }
2694
2802
  function printMainSection(main) {
2695
- printInfo(` ${chalk7.bold("\u25C6")} ${chalk7.bold(MESSAGES.STATUS_MAIN_SECTION)}`);
2696
- printInfo(` \u5206\u652F: ${chalk7.bold(main.branch)}`);
2803
+ printInfo(` ${chalk8.bold("\u25C6")} ${chalk8.bold(MESSAGES.STATUS_MAIN_SECTION)}`);
2804
+ printInfo(` \u5206\u652F: ${chalk8.bold(main.branch)}`);
2697
2805
  if (main.isClean) {
2698
- printInfo(` \u72B6\u6001: ${chalk7.green("\u2713 \u5E72\u51C0")}`);
2806
+ printInfo(` \u72B6\u6001: ${chalk8.green("\u2713 \u5E72\u51C0")}`);
2699
2807
  } else {
2700
- printInfo(` \u72B6\u6001: ${chalk7.yellow("\u2717 \u6709\u672A\u63D0\u4EA4\u4FEE\u6539")}`);
2808
+ printInfo(` \u72B6\u6001: ${chalk8.yellow("\u2717 \u6709\u672A\u63D0\u4EA4\u4FEE\u6539")}`);
2701
2809
  }
2702
2810
  printInfo("");
2703
2811
  }
2704
2812
  function printWorktreesSection(worktrees, total) {
2705
- printInfo(` ${chalk7.bold("\u25C6")} ${chalk7.bold(MESSAGES.STATUS_WORKTREES_SECTION)} (${total} \u4E2A)`);
2813
+ printInfo(` ${chalk8.bold("\u25C6")} ${chalk8.bold(MESSAGES.STATUS_WORKTREES_SECTION)} (${total} \u4E2A)`);
2706
2814
  printInfo("");
2707
2815
  if (worktrees.length === 0) {
2708
2816
  printInfo(` ${MESSAGES.STATUS_NO_WORKTREES}`);
@@ -2714,39 +2822,39 @@ function printWorktreesSection(worktrees, total) {
2714
2822
  }
2715
2823
  function printWorktreeItem(wt) {
2716
2824
  const statusLabel = formatChangeStatusLabel(wt.changeStatus);
2717
- printInfo(` ${chalk7.bold("\u25CF")} ${chalk7.bold(wt.branch)} [${statusLabel}]`);
2825
+ printInfo(` ${chalk8.bold("\u25CF")} ${chalk8.bold(wt.branch)} [${statusLabel}]`);
2718
2826
  const parts = [];
2719
2827
  if (wt.insertions > 0 || wt.deletions > 0) {
2720
- parts.push(`${chalk7.green(`+${wt.insertions}`)} ${chalk7.red(`-${wt.deletions}`)}`);
2828
+ parts.push(`${chalk8.green(`+${wt.insertions}`)} ${chalk8.red(`-${wt.deletions}`)}`);
2721
2829
  }
2722
2830
  if (wt.commitsAhead > 0) {
2723
- parts.push(chalk7.yellow(`${wt.commitsAhead} \u4E2A\u672C\u5730\u63D0\u4EA4`));
2831
+ parts.push(chalk8.yellow(`${wt.commitsAhead} \u4E2A\u672C\u5730\u63D0\u4EA4`));
2724
2832
  }
2725
2833
  if (wt.commitsBehind > 0) {
2726
- parts.push(chalk7.yellow(`\u843D\u540E\u4E3B\u5206\u652F ${wt.commitsBehind} \u4E2A\u63D0\u4EA4`));
2834
+ parts.push(chalk8.yellow(`\u843D\u540E\u4E3B\u5206\u652F ${wt.commitsBehind} \u4E2A\u63D0\u4EA4`));
2727
2835
  } else {
2728
- parts.push(chalk7.green("\u4E0E\u4E3B\u5206\u652F\u540C\u6B65"));
2836
+ parts.push(chalk8.green("\u4E0E\u4E3B\u5206\u652F\u540C\u6B65"));
2729
2837
  }
2730
2838
  printInfo(` ${parts.join(" ")}`);
2731
2839
  if (wt.hasSnapshot) {
2732
- printInfo(` ${chalk7.blue("\u6709 validate \u5FEB\u7167")}`);
2840
+ printInfo(` ${chalk8.blue("\u6709 validate \u5FEB\u7167")}`);
2733
2841
  }
2734
2842
  printInfo("");
2735
2843
  }
2736
2844
  function formatChangeStatusLabel(status) {
2737
2845
  switch (status) {
2738
2846
  case "committed":
2739
- return chalk7.green(MESSAGES.STATUS_CHANGE_COMMITTED);
2847
+ return chalk8.green(MESSAGES.STATUS_CHANGE_COMMITTED);
2740
2848
  case "uncommitted":
2741
- return chalk7.yellow(MESSAGES.STATUS_CHANGE_UNCOMMITTED);
2849
+ return chalk8.yellow(MESSAGES.STATUS_CHANGE_UNCOMMITTED);
2742
2850
  case "conflict":
2743
- return chalk7.red(MESSAGES.STATUS_CHANGE_CONFLICT);
2851
+ return chalk8.red(MESSAGES.STATUS_CHANGE_CONFLICT);
2744
2852
  case "clean":
2745
- return chalk7.gray(MESSAGES.STATUS_CHANGE_CLEAN);
2853
+ return chalk8.gray(MESSAGES.STATUS_CHANGE_CLEAN);
2746
2854
  }
2747
2855
  }
2748
2856
  function printSnapshotsSection(snapshots) {
2749
- printInfo(` ${chalk7.bold("\u25C6")} ${chalk7.bold(MESSAGES.STATUS_SNAPSHOTS_SECTION)} (${snapshots.length} \u4E2A)`);
2857
+ printInfo(` ${chalk8.bold("\u25C6")} ${chalk8.bold(MESSAGES.STATUS_SNAPSHOTS_SECTION)} (${snapshots.length} \u4E2A)`);
2750
2858
  printInfo("");
2751
2859
  if (snapshots.length === 0) {
2752
2860
  printInfo(` ${MESSAGES.STATUS_NO_SNAPSHOTS}`);
@@ -2754,15 +2862,15 @@ function printSnapshotsSection(snapshots) {
2754
2862
  return;
2755
2863
  }
2756
2864
  for (const snap of snapshots) {
2757
- const orphanLabel = snap.worktreeExists ? "" : ` ${chalk7.yellow(MESSAGES.STATUS_SNAPSHOT_ORPHANED)}`;
2758
- const icon = snap.worktreeExists ? chalk7.blue("\u25CF") : chalk7.yellow("\u26A0");
2865
+ const orphanLabel = snap.worktreeExists ? "" : ` ${chalk8.yellow(MESSAGES.STATUS_SNAPSHOT_ORPHANED)}`;
2866
+ const icon = snap.worktreeExists ? chalk8.blue("\u25CF") : chalk8.yellow("\u26A0");
2759
2867
  printInfo(` ${icon} ${snap.branch}${orphanLabel}`);
2760
2868
  }
2761
2869
  printInfo("");
2762
2870
  }
2763
2871
 
2764
2872
  // src/commands/alias.ts
2765
- import chalk8 from "chalk";
2873
+ import chalk9 from "chalk";
2766
2874
  function getRegisteredCommandNames(program2) {
2767
2875
  return program2.commands.map((cmd) => cmd.name());
2768
2876
  }
@@ -2783,7 +2891,7 @@ ${MESSAGES.ALIAS_LIST_TITLE}
2783
2891
  `);
2784
2892
  printSeparator();
2785
2893
  for (const [alias, command] of entries) {
2786
- printInfo(` ${chalk8.bold(alias)} \u2192 ${chalk8.cyan(command)}`);
2894
+ printInfo(` ${chalk9.bold(alias)} \u2192 ${chalk9.cyan(command)}`);
2787
2895
  }
2788
2896
  printInfo("");
2789
2897
  printSeparator();
@@ -90,7 +90,23 @@ var RUN_MESSAGES = {
90
90
  /** 任务文件加载成功 */
91
91
  TASK_FILE_LOADED: (count, path) => `\u2713 \u4ECE ${path} \u52A0\u8F7D\u4E86 ${count} \u4E2A\u4EFB\u52A1`,
92
92
  /** 未指定 -b 或 -f */
93
- BRANCH_OR_FILE_REQUIRED: "\u8BF7\u6307\u5B9A -b \u5206\u652F\u540D\u6216 -f \u4EFB\u52A1\u6587\u4EF6"
93
+ BRANCH_OR_FILE_REQUIRED: "\u8BF7\u6307\u5B9A -b \u5206\u652F\u540D\u6216 -f \u4EFB\u52A1\u6587\u4EF6",
94
+ /** dry-run 预览标题 */
95
+ DRY_RUN_TITLE: "Dry Run \u9884\u89C8",
96
+ /** dry-run 任务数量 */
97
+ DRY_RUN_TASK_COUNT: (count) => `\u4EFB\u52A1\u6570: ${count}`,
98
+ /** dry-run 并发数 */
99
+ DRY_RUN_CONCURRENCY: (concurrency) => `\u5E76\u53D1\u6570: ${concurrency === 0 ? "\u4E0D\u9650\u5236" : concurrency}`,
100
+ /** dry-run worktree 目录 */
101
+ DRY_RUN_WORKTREE_DIR: (dir) => `Worktree: ${dir}`,
102
+ /** dry-run 分支已存在警告 */
103
+ DRY_RUN_BRANCH_EXISTS_WARNING: (name) => `\u5206\u652F ${name} \u5DF2\u5B58\u5728`,
104
+ /** dry-run 交互式模式提示(无任务描述) */
105
+ DRY_RUN_INTERACTIVE_MODE: "\u6A21\u5F0F: \u4EA4\u4E92\u5F0F\uFF08\u65E0\u9884\u8BBE\u4EFB\u52A1\uFF09",
106
+ /** dry-run 预览完成且无冲突 */
107
+ DRY_RUN_READY: "\u9884\u89C8\u5B8C\u6210\uFF0C\u65E0\u51B2\u7A81\u3002\u79FB\u9664 --dry-run \u5373\u53EF\u6B63\u5F0F\u6267\u884C\u3002",
108
+ /** dry-run 存在分支冲突 */
109
+ DRY_RUN_HAS_CONFLICT: "\u5B58\u5728\u5206\u652F\u51B2\u7A81\uFF0C\u5B9E\u9645\u6267\u884C\u65F6\u5C06\u4F1A\u62A5\u9519\u3002\u8BF7\u5148\u5904\u7406\u51B2\u7A81\u7684\u5206\u652F\u3002"
94
110
  };
95
111
 
96
112
  // src/constants/messages/create.ts
package/docs/spec.md CHANGED
@@ -278,6 +278,7 @@ clawt run -b <branchName>
278
278
  | `--tasks` | 否 | 任务描述(可多次指定,每个 --tasks 对应一个任务,任务数量即 worktree 数量)。不传则在 worktree 中打开 Claude Code 交互式界面 |
279
279
  | `-f` | 否 | 从任务文件读取任务列表(与 `--tasks` 互斥) |
280
280
  | `-c` | 否 | 最大并发数,`0` 表示不限制 |
281
+ | `--dry-run` | 否 | 试运行模式,仅输出预览信息不实际执行 |
281
282
 
282
283
  **互斥约束:**
283
284
 
@@ -351,6 +352,51 @@ clawt run -b <branchName>
351
352
 
352
353
  **注意:** 当 `n = 1` 时(只有一个任务),worktree 目录命名规则同 **5.1**(不加 `-1` 后缀)。
353
354
 
355
+ #### `--dry-run` 预览模式
356
+
357
+ 传入 `--dry-run` 时不实际创建 worktree 和执行任务,仅输出预览信息供用户确认。预览由 `printDryRunPreview()`(`src/utils/dry-run.ts`)负责渲染。
358
+
359
+ **输出格式:**
360
+
361
+ ```
362
+ ════════════════════════════════════════
363
+ Dry Run 预览
364
+ ════════════════════════════════════════
365
+ 任务数: 3 │ 并发数: 不限制 │ Worktree: ~/.clawt/worktrees/project
366
+ ────────────────────────────────────────
367
+ ✓ [1/3] feat-login
368
+ 路径: ~/.clawt/worktrees/project/feat-login
369
+ 任务: 实现登录功能
370
+
371
+ ⚠ [2/3] feat-signup — 分支 feat-signup 已存在
372
+ 路径: ~/.clawt/worktrees/project/feat-signup
373
+ 任务: 实现注册功能
374
+
375
+ ✓ [3/3] fix-bug
376
+ 路径: ~/.clawt/worktrees/project/fix-bug
377
+ 任务: 修复内存泄漏
378
+
379
+ ════════════════════════════════════════
380
+ ✓ 预览完成,无冲突。移除 --dry-run 即可正式执行。
381
+ ```
382
+
383
+ **格式规则:**
384
+
385
+ 1. **标题区**:双线分隔符包裹标题 `Dry Run 预览`
386
+ 2. **摘要行**:任务数、并发数、Worktree 目录路径合并为一行,用灰色 `│` 分隔;交互式模式(无 `--tasks`)会额外追加模式信息
387
+ 3. **分支列表**:
388
+ - 正常分支:行首绿色 `✓` + 序号 + 青色分支名
389
+ - 冲突分支:行首黄色 `⚠` + 序号 + 黄色分支名 + 灰色 `—` + 黄色警告文本(如 `分支 xxx 已存在`),警告合并在序号行
390
+ 4. **路径/任务行**:2 空格缩进,灰色标签前缀(`路径:` / `任务:`)
391
+ 5. **任务描述截断**:超过 70 字符时末尾加 `...`,多行合并为单行
392
+ 6. **结尾**:双线分隔符后根据冲突情况输出结论——无冲突时绿色 `✓` 提示,有冲突时黄色 `⚠` 警告
393
+
394
+ **实现要点:**
395
+
396
+ - 常量定义在 `src/constants/messages/run.ts`(`DRY_RUN_*` 系列)
397
+ - `DRY_RUN_WORKTREE_DIR` 前缀为 `Worktree:`(简短形式)
398
+ - `truncateTaskDesc()` 负责截断任务描述(最大长度 70 字符)
399
+
354
400
  ---
355
401
 
356
402
  ### 5.3 任务完成通知机制
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawt",
3
- "version": "2.14.1",
3
+ "version": "2.15.0",
4
4
  "description": "本地并行执行多个Claude Code Agent任务,融合 Git Worktree 与 Claude Code CLI 的命令行工具",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -9,12 +9,16 @@ import {
9
9
  createWorktrees,
10
10
  createWorktreesByBranches,
11
11
  sanitizeBranchName,
12
+ generateBranchNames,
12
13
  checkBranchExists,
13
14
  getConfigValue,
15
+ parseConcurrency,
14
16
  printSuccess,
15
17
  launchInteractiveClaude,
16
18
  loadTaskFile,
19
+ parseTasksFromOptions,
17
20
  executeBatchTasks,
21
+ printDryRunPreview,
18
22
  } from '../utils/index.js';
19
23
 
20
24
  /**
@@ -29,28 +33,32 @@ export function registerRunCommand(program: Command): void {
29
33
  .option('--tasks <task...>', '任务列表(可多次指定),不传则在 worktree 中打开 Claude Code 交互式界面')
30
34
  .option('-c, --concurrency <n>', '最大并发数,0 表示不限制')
31
35
  .option('-f, --file <path>', '从任务文件读取任务列表(与 --tasks 互斥)')
36
+ .option('-d, --dry-run', '预览模式,仅展示任务计划不实际执行')
32
37
  .action(async (options: RunOptions) => {
33
38
  await handleRun(options);
34
39
  });
35
40
  }
36
41
 
37
42
  /**
38
- * 解析并发数参数
39
- * 优先级:命令行参数 > 全局配置 > 默认值 0
40
- * @param {string | undefined} optionValue - 命令行传入的并发数字符串
41
- * @param {number} configValue - 全局配置中的默认并发数
42
- * @returns {number} 解析后的并发数,0 表示不限制
43
+ * 从任务文件解析出分支名列表
44
+ * -b 参数时使用自动编号,否则使用文件中每个任务块的独立分支名
45
+ * @param {RunOptions} options - 命令选项
46
+ * @param {number} entryCount - 任务条目数量
47
+ * @param {Array<{branch?: string}>} entries - 解析出的任务条目(含可选分支名)
48
+ * @returns {string[]} 分支名列表
43
49
  */
44
- function parseConcurrency(optionValue: string | undefined, configValue: number): number {
45
- if (optionValue === undefined) {
46
- return configValue;
47
- }
48
-
49
- const parsed = parseInt(optionValue, 10);
50
- if (Number.isNaN(parsed) || parsed < 0) {
51
- throw new ClawtError(MESSAGES.CONCURRENCY_INVALID);
50
+ function resolveBranchNamesFromFile(
51
+ options: RunOptions,
52
+ entryCount: number,
53
+ entries: Array<{ branch?: string }>,
54
+ ): string[] {
55
+ if (options.branch) {
56
+ // -b 参数:忽略文件中的分支名,用 -b 自动编号
57
+ const sanitized = sanitizeBranchName(options.branch);
58
+ return generateBranchNames(sanitized, entryCount);
52
59
  }
53
- return parsed;
60
+ // 无 -b 参数:使用文件中每个任务的独立分支名
61
+ return entries.map((e) => sanitizeBranchName(e.branch!));
54
62
  }
55
63
 
56
64
  /**
@@ -85,6 +93,22 @@ async function handleRunFromFile(options: RunOptions): Promise<void> {
85
93
  await executeBatchTasks(worktrees, tasks, concurrency);
86
94
  }
87
95
 
96
+ /**
97
+ * 处理 dry-run 模式下从任务文件读取的逻辑
98
+ * @param {RunOptions} options - 命令选项(包含 file 字段)
99
+ */
100
+ function handleDryRunFromFile(options: RunOptions): void {
101
+ const branchRequired = !options.branch;
102
+ const entries = loadTaskFile(options.file!, { branchRequired });
103
+ printSuccess(MESSAGES.TASK_FILE_LOADED(entries.length, options.file!));
104
+
105
+ const tasks = entries.map((e) => e.task);
106
+ const branchNames = resolveBranchNamesFromFile(options, entries.length, entries);
107
+ const concurrency = parseConcurrency(options.concurrency, getConfigValue('maxConcurrency'));
108
+
109
+ printDryRunPreview(branchNames, tasks, concurrency);
110
+ }
111
+
88
112
  /**
89
113
  * 执行 run 命令的核心逻辑
90
114
  * 支持三种模式:
@@ -95,13 +119,41 @@ async function handleRunFromFile(options: RunOptions): Promise<void> {
95
119
  */
96
120
  async function handleRun(options: RunOptions): Promise<void> {
97
121
  validateMainWorktree();
98
- validateClaudeCodeInstalled();
99
122
 
100
123
  // 互斥校验:--file 和 --tasks 不能同时使用
101
124
  if (options.file && options.tasks) {
102
125
  throw new ClawtError(MESSAGES.FILE_AND_TASKS_CONFLICT);
103
126
  }
104
127
 
128
+ // dry-run 模式:仅解析和展示任务计划,不实际创建 worktree 或启动 Claude Code
129
+ if (options.dryRun) {
130
+ // dry-run 不需要校验 Claude Code 是否安装
131
+ if (options.file) {
132
+ return handleDryRunFromFile(options);
133
+ }
134
+
135
+ if (!options.branch) {
136
+ throw new ClawtError(MESSAGES.BRANCH_OR_FILE_REQUIRED);
137
+ }
138
+
139
+ const sanitized = sanitizeBranchName(options.branch);
140
+
141
+ if (!options.tasks || options.tasks.length === 0) {
142
+ // 交互式模式 dry-run:展示单个 worktree 信息
143
+ printDryRunPreview([sanitized], [], 0);
144
+ return;
145
+ }
146
+
147
+ const tasks = parseTasksFromOptions(options.tasks);
148
+ const branchNames = generateBranchNames(sanitized, tasks.length);
149
+ const concurrency = parseConcurrency(options.concurrency, getConfigValue('maxConcurrency'));
150
+ printDryRunPreview(branchNames, tasks, concurrency);
151
+ return;
152
+ }
153
+
154
+ // 正常执行模式需要校验 Claude Code
155
+ validateClaudeCodeInstalled();
156
+
105
157
  // --file 模式
106
158
  if (options.file) {
107
159
  return handleRunFromFile(options);
@@ -128,12 +180,7 @@ async function handleRun(options: RunOptions): Promise<void> {
128
180
  return;
129
181
  }
130
182
 
131
- const tasks = options.tasks.map((t) => t.trim()).filter(Boolean);
132
-
133
- if (tasks.length === 0) {
134
- throw new ClawtError('任务列表不能为空');
135
- }
136
-
183
+ const tasks = parseTasksFromOptions(options.tasks);
137
184
  const count = tasks.length;
138
185
 
139
186
  // 解析并发数:命令行参数 > 全局配置 > 默认值 0
@@ -43,4 +43,20 @@ export const RUN_MESSAGES = {
43
43
  TASK_FILE_LOADED: (count: number, path: string) => `✓ 从 ${path} 加载了 ${count} 个任务`,
44
44
  /** 未指定 -b 或 -f */
45
45
  BRANCH_OR_FILE_REQUIRED: '请指定 -b 分支名或 -f 任务文件',
46
+ /** dry-run 预览标题 */
47
+ DRY_RUN_TITLE: 'Dry Run 预览',
48
+ /** dry-run 任务数量 */
49
+ DRY_RUN_TASK_COUNT: (count: number) => `任务数: ${count}`,
50
+ /** dry-run 并发数 */
51
+ DRY_RUN_CONCURRENCY: (concurrency: number) => `并发数: ${concurrency === 0 ? '不限制' : concurrency}`,
52
+ /** dry-run worktree 目录 */
53
+ DRY_RUN_WORKTREE_DIR: (dir: string) => `Worktree: ${dir}`,
54
+ /** dry-run 分支已存在警告 */
55
+ DRY_RUN_BRANCH_EXISTS_WARNING: (name: string) => `分支 ${name} 已存在`,
56
+ /** dry-run 交互式模式提示(无任务描述) */
57
+ DRY_RUN_INTERACTIVE_MODE: '模式: 交互式(无预设任务)',
58
+ /** dry-run 预览完成且无冲突 */
59
+ DRY_RUN_READY: '预览完成,无冲突。移除 --dry-run 即可正式执行。',
60
+ /** dry-run 存在分支冲突 */
61
+ DRY_RUN_HAS_CONFLICT: '存在分支冲突,实际执行时将会报错。请先处理冲突的分支。',
46
62
  } as const;