hamster-wheel-cli 0.1.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.
Files changed (55) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.yml +107 -0
  2. package/.github/ISSUE_TEMPLATE/config.yml +15 -0
  3. package/.github/ISSUE_TEMPLATE/feature_request.yml +56 -0
  4. package/.github/workflows/ci-pr.yml +50 -0
  5. package/.github/workflows/publish.yml +121 -0
  6. package/.github/workflows/sync-master-to-dev.yml +100 -0
  7. package/AGENTS.md +20 -0
  8. package/CHANGELOG.md +12 -0
  9. package/LICENSE +21 -0
  10. package/README.md +90 -0
  11. package/dist/cli.d.ts +6 -0
  12. package/dist/cli.js +2678 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/index.d.ts +80 -0
  15. package/dist/index.js +2682 -0
  16. package/dist/index.js.map +1 -0
  17. package/docs/ai-workflow.md +58 -0
  18. package/package.json +44 -0
  19. package/src/ai.ts +173 -0
  20. package/src/cli.ts +189 -0
  21. package/src/config.ts +134 -0
  22. package/src/deps.ts +210 -0
  23. package/src/gh.ts +228 -0
  24. package/src/git.ts +285 -0
  25. package/src/global-config.ts +296 -0
  26. package/src/index.ts +3 -0
  27. package/src/logger.ts +122 -0
  28. package/src/logs-viewer.ts +420 -0
  29. package/src/logs.ts +132 -0
  30. package/src/loop.ts +422 -0
  31. package/src/monitor.ts +291 -0
  32. package/src/runtime-tracker.ts +65 -0
  33. package/src/summary.ts +255 -0
  34. package/src/types.ts +176 -0
  35. package/src/utils.ts +179 -0
  36. package/src/webhook.ts +107 -0
  37. package/tests/deps.test.ts +72 -0
  38. package/tests/e2e/cli.e2e.test.ts +77 -0
  39. package/tests/e2e/gh-pr-create.e2e.test.ts +55 -0
  40. package/tests/e2e/gh-run-list.e2e.test.ts +47 -0
  41. package/tests/gh-pr-create.test.ts +55 -0
  42. package/tests/gh-run-list.test.ts +35 -0
  43. package/tests/global-config.test.ts +52 -0
  44. package/tests/logger-file.test.ts +56 -0
  45. package/tests/logger.test.ts +72 -0
  46. package/tests/logs-viewer.test.ts +57 -0
  47. package/tests/logs.test.ts +33 -0
  48. package/tests/prompt.test.ts +20 -0
  49. package/tests/run-command-stream.test.ts +60 -0
  50. package/tests/summary.test.ts +58 -0
  51. package/tests/token-usage.test.ts +33 -0
  52. package/tests/utils.test.ts +8 -0
  53. package/tests/webhook.test.ts +89 -0
  54. package/tsconfig.json +18 -0
  55. package/tsup.config.ts +18 -0
