hamster-wheel-cli 0.1.0 → 0.2.0-beta.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/src/cli.ts CHANGED
@@ -1,15 +1,21 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import fs from 'fs-extra';
2
3
  import { Command } from 'commander';
3
4
  import { buildLoopConfig, CliOptions, defaultNotesPath, defaultPlanPath, defaultWorkflowDoc } from './config';
4
- import { applyShortcutArgv, loadGlobalConfig } from './global-config';
5
- import { generateBranchName, getCurrentBranch } from './git';
6
- import { buildAutoLogFilePath } from './logs';
5
+ import { applyShortcutArgv, loadGlobalConfig, normalizeAliasName, upsertAliasEntry } from './global-config';
6
+ import { getCurrentBranch } from './git';
7
+ import { buildAutoLogFilePath, formatCommandLine } from './logs';
8
+ import { runAliasViewer } from './alias-viewer';
7
9
  import { runLogsViewer } from './logs-viewer';
8
10
  import { runLoop } from './loop';
9
11
  import { defaultLogger } from './logger';
10
- import { runMonitor } from './monitor';
12
+ import { buildTaskPlans, normalizeTaskList, parseMultiTaskMode } from './multi-task';
13
+ import { resolveTerminationTarget, runMonitor } from './monitor';
14
+ import { tailLogFile } from './log-tailer';
11
15
  import { resolvePath } from './utils';
12
16
 
