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.
@@ -8,42 +8,33 @@
8
8
  - 与 git/PR 相关的操作仅使用 `gh` 命令(查看 PR、创建 PR、查看 Actions 失败记录)。
9
9
  - 默认测试命令:单元测试 `yarn test`,e2e 测试 `yarn e2e`,如需调整请在 plan 中显式写出新的命令。
10
10
 
11
- ## 步骤 1:完善需求
12
- - 复述并澄清任务,标出输入、输出、约束、验收标准。
13
- - 列出不确定点并自行做出合理假设(在 notes 记录假设)。
14
- - 产出:需求摘要(要写入 notes)。
15
-
16
- ## 步骤 2:生成计划
17
- - 形成分阶段的任务树:需求澄清 → 设计 → 开发 → 自审 → 测试 → PR。
18
- - 为每个阶段列出可执行子任务、负责人(默认 AI)、预计产出、检查点。
19
- - 将计划写入 `plan` 文件,保持随迭代更新,已完成项用 ✅ 标记。
20
-
21
- ## 步骤 3:设计与开发
22
- - 先给出技术方案:文件/模块变更列表、数据结构、关键算法、外部依赖、潜在风险。
23
- - 直接生成或修改代码;若需脚手架/依赖,请写出命令并在可行时直接执行。
24
- - 代码须符合 TypeScript 严格类型(避免 any),遵循项目现有规范。
25
-
26
- ## 步骤 4:代码自审
27
- - 自检清单:
28
- - 需求覆盖与边界条件是否完整。
29
- - 类型安全、错误处理、日志可读性。
30
- - git worktree 兼容性、命令幂等性。
31
- - 产出:风险与改进列表,写入 notes。
32
-
33
- ## 步骤 5:生成与执行测试
34
- - 至少输出:
35
- - 单元测试范围与用例表。
36
- - e2e 场景与前置条件。
37
- - 对应的命令行(可直接运行)。
38
- - 在环境允许时直接执行测试命令,收集结果与失败原因写入 notes。
39
-
40
- ## 步骤 6:推送并提交 PR
41
- - 检查 git 状态,准备提交信息(建议格式:`chore: <任务概要>`)。
42
- - 使用 `gh pr create --head <branch> --title "<标题>" --body-file <path>`(或 `--body "<正文>"`)创建 PR,正文应包含:
43
- - 变更摘要(bullet 列表)
44
- - 测试结果(含失败原因)
45
- - 风险与回滚方案
46
- - 若已有 PR,使用 `gh pr view <branch>` 获取链接;查看 Actions 失败可运行 `gh run list --branch <branch>`。
11
+ ## 步骤 1:初始化与计划
12
+ - 新建 AI session,基于 `-t` 任务生成规范分支名(<type>/<slug>),并写入 notes。
13
+ - 生成或优化 `plan.md`:
14
+ - 计划仅包含开发相关事项(设计/实现/重构/配置/文档更新),不包含测试、代码质量检查、PR 等环节。
15
+ - 若已有 plan.md 且合理则不改,不合理则优化或重写。
16
+ - 已完成项用 ✅ 标记。
17
+
18
+ ## 步骤 2:执行计划循环
19
+ - 每次循环仅执行 plan 中最后一条未完成项。
20
+ - 完成后立即在 plan.md 中标记 ✅,并将进展写入 notes。
21
+ - 当所有计划项完成时结束循环。
22
+
23
+ ## 步骤 3:代码质量审查
24
+ - 新建 AI session,读取项目配置并执行可用的代码质量检查(如 lint/typecheck)。
25
+ - 若 AGENTS.md 明确要求跳过,或命令行指定跳过该环节,则直接跳过并记录原因。
26
+ - 若检查失败:结束当前 session,创建新的 AI session 进行修复;修复后重新执行质量检查,直到通过。
27
+
28
+ ## 步骤 4:测试
29
+ - 新建 AI session,执行单元测试与 e2e 测试。
30
+ - 若测试失败:结束当前 session,创建新的 AI session 进行修复;修复后重新执行测试,直到通过。
31
+
32
+ ## 步骤 5:文档更新
33
+ - 更新版本号、CHANGELOG、README、docs 等必要文档,并记录到 notes。
34
+
35
+ ## 步骤 6:提交与 PR
36
+ - 如参数允许:`git add` → AI 总结变更 → `git commit` → `git push` → `gh pr create`。
37
+ - PR 标题/正文使用 AI 总结生成的内容;如配置自动合并则在检查通过后合并。
47
38
 
