hamster-wheel-cli 0.2.0 → 0.3.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.
@@ -20,6 +20,14 @@ export interface AliasEntry {
20
20
  readonly source: 'alias' | 'shortcut';
21
21
  }
22
22
 
23
+ /**
24
+ * 全局 agent 配置条目。
25
+ */
26
+ export interface AgentEntry {
27
+ readonly name: string;
28
+ readonly command: string;
29
+ }
30
+
23
31
  /**
24
32
  * 全局配置结构。
25
33
  */
@@ -177,6 +185,13 @@ export function normalizeAliasName(name: string): string | null {
177
185
  return normalizeShortcutName(name);
178
186
  }
179
187
 
188
+ /**
189
+ * 规范化 agent 名称。
190
+ */
191
+ export function normalizeAgentName(name: string): string | null {
192
+ return normalizeShortcutName(name);
193
+ }
194
+
180
195
  function formatTomlString(value: string): string {
181
196
  const escaped = value
182
197
  .replace(/\\/g, '\\\\')
@@ -187,6 +202,43 @@ function formatTomlString(value: string): string {
187
202
  return `"${escaped}"`;
188
203
  }
189
204
 
205
+ interface SectionRange {
206
+ readonly name: string;
207
+ readonly start: number;
208
+ end: number;
209
+ }
210
+
211
+ function collectSectionRanges(lines: string[]): SectionRange[] {
212
+ const ranges: SectionRange[] = [];
213
+ let current: SectionRange | null = null;
214
+
215
+ for (let i = 0; i < lines.length; i += 1) {
216
+ const match = /^\s*\[(.+?)\]\s*$/.exec(lines[i]);
217
+ if (!match) continue;
218
+
219
+ if (current) {
220
+ current.end = i;
221
+ ranges.push(current);
222
+ }
223
+
224
+ current = {
225
+ name: match[1].trim(),
226
+ start: i,
227
+ end: lines.length
228
+ };
229
+ }
230
+
231
+ if (current) {
232
+ ranges.push(current);
233
+ }
234
+
235
+ return ranges;
236
+ }
237
+
238
+ function isAgentSection(name: string | null): boolean {
239
+ return name === 'agent' || name === 'agents';
240
+ }
241
+
190
242
  /**
191
243
  * 更新 alias 配置内容,返回新文本。
192
244
  */
@@ -234,6 +286,43 @@ export function updateAliasContent(content: string, name: string, command: strin
234
286
  return output.endsWith('\n') ? output : `${output}\n`;
235
287
  }
236
288
 
289
+ /**
290
+ * 删除 alias 配置内容,返回删除结果与新文本。
291
+ */
292
+ export function removeAliasContent(content: string, name: string): { removed: boolean; nextContent: string } {
293
+ const lines = content.split(/\r?\n/);
294
+ let currentSection: string | null = null;
295
+ const removeIndices: number[] = [];
296
+
297
+ for (let i = 0; i < lines.length; i += 1) {
298
+ const sectionMatch = /^\s*\[(.+?)\]\s*$/.exec(lines[i]);
299
+ if (sectionMatch) {
300
+ currentSection = sectionMatch[1].trim();
301
+ continue;
302
+ }
303
+
304
+ if (currentSection !== 'alias') continue;
305
+ const parsed = parseTomlKeyValue(stripTomlComment(lines[i]).trim());
306
+ if (!parsed) continue;
307
+ if (parsed.key === name) {
308
+ removeIndices.push(i);
309
+ }
310
+ }
311
+
312
+ if (removeIndices.length === 0) {
313
+ return { removed: false, nextContent: ensureTrailingNewline(content) };
314
+ }
315
+
316
+ removeIndices
317
+ .sort((a, b) => b - a)
318
+ .forEach(index => {
319
+ lines.splice(index, 1);
320
+ });
321
+
322
+ const output = lines.join('\n');
323
+ return { removed: true, nextContent: output.endsWith('\n') ? output : `${output}\n` };
324
+ }
325
+
237
326
  /**
238
327
  * 写入或更新 alias 配置。
239
328
  */
@@ -245,6 +334,130 @@ export async function upsertAliasEntry(name: string, command: string, filePath:
245
334
  await fs.writeFile(filePath, nextContent, 'utf8');
246
335
  }
247
336
 
337
+ /**
338
+ * 删除 alias 配置,返回是否删除成功。
339
+ */
340
+ export async function removeAliasEntry(name: string, filePath: string = getGlobalConfigPath()): Promise<boolean> {
341
+ const exists = await fs.pathExists(filePath);
342
+ if (!exists) return false;
343
+ const content = await fs.readFile(filePath, 'utf8');
344
+ const { removed, nextContent } = removeAliasContent(content, name);
345
+ if (!removed) return false;
346
+ await fs.writeFile(filePath, nextContent, 'utf8');
347
+ return true;
348
+ }
349
+
350
+ function ensureTrailingNewline(content: string): string {
351
+ if (!content) return '';
352
+ return content.endsWith('\n') ? content : `${content}\n`;
353
+ }
354
+
355
+ function findAgentRangeWithEntry(lines: string[], ranges: SectionRange[], name: string): SectionRange | null {
356
+ for (const range of ranges) {
357
+ for (let i = range.start + 1; i < range.end; i += 1) {
358
+ const parsed = parseTomlKeyValue(stripTomlComment(lines[i]).trim());
359
+ if (!parsed) continue;
360
+ if (parsed.key === name) return range;
361
+ }
362
+ }
363
+ return null;
364
+ }
365
+
366
+ /**
367
+ * 更新 agent 配置内容,返回新文本。
368
+ */
369
+ export function updateAgentContent(content: string, name: string, command: string): string {
370
+ const lines = content.split(/\r?\n/);
371
+ const entryLine = `${name} = ${formatTomlString(command)}`;
372
+ const ranges = collectSectionRanges(lines);
373
+ const agentRanges = ranges.filter(range => isAgentSection(range.name));
374
+ let targetRange = findAgentRangeWithEntry(lines, agentRanges, name);
375
+
376
+ if (!targetRange) {
377
+ targetRange = agentRanges.find(range => range.name === 'agent') ?? agentRanges.find(range => range.name === 'agents') ?? null;
378
+ }
379
+
380
+ if (!targetRange) {
381
+ const trimmed = content.trimEnd();
382
+ const prefix = trimmed.length > 0 ? `${trimmed}\n\n` : '';
383
+ return `${prefix}[agent]\n${entryLine}\n`;
384
+ }
385
+
386
+ let replaced = false;
387
+ for (let i = targetRange.start + 1; i < targetRange.end; i += 1) {
388
+ const parsed = parseTomlKeyValue(stripTomlComment(lines[i]).trim());
389
+ if (!parsed) continue;
390
+ if (parsed.key === name) {
391
+ lines[i] = entryLine;
392
+ replaced = true;
393
+ break;
394
+ }
395
+ }
396
+
397
+ if (!replaced) {
398
+ lines.splice(targetRange.end, 0, entryLine);
399
+ }
400
+
401
+ const output = lines.join('\n');
402
+ return output.endsWith('\n') ? output : `${output}\n`;
403
+ }
404
+
405
+ /**
406
+ * 删除 agent 配置内容,返回删除结果与新文本。
407
+ */
408
+ export function removeAgentContent(content: string, name: string): { removed: boolean; nextContent: string } {
409
+ const lines = content.split(/\r?\n/);
410
+ const ranges = collectSectionRanges(lines).filter(range => isAgentSection(range.name));
411
+ const removeIndices: number[] = [];
412
+
413
+ for (const range of ranges) {
414
+ for (let i = range.start + 1; i < range.end; i += 1) {
415
+ const parsed = parseTomlKeyValue(stripTomlComment(lines[i]).trim());
416
+ if (!parsed) continue;
417
+ if (parsed.key === name) {
418
+ removeIndices.push(i);
419
+ }
420
+ }
421
+ }
422
+
423
+ if (removeIndices.length === 0) {
424
+ return { removed: false, nextContent: ensureTrailingNewline(content) };
425
+ }
426
+
427
+ removeIndices
428
+ .sort((a, b) => b - a)
429
+ .forEach(index => {
430
+ lines.splice(index, 1);
431
+ });
432
+
433
+ const output = lines.join('\n');
434
+ return { removed: true, nextContent: output.endsWith('\n') ? output : `${output}\n` };
435
+ }
436
+
437
+ /**
438
+ * 写入或更新 agent 配置。
439
+ */
440
+ export async function upsertAgentEntry(name: string, command: string, filePath: string = getGlobalConfigPath()): Promise<void> {
441
+ const exists = await fs.pathExists(filePath);
442
+ const content = exists ? await fs.readFile(filePath, 'utf8') : '';
443
+ const nextContent = updateAgentContent(content, name, command);
444
+ await fs.mkdirp(path.dirname(filePath));
445
+ await fs.writeFile(filePath, nextContent, 'utf8');
446
+ }
447
+
448
+ /**
449
+ * 删除 agent 配置,返回是否删除成功。
450
+ */
451
+ export async function removeAgentEntry(name: string, filePath: string = getGlobalConfigPath()): Promise<boolean> {
452
+ const exists = await fs.pathExists(filePath);
453
+ if (!exists) return false;
454
+ const content = await fs.readFile(filePath, 'utf8');
455
+ const { removed, nextContent } = removeAgentContent(content, name);
456
+ if (!removed) return false;
457
+ await fs.writeFile(filePath, nextContent, 'utf8');
458
+ return true;
459
+ }
460
+
248
461
  /**
249
462
  * 解析全局 TOML 配置文本。
250
463
  */
@@ -332,6 +545,44 @@ export function parseAliasEntries(content: string): AliasEntry[] {
332
545
  return entries;
333
546
  }
334
547
 
548
+ /**
549
+ * 解析 agent 配置条目(支持 [agent]/[agents])。
550
+ */
551
+ export function parseAgentEntries(content: string): AgentEntry[] {
552
+ const lines = content.split(/\r?\n/);
553
+ let currentSection: string | null = null;
554
+ const entries: AgentEntry[] = [];
555
+ const names = new Set<string>();
556
+
557
+ for (const rawLine of lines) {
558
+ const line = stripTomlComment(rawLine).trim();
559
+ if (!line) continue;
560
+
561
+ const sectionMatch = /^\[(.+)\]$/.exec(line);
562
+ if (sectionMatch) {
563
+ currentSection = sectionMatch[1].trim();
564
+ continue;
565
+ }
566
+
567
+ if (!isAgentSection(currentSection)) continue;
568
+ const parsed = parseTomlKeyValue(line);
569
+ if (!parsed) continue;
570
+
571
+ const name = normalizeShortcutName(parsed.key);
572
+ const command = parsed.value.trim();
573
+ if (!name || !command) continue;
574
+ if (names.has(name)) continue;
575
+
576
+ names.add(name);
577
+ entries.push({
578
+ name,
579
+ command
580
+ });
581
+ }
582
+
583
+ return entries;
584
+ }
585
+
335
586
  /**
336
587
  * 读取用户目录下的全局配置。
337
588
  */
@@ -144,7 +144,7 @@ async function readLogLines(logFile: string): Promise<string[]> {
144
144
 
145
145
  function buildListHeader(state: LogsViewerState, columns: number): string {
146
146
  const total = state.logs.length;
147
- const title = `日志列表(${total} 条)|↑/↓ 选择 Enter 查看 q 退出`;
147
+ const title = `日志列表(${total} 条)|↑/↓ 选择 PageUp/PageDown 翻页 Enter 查看 q 退出`;
148
148
  return truncateLine(title, columns);
149
149
  }
150
150
 
@@ -397,6 +397,18 @@ export async function runLogsViewer(): Promise<void> {
397
397
  render(state);
398
398
  return;
399
399
  }
400
+ if (isPageUp(input)) {
401
+ const pageSize = getPageSize(getTerminalSize().rows);
402
+ state.selectedIndex = clampIndex(state.selectedIndex - pageSize, state.logs.length);
403
+ render(state);
404
+ return;
405
+ }
406
+ if (isPageDown(input)) {
407
+ const pageSize = getPageSize(getTerminalSize().rows);
408
+ state.selectedIndex = clampIndex(state.selectedIndex + pageSize, state.logs.length);
409
+ render(state);
410
+ return;
411
+ }
400
412
  if (isEnter(input)) {
401
413
  void openView();
402
414
  return;
package/src/loop.ts CHANGED
@@ -24,7 +24,7 @@ import { createRunTracker } from './runtime-tracker';
24
24
  import { buildFallbackSummary, buildSummaryPrompt, ensurePrBodySections, parseDeliverySummary } from './summary';
25
25
  import { CheckRunResult, CommitMessage, DeliverySummary, LoopConfig, LoopResult, TestRunResult, TokenUsage, WorkflowFiles, WorktreeResult } from './types';
26
26
  import { appendSection, ensureFile, isoNow, readFileSafe, runCommand } from './utils';
27
- import { buildWebhookPayload, sendWebhookNotifications } from './webhook';
27
+ import { WebhookPayload, buildWebhookPayload, sendWebhookNotifications } from './webhook';
28
28
 
29
29
  async function ensureWorkflowFiles(workflowFiles: WorkflowFiles): Promise<void> {
30
30
  await ensureFile(workflowFiles.workflowDoc, '# AI 工作流程基线\n');
@@ -40,12 +40,16 @@ function trimOutput(output: string, limit = MAX_LOG_LENGTH): string {
40
40
  return `${output.slice(0, limit)}\n……(输出已截断,原始长度 ${output.length} 字符)`;
41
41
  }
42
42
 
43
- function truncateText(text: string, limit = 24): string {
43
+ function truncateText(text: string, limit = 100): string {
44
44
  const trimmed = text.trim();
45
45
  if (trimmed.length <= limit) return trimmed;
46
46
  return `${trimmed.slice(0, limit)}...`;
47
47
  }
48
48
 
49
+ function normalizePlanForWebhook(plan: string): string {
50
+ return plan.replace(/\r\n?/g, '\n');
51
+ }
52
+
49
53
  async function safeCommandOutput(command: string, args: string[], cwd: string, logger: Logger, label: string, verboseCommand: string): Promise<string> {
50
54
  const result = await runCommand(command, args, {
51
55
  cwd,
@@ -60,6 +64,45 @@ async function safeCommandOutput(command: string, args: string[], cwd: string, l
60
64
  return result.stdout.trim();
61
65
  }
62
66
 
67
+ function normalizeWebhookUrl(value?: string | null): string {
68
+ if (!value) return '';
69
+ const trimmed = value.trim();
70
+ if (!/^https?:\/\//i.test(trimmed)) return '';
71
+ return trimmed;
72
+ }
73
+
74
+ function normalizeRepoUrl(remoteUrl: string): string | null {
75
+ const trimmed = remoteUrl.trim();
76
+ if (!trimmed) return null;
77
+ const withoutGit = trimmed.replace(/\.git$/i, '');
78
+ if (withoutGit.includes('://')) {
79
+ try {
80
+ const parsed = new URL(withoutGit);
81
+ const protocol = parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.protocol : 'https:';
82
+ const pathname = parsed.pathname.replace(/\.git$/i, '');
83
+ return `${protocol}//${parsed.host}${pathname}`.replace(/\/+$/, '');
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ const scpMatch = withoutGit.match(/^(?:[^@]+@)?([^:]+):(.+)$/);
90
+ if (!scpMatch) return null;
91
+ const host = scpMatch[1];
92
+ const repoPath = scpMatch[2];
93
+ return `https://${host}/${repoPath}`.replace(/\/+$/, '');
94
+ }
95
+
96
+ async function resolveCommitLink(cwd: string, logger: Logger): Promise<string> {
97
+ const sha = await safeCommandOutput('git', ['rev-parse', 'HEAD'], cwd, logger, 'git', 'git rev-parse HEAD');
98
+ if (!sha) return '';
99
+ const remote = await safeCommandOutput('git', ['remote', 'get-url', 'origin'], cwd, logger, 'git', 'git remote get-url origin');
100
+ if (!remote) return '';
101
+ const repoUrl = normalizeRepoUrl(remote);
102
+ if (!repoUrl) return '';
103
+ return `${repoUrl}/commit/${sha}`;
104
+ }
105
+
63
106
  async function runSingleTest(kind: 'unit' | 'e2e', command: string, cwd: string, logger: Logger): Promise<TestRunResult> {
64
107
  const label = kind === 'unit' ? '单元测试' : 'e2e 测试';
65
108
  logger.info(`执行${label}: ${command}`);
@@ -289,17 +332,43 @@ export async function runLoop(config: LoopConfig): Promise<LoopResult> {
289
332
  let prInfo: GhPrInfo | null = null;
290
333
  let prFailed = false;
291
334
  let sessionIndex = 0;
335
+ let commitLink = '';
336
+ let prLink = '';
337
+ let commitCreated = false;
338
+ let pushSucceeded = false;
292
339
 
293
340
  const preWorktreeRecords: string[] = [];
294
-
295
- const notifyWebhook = async (event: 'task_start' | 'iteration_start' | 'task_end', iteration: number, stage: string): Promise<void> => {
296
- const payload = buildWebhookPayload({
297
- event,
298
- task: config.task,
299
- branch: branchName,
300
- iteration,
301
- stage
302
- });
341
+ const resolveProjectName = (): string => path.basename(workDir);
342
+
343
+ const notifyWebhook = async (
344
+ event: 'task_start' | 'iteration_start' | 'task_end',
345
+ iteration: number,
346
+ stage: string,
347
+ plan?: string
348
+ ): Promise<void> => {
349
+ const project = resolveProjectName();
350
+ const payload = event === 'task_start'
351
+ ? buildWebhookPayload({
352
+ event,
353
+ task: config.task,
354
+ branch: branchName,
355
+ iteration,
356
+ stage,
357
+ project,
358
+ commit: commitLink,
359
+ pr: prLink,
360
+ plan
361
+ })
362
+ : buildWebhookPayload({
363
+ event,
364
+ branch: branchName,
365
+ iteration,
366
+ stage,
367
+ project,
368
+ commit: commitLink,
369
+ pr: prLink,
370
+ plan
371
+ });
303
372
  await sendWebhookNotifications(config.webhooks, payload, logger);
304
373
  };
305
374
 
@@ -396,7 +465,10 @@ export async function runLoop(config: LoopConfig): Promise<LoopResult> {
396
465
  extras?: { testResults?: TestRunResult[]; checkResults?: CheckRunResult[]; cwd?: string }
397
466
  ): Promise<void> => {
398
467
  sessionIndex += 1;
399
- await notifyWebhook('iteration_start', sessionIndex, stage);
468
+ const webhookPlan = stage === '计划生成'
469
+ ? normalizePlanForWebhook(await readFileSafe(workflowFiles.planFile))
470
+ : undefined;
471
+ await notifyWebhook('iteration_start', sessionIndex, stage, webhookPlan);
400
472
  logger.info(`${stage} 提示构建完成,调用 AI CLI...`);
401
473
 
402
474
  const aiResult = await runAi(prompt, aiConfig, logger, extras?.cwd ?? workDir);
@@ -633,6 +705,7 @@ export async function runLoop(config: LoopConfig): Promise<LoopResult> {
633
705
  };
634
706
  try {
635
707
  const committed = await commitAll(commitMessage, workDir, logger);
708
+ commitCreated = committed;
636
709
  deliveryNotes.push(committed ? `自动提交:已提交(${commitMessage.title})` : '自动提交:未生成提交(可能无变更或提交失败)');
637
710
  } catch (error) {
638
711
  deliveryNotes.push(`自动提交:失败(${String(error)})`);
@@ -650,6 +723,7 @@ export async function runLoop(config: LoopConfig): Promise<LoopResult> {
650
723
  } else {
651
724
  try {
652
725
  await pushBranch(branchName, workDir, logger);
726
+ pushSucceeded = true;
653
727
  deliveryNotes.push(`自动推送:已推送(${branchName})`);
654
728
  } catch (error) {
655
729
  deliveryNotes.push(`自动推送:失败(${String(error)})`);
@@ -659,6 +733,10 @@ export async function runLoop(config: LoopConfig): Promise<LoopResult> {
659
733
  deliveryNotes.push('自动推送:未开启');
660
734
  }
661
735
 
736
+ if (commitCreated && pushSucceeded) {
737
+ commitLink = await resolveCommitLink(workDir, logger);
738
+ }
739
+
662
740
  if (config.pr.enable) {
663
741
  if (lastTestFailed) {
664
742
  deliveryNotes.push('PR 创建:已跳过(测试未通过)');
@@ -719,6 +797,8 @@ export async function runLoop(config: LoopConfig): Promise<LoopResult> {
719
797
  deliveryNotes.push('PR 创建:未开启(缺少分支名)');
720
798
  }
721
799
 
800
+ prLink = normalizeWebhookUrl(prInfo?.url);
801
+
722
802
  if (deliveryNotes.length > 0) {
723
803
  const record = formatSystemRecord('提交与PR', deliveryNotes.join('\n'), isoNow());
724
804
  await appendSection(workflowFiles.notesFile, record);
package/src/utils.ts CHANGED
@@ -122,6 +122,19 @@ export function isoNow(): string {
122
122
  return new Date().toISOString();
123
123
  }
124
124
 
125
+ /**
126
+ * 返回本地时区时间戳(YYYYMMDD-HHmmss)。
127
+ */
128
+ export function localTimestamp(date: Date = new Date()): string {
129
+ const year = date.getFullYear();
130
+ const month = pad2(date.getMonth() + 1);
131
+ const day = pad2(date.getDate());
132
+ const hours = pad2(date.getHours());
133
+ const minutes = pad2(date.getMinutes());
134
+ const seconds = pad2(date.getSeconds());
135
+ return `${year}${month}${day}-${hours}${minutes}${seconds}`;
136
+ }
137
+
125
138
  /**
126
139
  * 基于 cwd 解析相对路径。
127
140
  */
package/src/webhook.ts CHANGED
@@ -1,27 +1,57 @@
1
1
  import { Logger } from './logger';
2
- import { isoNow } from './utils';
2
+ import { localTimestamp } from './utils';
3
3
  import type { WebhookConfig } from './types';
4
4
 
5
5
  export type WebhookEvent = 'task_start' | 'iteration_start' | 'task_end';
6
6
 
7
- export interface WebhookPayload {
7
+ export interface WebhookPayloadBase {
8
8
  readonly event: WebhookEvent;
9
- readonly task: string;
10
9
  readonly branch: string;
11
10
  readonly iteration: number;
12
11
  readonly stage: string;
13
12
  readonly timestamp: string;
13
+ readonly project: string;
14
+ readonly commit: string;
15
+ readonly pr: string;
16
+ /**
17
+ * 计划内容,仅在 iteration_start 且 stage 为“计划生成”时携带(换行已标准化为 \n)。
18
+ */
19
+ readonly plan?: string;
14
20
  }
15
21
 
16
- export interface WebhookPayloadInput {
22
+ export type WebhookPayload =
23
+ | (WebhookPayloadBase & {
24
+ readonly event: 'task_start';
25
+ readonly task: string;
26
+ })
27
+ | (WebhookPayloadBase & {
28
+ readonly event: 'iteration_start' | 'task_end';
29
+ });
30
+
31
+ export interface WebhookPayloadInputBase {
17
32
  readonly event: WebhookEvent;
18
- readonly task: string;
19
33
  readonly branch?: string;
20
34
  readonly iteration: number;
21
35
  readonly stage: string;
22
36
  readonly timestamp?: string;
37
+ readonly project: string;
38
+ readonly commit?: string;
39
+ readonly pr?: string;
40
+ /**
41
+ * 计划内容,仅在 iteration_start 且 stage 为“计划生成”时输入(换行已标准化为 \n)。
42
+ */
43
+ readonly plan?: string;
23
44
  }
24
45
 
46
+ export type WebhookPayloadInput =
47
+ | (WebhookPayloadInputBase & {
48
+ readonly event: 'task_start';
49
+ readonly task: string;
50
+ })
51
+ | (WebhookPayloadInputBase & {
52
+ readonly event: 'iteration_start' | 'task_end';
53
+ });
54
+
25
55
  export type FetchLikeResponse = {
26
56
  readonly ok: boolean;
27
57
  readonly status: number;
@@ -45,13 +75,28 @@ export function normalizeWebhookUrls(urls?: string[]): string[] {
45
75
  }
46
76
 
47
77
  export function buildWebhookPayload(input: WebhookPayloadInput): WebhookPayload {
48
- return {
49
- event: input.event,
50
- task: input.task,
78
+ const base = {
51
79
  branch: input.branch ?? '',
52
80
  iteration: input.iteration,
53
81
  stage: input.stage,
54
- timestamp: input.timestamp ?? isoNow()
82
+ timestamp: input.timestamp ?? localTimestamp(),
83
+ project: input.project,
84
+ commit: input.commit ?? '',
85
+ pr: input.pr ?? '',
86
+ ...(input.plan !== undefined ? { plan: input.plan } : {})
87
+ };
88
+
89
+ if (input.event === 'task_start') {
90
+ return {
91
+ ...base,
92
+ event: 'task_start',
93
+ task: input.task
94
+ };
95
+ }
96
+
97
+ return {
98
+ ...base,
99
+ event: input.event
55
100
  };
56
101
  }
57
102
 
@@ -1,5 +1,7 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { execFile } from 'node:child_process';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
3
5
  import path from 'node:path';
4
6
  import { promisify } from 'node:util';
5
7
  import { test } from 'node:test';
@@ -78,17 +80,53 @@ test('CLI logs 在非 TTY 下输出提示', async () => {
78
80
  assert.ok(stdout.includes('当前终端不支持交互式 logs。'));
79
81
  });
80
82
 
81
- test('CLI set alias 帮助信息可正常输出', async () => {
83
+ test('CLI agent 帮助信息可正常输出', async () => {
82
84
  const execFileAsync = promisify(execFile);
83
85
  const cliPath = path.join(process.cwd(), 'src', 'cli.ts');
84
- const { stdout } = await execFileAsync('node', ['--require', 'ts-node/register', cliPath, 'set', 'alias', '--help'], {
86
+ const { stdout } = await execFileAsync('node', ['--require', 'ts-node/register', cliPath, 'agent', '--help'], {
85
87
  env: {
86
88
  ...process.env,
87
89
  FORCE_COLOR: '0'
88
90
  }
89
91
  });
90
92
 
91
- assert.ok(stdout.includes('Usage: wheel-ai set alias'));
93
+ assert.ok(stdout.includes('Usage: wheel-ai agent'));
94
+ });
95
+
96
+ test('CLI agent list 输出配置内容', async () => {
97
+ const execFileAsync = promisify(execFile);
98
+ const cliPath = path.join(process.cwd(), 'src', 'cli.ts');
99
+ const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wheel-ai-agent-'));
100
+ const configDir = path.join(homeDir, '.wheel-ai');
101
+ await fs.mkdir(configDir, { recursive: true });
102
+ await fs.writeFile(
103
+ path.join(configDir, 'config.toml'),
104
+ `[agent]\nclaude = "claude --model sonnet"\n`,
105
+ 'utf8'
106
+ );
107
+
108
+ const { stdout } = await execFileAsync('node', ['--require', 'ts-node/register', cliPath, 'agent', 'list'], {
109
+ env: {
110
+ ...process.env,
111
+ HOME: homeDir,
112
+ FORCE_COLOR: '0'
113
+ }
114
+ });
115
+
116
+ assert.ok(stdout.includes('claude: claude --model sonnet'));
117
+ });
118
+
119
+ test('CLI alias set 帮助信息可正常输出', async () => {
120
+ const execFileAsync = promisify(execFile);
121
+ const cliPath = path.join(process.cwd(), 'src', 'cli.ts');
122
+ const { stdout } = await execFileAsync('node', ['--require', 'ts-node/register', cliPath, 'alias', 'set', '--help'], {
123
+ env: {
124
+ ...process.env,
125
+ FORCE_COLOR: '0'
126
+ }
127
+ });
128
+
129
+ assert.ok(stdout.includes('Usage: wheel-ai alias set'));
92
130
  });
93
131
 
94
132
  test('CLI alias 帮助信息可正常输出', async () => {