hamster-wheel-cli 0.2.0-beta.1 → 0.2.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hamster-wheel-cli",
3
- "version": "0.2.0-beta.1",
3
+ "version": "0.2.0",
4
4
  "description": "基于 AI CLI 的持续迭代开发工具,封装工作流、git worktree 与 gh PR 协作",
5
5
  "main": "dist/index.js",
6
6
  "keywords": [
package/src/cli.ts CHANGED
@@ -2,7 +2,15 @@ import { spawn } from 'node:child_process';
2
2
  import fs from 'fs-extra';
3
3
  import { Command } from 'commander';
4
4
  import { buildLoopConfig, CliOptions, defaultNotesPath, defaultPlanPath, defaultWorkflowDoc } from './config';
5
- import { applyShortcutArgv, loadGlobalConfig, normalizeAliasName, upsertAliasEntry } from './global-config';
5
+ import {
6
+ applyShortcutArgv,
7
+ getGlobalConfigPath,
8
+ loadGlobalConfig,
9
+ normalizeAliasName,
10
+ parseAliasEntries,
11
+ splitCommandArgs,
12
+ upsertAliasEntry
13
+ } from './global-config';
6
14
  import { getCurrentBranch } from './git';
7
15
  import { buildAutoLogFilePath, formatCommandLine } from './logs';
8
16
  import { runAliasViewer } from './alias-viewer';
@@ -16,6 +24,59 @@ import { resolvePath } from './utils';
16
24
 
17
25
  const FOREGROUND_CHILD_ENV = 'WHEEL_AI_FOREGROUND_CHILD';
18
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
+
19
80
  function parseInteger(value: string, defaultValue: number): number {
20
81
  const parsed = Number.parseInt(value, 10);
21
82
  if (Number.isNaN(parsed)) return defaultValue;
@@ -57,6 +118,116 @@ function extractAliasCommandArgs(argv: string[], name: string): string[] {
57
118
  return rest;
58
119
  }
59
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
+
60
231
  async function runForegroundWithDetach(options: {
61
232
  argv: string[];
62
233
  logFile: string;
@@ -161,7 +332,11 @@ export async function runCli(argv: string[]): Promise<void> {
161
332
  program
162
333
  .name('wheel-ai')
163
334
  .description('基于 AI CLI 的持续迭代开发工具')
164
- .version('1.0.0');
335
+ .version('0.2.0');
336
+ program.addHelpText(
337
+ 'after',
338
+ '\n别名执行:\n wheel-ai alias run <alias> <addition...>\n 追加命令与 alias 重叠时,以追加为准。\n'
339
+ );
165
340
 
166
341
  program
167
342
  .command('run')
@@ -403,14 +578,56 @@ export async function runCli(argv: string[]): Promise<void> {
403
578
  console.log(`已写入 alias:${normalized}`);
404
579
  });
405
580
 
406
- program
581
+ const aliasCommand = program
407
582
  .command('alias')
408
583
  .alias('aliases')
409
- .description('浏览全局 alias 配置')
410
- .action(async () => {
411
- await runAliasViewer();
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
+ }
412
625
  });
413
626
 
627
+ aliasCommand.action(async () => {
628
+ await runAliasViewer();
629
+ });
630
+
414
631
  await program.parseAsync(effectiveArgv);
415
632
  }
416
633