48
39
  ## 步骤 7:持久化记忆与对话续航
49
40
  - 每轮结束必须向 notes 追加:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hamster-wheel-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0-beta.1",
4
4
  "description": "基于 AI CLI 的持续迭代开发工具,封装工作流、git worktree 与 gh PR 协作",
5
5
  "main": "dist/index.js",
6
6
  "keywords": [
package/src/ai.ts CHANGED
@@ -39,6 +39,334 @@ export function buildPrompt(input: PromptInput): string {
39
39
  return sections.join('\n\n');
40
40
  }
41
41
 
42
+ function compactLine(text: string): string {
43
+ return text.replace(/\s+/g, ' ').trim();
44
+ }
45
+
46
+ interface BranchNamePromptInput {
47
+ readonly task: string;
48
+ }
49
+
50
+ /**
51
+ * 构建分支名生成提示。
52
+ */
53
+ export function buildBranchNamePrompt(input: BranchNamePromptInput): string {
54
+ return [
55
+ '# 角色',
56
+ '你是资深工程师,需要根据任务生成规范的 git 分支名。',
57
+ '# 规则',
58
+ '- 输出格式仅限严格 JSON(不要 markdown、不要代码块、不要解释)。',
59
+ '- 分支名格式:<type>/<slug>。',
60
+ '- type 可选:feat、fix、docs、refactor、chore、test。',
61
+ '- slug 使用小写英文、数字、连字符,长度 3~40,避免空格与中文。',
62
+ '# 输出 JSON',
63
+ '{"branch":"..."}',
64
+ '# 任务描述',
65
+ compactLine(input.task) || '(空)'
66
+ ].join('\n\n');
67
+ }
68
+
69
+ interface PlanningPromptInput {
70
+ readonly task: string;
71
+ readonly workflowGuide: string;
72
+ readonly plan: string;
73
+ readonly notes: string;
74
+ readonly branchName?: string;
75
+ }
76
+
77
+ /**
78
+ * 构建计划生成提示。
79
+ */
80
+ export function buildPlanningPrompt(input: PlanningPromptInput): string {
81
+ return [
82
+ '# 背景任务',
83
+ input.task,
84
+ '# 分支信息',
85
+ input.branchName ? `计划使用分支:${input.branchName}` : '未指定分支名,请按任务语义给出建议',
86
+ '# 工作流程基线(供 AI 自主执行)',
87
+ input.workflowGuide,
88
+ '# 当前计划',
89
+ input.plan || '(暂无计划)',
90
+ '# 历史记忆',
91
+ input.notes || '(暂无历史)',
92
+ '# 本轮执行要求',
93
+ [
94
+ '1. 分析任务输入/输出/约束/验收标准,必要时补充合理假设(写入 notes)。',
95
+ '2. 若 plan.md 已存在,请判断是否合理;合理则不修改,不合理则优化或重写。',
96
+ '3. 计划只包含开发相关任务(设计/实现/重构/配置/文档更新),不要包含测试、自审、PR、提交等内容。',
97
+ '4. 计划项需可执行、颗粒度清晰,已完成项使用 ✅ 标记。',
98
+ '5. 更新 memory/plan.md 与 memory/notes.md 后结束本轮。'
99
+ ].join('\n')
100
+ ].join('\n\n');
101
+ }
102
+
103
+ interface PlanItemPromptInput {
104
+ readonly task: string;
105
+ readonly workflowGuide: string;
106
+ readonly plan: string;
107
+ readonly notes: string;
108
+ readonly item: string;
109
+ }
110
+
111
+ /**
112
+ * 构建单条计划执行提示。
113
+ */
114
+ export function buildPlanItemPrompt(input: PlanItemPromptInput): string {
115
+ return [
116
+ '# 背景任务',
117
+ input.task,
118
+ '# 工作流程基线(供 AI 自主执行)',
119
+ input.workflowGuide,
120
+ '# 当前计划',
121
+ input.plan || '(暂无计划)',
122
+ '# 历史记忆',
123
+ input.notes || '(暂无历史)',
124
+ '# 本轮要执行的计划项(仅此一条)',
125
+ input.item,
126
+ '# 本轮执行要求',
127
+ [
128
+ '1. 只执行上述计划项,避免提前处理其它计划项。',
129
+ '2. 完成后立即在 plan.md 中将该项标记为 ✅。',
130
+ '3. 必要时可对计划项进行微调,但仍需确保当前项完成。',
131
+ '4. 本轮不执行测试或质量检查。',
132
+ '5. 将进展、关键改动与风险写入 notes。'
133
+ ].join('\n')
134
+ ].join('\n\n');
135
+ }
136
+
137
+ interface QualityPromptInput {
138
+ readonly task: string;
139
+ readonly workflowGuide: string;
140
+ readonly plan: string;
141
+ readonly notes: string;
142
+ readonly commands: string[];
143
+ readonly results?: string;
144
+ }
145
+
146
+ /**
147
+ * 构建质量检查提示。
148
+ */
149
+ export function buildQualityPrompt(input: QualityPromptInput): string {
150
+ return [
151
+ '# 背景任务',
152
+ input.task,
153
+ '# 工作流程基线(供 AI 自主执行)',
154
+ input.workflowGuide,
155
+ '# 当前计划',
156
+ input.plan || '(暂无计划)',
157
+ '# 历史记忆',
158
+ input.notes || '(暂无历史)',
159
+ '# 本轮代码质量检查',
160
+ input.commands.length > 0 ? input.commands.map(cmd => `- ${cmd}`).join('\n') : '未检测到可执行的质量检查命令。',
161
+ input.results ? `# 命令执行结果\n${input.results}` : '',
162
+ '# 本轮执行要求',
163
+ [
164
+ '1. 本轮仅进行代码质量检查,不要修复问题。',
165
+ '2. 若出现失败,记录失败要点,等待下一轮修复。',
166
+ '3. 将结论与风险写入 notes。'
167
+ ].join('\n')
168
+ ].filter(Boolean).join('\n\n');
169
+ }
170
+
171
+ interface FixPromptInput {
172
+ readonly task: string;
173
+ readonly workflowGuide: string;
174
+ readonly plan: string;
175
+ readonly notes: string;
176
+ readonly stage: string;
177
+ readonly errors: string;
178
+ }
179
+
180
+ /**
181
+ * 构建问题修复提示(质量检查 / 测试)。
182
+ */
183
+ export function buildFixPrompt(input: FixPromptInput): string {
184
+ return [
185
+ '# 背景任务',
186
+ input.task,
187
+ '# 工作流程基线(供 AI 自主执行)',
188
+ input.workflowGuide,
189
+ '# 当前计划',
190
+ input.plan || '(暂无计划)',
191
+ '# 历史记忆',
192
+ input.notes || '(暂无历史)',
193
+ `# 需要修复的问题(${input.stage})`,
194
+ input.errors || '(无错误信息)',
195
+ '# 本轮执行要求',
196
+ [
197
+ '1. 聚焦修复当前问题,不要扩展范围。',
198
+ '2. 修复完成后更新 notes,说明修改点与影响。',
199
+ '3. 如需调整计划,请同步更新 plan.md。'
200
+ ].join('\n')
201
+ ].join('\n\n');
202
+ }
203
+
204
+ interface TestPromptInput {
205
+ readonly task: string;
206
+ readonly workflowGuide: string;
207
+ readonly plan: string;
208
+ readonly notes: string;
209
+ readonly commands: string[];
210
+ readonly results?: string;
211
+ }
212
+
213
+ /**
214
+ * 构建测试执行提示。
215
+ */
216
+ export function buildTestPrompt(input: TestPromptInput): string {
217
+ return [
218
+ '# 背景任务',
219
+ input.task,
220
+ '# 工作流程基线(供 AI 自主执行)',
221
+ input.workflowGuide,
222
+ '# 当前计划',
223
+ input.plan || '(暂无计划)',
224
+ '# 历史记忆',
225
+ input.notes || '(暂无历史)',
226
+ '# 本轮测试命令',
227
+ input.commands.length > 0 ? input.commands.map(cmd => `- ${cmd}`).join('\n') : '未配置测试命令。',
228
+ input.results ? `# 测试结果\n${input.results}` : '',
229
+ '# 本轮执行要求',
230
+ [
231
+ '1. 本轮仅执行测试,不要修复问题。',
232
+ '2. 若出现失败,记录失败要点,等待下一轮修复。',
233
+ '3. 将测试结论写入 notes。'
234
+ ].join('\n')
235
+ ].filter(Boolean).join('\n\n');
236
+ }
237
+
238
+ interface DocsPromptInput {
239
+ readonly task: string;
240
+ readonly workflowGuide: string;
241
+ readonly plan: string;
242
+ readonly notes: string;
243
+ }
244
+
245
+ /**
246
+ * 构建文档更新提示。
247
+ */
248
+ export function buildDocsPrompt(input: DocsPromptInput): string {
249
+ return [
250
+ '# 背景任务',
251
+ input.task,
252
+ '# 工作流程基线(供 AI 自主执行)',
253
+ input.workflowGuide,
254
+ '# 当前计划',
255
+ input.plan || '(暂无计划)',
256
+ '# 历史记忆',
257
+ input.notes || '(暂无历史)',
258
+ '# 本轮执行要求',
259
+ [
260
+ '1. 根据本次改动更新版本号、CHANGELOG、README、docs 等相关文档。',
261
+ '2. 仅更新确有变化的文档,保持中文说明。',
262
+ '3. 将更新摘要写入 notes。'
263
+ ].join('\n')
264
+ ].join('\n\n');
265
+ }
266
+
267
+ function extractJson(text: string): string | null {
268
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
269
+ if (fenced?.[1]) return fenced[1].trim();
270
+ const start = text.indexOf('{');
271
+ const end = text.lastIndexOf('}');
272
+ if (start >= 0 && end > start) {
273
+ return text.slice(start, end + 1).trim();
274
+ }
275
+ return null;
276
+ }
277
+
278
+ const BRANCH_TYPES = ['feat', 'fix', 'docs', 'refactor', 'chore', 'test'] as const;
279
+ type BranchType = typeof BRANCH_TYPES[number];
280
+
281
+ const BRANCH_TYPE_ALIASES: Record<string, BranchType> = {
282
+ feature: 'feat',
283
+ features: 'feat',
284
+ bugfix: 'fix',
285
+ hotfix: 'fix',
286
+ doc: 'docs',
287
+ documentation: 'docs',
288
+ refactoring: 'refactor',
289
+ chores: 'chore',
290
+ tests: 'test'
291
+ };
292
+
293
+ function isBranchType(value: string): value is BranchType {
294
+ return BRANCH_TYPES.includes(value as BranchType);
295
+ }
296
+
297
+ function normalizeBranchType(value: string): BranchType | null {
298
+ const trimmed = value.trim().toLowerCase();
299
+ if (!trimmed) return null;
300
+ if (isBranchType(trimmed)) return trimmed;
301
+ return BRANCH_TYPE_ALIASES[trimmed] ?? null;
302
+ }
303
+
304
+ function normalizeBranchSlug(value: string): string | null {
305
+ const cleaned = value
306
+ .toLowerCase()
307
+ .replace(/\s+/g, '-')
308
+ .replace(/_/g, '-')
309
+ .replace(/[^a-z0-9-]/g, '-')
310
+ .replace(/-+/g, '-')
311
+ .replace(/^-+|-+$/g, '');
312
+ if (!cleaned) return null;
313
+ const trimmed = cleaned.slice(0, 40);
314
+ if (trimmed.length < 3) return null;
315
+ return trimmed;
316
+ }
317
+
318
+ function normalizeBranchNameCandidate(value: string): string | null {
319
+ const trimmed = value.trim();
320
+ if (!trimmed) return null;
321
+ const lowered = trimmed.toLowerCase();
322
+ const parts = lowered.split('/').filter(part => part.length > 0);
323
+ const hasExplicitType = lowered.includes('/') && parts.length >= 2;
324
+ const rawType = hasExplicitType ? parts.shift() ?? '' : '';
325
+ const rawSlug = hasExplicitType ? parts.join('-') : lowered;
326
+
327
+ const type = rawType ? normalizeBranchType(rawType) : 'feat';
328
+ if (!type) return null;
329
+
330
+ const slug = normalizeBranchSlug(rawSlug);
331
+ if (!slug) return null;
332
+
333
+ return `${type}/${slug}`;
334
+ }
335
+
336
+ /**
337
+ * 解析 AI 输出中的分支名。
338
+ */
339
+ export function parseBranchName(output: string): string | null {
340
+ const jsonText = extractJson(output);
341
+ if (jsonText) {
342
+ try {
343
+ const parsed = JSON.parse(jsonText) as Record<string, unknown>;
344
+ const raw = typeof parsed.branch === 'string'
345
+ ? parsed.branch
346
+ : typeof parsed.branchName === 'string'
347
+ ? parsed.branchName
348
+ : typeof parsed['分支'] === 'string'
349
+ ? (parsed['分支'] as string)
350
+ : typeof parsed['分支名'] === 'string'
351
+ ? (parsed['分支名'] as string)
352
+ : null;
353
+ if (raw) {
354
+ const normalized = normalizeBranchNameCandidate(raw);
355
+ if (normalized) return normalized;
356
+ }
357
+ } catch {
358
+ // 忽略解析失败,回退到文本匹配
359
+ }
360
+ }
361
+
362
+ const lineMatch = output.match(/(?:branch(?:name)?|分支名|分支)\s*[::]\s*([^\s]+)/i);
363
+ if (lineMatch?.[1]) {
364
+ const normalized = normalizeBranchNameCandidate(lineMatch[1]);
365
+ if (normalized) return normalized;
366
+ }
367
+ return null;
368
+ }
369
+
42
370
  function pickNumber(pattern: RegExp, text: string): number | undefined {
43
371
  const match = pattern.exec(text);
44
372
  if (!match || match.length < 2) return undefined;
@@ -139,8 +467,11 @@ export async function runAi(prompt: string, ai: AiCliConfig, logger: Logger, cwd
139
467
  * 生成 notes 迭代记录文本。
140
468
  */
141
469
  export function formatIterationRecord(record: IterationRecord): string {
470
+ const title = record.stage
471
+ ? `### 迭代 ${record.iteration} | ${record.timestamp} | ${record.stage}`
472
+ : `### 迭代 ${record.iteration} | ${record.timestamp}`;
142
473
  const lines = [
143
- `### 迭代 ${record.iteration} | ${record.timestamp}`,
474
+ title,
144
475
  '',
145
476
  '#### 提示上下文',
146
477
  '```',
@@ -154,6 +485,20 @@ export function formatIterationRecord(record: IterationRecord): string {
154
485
  ''
155
486
  ];
156
487
 
488
+ if (record.checkResults && record.checkResults.length > 0) {
489
+ lines.push('#### 质量检查结果');
490
+ record.checkResults.forEach(result => {
491
+ const status = result.success ? '✅ 通过' : '❌ 失败';
492
+ lines.push(`${status} | ${result.name} | 命令: ${result.command} | 退出码: ${result.exitCode}`);
493
+ if (!result.success) {
494
+ lines.push('```');
495
+ lines.push(result.stderr || result.stdout || '(无输出)');
496
+ lines.push('```');
497
+ lines.push('');
498
+ }
499
+ });
500
+ }
501
+
157
502
  if (record.testResults && record.testResults.length > 0) {
158
503
  lines.push('#### 测试结果');
159
504
  record.testResults.forEach(result => {
@@ -0,0 +1,221 @@
1
+ import fs from 'fs-extra';
2
+ import { AliasEntry, getGlobalConfigPath, parseAliasEntries } from './global-config';
3
+
4
+ interface AliasViewerState {
5
+ aliases: AliasEntry[];
6
+ selectedIndex: number;
7
+ listOffset: number;
8
+ missingConfig: boolean;
9
+ lastError?: string;
10
+ }
11
+
12
+ function getTerminalSize(): { rows: number; columns: number } {
13
+ const rows = process.stdout.rows ?? 24;
14
+ const columns = process.stdout.columns ?? 80;
15
+ return { rows, columns };
16
+ }
17
+
18
+ function truncateLine(line: string, width: number): string {
19
+ if (width <= 0) return '';
20
+ if (line.length <= width) return line;
21
+ return line.slice(0, width);
22
+ }
23
+
24
+ function getPageSize(rows: number): number {
25
+ return Math.max(1, rows - 2);
26
+ }
27
+
28
+ function buildAliasLabel(entry: AliasEntry): string {
29
+ if (entry.source === 'shortcut') {
30
+ return `${entry.name}(shortcut)`;
31
+ }
32
+ return entry.name;
33
+ }
34
+
35
+ function buildHeader(state: AliasViewerState, columns: number): string {
36
+ const total = state.aliases.length;
37
+ const title = `别名列表(${total} 条)|↑/↓ 选择 q 退出`;
38
+ return truncateLine(title, columns);
39
+ }
40
+
41
+ function buildStatus(state: AliasViewerState, columns: number): string {
42
+ if (state.aliases.length === 0) {
43
+ if (state.lastError) {
44
+ return truncateLine(`读取失败:${state.lastError}`, columns);
45
+ }
46
+ if (state.missingConfig) {
47
+ return truncateLine(`未找到配置文件:${getGlobalConfigPath()}`, columns);
48
+ }
49
+ return truncateLine('未发现 alias 配置', columns);
50
+ }
51
+
52
+ const entry = state.aliases[state.selectedIndex];
53
+ const sourceText = entry.source === 'shortcut' ? '(shortcut)' : '';
54
+ return truncateLine(`命令${sourceText}:${entry.command}`, columns);
55
+ }
56
+
57
+ function buildListLine(entry: AliasEntry, selected: boolean, columns: number): string {
58
+ const marker = selected ? '>' : ' ';
59
+ return truncateLine(`${marker} ${buildAliasLabel(entry)}`, columns);
60
+ }
61
+
62
+ function ensureListOffset(state: AliasViewerState, pageSize: number): void {
63
+ const total = state.aliases.length;
64
+ if (total === 0) {
65
+ state.listOffset = 0;
66
+ state.selectedIndex = 0;
67
+ return;
68
+ }
69
+ const maxOffset = Math.max(0, total - pageSize);
70
+ if (state.selectedIndex < state.listOffset) {
71
+ state.listOffset = state.selectedIndex;
72
+ }
73
+ if (state.selectedIndex >= state.listOffset + pageSize) {
74
+ state.listOffset = state.selectedIndex - pageSize + 1;
75
+ }
76
+ state.listOffset = Math.min(Math.max(state.listOffset, 0), maxOffset);
77
+ }
78
+
79
+ function render(state: AliasViewerState): void {
80
+ const { rows, columns } = getTerminalSize();
81
+ const pageSize = getPageSize(rows);
82
+ const header = buildHeader(state, columns);
83
+ ensureListOffset(state, pageSize);
84
+
85
+ if (state.aliases.length === 0) {
86
+ const filler = Array.from({ length: pageSize }, () => '');
87
+ const status = buildStatus(state, columns);
88
+ const content = [header, ...filler, status].join('\n');
89
+ process.stdout.write(`\u001b[2J\u001b[H${content}`);
90
+ return;
91
+ }
92
+
93
+ const start = state.listOffset;
94
+ const slice = state.aliases.slice(start, start + pageSize);
95
+ const lines = slice.map((entry, index) => {
96
+ const selected = start + index === state.selectedIndex;
97
+ return buildListLine(entry, selected, columns);
98
+ });
99
+ while (lines.length < pageSize) {
100
+ lines.push('');
101
+ }
102
+ const status = buildStatus(state, columns);
103
+ const content = [header, ...lines, status].join('\n');
104
+ process.stdout.write(`\u001b[2J\u001b[H${content}`);
105
+ }
106
+
107
+ function shouldExit(input: string): boolean {
108
+ if (input === '\u0003') return true;
109
+ if (input.toLowerCase() === 'q') return true;
110
+ return false;
111
+ }
112
+
113
+ function isArrowUp(input: string): boolean {
114
+ return input.includes('\u001b[A');
115
+ }
116
+
117
+ function isArrowDown(input: string): boolean {
118
+ return input.includes('\u001b[B');
119
+ }
120
+
121
+ function setupCleanup(cleanup: () => void): void {
122
+ const exitHandler = (): void => {
123
+ cleanup();
124
+ };
125
+ const signalHandler = (): void => {
126
+ cleanup();
127
+ process.exit(0);
128
+ };
129
+ process.on('SIGINT', signalHandler);
130
+ process.on('SIGTERM', signalHandler);
131
+ process.on('exit', exitHandler);
132
+ }
133
+
134
+ function clampIndex(value: number, total: number): number {
135
+ if (total <= 0) return 0;
136
+ return Math.min(Math.max(value, 0), total - 1);
137
+ }
138
+
139
+ /**
140
+ * 启动 alias 浏览界面。
141
+ */
142
+ export async function runAliasViewer(): Promise<void> {
143
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
144
+ console.log('当前终端不支持交互式 alias。');
145
+ return;
146
+ }
147
+
148
+ const state: AliasViewerState = {
149
+ aliases: [],
150
+ selectedIndex: 0,
151
+ listOffset: 0,
152
+ missingConfig: false
153
+ };
154
+
155
+ let cleaned = false;
156
+ const cleanup = (): void => {
157
+ if (cleaned) return;
158
+ cleaned = true;
159
+ if (process.stdin.isTTY) {
160
+ process.stdin.setRawMode(false);
161
+ process.stdin.pause();
162
+ }
163
+ process.stdout.write('\u001b[?25h');
164
+ };
165
+
166
+ setupCleanup(cleanup);
167
+ process.stdout.write('\u001b[?25l');
168
+ process.stdin.setRawMode(true);
169
+ process.stdin.resume();
170
+
171
+ const loadAliases = async (): Promise<void> => {
172
+ const filePath = getGlobalConfigPath();
173
+ const exists = await fs.pathExists(filePath);
174
+ if (!exists) {
175
+ state.aliases = [];
176
+ state.selectedIndex = 0;
177
+ state.lastError = undefined;
178
+ state.missingConfig = true;
179
+ return;
180
+ }
181
+
182
+ try {
183
+ const content = await fs.readFile(filePath, 'utf8');
184
+ state.aliases = parseAliasEntries(content);
185
+ state.selectedIndex = clampIndex(state.selectedIndex, state.aliases.length);
186
+ state.lastError = undefined;
187
+ state.missingConfig = false;
188
+ } catch (error) {
189
+ const message = error instanceof Error ? error.message : String(error);
190
+ state.aliases = [];
191
+ state.selectedIndex = 0;
192
+ state.lastError = message;
193
+ state.missingConfig = false;
194
+ }
195
+ };
196
+
197
+ await loadAliases();
198
+ render(state);
199
+
200
+ process.stdin.on('data', (data: Buffer) => {
201
+ const input = data.toString('utf8');
202
+ if (shouldExit(input)) {
203
+ cleanup();
204
+ process.exit(0);
205
+ }
206
+
207
+ if (isArrowUp(input)) {
208
+ state.selectedIndex = clampIndex(state.selectedIndex - 1, state.aliases.length);
209
+ render(state);
210
+ return;
211
+ }
212
+ if (isArrowDown(input)) {
213
+ state.selectedIndex = clampIndex(state.selectedIndex + 1, state.aliases.length);
214
+ render(state);
215
+ }
216
+ });
217
+
218
+ process.stdout.on('resize', () => {
219
+ render(state);
220
+ });
221
+ }