@@ -0,0 +1,296 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import fs from 'fs-extra';
4
+ import type { Logger } from './logger';
5
+
6
+ /**
7
+ * 全局快捷指令配置。
8
+ */
9
+ export interface ShortcutConfig {
10
+ readonly name: string;
11
+ readonly command: string;
12
+ }
13
+
14
+ /**
15
+ * 全局配置结构。
16
+ */
17
+ export interface GlobalConfig {
18
+ readonly shortcut?: ShortcutConfig;
19
+ }
20
+
21
+ /**
22
+ * 获取全局配置文件路径。
23
+ */
24
+ export function getGlobalConfigPath(): string {
25
+ return path.join(os.homedir(), '.wheel-ai', 'config.toml');
26
+ }
27
+
28
+ function stripTomlComment(line: string): string {
29
+ let quote: '"' | '\'' | null = null;
30
+ let escaped = false;
31
+ for (let i = 0; i < line.length; i += 1) {
32
+ const char = line[i];
33
+ if (escaped) {
34
+ escaped = false;
35
+ continue;
36
+ }
37
+ if (quote) {
38
+ if (quote === '"' && char === '\\') {
39
+ escaped = true;
40
+ continue;
41
+ }
42
+ if (char === quote) {
43
+ quote = null;
44
+ }
45
+ continue;
46
+ }
47
+ if (char === '"' || char === '\'') {
48
+ quote = char;
49
+ continue;
50
+ }
51
+ if (char === '#' || char === ';') {
52
+ return line.slice(0, i);
53
+ }
54
+ }
55
+ return line;
56
+ }
57
+
58
+ function findUnquotedIndex(text: string, target: string): number {
59
+ let quote: '"' | '\'' | null = null;
60
+ let escaped = false;
61
+ for (let i = 0; i < text.length; i += 1) {
62
+ const char = text[i];
63
+ if (escaped) {
64
+ escaped = false;
65
+ continue;
66
+ }
67
+ if (quote) {
68
+ if (quote === '"' && char === '\\') {
69
+ escaped = true;
70
+ continue;
71
+ }
72
+ if (char === quote) {
73
+ quote = null;
74
+ }
75
+ continue;
76
+ }
77
+ if (char === '"' || char === '\'') {
78
+ quote = char;
79
+ continue;
80
+ }
81
+ if (char === target) {
82
+ return i;
83
+ }
84
+ }
85
+ return -1;
86
+ }
87
+
88
+ function parseTomlString(raw: string): string | null {
89
+ const value = raw.trim();
90
+ if (value.length < 2) return null;
91
+ const quote = value[0];
92
+ if (quote !== '"' && quote !== '\'') return null;
93
+ let result = '';
94
+ let escaped = false;
95
+ for (let i = 1; i < value.length; i += 1) {
96
+ const char = value[i];
97
+ if (quote === '"') {
98
+ if (escaped) {
99
+ switch (char) {
100
+ case 'n':
101
+ result += '\n';
102
+ break;
103
+ case 't':
104
+ result += '\t';
105
+ break;
106
+ case 'r':
107
+ result += '\r';
108
+ break;
109
+ case '"':
110
+ case '\\':
111
+ result += char;
112
+ break;
113
+ default:
114
+ result += char;
115
+ break;
116
+ }
117
+ escaped = false;
118
+ continue;
119
+ }
120
+ if (char === '\\') {
121
+ escaped = true;
122
+ continue;
123
+ }
124
+ if (char === quote) {
125
+ const rest = value.slice(i + 1).trim();
126
+ if (rest.length > 0) return null;
127
+ return result;
128
+ }
129
+ result += char;
130
+ continue;
131
+ }
132
+
133
+ if (char === quote) {
134
+ const rest = value.slice(i + 1).trim();
135
+ if (rest.length > 0) return null;
136
+ return result;
137
+ }
138
+ result += char;
139
+ }
140
+ return null;
141
+ }
142
+
143
+ function normalizeShortcutName(name: string): string | null {
144
+ const trimmed = name.trim();
145
+ if (!trimmed) return null;
146
+ if (/\s/.test(trimmed)) return null;
147
+ return trimmed;
148
+ }
149
+
150
+ /**
151
+ * 解析全局 TOML 配置文本。
152
+ */
153
+ export function parseGlobalConfig(content: string): GlobalConfig {
154
+ const lines = content.split(/\r?\n/);
155
+ let currentSection: string | null = null;
156
+ const shortcut: Record<string, string> = {};
157
+
158
+ for (const rawLine of lines) {
159
+ const line = stripTomlComment(rawLine).trim();
160
+ if (!line) continue;
161
+
162
+ const sectionMatch = /^\[(.+)\]$/.exec(line);
163
+ if (sectionMatch) {
164
+ currentSection = sectionMatch[1].trim();
165
+ continue;
166
+ }
167
+
168
+ if (currentSection !== 'shortcut') continue;
169
+
170
+ const equalIndex = findUnquotedIndex(line, '=');
171
+ if (equalIndex <= 0) continue;
172
+
173
+ const key = line.slice(0, equalIndex).trim();
174
+ const valuePart = line.slice(equalIndex + 1).trim();
175
+ if (!key || !valuePart) continue;
176
+
177
+ const parsedValue = parseTomlString(valuePart);
178
+ if (parsedValue === null) continue;
179
+
180
+ shortcut[key] = parsedValue;
181
+ }
182
+
183
+ const name = normalizeShortcutName(shortcut.name ?? '');
184
+ const command = (shortcut.command ?? '').trim();
185
+ if (!name || !command) {
186
+ return {};
187
+ }
188
+
189
+ return {
190
+ shortcut: {
191
+ name,
192
+ command
193
+ }
194
+ };
195
+ }
196
+
197
+ /**
198
+ * 读取用户目录下的全局配置。
199
+ */
200
+ export async function loadGlobalConfig(logger?: Logger): Promise<GlobalConfig | null> {
201
+ const filePath = getGlobalConfigPath();
202
+ const exists = await fs.pathExists(filePath);
203
+ if (!exists) return null;
204
+
205
+ try {
206
+ const content = await fs.readFile(filePath, 'utf8');
207
+ return parseGlobalConfig(content);
208
+ } catch (error) {
209
+ const message = error instanceof Error ? error.message : String(error);
210
+ logger?.warn(`读取全局配置失败,已忽略:${message}`);
211
+ return null;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * 将命令行字符串拆解为参数数组(支持引号与转义)。
217
+ */
218
+ export function splitCommandArgs(command: string): string[] {
219
+ const args: string[] = [];
220
+ let current = '';
221
+ let quote: '"' | '\'' | null = null;
222
+ let escaped = false;
223
+
224
+ for (let i = 0; i < command.length; i += 1) {
225
+ const char = command[i];
226
+ if (escaped) {
227
+ current += char;
228
+ escaped = false;
229
+ continue;
230
+ }
231
+
232
+ if (quote) {
233
+ if (quote === '"' && char === '\\') {
234
+ escaped = true;
235
+ continue;
236
+ }
237
+ if (char === quote) {
238
+ quote = null;
239
+ continue;
240
+ }
241
+ current += char;
242
+ continue;
243
+ }
244
+
245
+ if (char === '"' || char === '\'') {
246
+ quote = char;
247
+ continue;
248
+ }
249
+
250
+ if (/\s/.test(char)) {
251
+ if (current.length > 0) {
252
+ args.push(current);
253
+ current = '';
254
+ }
255
+ continue;
256
+ }
257
+
258
+ if (char === '\\') {
259
+ escaped = true;
260
+ continue;
261
+ }
262
+
263
+ current += char;
264
+ }
265
+
266
+ if (current.length > 0) {
267
+ args.push(current);
268
+ }
269
+
270
+ return args;
271
+ }
272
+
273
+ function normalizeShortcutArgs(args: string[]): string[] {
274
+ if (args.length > 0 && args[0] === 'run') {
275
+ return args.slice(1);
276
+ }
277
+ return args;
278
+ }
279
+
280
+ /**
281
+ * 应用全局快捷指令,将别名替换为 run 子命令参数。
282
+ */
283
+ export function applyShortcutArgv(argv: string[], config: GlobalConfig | null): string[] {
284
+ if (!config?.shortcut) return argv;
285
+ if (argv.length < 3) return argv;
286
+ const commandIndex = 2;
287
+ if (argv[commandIndex] !== config.shortcut.name) return argv;
288
+
289
+ const shortcutArgs = normalizeShortcutArgs(splitCommandArgs(config.shortcut.command));
290
+ return [
291
+ ...argv.slice(0, commandIndex),
292
+ 'run',
293
+ ...shortcutArgs,
294
+ ...argv.slice(commandIndex + 1)
295
+ ];
296
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { runCli } from './cli';
2
+ export { runLoop } from './loop';
3
+ export type { LoopConfig, AiCliConfig, WorktreeConfig, PrConfig, TestConfig, WebhookConfig } from './types';
package/src/logger.ts ADDED
@@ -0,0 +1,122 @@
1
+ import fs from 'fs-extra';
2
+ import { pad2 } from './utils';
3
+
4
+ type Colorizer = (value: string) => string;
5
+ type ConsoleMethodName = 'log' | 'warn' | 'error';
6
+
7
+ const wrap = (code: string): Colorizer => (value: string) => `\u001b[${code}m${value}\u001b[0m`;
8
+
9
+ const colors = {
10
+ blue: wrap('34'),
11
+ green: wrap('32'),
12
+ yellow: wrap('33'),
13
+ red: wrap('31'),
14
+ magenta: wrap('35'),
15
+ gray: wrap('90')
16
+ } as const;
17
+
18
+ /**
19
+ * 日志器配置项。
20
+ */
21
+ export interface LoggerOptions {
22
+ readonly verbose?: boolean;
23
+ readonly logFile?: string;
24
+ }
25
+
26
+ /**
27
+ * 带颜色的日志输出器,可选写入日志文件。
28
+ */
29
+ export class Logger {
30
+ private readonly verbose: boolean;
31
+ private readonly logFile?: string;
32
+ private logFileEnabled: boolean;
33
+ private logFileErrored: boolean;
34
+
35
+ constructor(options: LoggerOptions = {}) {
36
+ this.verbose = options.verbose ?? false;
37
+ const trimmedPath = options.logFile?.trim();
38
+ this.logFile = trimmedPath && trimmedPath.length > 0 ? trimmedPath : undefined;
39
+ this.logFileEnabled = Boolean(this.logFile);
40
+ this.logFileErrored = false;
41
+
42
+ if (this.logFile) {
43
+ try {
44
+ fs.ensureFileSync(this.logFile);
45
+ } catch (error) {
46
+ this.disableFileWithError(error);
47
+ }
48
+ }
49
+ }
50
+
51
+ info(message: string): void {
52
+ this.emit('log', colors.blue, 'info', ' ', message);
53
+ }
54
+
55
+ success(message: string): void {
56
+ this.emit('log', colors.green, 'ok', ' ', message);
57
+ }
58
+
59
+ warn(message: string): void {
60
+ this.emit('warn', colors.yellow, 'warn', ' ', message);
61
+ }
62
+
63
+ error(message: string): void {
64
+ this.emit('error', colors.red, 'err', ' ', message);
65
+ }
66
+
67
+ debug(message: string): void {
68
+ if (!this.verbose) return;
69
+ this.emit('log', colors.magenta, 'dbg', ' ', message);
70
+ }
71
+
72
+ private emit(method: ConsoleMethodName, colorizer: Colorizer, label: string, padding: string, message: string): void {
73
+ const now = new Date();
74
+ const consoleLine = this.formatConsoleLine(now, colorizer(label), padding, message);
75
+ const fileLine = this.formatFileLine(now, label, padding, message);
76
+ console[method](consoleLine);
77
+ this.writeFileLine(fileLine);
78
+ }
79
+
80
+ private formatConsoleLine(date: Date, label: string, padding: string, message: string): string {
81
+ const timestamp = this.formatTimestamp(date);
82
+ return `${colors.gray(timestamp)} ${label}${padding}${message}`;
83
+ }
84
+
85
+ private formatFileLine(date: Date, label: string, padding: string, message: string): string {
86
+ const timestamp = this.formatTimestamp(date);
87
+ return `${timestamp} ${label}${padding}${message}`;
88
+ }
89
+
90
+ private writeFileLine(line: string): void {
91
+ if (!this.logFileEnabled || !this.logFile) return;
92
+ try {
93
+ fs.appendFileSync(this.logFile, `${line}\n`, 'utf8');
94
+ } catch (error) {
95
+ this.disableFileWithError(error);
96
+ }
97
+ }
98
+
99
+ private disableFileWithError(error: unknown): void {
100
+ this.logFileEnabled = false;
101
+ if (this.logFileErrored) return;
102
+ this.logFileErrored = true;
103
+ const message = error instanceof Error ? error.message : String(error);
104
+ const target = this.logFile ? ` (${this.logFile})` : '';
105
+ console.warn(`日志文件写入失败${target},已停止写入:${message}`);
106
+ }
107
+
108
+ private formatTimestamp(date: Date): string {
109
+ const year = date.getFullYear();
110
+ const month = pad2(date.getMonth() + 1);
111
+ const day = pad2(date.getDate());
112
+ const hours = pad2(date.getHours());
113
+ const minutes = pad2(date.getMinutes());
114
+ const seconds = pad2(date.getSeconds());
115
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * 默认日志器实例。
121
+ */
122
+ export const defaultLogger = new Logger();