hamster-wheel-cli 0.1.0 → 0.2.0-beta.2

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,82 @@
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 {
6
+ applyShortcutArgv,
7
+ getGlobalConfigPath,
8
+ loadGlobalConfig,
9
+ normalizeAliasName,
10
+ parseAliasEntries,
11
+ splitCommandArgs,
12
+ upsertAliasEntry
13
+ } from './global-config';
14
+ import { getCurrentBranch } from './git';
15
+ import { buildAutoLogFilePath, formatCommandLine } from './logs';
16
+ import { runAliasViewer } from './alias-viewer';
7
17
  import { runLogsViewer } from './logs-viewer';
8
18
  import { runLoop } from './loop';
9
19
  import { defaultLogger } from './logger';
10
- import { runMonitor } from './monitor';
20
+ import { buildTaskPlans, normalizeTaskList, parseMultiTaskMode } from './multi-task';
21
+ import { resolveTerminationTarget, runMonitor } from './monitor';
22
+ import { tailLogFile } from './log-tailer';
11
23
  import { resolvePath } from './utils';
12
24
 
25
+ const FOREGROUND_CHILD_ENV = 'WHEEL_AI_FOREGROUND_CHILD';
26
+
27
+ type OptionValueMode = 'none' | 'single' | 'variadic';
28
+
29
+ interface RunOptionSpec {
30
+ readonly name: string;
31
+ readonly flags: readonly string[];
32
+ readonly valueMode: OptionValueMode;
33
+ }
34
+
35
+ interface ParsedArgSegment {
36
+ readonly name?: string;
37
+ readonly tokens: string[];
38
+ }
39
+
40
+ const RUN_OPTION_SPECS: RunOptionSpec[] = [
41
+ { name: 'task', flags: ['-t', '--task'], valueMode: 'single' },
42
+ { name: 'iterations', flags: ['-i', '--iterations'], valueMode: 'single' },
43
+ { name: 'ai-cli', flags: ['--ai-cli'], valueMode: 'single' },
44
+ { name: 'ai-args', flags: ['--ai-args'], valueMode: 'variadic' },
45
+ { name: 'ai-prompt-arg', flags: ['--ai-prompt-arg'], valueMode: 'single' },
46
+ { name: 'notes-file', flags: ['--notes-file'], valueMode: 'single' },
47
+ { name: 'plan-file', flags: ['--plan-file'], valueMode: 'single' },
48
+ { name: 'workflow-doc', flags: ['--workflow-doc'], valueMode: 'single' },
49
+ { name: 'worktree', flags: ['--worktree'], valueMode: 'none' },
50
+ { name: 'branch', flags: ['--branch'], valueMode: 'single' },
51
+ { name: 'worktree-path', flags: ['--worktree-path'], valueMode: 'single' },
52
+ { name: 'base-branch', flags: ['--base-branch'], valueMode: 'single' },
53
+ { name: 'skip-install', flags: ['--skip-install'], valueMode: 'none' },
54
+ { name: 'run-tests', flags: ['--run-tests'], valueMode: 'none' },
55
+ { name: 'run-e2e', flags: ['--run-e2e'], valueMode: 'none' },
56
+ { name: 'unit-command', flags: ['--unit-command'], valueMode: 'single' },
57
+ { name: 'e2e-command', flags: ['--e2e-command'], valueMode: 'single' },
58
+ { name: 'auto-commit', flags: ['--auto-commit'], valueMode: 'none' },
59
+ { name: 'auto-push', flags: ['--auto-push'], valueMode: 'none' },
60
+ { name: 'pr', flags: ['--pr'], valueMode: 'none' },
61
+ { name: 'pr-title', flags: ['--pr-title'], valueMode: 'single' },
62
+ { name: 'pr-body', flags: ['--pr-body'], valueMode: 'single' },
63
+ { name: 'draft', flags: ['--draft'], valueMode: 'none' },
64
+ { name: 'reviewer', flags: ['--reviewer'], valueMode: 'variadic' },
65
+ { name: 'auto-merge', flags: ['--auto-merge'], valueMode: 'none' },
66
+ { name: 'webhook', flags: ['--webhook'], valueMode: 'single' },
67
+ { name: 'webhook-timeout', flags: ['--webhook-timeout'], valueMode: 'single' },
68
+ { name: 'multi-task-mode', flags: ['--multi-task-mode'], valueMode: 'single' },
69
+ { name: 'stop-signal', flags: ['--stop-signal'], valueMode: 'single' },
70
+ { name: 'log-file', flags: ['--log-file'], valueMode: 'single' },
71
+ { name: 'background', flags: ['--background'], valueMode: 'none' },
72
+ { name: 'verbose', flags: ['-v', '--verbose'], valueMode: 'none' },
73
+ { name: 'skip-quality', flags: ['--skip-quality'], valueMode: 'none' }
74
+ ];
75
+
76
+ const RUN_OPTION_FLAG_MAP = new Map<string, RunOptionSpec>(
77
+ RUN_OPTION_SPECS.flatMap(spec => spec.flags.map(flag => [flag, spec] as const))
78
+ );
79
+
13
80
  function parseInteger(value: string, defaultValue: number): number {
14
81
  const parsed = Number.parseInt(value, 10);
15
82
  if (Number.isNaN(parsed)) return defaultValue;
@@ -42,6 +109,218 @@ function buildBackgroundArgs(argv: string[], logFile: string, branchName?: strin
42
109
  return filtered;
43
110
  }
44
111
 
112
+ function extractAliasCommandArgs(argv: string[], name: string): string[] {
113
+ const args = argv.slice(2);
114
+ const start = args.findIndex((arg, index) => arg === 'set' && args[index + 1] === 'alias' && args[index + 2] === name);
115
+ if (start < 0) return [];
116
+ const rest = args.slice(start + 3);
117
+ if (rest[0] === '--') return rest.slice(1);
118
+ return rest;
119
+ }
120
+
121
+ function isAliasCommandToken(token: string): boolean {
122
+ return token === 'alias' || token === 'aliases';
123
+ }
124
+
125
+ function extractAliasRunArgs(argv: string[], name: string): string[] {
126
+ const args = argv.slice(2);
127
+ const start = args.findIndex(
128
+ (arg, index) => isAliasCommandToken(arg) && args[index + 1] === 'run' && args[index + 2] === name
129
+ );
130
+ if (start < 0) return [];
131
+ const rest = args.slice(start + 3);
132
+ if (rest[0] === '--') return rest.slice(1);
133
+ return rest;
134
+ }
135
+
136
+ function normalizeAliasCommandArgs(args: string[]): string[] {
137
+ let start = 0;
138
+ if (args[start] === 'wheel-ai') {
139
+ start += 1;
140
+ }
141
+ if (args[start] === 'run') {
142
+ start += 1;
143
+ }
144
+ return args.slice(start);
145
+ }
146
+
147
+ function resolveRunOptionSpec(token: string): { spec: RunOptionSpec; inlineValue?: string } | null {
148
+ const equalIndex = token.indexOf('=');
149
+ const flag = equalIndex > 0 ? token.slice(0, equalIndex) : token;
150
+ const spec = RUN_OPTION_FLAG_MAP.get(flag);
151
+ if (!spec) return null;
152
+ if (equalIndex > 0) {
153
+ return { spec, inlineValue: token.slice(equalIndex + 1) };
154
+ }
155
+ return { spec };
156
+ }
157
+
158
+ function parseArgSegments(tokens: string[]): ParsedArgSegment[] {
159
+ const segments: ParsedArgSegment[] = [];
160
+ let index = 0;
161
+ while (index < tokens.length) {
162
+ const token = tokens[index];
163
+ if (token === '--') {
164
+ segments.push({ tokens: tokens.slice(index) });
165
+ break;
166
+ }
167
+
168
+ const match = resolveRunOptionSpec(token);
169
+ if (!match) {
170
+ segments.push({ tokens: [token] });
171
+ index += 1;
172
+ continue;
173
+ }
174
+
175
+ if (match.inlineValue !== undefined) {
176
+ segments.push({ name: match.spec.name, tokens: [token] });
177
+ index += 1;
178
+ continue;
179
+ }
180
+
181
+ if (match.spec.valueMode === 'none') {
182
+ segments.push({ name: match.spec.name, tokens: [token] });
183
+ index += 1;
184
+ continue;
185
+ }
186
+
187
+ if (match.spec.valueMode === 'single') {
188
+ const next = tokens[index + 1];
189
+ if (next !== undefined) {
190
+ segments.push({ name: match.spec.name, tokens: [token, next] });
191
+ index += 2;
192
+ } else {
193
+ segments.push({ name: match.spec.name, tokens: [token] });
194
+ index += 1;
195
+ }
196
+ continue;
197
+ }
198
+
199
+ const values: string[] = [];
200
+ let cursor = index + 1;
201
+ while (cursor < tokens.length) {
202
+ const next = tokens[cursor];
203
+ if (next === '--') break;
204
+ const nextMatch = resolveRunOptionSpec(next);
205
+ if (nextMatch) break;
206
+ values.push(next);
207
+ cursor += 1;
208
+ }
209
+ segments.push({ name: match.spec.name, tokens: [token, ...values] });
210
+ index = cursor;
211
+ }
212
+
213
+ return segments;
214
+ }
215
+
216
+ function mergeAliasCommandArgs(aliasTokens: string[], additionTokens: string[]): string[] {
217
+ const aliasSegments = parseArgSegments(aliasTokens);
218
+ const additionSegments = parseArgSegments(additionTokens);
219
+ const overrideNames = new Set(
220
+ additionSegments.flatMap(segment => (segment.name ? [segment.name] : []))
221
+ );
222
+
223
+ const merged = [
224
+ ...aliasSegments.filter(segment => !segment.name || !overrideNames.has(segment.name)),
225
+ ...additionSegments
226
+ ];
227
+
228
+ return merged.flatMap(segment => segment.tokens);
229
+ }
230
+
231
+ async function runForegroundWithDetach(options: {
232
+ argv: string[];
233
+ logFile: string;
234
+ branchName?: string;
235
+ injectBranch: boolean;
236
+ isMultiTask: boolean;
237
+ }): Promise<void> {
238
+ const args = buildBackgroundArgs(options.argv, options.logFile, options.branchName, options.injectBranch);
239
+ const child = spawn(process.execPath, [...process.execArgv, ...args], {
240
+ detached: true,
241
+ stdio: 'ignore',
242
+ env: {
243
+ ...process.env,
244
+ [FOREGROUND_CHILD_ENV]: '1'
245
+ }
246
+ });
247
+ child.unref();
248
+
249
+ const resolvedLogFile = resolvePath(process.cwd(), options.logFile);
250
+ const existed = await fs.pathExists(resolvedLogFile);
251
+ const tailer = await tailLogFile({
252
+ filePath: resolvedLogFile,
253
+ startFromEnd: existed,
254
+ onLine: line => {
255
+ process.stdout.write(`${line}\n`);
256
+ },
257
+ onError: message => {
258
+ defaultLogger.warn(`日志读取失败:${message}`);
259
+ }
260
+ });
261
+
262
+ const suffixNote = options.isMultiTask ? '(多任务将追加序号)' : '';
263
+ console.log(`已进入前台日志查看,按 Esc 切到后台运行,日志输出至 ${resolvedLogFile}${suffixNote}`);
264
+
265
+ let cleaned = false;
266
+ const cleanup = async (): Promise<void> => {
267
+ if (cleaned) return;
268
+ cleaned = true;
269
+ await tailer.stop();
270
+ if (process.stdin.isTTY) {
271
+ process.stdin.setRawMode(false);
272
+ process.stdin.pause();
273
+ }
274
+ };
275
+
276
+ const detach = async (): Promise<void> => {
277
+ await cleanup();
278
+ console.log(`已切入后台运行,日志输出至 ${resolvedLogFile}${suffixNote}`);
279
+ process.exit(0);
280
+ };
281
+
282
+ const terminate = async (): Promise<void> => {
283
+ if (child.pid) {
284
+ try {
285
+ const target = resolveTerminationTarget(child.pid);
286
+ process.kill(target, 'SIGTERM');
287
+ } catch (error) {
288
+ const message = error instanceof Error ? error.message : String(error);
289
+ defaultLogger.warn(`终止子进程失败:${message}`);
290
+ }
291
+ }
292
+ await cleanup();
293
+ process.exit(0);
294
+ };
295
+
296
+ if (process.stdin.isTTY) {
297
+ process.stdin.setRawMode(true);
298
+ process.stdin.resume();
299
+ process.stdin.on('data', (data: Buffer) => {
300
+ const input = data.toString('utf8');
301
+ if (input === '\u001b') {
302
+ void detach();
303
+ return;
304
+ }
305
+ if (input === '\u0003') {
306
+ void terminate();
307
+ }
308
+ });
309
+ }
310
+
311
+ process.on('SIGINT', () => {
312
+ void terminate();
313
+ });
314
+ process.on('SIGTERM', () => {
315
+ void terminate();
316
+ });
317
+
318
+ child.on('exit', async code => {
319
+ await cleanup();
320
+ process.exit(code ?? 0);
321
+ });
322
+ }
323
+
45
324
  /**
46
325
  * CLI 入口。
47
326
  */
@@ -54,10 +333,14 @@ export async function runCli(argv: string[]): Promise<void> {
54
333
  .name('wheel-ai')
55
334
  .description('基于 AI CLI 的持续迭代开发工具')
56
335
  .version('1.0.0');
336
+ program.addHelpText(
337
+ 'after',
338
+ '\n别名执行:\n wheel-ai alias run <alias> <addition...>\n 追加命令与 alias 重叠时,以追加为准。\n'
339
+ );
57
340
 
58
341
  program
59
342
  .command('run')
60
- .requiredOption('-t, --task <task>', '需要完成的任务描述(会进入 AI 提示)')
343
+ .option('-t, --task <task>', '需要完成的任务描述(可重复传入,独立处理)', collect, [])
61
344
  .option('-i, --iterations <number>', '最大迭代次数', value => parseInteger(value, 5), 5)
62
345
  .option('--ai-cli <command>', 'AI CLI 命令', 'claude')
63
346
  .option('--ai-args <args...>', 'AI CLI 参数', [])
@@ -81,33 +364,53 @@ export async function runCli(argv: string[]): Promise<void> {
81
364
  .option('--pr-body <path>', 'PR 描述文件路径(可留空自动生成)')
82
365
  .option('--draft', '以草稿形式创建 PR', false)
83
366
  .option('--reviewer <user...>', 'PR reviewers', collect, [])
367
+ .option('--auto-merge', 'PR 检查通过后自动合并', false)
84
368
  .option('--webhook <url>', 'webhook 通知 URL(可重复)', collect, [])
85
369
  .option('--webhook-timeout <ms>', 'webhook 请求超时(毫秒)', value => parseInteger(value, 8000))
370
+ .option('--multi-task-mode <mode>', '多任务执行模式(relay/serial/serial-continue/parallel,或中文描述)', 'relay')
86
371
  .option('--stop-signal <token>', 'AI 输出中的停止标记', '<<DONE>>')
87
372
  .option('--log-file <path>', '日志输出文件路径')
88
373
  .option('--background', '切入后台运行', false)
89
374
  .option('-v, --verbose', '输出调试日志', false)
375
+ .option('--skip-quality', '跳过代码质量检查', false)
90
376
  .action(async (options) => {
377
+ const tasks = normalizeTaskList(options.task as string[] | string | undefined);
378
+ if (tasks.length === 0) {
379
+ throw new Error('需要至少提供一个任务描述');
380
+ }
381
+
382
+ const multiTaskMode = parseMultiTaskMode(options.multiTaskMode as string | undefined);
91
383
  const useWorktree = Boolean(options.worktree);
384
+ if (multiTaskMode === 'parallel' && !useWorktree) {
385
+ throw new Error('并行模式必须启用 --worktree');
386
+ }
387
+
92
388
  const branchInput = normalizeOptional(options.branch);
93
389
  const logFileInput = normalizeOptional(options.logFile);
390
+ const worktreePathInput = normalizeOptional(options.worktreePath);
94
391
  const background = Boolean(options.background);
392
+ const isMultiTask = tasks.length > 1;
393
+ const isForegroundChild = process.env[FOREGROUND_CHILD_ENV] === '1';
394
+ const canForegroundDetach = !background && !isForegroundChild && process.stdout.isTTY && process.stdin.isTTY;
95
395
 
96
- let branchName = branchInput;
97
- if (useWorktree && !branchName) {
98
- branchName = generateBranchName();
99
- }
396
+ const shouldInjectBranch = Boolean(useWorktree && branchInput && !isMultiTask);
397
+ const branchNameForBackground = branchInput;
100
398
 
101
399
  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';
400
+ if ((background || canForegroundDetach) && !logFile) {
401
+ let branchForLog = 'multi-task';
402
+ if (!isMultiTask) {
403
+ branchForLog = branchNameForBackground ?? '';
404
+ if (!branchForLog) {
405
+ try {
406
+ const current = await getCurrentBranch(process.cwd(), defaultLogger);
407
+ branchForLog = current || 'detached';
408
+ } catch {
409
+ branchForLog = 'unknown';
410
+ }
110
411
  }
412
+ } else if (branchInput) {
413
+ branchForLog = `${branchInput}-multi`;
111
414
  }
112
415
  logFile = buildAutoLogFilePath(branchForLog);
113
416
  }
@@ -116,19 +419,43 @@ export async function runCli(argv: string[]): Promise<void> {
116
419
  if (!logFile) {
117
420
  throw new Error('后台运行需要指定日志文件');
118
421
  }
119
- const args = buildBackgroundArgs(effectiveArgv, logFile, branchName, useWorktree && !branchInput);
422
+ const args = buildBackgroundArgs(effectiveArgv, logFile, branchNameForBackground, shouldInjectBranch);
120
423
  const child = spawn(process.execPath, [...process.execArgv, ...args], {
121
424
  detached: true,
122
425
  stdio: 'ignore'
123
426
  });
124
427
  child.unref();
125
428
  const displayLogFile = resolvePath(process.cwd(), logFile);
126
- console.log(`已切入后台运行,日志输出至 ${displayLogFile}`);
429
+ const suffixNote = isMultiTask ? '(多任务将追加序号)' : '';
430
+ console.log(`已切入后台运行,日志输出至 ${displayLogFile}${suffixNote}`);
431
+ return;
432
+ }
433
+
434
+ if (canForegroundDetach) {
435
+ if (!logFile) {
436
+ throw new Error('切入后台需要指定日志文件');
437
+ }
438
+ await runForegroundWithDetach({
439
+ argv: effectiveArgv,
440
+ logFile,
441
+ branchName: branchNameForBackground,
442
+ injectBranch: shouldInjectBranch,
443
+ isMultiTask
444
+ });
127
445
  return;
128
446
  }
129
447
 
130
- const cliOptions: CliOptions = {
131
- task: options.task as string,
448
+ const taskPlans = buildTaskPlans({
449
+ tasks,
450
+ mode: multiTaskMode,
451
+ useWorktree,
452
+ baseBranch: options.baseBranch as string,
453
+ branchInput,
454
+ worktreePath: worktreePathInput,
455
+ logFile: logFileInput
456
+ });
457
+
458
+ const baseOptions = {
132
459
  iterations: options.iterations as number,
133
460
  aiCli: options.aiCli as string,
134
461
  aiArgs: (options.aiArgs as string[]) ?? [],
@@ -137,9 +464,6 @@ export async function runCli(argv: string[]): Promise<void> {
137
464
  planFile: options.planFile as string,
138
465
  workflowDoc: options.workflowDoc as string,
139
466
  useWorktree,
140
- branch: branchName,
141
- worktreePath: options.worktreePath as string | undefined,
142
- baseBranch: options.baseBranch as string,
143
467
  runTests: Boolean(options.runTests),
144
468
  runE2e: Boolean(options.runE2e),
145
469
  unitCommand: options.unitCommand as string | undefined,
@@ -151,21 +475,78 @@ export async function runCli(argv: string[]): Promise<void> {
151
475
  prBody: options.prBody as string | undefined,
152
476
  draft: Boolean(options.draft),
153
477
  reviewers: (options.reviewer as string[]) ?? [],
478
+ autoMerge: Boolean(options.autoMerge),
154
479
  webhookUrls: (options.webhook as string[]) ?? [],
155
480
  webhookTimeout: options.webhookTimeout as number | undefined,
156
481
  stopSignal: options.stopSignal as string,
157
- logFile,
158
482
  verbose: Boolean(options.verbose),
159
- skipInstall: Boolean(options.skipInstall)
483
+ skipInstall: Boolean(options.skipInstall),
484
+ skipQuality: Boolean(options.skipQuality)
485
+ };
486
+
487
+ const dynamicRelay = useWorktree && multiTaskMode === 'relay' && !branchInput;
488
+ let relayBaseBranch = options.baseBranch as string;
489
+
490
+ const runPlan = async (plan: typeof taskPlans[number], baseBranchOverride?: string): Promise<Awaited<ReturnType<typeof runLoop>>> => {
491
+ const cliOptions: CliOptions = {
492
+ task: plan.task,
493
+ ...baseOptions,
494
+ branch: plan.branchName,
495
+ worktreePath: plan.worktreePath,
496
+ baseBranch: baseBranchOverride ?? plan.baseBranch,
497
+ logFile: plan.logFile
498
+ };
499
+ const config = buildLoopConfig(cliOptions, process.cwd());
500
+ return runLoop(config);
160
501
  };
161
502
 
162
- const config = buildLoopConfig(cliOptions, process.cwd());
163
- await runLoop(config);
503
+ if (multiTaskMode === 'parallel') {
504
+ const results = await Promise.allSettled(taskPlans.map(plan => runPlan(plan)));
505
+ const errors = results.flatMap((result, index) => {
506
+ if (result.status === 'fulfilled') return [];
507
+ const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
508
+ return [`任务 ${index + 1} 失败: ${reason}`];
509
+ });
510
+ if (errors.length > 0) {
511
+ errors.forEach(message => defaultLogger.warn(message));
512
+ throw new Error(errors.join('\n'));
513
+ }
514
+ return;
515
+ }
516
+
517
+ if (multiTaskMode === 'serial-continue') {
518
+ const errors: string[] = [];
519
+ for (const plan of taskPlans) {
520
+ try {
521
+ const baseBranch = dynamicRelay ? relayBaseBranch : plan.baseBranch;
522
+ const result = await runPlan(plan, baseBranch);
523
+ if (dynamicRelay && result.branchName) {
524
+ relayBaseBranch = result.branchName;
525
+ }
526
+ } catch (error) {
527
+ const message = error instanceof Error ? error.message : String(error);
528
+ errors.push(`任务 ${plan.index + 1} 失败: ${message}`);
529
+ defaultLogger.warn(`任务 ${plan.index + 1} 执行失败,继续下一任务:${message}`);
530
+ }
531
+ }
532
+ if (errors.length > 0) {
533
+ throw new Error(errors.join('\n'));
534
+ }
535
+ return;
536
+ }
537
+
538
+ for (const plan of taskPlans) {
539
+ const baseBranch = dynamicRelay ? relayBaseBranch : plan.baseBranch;
540
+ const result = await runPlan(plan, baseBranch);
541
+ if (dynamicRelay && result.branchName) {
542
+ relayBaseBranch = result.branchName;
543
+ }
544
+ }
164
545
  });
165
546
 
166
547
  program
167
548
  .command('monitor')
168
- .description('查看后台运行日志')
549
+ .description('查看后台运行日志(t 终止任务)')
169
550
  .action(async () => {
170
551
  await runMonitor();
171
552
  });
@@ -177,6 +558,76 @@ export async function runCli(argv: string[]): Promise<void> {
177
558
  await runLogsViewer();
178
559
  });
179
560
 
561
+ program
562
+ .command('set')
563
+ .description('写入全局配置')
564
+ .command('alias <name> [options...]')
565
+ .description('设置 alias')
566
+ .allowUnknownOption(true)
567
+ .action(async (name: string) => {
568
+ const normalized = normalizeAliasName(name);
569
+ if (!normalized) {
570
+ throw new Error('alias 名称不能为空且不能包含空白字符');
571
+ }
572
+ const commandArgs = extractAliasCommandArgs(effectiveArgv, name);
573
+ const commandLine = formatCommandLine(commandArgs);
574
+ if (!commandLine) {
575
+ throw new Error('alias 命令不能为空');
576
+ }
577
+ await upsertAliasEntry(normalized, commandLine);
578
+ console.log(`已写入 alias:${normalized}`);
579
+ });
580
+
581
+ const aliasCommand = program
582
+ .command('alias')
583
+ .alias('aliases')
584
+ .description('浏览全局 alias 配置(alias run 可执行并追加命令)');
585
+
586
+ aliasCommand
587
+ .command('run <name> [addition...]')
588
+ .description('执行 alias 并追加命令')
589
+ .allowUnknownOption(true)
590
+ .allowExcessArguments(true)
591
+ .action(async (name: string) => {
592
+ const normalized = normalizeAliasName(name);
593
+ if (!normalized) {
594
+ throw new Error('alias 名称不能为空且不能包含空白字符');
595
+ }
596
+
597
+ const filePath = getGlobalConfigPath();
598
+ const exists = await fs.pathExists(filePath);
599
+ if (!exists) {
600
+ throw new Error(`未找到 alias 配置文件:${filePath}`);
601
+ }
602
+
603
+ const content = await fs.readFile(filePath, 'utf8');
604
+ const entries = parseAliasEntries(content);
605
+ const entry = entries.find(item => item.name === normalized);
606
+ if (!entry) {
607
+ throw new Error(`未找到 alias:${normalized}`);
608
+ }
609
+
610
+ const aliasTokens = normalizeAliasCommandArgs(splitCommandArgs(entry.command));
611
+ const additionTokens = extractAliasRunArgs(effectiveArgv, normalized);
612
+ const mergedTokens = mergeAliasCommandArgs(aliasTokens, additionTokens);
613
+ if (mergedTokens.length === 0) {
614
+ throw new Error('alias 命令不能为空');
615
+ }
616
+
617
+ const nextArgv = [process.argv[0], process.argv[1], 'run', ...mergedTokens];
618
+ const originalArgv = process.argv;
619
+ process.argv = nextArgv;
620
+ try {
621
+ await runCli(nextArgv);
622
+ } finally {
623
+ process.argv = originalArgv;
624
+ }
625
+ });
626
+
627
+ aliasCommand.action(async () => {
628
+ await runAliasViewer();
629
+ });
630
+
180
631
  await program.parseAsync(effectiveArgv);
181
632
  }
182
633
 
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
+ }