17
+ const FOREGROUND_CHILD_ENV = 'WHEEL_AI_FOREGROUND_CHILD';
18
+
13
19
  function parseInteger(value: string, defaultValue: number): number {
14
20
  const parsed = Number.parseInt(value, 10);
15
21
  if (Number.isNaN(parsed)) return defaultValue;
@@ -42,6 +48,108 @@ function buildBackgroundArgs(argv: string[], logFile: string, branchName?: strin
42
48
  return filtered;
43
49
  }
44
50
 
51
+ function extractAliasCommandArgs(argv: string[], name: string): string[] {
52
+ const args = argv.slice(2);
53
+ const start = args.findIndex((arg, index) => arg === 'set' && args[index + 1] === 'alias' && args[index + 2] === name);
54
+ if (start < 0) return [];
55
+ const rest = args.slice(start + 3);
56
+ if (rest[0] === '--') return rest.slice(1);
57
+ return rest;
58
+ }
59
+
60
+ async function runForegroundWithDetach(options: {
61
+ argv: string[];
62
+ logFile: string;
63
+ branchName?: string;
64
+ injectBranch: boolean;
65
+ isMultiTask: boolean;
66
+ }): Promise<void> {
67
+ const args = buildBackgroundArgs(options.argv, options.logFile, options.branchName, options.injectBranch);
68
+ const child = spawn(process.execPath, [...process.execArgv, ...args], {
69
+ detached: true,
70
+ stdio: 'ignore',
71
+ env: {
72
+ ...process.env,
73
+ [FOREGROUND_CHILD_ENV]: '1'
74
+ }
75
+ });
76
+ child.unref();
77
+
78
+ const resolvedLogFile = resolvePath(process.cwd(), options.logFile);
79
+ const existed = await fs.pathExists(resolvedLogFile);
80
+ const tailer = await tailLogFile({
81
+ filePath: resolvedLogFile,
82
+ startFromEnd: existed,
83
+ onLine: line => {
84
+ process.stdout.write(`${line}\n`);
85
+ },
86
+ onError: message => {
87
+ defaultLogger.warn(`日志读取失败:${message}`);
88
+ }
89
+ });
90
+
91
+ const suffixNote = options.isMultiTask ? '(多任务将追加序号)' : '';
92
+ console.log(`已进入前台日志查看,按 Esc 切到后台运行,日志输出至 ${resolvedLogFile}${suffixNote}`);
93
+
94
+ let cleaned = false;
95
+ const cleanup = async (): Promise<void> => {
96
+ if (cleaned) return;
97
+ cleaned = true;
98
+ await tailer.stop();
99
+ if (process.stdin.isTTY) {
100
+ process.stdin.setRawMode(false);
101
+ process.stdin.pause();
102
+ }
103
+ };
104
+
105
+ const detach = async (): Promise<void> => {
106
+ await cleanup();
107
+ console.log(`已切入后台运行,日志输出至 ${resolvedLogFile}${suffixNote}`);
108
+ process.exit(0);
109
+ };
110
+
111
+ const terminate = async (): Promise<void> => {
112
+ if (child.pid) {
113
+ try {
114
+ const target = resolveTerminationTarget(child.pid);
115
+ process.kill(target, 'SIGTERM');
116
+ } catch (error) {
117
+ const message = error instanceof Error ? error.message : String(error);
118
+ defaultLogger.warn(`终止子进程失败:${message}`);
119
+ }
120
+ }
121
+ await cleanup();
122
+ process.exit(0);
123
+ };
124
+
125
+ if (process.stdin.isTTY) {
126
+ process.stdin.setRawMode(true);
127
+ process.stdin.resume();
128
+ process.stdin.on('data', (data: Buffer) => {
129
+ const input = data.toString('utf8');
130
+ if (input === '\u001b') {
131
+ void detach();
132
+ return;
133
+ }
134
+ if (input === '\u0003') {
135
+ void terminate();
136
+ }
137
+ });
138
+ }
139
+
140
+ process.on('SIGINT', () => {
141
+ void terminate();
142
+ });
143
+ process.on('SIGTERM', () => {
144
+ void terminate();
145
+ });
146
+
147
+ child.on('exit', async code => {
148
+ await cleanup();
149
+ process.exit(code ?? 0);
150
+ });
151
+ }
152
+
45
153
  /**
46
154
  * CLI 入口。
47
155
  */
@@ -57,7 +165,7 @@ export async function runCli(argv: string[]): Promise<void> {
57
165
 
58
166
  program
59
167
  .command('run')
60
- .requiredOption('-t, --task <task>', '需要完成的任务描述(会进入 AI 提示)')
168
+ .option('-t, --task <task>', '需要完成的任务描述(可重复传入,独立处理)', collect, [])
61
169
  .option('-i, --iterations <number>', '最大迭代次数', value => parseInteger(value, 5), 5)
62
170
  .option('--ai-cli <command>', 'AI CLI 命令', 'claude')
63
171
  .option('--ai-args <args...>', 'AI CLI 参数', [])
@@ -81,33 +189,53 @@ export async function runCli(argv: string[]): Promise<void> {
81
189
  .option('--pr-body <path>', 'PR 描述文件路径(可留空自动生成)')
82
190
  .option('--draft', '以草稿形式创建 PR', false)
83
191
  .option('--reviewer <user...>', 'PR reviewers', collect, [])
192
+ .option('--auto-merge', 'PR 检查通过后自动合并', false)
84
193
  .option('--webhook <url>', 'webhook 通知 URL(可重复)', collect, [])
85
194
  .option('--webhook-timeout <ms>', 'webhook 请求超时(毫秒)', value => parseInteger(value, 8000))
195
+ .option('--multi-task-mode <mode>', '多任务执行模式(relay/serial/serial-continue/parallel,或中文描述)', 'relay')
86
196
  .option('--stop-signal <token>', 'AI 输出中的停止标记', '<<DONE>>')
87
197
  .option('--log-file <path>', '日志输出文件路径')
88
198
  .option('--background', '切入后台运行', false)
89
199
  .option('-v, --verbose', '输出调试日志', false)
200
+ .option('--skip-quality', '跳过代码质量检查', false)
90
201
  .action(async (options) => {
202
+ const tasks = normalizeTaskList(options.task as string[] | string | undefined);
203
+ if (tasks.length === 0) {
204
+ throw new Error('需要至少提供一个任务描述');
205
+ }
206
+
207
+ const multiTaskMode = parseMultiTaskMode(options.multiTaskMode as string | undefined);
91
208
  const useWorktree = Boolean(options.worktree);
209
+ if (multiTaskMode === 'parallel' && !useWorktree) {
210
+ throw new Error('并行模式必须启用 --worktree');
211
+ }
212
+
92
213
  const branchInput = normalizeOptional(options.branch);
93
214
  const logFileInput = normalizeOptional(options.logFile);
215
+ const worktreePathInput = normalizeOptional(options.worktreePath);
94
216
  const background = Boolean(options.background);
217
+ const isMultiTask = tasks.length > 1;
218
+ const isForegroundChild = process.env[FOREGROUND_CHILD_ENV] === '1';
219
+ const canForegroundDetach = !background && !isForegroundChild && process.stdout.isTTY && process.stdin.isTTY;
95
220
 
96
- let branchName = branchInput;
97
- if (useWorktree && !branchName) {
98
- branchName = generateBranchName();
99
- }
221
+ const shouldInjectBranch = Boolean(useWorktree && branchInput && !isMultiTask);
222
+ const branchNameForBackground = branchInput;
100
223
 
101
224
  let logFile = logFileInput;
102
- if (background && !logFile) {
103
- let branchForLog = branchName;
104
- if (!branchForLog) {
105
- try {
106
- const current = await getCurrentBranch(process.cwd(), defaultLogger);
107
- branchForLog = current || 'detached';
108
- } catch {
109
- branchForLog = 'unknown';
225
+ if ((background || canForegroundDetach) && !logFile) {
226
+ let branchForLog = 'multi-task';
227
+ if (!isMultiTask) {
228
+ branchForLog = branchNameForBackground ?? '';
229
+ if (!branchForLog) {
230
+ try {
231
+ const current = await getCurrentBranch(process.cwd(), defaultLogger);
232
+ branchForLog = current || 'detached';
233
+ } catch {
234
+ branchForLog = 'unknown';
235
+ }
110
236
  }
237
+ } else if (branchInput) {
238
+ branchForLog = `${branchInput}-multi`;
111
239
  }
112
240
  logFile = buildAutoLogFilePath(branchForLog);
113
241
  }
@@ -116,19 +244,43 @@ export async function runCli(argv: string[]): Promise<void> {
116
244
  if (!logFile) {
117
245
  throw new Error('后台运行需要指定日志文件');
118
246
  }
119
- const args = buildBackgroundArgs(effectiveArgv, logFile, branchName, useWorktree && !branchInput);
247
+ const args = buildBackgroundArgs(effectiveArgv, logFile, branchNameForBackground, shouldInjectBranch);
120
248
  const child = spawn(process.execPath, [...process.execArgv, ...args], {
121
249
  detached: true,
122
250
  stdio: 'ignore'
123
251
  });
124
252
  child.unref();
125
253
  const displayLogFile = resolvePath(process.cwd(), logFile);
126
- console.log(`已切入后台运行,日志输出至 ${displayLogFile}`);
254
+ const suffixNote = isMultiTask ? '(多任务将追加序号)' : '';
255
+ console.log(`已切入后台运行,日志输出至 ${displayLogFile}${suffixNote}`);
256
+ return;
257
+ }
258
+
259
+ if (canForegroundDetach) {
260
+ if (!logFile) {
261
+ throw new Error('切入后台需要指定日志文件');
262
+ }
263
+ await runForegroundWithDetach({
264
+ argv: effectiveArgv,
265
+ logFile,
266
+ branchName: branchNameForBackground,
267
+ injectBranch: shouldInjectBranch,
268
+ isMultiTask
269
+ });
127
270
  return;
128
271
  }
129
272
 
130
- const cliOptions: CliOptions = {
131
- task: options.task as string,
273
+ const taskPlans = buildTaskPlans({
274
+ tasks,
275
+ mode: multiTaskMode,
276
+ useWorktree,
277
+ baseBranch: options.baseBranch as string,
278
+ branchInput,
279
+ worktreePath: worktreePathInput,
280
+ logFile: logFileInput
281
+ });
282
+
283
+ const baseOptions = {
132
284
  iterations: options.iterations as number,
133
285
  aiCli: options.aiCli as string,
134
286
  aiArgs: (options.aiArgs as string[]) ?? [],
@@ -137,9 +289,6 @@ export async function runCli(argv: string[]): Promise<void> {
137
289
  planFile: options.planFile as string,
138
290
  workflowDoc: options.workflowDoc as string,
139
291
  useWorktree,
140
- branch: branchName,
141
- worktreePath: options.worktreePath as string | undefined,
142
- baseBranch: options.baseBranch as string,
143
292
  runTests: Boolean(options.runTests),
144
293
  runE2e: Boolean(options.runE2e),
145
294
  unitCommand: options.unitCommand as string | undefined,
@@ -151,21 +300,78 @@ export async function runCli(argv: string[]): Promise<void> {
151
300
  prBody: options.prBody as string | undefined,
152
301
  draft: Boolean(options.draft),
153
302
  reviewers: (options.reviewer as string[]) ?? [],
303
+ autoMerge: Boolean(options.autoMerge),
154
304
  webhookUrls: (options.webhook as string[]) ?? [],
155
305
  webhookTimeout: options.webhookTimeout as number | undefined,
156
306
  stopSignal: options.stopSignal as string,
157
- logFile,
158
307
  verbose: Boolean(options.verbose),
159
- skipInstall: Boolean(options.skipInstall)
308
+ skipInstall: Boolean(options.skipInstall),
309
+ skipQuality: Boolean(options.skipQuality)
160
310
  };
161
311
 
162
- const config = buildLoopConfig(cliOptions, process.cwd());
163
- await runLoop(config);
312
+ const dynamicRelay = useWorktree && multiTaskMode === 'relay' && !branchInput;
313
+ let relayBaseBranch = options.baseBranch as string;
314
+
315
+ const runPlan = async (plan: typeof taskPlans[number], baseBranchOverride?: string): Promise<Awaited<ReturnType<typeof runLoop>>> => {
316
+ const cliOptions: CliOptions = {
317
+ task: plan.task,
318
+ ...baseOptions,
319
+ branch: plan.branchName,
320
+ worktreePath: plan.worktreePath,
321
+ baseBranch: baseBranchOverride ?? plan.baseBranch,
322
+ logFile: plan.logFile
323
+ };
324
+ const config = buildLoopConfig(cliOptions, process.cwd());
325
+ return runLoop(config);
326
+ };
327
+
328
+ if (multiTaskMode === 'parallel') {
329
+ const results = await Promise.allSettled(taskPlans.map(plan => runPlan(plan)));
330
+ const errors = results.flatMap((result, index) => {
331
+ if (result.status === 'fulfilled') return [];
332
+ const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
333
+ return [`任务 ${index + 1} 失败: ${reason}`];
334
+ });
335
+ if (errors.length > 0) {
336
+ errors.forEach(message => defaultLogger.warn(message));
337
+ throw new Error(errors.join('\n'));
338
+ }
339
+ return;
340
+ }
341
+
342
+ if (multiTaskMode === 'serial-continue') {
343
+ const errors: string[] = [];
344
+ for (const plan of taskPlans) {
345
+ try {
346
+ const baseBranch = dynamicRelay ? relayBaseBranch : plan.baseBranch;
347
+ const result = await runPlan(plan, baseBranch);
348
+ if (dynamicRelay && result.branchName) {
349
+ relayBaseBranch = result.branchName;
350
+ }
351
+ } catch (error) {
352
+ const message = error instanceof Error ? error.message : String(error);
353
+ errors.push(`任务 ${plan.index + 1} 失败: ${message}`);
354
+ defaultLogger.warn(`任务 ${plan.index + 1} 执行失败,继续下一任务:${message}`);
355
+ }
356
+ }
357
+ if (errors.length > 0) {
358
+ throw new Error(errors.join('\n'));
359
+ }
360
+ return;
361
+ }
362
+
363
+ for (const plan of taskPlans) {
364
+ const baseBranch = dynamicRelay ? relayBaseBranch : plan.baseBranch;
365
+ const result = await runPlan(plan, baseBranch);
366
+ if (dynamicRelay && result.branchName) {
367
+ relayBaseBranch = result.branchName;
368
+ }
369
+ }
164
370
  });
165
371
 
166
372
  program
167
373
  .command('monitor')
168
- .description('查看后台运行日志')
374
+ .description('查看后台运行日志(t 终止任务)')
169
375
  .action(async () => {
170
376
  await runMonitor();
171
377
  });
@@ -177,6 +383,34 @@ export async function runCli(argv: string[]): Promise<void> {
177
383
  await runLogsViewer();
178
384
  });
179
385
 
386
+ program
387
+ .command('set')
388
+ .description('写入全局配置')
389
+ .command('alias <name> [options...]')
390
+ .description('设置 alias')
391
+ .allowUnknownOption(true)
392
+ .action(async (name: string) => {
393
+ const normalized = normalizeAliasName(name);
394
+ if (!normalized) {
395
+ throw new Error('alias 名称不能为空且不能包含空白字符');
396
+ }
397
+ const commandArgs = extractAliasCommandArgs(effectiveArgv, name);
398
+ const commandLine = formatCommandLine(commandArgs);
399
+ if (!commandLine) {
400
+ throw new Error('alias 命令不能为空');
401
+ }
402
+ await upsertAliasEntry(normalized, commandLine);
403
+ console.log(`已写入 alias:${normalized}`);
404
+ });
405
+
406
+ program
407
+ .command('alias')
408
+ .alias('aliases')
409
+ .description('浏览全局 alias 配置')
410
+ .action(async () => {
411
+ await runAliasViewer();
412
+ });
413
+
180
414
  await program.parseAsync(effectiveArgv);
181
415
  }
182
416
 
package/src/config.ts CHANGED
@@ -29,12 +29,14 @@ export interface CliOptions {
29
29
  readonly prBody?: string;
30
30
  readonly draft: boolean;
31
31
  readonly reviewers?: string[];
32
+ readonly autoMerge: boolean;
32
33
  readonly webhookUrls: string[];
33
34
  readonly webhookTimeout?: number;
34
35
  readonly stopSignal: string;
35
36
  readonly logFile?: string;
36
37
  readonly verbose: boolean;
37
38
  readonly skipInstall: boolean;
39
+ readonly skipQuality: boolean;
38
40
  }
39
41
 
40
42
  function buildAiConfig(options: CliOptions): AiCliConfig {
@@ -67,7 +69,8 @@ function buildPrConfig(options: CliOptions): PrConfig {
67
69
  title: options.prTitle,
68
70
  bodyPath: options.prBody,
69
71
  draft: options.draft,
70
- reviewers: options.reviewers
72
+ reviewers: options.reviewers,
73
+ autoMerge: options.autoMerge
71
74
  };
72
75
  }
73
76
 
@@ -108,7 +111,8 @@ export function buildLoopConfig(options: CliOptions, cwd: string): LoopConfig {
108
111
  runE2e: options.runE2e,
109
112
  autoCommit: options.autoCommit,
110
113
  autoPush: options.autoPush,
111
- skipInstall: options.skipInstall
114
+ skipInstall: options.skipInstall,
115
+ skipQuality: options.skipQuality
112
116
  };
113
117
  }
114
118
 
package/src/gh.ts CHANGED
@@ -226,3 +226,23 @@ export async function listFailedRuns(branch: string, cwd: string, logger: Logger
226
226
  return [];
227
227
  }
228
228
  }
229
+
230
+ /**
231
+ * 启用 PR 自动合并。
232
+ */
233
+ export async function enableAutoMerge(target: string | number, cwd: string, logger: Logger): Promise<boolean> {
234
+ const targetValue = String(target);
235
+ const args = ['pr', 'merge', targetValue, '--auto', '--merge'];
236
+ const result = await runCommand('gh', args, {
237
+ cwd,
238
+ logger,
239
+ verboseLabel: 'gh',
240
+ verboseCommand: `gh ${args.join(' ')}`
241
+ });
242
+ if (result.exitCode !== 0) {
243
+ logger.warn(`启用自动合并失败: ${result.stderr || result.stdout}`);
244
+ return false;
245
+ }
246
+ logger.success('已启用 PR 自动合并');
247
+ return true;
248
+ }
package/src/git.ts CHANGED
@@ -209,7 +209,7 @@ function buildCommitArgs(message: CommitMessage): string[] {
209
209
  /**
210
210
  * 提交当前变更。
211
211
  */
212
- export async function commitAll(message: CommitMessage, cwd: string, logger: Logger): Promise<void> {
212
+ export async function commitAll(message: CommitMessage, cwd: string, logger: Logger): Promise<boolean> {
213
213
  const add = await runCommand('git', ['add', '-A'], {
214
214
  cwd,
215
215
  logger,
@@ -227,9 +227,10 @@ export async function commitAll(message: CommitMessage, cwd: string, logger: Log
227
227
  });
228
228
  if (commit.exitCode !== 0) {
229
229
  logger.warn(`git commit 跳过或失败: ${commit.stderr}`);
230
- return;
230
+ return false;
231
231
  }
232
232
  logger.success('已提交当前变更');
233
+ return true;
233
234
  }
234
235
 
235
236
  /**
@@ -281,5 +282,39 @@ export async function removeWorktree(worktreePath: string, repoRoot: string, log
281
282
  export function generateBranchName(): string {
282
283
  const now = new Date();
283
284
  const stamp = `${now.getFullYear()}${(now.getMonth() + 1).toString().padStart(2, '0')}${now.getDate().toString().padStart(2, '0')}-${now.getHours().toString().padStart(2, '0')}${now.getMinutes().toString().padStart(2, '0')}`;
284
- return `wheel-aii/${stamp}`;
285
+ return `wheel-ai/${stamp}`;
286
+ }
287
+
288
+ function guessBranchType(task: string): string {
289
+ const text = task.toLowerCase();
290
+ if (/fix|bug|修复|错误|异常|问题/.test(text)) return 'fix';
291
+ if (/docs|readme|changelog|文档/.test(text)) return 'docs';
292
+ if (/test|e2e|单测|测试/.test(text)) return 'test';
293
+ if (/refactor|重构/.test(text)) return 'refactor';
294
+ if (/chore|构建|依赖|配置/.test(text)) return 'chore';
295
+ return 'feat';
296
+ }
297
+
298
+ function slugifyTask(task: string): string {
299
+ const slug = task
300
+ .toLowerCase()
301
+ .replace(/[^a-z0-9]+/g, '-')
302
+ .replace(/-+/g, '-')
303
+ .replace(/^-+|-+$/g, '');
304
+ return slug.slice(0, 40);
305
+ }
306
+
307
+ function buildTimestampSlug(now: Date): string {
308
+ const stamp = `${now.getFullYear()}${(now.getMonth() + 1).toString().padStart(2, '0')}${now.getDate().toString().padStart(2, '0')}-${now.getHours().toString().padStart(2, '0')}${now.getMinutes().toString().padStart(2, '0')}`;
309
+ return `auto-${stamp}`;
310
+ }
311
+
312
+ /**
313
+ * 基于任务生成分支名(AI 失败时兜底)。
314
+ */
315
+ export function generateBranchNameFromTask(task: string, now: Date = new Date()): string {
316
+ const slug = slugifyTask(task);
317
+ const type = guessBranchType(task);
318
+ const suffix = slug || buildTimestampSlug(now);
319
+ return `${type}/${suffix}`;
285
320
  }