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/CHANGELOG.md +31 -1
- package/README.md +36 -0
- package/dist/cli.js +1917 -309
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +1917 -309
- package/dist/index.js.map +1 -1
- package/docs/ai-workflow.md +27 -36
- package/package.json +1 -1
- package/src/ai.ts +346 -1
- package/src/alias-viewer.ts +221 -0
- package/src/cli.ts +480 -29
- package/src/config.ts +6 -2
- package/src/gh.ts +20 -0
- package/src/git.ts +38 -3
- package/src/global-config.ts +149 -11
- package/src/log-tailer.ts +103 -0
- package/src/logs-viewer.ts +33 -11
- package/src/logs.ts +1 -0
- package/src/loop.ts +458 -120
- package/src/monitor.ts +240 -23
- package/src/multi-task.ts +117 -0
- package/src/plan.ts +61 -0
- package/src/quality.ts +48 -0
- package/src/runtime-tracker.ts +2 -1
- package/src/types.ts +23 -0
- package/tests/branch-name.test.ts +28 -0
- package/tests/e2e/cli.e2e.test.ts +41 -0
- package/tests/global-config.test.ts +52 -1
- package/tests/monitor.test.ts +17 -0
- package/tests/multi-task.test.ts +77 -0
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 {
|
|
5
|
-
|
|
6
|
-
|
|
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 {
|
|
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
|
-
.
|
|
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
|
-
|
|
97
|
-
|
|
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 =
|
|
104
|
-
if (!
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
131
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
+
}
|