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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hamster-wheel-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "基于 AI CLI 的持续迭代开发工具,封装工作流、git worktree 与 gh PR 协作",
5
5
  "main": "dist/index.js",
6
6
  "keywords": [
@@ -15,7 +15,7 @@
15
15
  "wheel-ai": "dist/cli.js"
16
16
  },
17
17
  "repository": "git@github.com:wszxdhr/hamster-wheel-cli.git",
18
- "author": "ZX <wszxdhr@126.com>",
18
+ "author": "ZX <x@z-x.vip>",
19
19
  "license": "MIT",
20
20
  "type": "commonjs",
21
21
  "engines": {
package/src/cli.ts CHANGED
@@ -7,10 +7,16 @@ import {
7
7
  getGlobalConfigPath,
8
8
  loadGlobalConfig,
9
9
  normalizeAliasName,
10
+ normalizeAgentName,
10
11
  parseAliasEntries,
12
+ parseAgentEntries,
11
13
  splitCommandArgs,
14
+ removeAliasEntry,
15
+ removeAgentEntry,
16
+ upsertAgentEntry,
12
17
  upsertAliasEntry
13
18
  } from './global-config';
19
+ import type { AgentEntry, AliasEntry } from './global-config';
14
20
  import { getCurrentBranch } from './git';
15
21
  import { buildAutoLogFilePath, formatCommandLine } from './logs';
16
22
  import { runAliasViewer } from './alias-viewer';
@@ -77,6 +83,9 @@ const RUN_OPTION_FLAG_MAP = new Map<string, RunOptionSpec>(
77
83
  RUN_OPTION_SPECS.flatMap(spec => spec.flags.map(flag => [flag, spec] as const))
78
84
  );
79
85
 
86
+ const USE_ALIAS_FLAG = '--use-alias';
87
+ const USE_AGENT_FLAG = '--use-agent';
88
+
80
89
  function parseInteger(value: string, defaultValue: number): number {
81
90
  const parsed = Number.parseInt(value, 10);
82
91
  if (Number.isNaN(parsed)) return defaultValue;
@@ -111,7 +120,23 @@ function buildBackgroundArgs(argv: string[], logFile: string, branchName?: strin
111
120
 
112
121
  function extractAliasCommandArgs(argv: string[], name: string): string[] {
113
122
  const args = argv.slice(2);
114
- const start = args.findIndex((arg, index) => arg === 'set' && args[index + 1] === 'alias' && args[index + 2] === name);
123
+ const start = args.findIndex((arg, index) => {
124
+ const legacyMatch = arg === 'set' && args[index + 1] === 'alias' && args[index + 2] === name;
125
+ const aliasMatch =
126
+ isAliasCommandToken(arg) && args[index + 1] === 'set' && args[index + 2] === name;
127
+ return legacyMatch || aliasMatch;
128
+ });
129
+ if (start < 0) return [];
130
+ const rest = args.slice(start + 3);
131
+ if (rest[0] === '--') return rest.slice(1);
132
+ return rest;
133
+ }
134
+
135
+ function extractAgentCommandArgs(argv: string[], action: 'add' | 'set', name: string): string[] {
136
+ const args = argv.slice(2);
137
+ const start = args.findIndex(
138
+ (arg, index) => arg === 'agent' && args[index + 1] === action && args[index + 2] === name
139
+ );
115
140
  if (start < 0) return [];
116
141
  const rest = args.slice(start + 3);
117
142
  if (rest[0] === '--') return rest.slice(1);
@@ -133,7 +158,7 @@ function extractAliasRunArgs(argv: string[], name: string): string[] {
133
158
  return rest;
134
159
  }
135
160
 
136
- function normalizeAliasCommandArgs(args: string[]): string[] {
161
+ function normalizeRunCommandArgs(args: string[]): string[] {
137
162
  let start = 0;
138
163
  if (args[start] === 'wheel-ai') {
139
164
  start += 1;
@@ -213,21 +238,168 @@ function parseArgSegments(tokens: string[]): ParsedArgSegment[] {
213
238
  return segments;
214
239
  }
215
240
 
216
- function mergeAliasCommandArgs(aliasTokens: string[], additionTokens: string[]): string[] {
217
- const aliasSegments = parseArgSegments(aliasTokens);
241
+ // 按选项名合并 run 参数,同名选项以“后出现覆盖前出现”为准。
242
+ function mergeRunCommandArgs(baseTokens: string[], additionTokens: string[]): string[] {
243
+ const baseSegments = parseArgSegments(baseTokens);
218
244
  const additionSegments = parseArgSegments(additionTokens);
219
245
  const overrideNames = new Set(
220
246
  additionSegments.flatMap(segment => (segment.name ? [segment.name] : []))
221
247
  );
222
248
 
223
249
  const merged = [
224
- ...aliasSegments.filter(segment => !segment.name || !overrideNames.has(segment.name)),
250
+ ...baseSegments.filter(segment => !segment.name || !overrideNames.has(segment.name)),
225
251
  ...additionSegments
226
252
  ];
227
253
 
228
254
  return merged.flatMap(segment => segment.tokens);
229
255
  }
230
256
 
257
+ interface ExpandedRunTokens {
258
+ readonly tokens: string[];
259
+ readonly expanded: boolean;
260
+ }
261
+
262
+ interface UseOptionMatch {
263
+ readonly type: 'alias' | 'agent';
264
+ readonly name: string;
265
+ readonly nextIndex: number;
266
+ }
267
+
268
+ function extractRunCommandArgs(argv: string[]): string[] {
269
+ const args = argv.slice(2);
270
+ const start = args.findIndex(arg => arg === 'run');
271
+ if (start < 0) return [];
272
+ return args.slice(start + 1);
273
+ }
274
+
275
+ function parseUseOptionToken(token: string, flag: string): { matched: boolean; value?: string } {
276
+ if (token === flag) {
277
+ return { matched: true };
278
+ }
279
+ if (token.startsWith(`${flag}=`)) {
280
+ return { matched: true, value: token.slice(flag.length + 1) };
281
+ }
282
+ return { matched: false };
283
+ }
284
+
285
+ function resolveUseOption(tokens: string[], index: number): UseOptionMatch | null {
286
+ const token = tokens[index];
287
+ const aliasMatch = parseUseOptionToken(token, USE_ALIAS_FLAG);
288
+ if (aliasMatch.matched) {
289
+ const value = aliasMatch.value ?? tokens[index + 1];
290
+ if (!value) {
291
+ throw new Error(`${USE_ALIAS_FLAG} 需要提供名称`);
292
+ }
293
+ const nextIndex = aliasMatch.value ? index + 1 : index + 2;
294
+ return { type: 'alias', name: value, nextIndex };
295
+ }
296
+
297
+ const agentMatch = parseUseOptionToken(token, USE_AGENT_FLAG);
298
+ if (agentMatch.matched) {
299
+ const value = agentMatch.value ?? tokens[index + 1];
300
+ if (!value) {
301
+ throw new Error(`${USE_AGENT_FLAG} 需要提供名称`);
302
+ }
303
+ const nextIndex = agentMatch.value ? index + 1 : index + 2;
304
+ return { type: 'agent', name: value, nextIndex };
305
+ }
306
+
307
+ return null;
308
+ }
309
+
310
+ function buildAliasRunArgs(entry: AliasEntry): string[] {
311
+ return normalizeRunCommandArgs(splitCommandArgs(entry.command));
312
+ }
313
+
314
+ function buildAgentRunArgs(entry: AgentEntry): string[] {
315
+ const tokens = normalizeRunCommandArgs(splitCommandArgs(entry.command));
316
+ if (tokens.length === 0) return [];
317
+ if (tokens[0].startsWith('-')) {
318
+ return tokens;
319
+ }
320
+ const [command, ...args] = tokens;
321
+ if (args.length === 0) {
322
+ return ['--ai-cli', command];
323
+ }
324
+ return ['--ai-cli', command, '--ai-args', ...args];
325
+ }
326
+
327
+ function expandRunTokens(
328
+ tokens: string[],
329
+ lookup: { aliasEntries: Map<string, AliasEntry>; agentEntries: Map<string, AgentEntry> },
330
+ stack: { alias: Set<string>; agent: Set<string> }
331
+ ): ExpandedRunTokens {
332
+ let mergedTokens: string[] = [];
333
+ let buffer: string[] = [];
334
+ let expanded = false;
335
+ let index = 0;
336
+
337
+ while (index < tokens.length) {
338
+ const token = tokens[index];
339
+ if (token === '--') {
340
+ buffer.push(...tokens.slice(index));
341
+ break;
342
+ }
343
+
344
+ const match = resolveUseOption(tokens, index);
345
+ if (!match) {
346
+ buffer.push(token);
347
+ index += 1;
348
+ continue;
349
+ }
350
+
351
+ if (buffer.length > 0) {
352
+ mergedTokens = mergeRunCommandArgs(mergedTokens, buffer);
353
+ buffer = [];
354
+ }
355
+
356
+ expanded = true;
357
+
358
+ if (match.type === 'alias') {
359
+ const normalized = normalizeAliasName(match.name);
360
+ if (!normalized) {
361
+ throw new Error('alias 名称不能为空且不能包含空白字符');
362
+ }
363
+ if (stack.alias.has(normalized)) {
364
+ throw new Error(`alias 循环引用:${normalized}`);
365
+ }
366
+ const entry = lookup.aliasEntries.get(normalized);
367
+ if (!entry) {
368
+ throw new Error(`未找到 alias:${normalized}`);
369
+ }
370
+ stack.alias.add(normalized);
371
+ const resolved = expandRunTokens(buildAliasRunArgs(entry), lookup, stack);
372
+ stack.alias.delete(normalized);
373
+ mergedTokens = mergeRunCommandArgs(mergedTokens, resolved.tokens);
374
+ index = match.nextIndex;
375
+ continue;
376
+ }
377
+
378
+ const normalized = normalizeAgentName(match.name);
379
+ if (!normalized) {
380
+ throw new Error('agent 名称不能为空且不能包含空白字符');
381
+ }
382
+ if (stack.agent.has(normalized)) {
383
+ throw new Error(`agent 循环引用:${normalized}`);
384
+ }
385
+ const entry = lookup.agentEntries.get(normalized);
386
+ if (!entry) {
387
+ throw new Error(`未找到 agent:${normalized}`);
388
+ }
389
+ stack.agent.add(normalized);
390
+ const resolved = expandRunTokens(buildAgentRunArgs(entry), lookup, stack);
391
+ stack.agent.delete(normalized);
392
+ mergedTokens = mergeRunCommandArgs(mergedTokens, resolved.tokens);
393
+ index = match.nextIndex;
394
+ }
395
+
396
+ if (buffer.length > 0) {
397
+ mergedTokens = mergeRunCommandArgs(mergedTokens, buffer);
398
+ }
399
+
400
+ return { tokens: mergedTokens, expanded };
401
+ }
402
+
231
403
  async function runForegroundWithDetach(options: {
232
404
  argv: string[];
233
405
  logFile: string;
@@ -332,15 +504,17 @@ export async function runCli(argv: string[]): Promise<void> {
332
504
  program
333
505
  .name('wheel-ai')
334
506
  .description('基于 AI CLI 的持续迭代开发工具')
335
- .version('0.2.0');
507
+ .version('0.2.1');
336
508
  program.addHelpText(
337
509
  'after',
338
- '\n别名执行:\n wheel-ai alias run <alias> <addition...>\n 追加命令与 alias 重叠时,以追加为准。\n'
510
+ '\nalias 管理:\n wheel-ai alias set <name> <options...>\n wheel-ai alias list\n wheel-ai alias delete <name>\n\nalias/agent 叠加:\n wheel-ai run --use-alias <name> [--use-alias <name>...]\n wheel-ai run --use-agent <name> [--use-agent <name>...]\n 同名选项按出现顺序覆盖。\n'
339
511
  );
340
512
 
341
513
  program
342
514
  .command('run')
343
515
  .option('-t, --task <task>', '需要完成的任务描述(可重复传入,独立处理)', collect, [])
516
+ .option('--use-alias <name>', '叠加 alias 配置(可重复)', collect, [])
517
+ .option('--use-agent <name>', '叠加 agent 配置(可重复)', collect, [])
344
518
  .option('-i, --iterations <number>', '最大迭代次数', value => parseInteger(value, 5), 5)
345
519
  .option('--ai-cli <command>', 'AI CLI 命令', 'claude')
346
520
  .option('--ai-args <args...>', 'AI CLI 参数', [])
@@ -374,6 +548,46 @@ export async function runCli(argv: string[]): Promise<void> {
374
548
  .option('-v, --verbose', '输出调试日志', false)
375
549
  .option('--skip-quality', '跳过代码质量检查', false)
376
550
  .action(async (options) => {
551
+ const rawRunArgs = extractRunCommandArgs(effectiveArgv);
552
+ const hasUseOptions = rawRunArgs.some(
553
+ token =>
554
+ token === USE_ALIAS_FLAG ||
555
+ token.startsWith(`${USE_ALIAS_FLAG}=`) ||
556
+ token === USE_AGENT_FLAG ||
557
+ token.startsWith(`${USE_AGENT_FLAG}=`)
558
+ );
559
+
560
+ if (hasUseOptions) {
561
+ const filePath = getGlobalConfigPath();
562
+ const exists = await fs.pathExists(filePath);
563
+ const content = exists ? await fs.readFile(filePath, 'utf8') : '';
564
+ const aliasEntries = parseAliasEntries(content);
565
+ const agentEntries = parseAgentEntries(content);
566
+ const resolved = expandRunTokens(
567
+ rawRunArgs,
568
+ {
569
+ aliasEntries: new Map(aliasEntries.map(entry => [entry.name, entry])),
570
+ agentEntries: new Map(agentEntries.map(entry => [entry.name, entry]))
571
+ },
572
+ {
573
+ alias: new Set<string>(),
574
+ agent: new Set<string>()
575
+ }
576
+ );
577
+
578
+ if (resolved.expanded) {
579
+ const nextArgv = [process.argv[0], process.argv[1], 'run', ...resolved.tokens];
580
+ const originalArgv = process.argv;
581
+ process.argv = nextArgv;
582
+ try {
583
+ await runCli(nextArgv);
584
+ } finally {
585
+ process.argv = originalArgv;
586
+ }
587
+ return;
588
+ }
589
+ }
590
+
377
591
  const tasks = normalizeTaskList(options.task as string[] | string | undefined);
378
592
  if (tasks.length === 0) {
379
593
  throw new Error('需要至少提供一个任务描述');
@@ -558,12 +772,116 @@ export async function runCli(argv: string[]): Promise<void> {
558
772
  await runLogsViewer();
559
773
  });
560
774
 
561
- program
562
- .command('set')
563
- .description('写入全局配置')
564
- .command('alias <name> [options...]')
565
- .description('设置 alias')
775
+ const agentCommand = program
776
+ .command('agent')
777
+ .description('管理 AI CLI agent 配置');
778
+
779
+ agentCommand
780
+ .command('add <name> [command...]')
781
+ .description('新增 agent')
782
+ .allowUnknownOption(true)
783
+ .allowExcessArguments(true)
784
+ .action(async (name: string) => {
785
+ const normalized = normalizeAgentName(name);
786
+ if (!normalized) {
787
+ throw new Error('agent 名称不能为空且不能包含空白字符');
788
+ }
789
+ const commandArgs = extractAgentCommandArgs(effectiveArgv, 'add', name);
790
+ const commandLine = formatCommandLine(commandArgs);
791
+ if (!commandLine) {
792
+ throw new Error('agent 命令不能为空');
793
+ }
794
+
795
+ const filePath = getGlobalConfigPath();
796
+ const exists = await fs.pathExists(filePath);
797
+ const content = exists ? await fs.readFile(filePath, 'utf8') : '';
798
+ const entries = parseAgentEntries(content);
799
+ if (entries.some(entry => entry.name === normalized)) {
800
+ throw new Error(`agent 已存在:${normalized}`);
801
+ }
802
+
803
+ await upsertAgentEntry(normalized, commandLine);
804
+ console.log(`已新增 agent:${normalized}`);
805
+ });
806
+
807
+ agentCommand
808
+ .command('set <name> [command...]')
809
+ .description('写入 agent')
810
+ .allowUnknownOption(true)
811
+ .allowExcessArguments(true)
812
+ .action(async (name: string) => {
813
+ const normalized = normalizeAgentName(name);
814
+ if (!normalized) {
815
+ throw new Error('agent 名称不能为空且不能包含空白字符');
816
+ }
817
+ const commandArgs = extractAgentCommandArgs(effectiveArgv, 'set', name);
818
+ const commandLine = formatCommandLine(commandArgs);
819
+ if (!commandLine) {
820
+ throw new Error('agent 命令不能为空');
821
+ }
822
+ await upsertAgentEntry(normalized, commandLine);
823
+ console.log(`已写入 agent:${normalized}`);
824
+ });
825
+
826
+ agentCommand
827
+ .command('delete <name>')
828
+ .description('删除 agent')
829
+ .action(async (name: string) => {
830
+ const normalized = normalizeAgentName(name);
831
+ if (!normalized) {
832
+ throw new Error('agent 名称不能为空且不能包含空白字符');
833
+ }
834
+
835
+ const filePath = getGlobalConfigPath();
836
+ const exists = await fs.pathExists(filePath);
837
+ if (!exists) {
838
+ throw new Error(`未找到 agent 配置文件:${filePath}`);
839
+ }
840
+
841
+ const removed = await removeAgentEntry(normalized);
842
+ if (!removed) {
843
+ throw new Error(`未找到 agent:${normalized}`);
844
+ }
845
+ console.log(`已删除 agent:${normalized}`);
846
+ });
847
+
848
+ agentCommand
849
+ .command('list')
850
+ .description('列出 agent 配置')
851
+ .action(async () => {
852
+ const filePath = getGlobalConfigPath();
853
+ const exists = await fs.pathExists(filePath);
854
+ if (!exists) {
855
+ console.log('未发现 agent 配置');
856
+ return;
857
+ }
858
+
859
+ const content = await fs.readFile(filePath, 'utf8');
860
+ const entries = parseAgentEntries(content);
861
+ if (entries.length === 0) {
862
+ console.log('未发现 agent 配置');
863
+ return;
864
+ }
865
+
866
+ entries.forEach(entry => {
867
+ console.log(`${entry.name}: ${entry.command}`);
868
+ });
869
+ });
870
+
871
+ agentCommand.action(() => {
872
+ agentCommand.help();
873
+ });
874
+
875
+ const aliasCommand = program
876
+ .command('alias')
877
+ .alias('aliases')
878
+ .description('管理全局 alias 配置');
879
+
880
+ aliasCommand
881
+ .command('set <name> [options...]')
882
+ .description('写入 alias')
566
883
  .allowUnknownOption(true)
884
+ .allowExcessArguments(true)
567
885
  .action(async (name: string) => {
568
886
  const normalized = normalizeAliasName(name);
569
887
  if (!normalized) {
@@ -578,10 +896,50 @@ export async function runCli(argv: string[]): Promise<void> {
578
896
  console.log(`已写入 alias:${normalized}`);
579
897
  });
580
898
 
581
- const aliasCommand = program
582
- .command('alias')
583
- .alias('aliases')
584
- .description('浏览全局 alias 配置(alias run 可执行并追加命令)');
899
+ aliasCommand
900
+ .command('list')
901
+ .description('列出 alias 配置')
902
+ .action(async () => {
903
+ const filePath = getGlobalConfigPath();
904
+ const exists = await fs.pathExists(filePath);
905
+ if (!exists) {
906
+ console.log('未发现 alias 配置');
907
+ return;
908
+ }
909
+
910
+ const content = await fs.readFile(filePath, 'utf8');
911
+ const entries = parseAliasEntries(content).filter(entry => entry.source === 'alias');
912
+ if (entries.length === 0) {
913
+ console.log('未发现 alias 配置');
914
+ return;
915
+ }
916
+
917
+ entries.forEach(entry => {
918
+ console.log(`${entry.name}: ${entry.command}`);
919
+ });
920
+ });
921
+
922
+ aliasCommand
923
+ .command('delete <name>')
924
+ .description('删除 alias')
925
+ .action(async (name: string) => {
926
+ const normalized = normalizeAliasName(name);
927
+ if (!normalized) {
928
+ throw new Error('alias 名称不能为空且不能包含空白字符');
929
+ }
930
+
931
+ const filePath = getGlobalConfigPath();
932
+ const exists = await fs.pathExists(filePath);
933
+ if (!exists) {
934
+ throw new Error(`未找到 alias 配置文件:${filePath}`);
935
+ }
936
+
937
+ const removed = await removeAliasEntry(normalized);
938
+ if (!removed) {
939
+ throw new Error(`未找到 alias:${normalized}`);
940
+ }
941
+ console.log(`已删除 alias:${normalized}`);
942
+ });
585
943
 
586
944
  aliasCommand
587
945
  .command('run <name> [addition...]')
@@ -607,9 +965,9 @@ export async function runCli(argv: string[]): Promise<void> {
607
965
  throw new Error(`未找到 alias:${normalized}`);
608
966
  }
609
967
 
610
- const aliasTokens = normalizeAliasCommandArgs(splitCommandArgs(entry.command));
968
+ const aliasTokens = normalizeRunCommandArgs(splitCommandArgs(entry.command));
611
969
  const additionTokens = extractAliasRunArgs(effectiveArgv, normalized);
612
- const mergedTokens = mergeAliasCommandArgs(aliasTokens, additionTokens);
970
+ const mergedTokens = mergeRunCommandArgs(aliasTokens, additionTokens);
613
971
  if (mergedTokens.length === 0) {
614
972
  throw new Error('alias 命令不能为空');
615
973
  }