@huicai/cli 2026.711.0 → 2026.711.24617

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 (49) hide show
  1. package/LICENSE +43 -21
  2. package/package.json +4 -3
  3. package/src/advisor.js +1 -46
  4. package/src/agent.js +1 -518
  5. package/src/api.js +1 -359
  6. package/src/autofix.js +1 -90
  7. package/src/banner.js +1 -83
  8. package/src/checkpoints.js +1 -99
  9. package/src/clipboard.js +1 -34
  10. package/src/codeindex.js +1 -229
  11. package/src/colors.js +1 -47
  12. package/src/commands.js +1 -1294
  13. package/src/compaction.js +1 -144
  14. package/src/config.js +1 -86
  15. package/src/consult.js +1 -65
  16. package/src/context-report.js +1 -119
  17. package/src/context.js +1 -152
  18. package/src/cron.js +1 -64
  19. package/src/daemon.js +1 -62
  20. package/src/device.js +1 -88
  21. package/src/diffview.js +1 -115
  22. package/src/doctor.js +1 -72
  23. package/src/executors.js +1 -391
  24. package/src/forks.js +1 -59
  25. package/src/gitflow.js +1 -140
  26. package/src/hooks.js +1 -207
  27. package/src/ide.js +1 -53
  28. package/src/input.js +1 -182
  29. package/src/insights.js +1 -102
  30. package/src/kbsync.js +1 -141
  31. package/src/markdown.js +0 -0
  32. package/src/markup-filter.js +1 -85
  33. package/src/mcp.js +1 -207
  34. package/src/memory.js +1 -161
  35. package/src/permissions.js +1 -120
  36. package/src/render.js +1 -138
  37. package/src/repl.js +1 -271
  38. package/src/scan.js +1 -84
  39. package/src/schedule.js +1 -48
  40. package/src/session.js +1 -140
  41. package/src/settings.js +0 -0
  42. package/src/skills.js +1 -248
  43. package/src/subagent.js +1 -183
  44. package/src/todo.js +1 -75
  45. package/src/tools.js +1 -147
  46. package/src/transcript.js +1 -94
  47. package/src/tui/app.js +1 -666
  48. package/src/usage.js +1 -43
  49. package/src/watch.js +1 -74
package/src/executors.js CHANGED
@@ -1,391 +1 @@
1
- // 本地工具的执行实现(在用户本机的文件系统 / shell 上运行)。
2
- // 所有写操作强制限定在当前项目目录内(防误伤系统路径);读/搜索可跨目录。
3
-
4
- import fs from 'node:fs';
5
- import path from 'node:path';
6
- import { spawn } from 'node:child_process';
7
- import * as ide from './ide.js';
8
-
9
- const MAX_READ_BYTES = 256 * 1024;
10
- const MAX_FILE_BYTES = 16 * 1024 * 1024; // 超过则要求用 offset/limit 或 grep
11
- const DEFAULT_READ_LINES = 2000;
12
- const MAX_LINE_CHARS = 2000;
13
- const MAX_GREP_MATCHES = 200;
14
- const MAX_LIST_ENTRIES = 400;
15
- const MAX_GLOB_RESULTS = 300;
16
- const BASH_DEFAULT_TIMEOUT = 120_000;
17
- const BASH_MAX_OUTPUT = 30_000;
18
- const FETCH_TIMEOUT = 30_000;
19
- const FETCH_MAX_BYTES = 2 * 1024 * 1024;
20
- const FETCH_MAX_CHARS = 80_000;
21
-
22
- const DEFAULT_IGNORES = new Set([
23
- '.git', 'node_modules', 'dist', 'build', 'target', 'out', '.next', '.nuxt',
24
- '.venv', 'venv', '__pycache__', '.idea', '.gradle', '.mvn', 'coverage', '.cache',
25
- ]);
26
-
27
- /** 解析路径为绝对路径;mustBeInside 时强制落在项目目录内。 */
28
- function resolvePath(cwd, p, mustBeInside) {
29
- if (!cwd || typeof cwd !== 'string') {
30
- throw new Error('工作目录未设置:请在项目目录中运行(VS Code 中需先「打开文件夹」再对话)');
31
- }
32
- if (!p || typeof p !== 'string') throw new Error('缺少路径 path(工具参数里必须提供 path 字段)');
33
- const abs = path.resolve(cwd, p);
34
- if (mustBeInside) {
35
- const rel = path.relative(cwd, abs);
36
- if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
37
- throw new Error(`路径超出项目目录(写操作仅限当前项目内):${p}`);
38
- }
39
- }
40
- return abs;
41
- }
42
-
43
- function rel(cwd, abs) {
44
- const r = path.relative(cwd, abs);
45
- return r === '' ? '.' : r;
46
- }
47
-
48
- function isProbablyBinary(buf) {
49
- const n = Math.min(buf.length, 4096);
50
- for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
51
- return false;
52
- }
53
-
54
- /** 深度优先遍历文件(跳过忽略目录),产出绝对文件路径。 */
55
- function* walkFiles(dir, depth = 0, maxDepth = 14) {
56
- let entries;
57
- try {
58
- entries = fs.readdirSync(dir, { withFileTypes: true });
59
- } catch {
60
- return;
61
- }
62
- for (const e of entries) {
63
- if (DEFAULT_IGNORES.has(e.name)) continue;
64
- const full = path.join(dir, e.name);
65
- if (e.isDirectory()) {
66
- if (depth < maxDepth) yield* walkFiles(full, depth + 1, maxDepth);
67
- } else if (e.isFile()) {
68
- yield full;
69
- }
70
- }
71
- }
72
-
73
- /** 简易 glob → 正则:支持 **(跨目录)、*(同级任意)、?(单字符)。 */
74
- function globToRegex(glob) {
75
- let re = '';
76
- for (let i = 0; i < glob.length; i++) {
77
- const ch = glob[i];
78
- if (ch === '*') {
79
- if (glob[i + 1] === '*') {
80
- re += '.*';
81
- i++;
82
- if (glob[i + 1] === '/') i++;
83
- } else {
84
- re += '[^/]*';
85
- }
86
- } else if (ch === '?') {
87
- re += '[^/]';
88
- } else if ('\\^$+.()|{}[]'.includes(ch)) {
89
- re += '\\' + ch;
90
- } else {
91
- re += ch;
92
- }
93
- }
94
- return new RegExp('^' + re + '$');
95
- }
96
-
97
- /**
98
- * 读取文件:输出「行号→内容」格式(行号仅供定位,不属于文件内容),默认最多 2000 行。
99
- * 大文件(>16MB)要求用 offset/limit 分段或 grep 检索,避免占满内存与上下文。
100
- */
101
- export function readFile(cwd, args) {
102
- const abs = resolvePath(cwd, args.path, false);
103
- const st = fs.statSync(abs);
104
- if (st.isDirectory()) throw new Error(`是目录而非文件:${args.path}`);
105
- if (st.size > MAX_FILE_BYTES) {
106
- throw new Error(`文件过大(${Math.round(st.size / 1024 / 1024)}MB):请用 grep 检索,或用 run_bash 的 head/tail/sed 分段查看`);
107
- }
108
- const buf = fs.readFileSync(abs);
109
- if (isProbablyBinary(buf)) return `(二进制文件,${st.size} 字节,未展示内容)`;
110
- const text = buf.toString('utf8');
111
- if (text === '') return '(空文件)';
112
-
113
- const lines = text.split('\n');
114
- // 尾部空行(文件以 \n 结尾产生)不计入展示行数
115
- if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
116
- const total = lines.length;
117
- const from = Math.max(0, (Number.isFinite(args.offset) ? args.offset : 1) - 1);
118
- const limit = Number.isFinite(args.limit) && args.limit > 0 ? args.limit : DEFAULT_READ_LINES;
119
- const to = Math.min(total, from + limit);
120
- if (from >= total) return `(起始行 ${from + 1} 超出文件总行数 ${total})`;
121
-
122
- const width = String(to).length;
123
- const outLines = [];
124
- for (let i = from; i < to; i++) {
125
- let line = lines[i];
126
- if (line.length > MAX_LINE_CHARS) line = line.slice(0, MAX_LINE_CHARS) + '…(行过长已截断)';
127
- outLines.push(`${String(i + 1).padStart(width)}→${line}`);
128
- }
129
- let outText = outLines.join('\n');
130
- if (from > 0 || to < total) {
131
- outText += `\n…(文件共 ${total} 行,本次显示第 ${from + 1}-${to} 行,可用 offset/limit 继续读取)`;
132
- }
133
- return outText;
134
- }
135
-
136
- export async function writeFile(cwd, args) {
137
- const abs = resolvePath(cwd, args.path, true);
138
- const content = args.content == null ? '' : String(args.content);
139
- // IDE 内联审阅:把拟写内容交扩展做 diff + 应用;仅传输失败时回退直接写
140
- if (ide.isActive()) {
141
- const d = await ide.proposeWrite(abs, content, cwd);
142
- if (d.decided) {
143
- return d.applied
144
- ? `已写入 ${rel(cwd, abs)}(经 IDE 审阅应用)`
145
- : `用户在 IDE 中拒绝了对 ${rel(cwd, abs)} 的修改${d.reason ? ':' + d.reason : ''}`;
146
- }
147
- }
148
- fs.mkdirSync(path.dirname(abs), { recursive: true });
149
- fs.writeFileSync(abs, content, 'utf8');
150
- return `已写入 ${rel(cwd, abs)}(${Buffer.byteLength(content)} 字节)`
151
- + (ide.isActive() ? '(IDE 未响应,已直接写入)' : '');
152
- }
153
-
154
- export async function editFile(cwd, args) {
155
- const abs = resolvePath(cwd, args.path, true);
156
- const oldStr = args.old_string == null ? '' : String(args.old_string);
157
- const newStr = args.new_string == null ? '' : String(args.new_string);
158
- if (oldStr === '') throw new Error('edit_file 需要非空 old_string');
159
- const content = fs.readFileSync(abs, 'utf8');
160
- const count = content.split(oldStr).length - 1;
161
- if (count === 0) {
162
- throw new Error('未找到要替换的内容(old_string 未出现在文件中)。注意:old_string 必须是文件原文,不能包含 read_file 显示的「行号→」前缀');
163
- }
164
- if (count > 1 && !args.replace_all) {
165
- throw new Error(`old_string 在文件中出现 ${count} 处,不唯一;请补充上下文使其唯一,或设 replace_all=true`);
166
- }
167
- const updated = args.replace_all ? content.split(oldStr).join(newStr) : content.replace(oldStr, newStr);
168
- const times = args.replace_all ? count : 1;
169
- if (ide.isActive()) {
170
- const d = await ide.proposeWrite(abs, updated, cwd);
171
- if (d.decided) {
172
- return d.applied
173
- ? `已编辑 ${rel(cwd, abs)}(经 IDE 审阅应用,替换 ${times} 处)`
174
- : `用户在 IDE 中拒绝了对 ${rel(cwd, abs)} 的修改${d.reason ? ':' + d.reason : ''}`;
175
- }
176
- }
177
- fs.writeFileSync(abs, updated, 'utf8');
178
- return `已编辑 ${rel(cwd, abs)},替换 ${times} 处` + (ide.isActive() ? '(IDE 未响应,已直接写入)' : '');
179
- }
180
-
181
- export function listFiles(cwd, args) {
182
- const base = resolvePath(cwd, args.path || '.', false);
183
- const out = [];
184
- let count = 0;
185
- const walk = (dir, depth) => {
186
- if (count >= MAX_LIST_ENTRIES) return;
187
- let entries;
188
- try {
189
- entries = fs.readdirSync(dir, { withFileTypes: true });
190
- } catch {
191
- return;
192
- }
193
- entries.sort((a, b) => a.name.localeCompare(b.name));
194
- for (const e of entries) {
195
- if (count >= MAX_LIST_ENTRIES) break;
196
- if (DEFAULT_IGNORES.has(e.name)) continue;
197
- const full = path.join(dir, e.name);
198
- const r = path.relative(base, full);
199
- if (e.isDirectory()) {
200
- out.push(r + '/');
201
- count++;
202
- if (depth < 6) walk(full, depth + 1);
203
- } else {
204
- out.push(r);
205
- count++;
206
- }
207
- }
208
- };
209
- walk(base, 0);
210
- if (out.length === 0) return '(空目录)';
211
- return out.join('\n') + (count >= MAX_LIST_ENTRIES ? '\n…(条目已截断)' : '');
212
- }
213
-
214
- export function glob(cwd, args) {
215
- if (!args.pattern) throw new Error('缺少 pattern');
216
- const re = globToRegex(args.pattern);
217
- const results = [];
218
- for (const abs of walkFiles(cwd)) {
219
- const r = path.relative(cwd, abs).split(path.sep).join('/');
220
- if (re.test(r)) {
221
- results.push(r);
222
- if (results.length >= MAX_GLOB_RESULTS) break;
223
- }
224
- }
225
- if (results.length === 0) return '(无匹配文件)';
226
- return results.join('\n');
227
- }
228
-
229
- export function grep(cwd, args) {
230
- if (!args.pattern) throw new Error('缺少 pattern');
231
- let re;
232
- try {
233
- re = new RegExp(args.pattern, args.ignore_case ? 'i' : undefined);
234
- } catch (e) {
235
- throw new Error(`无效的正则:${e.message}`);
236
- }
237
- const globRe = args.glob ? globToRegex(args.glob) : null;
238
- const base = resolvePath(cwd, args.path || '.', false);
239
- const maxMatches = Number.isFinite(args.max_results) && args.max_results > 0
240
- ? Math.min(args.max_results, 500)
241
- : MAX_GREP_MATCHES;
242
- const matches = [];
243
- const filesHit = [];
244
- for (const abs of walkFiles(base)) {
245
- if (matches.length >= maxMatches) break;
246
- const r = path.relative(cwd, abs).split(path.sep).join('/');
247
- if (globRe && !globRe.test(r)) continue;
248
- let buf;
249
- try {
250
- buf = fs.readFileSync(abs);
251
- } catch {
252
- continue;
253
- }
254
- if (buf.length > MAX_READ_BYTES || isProbablyBinary(buf)) continue;
255
- const lines = buf.toString('utf8').split('\n');
256
- let hit = false;
257
- for (let i = 0; i < lines.length; i++) {
258
- if (re.test(lines[i])) {
259
- hit = true;
260
- if (args.files_only) break;
261
- matches.push(`${r}:${i + 1}: ${lines[i].slice(0, 240)}`);
262
- if (matches.length >= maxMatches) break;
263
- }
264
- }
265
- if (hit) {
266
- filesHit.push(r);
267
- if (args.files_only && filesHit.length >= maxMatches) break;
268
- }
269
- }
270
- if (args.files_only) {
271
- return filesHit.length ? filesHit.join('\n') : '(无匹配)';
272
- }
273
- if (matches.length === 0) return '(无匹配)';
274
- return matches.join('\n') + (matches.length >= maxMatches ? '\n…(结果已截断)' : '');
275
- }
276
-
277
- export function runBash(cwd, args) {
278
- const command = args.command == null ? '' : String(args.command);
279
- if (command.trim() === '') return Promise.resolve('(未提供命令)');
280
- const timeout = Number.isFinite(args.timeout) ? Math.min(args.timeout, 600_000) : BASH_DEFAULT_TIMEOUT;
281
- const isWin = process.platform === 'win32';
282
- return new Promise((resolve) => {
283
- let out = '';
284
- let killed = false;
285
- // POSIX 下 detached 让子命令自成进程组,超时可整组击杀(防止 shell 被杀而孙进程残留)
286
- const child = isWin
287
- ? spawn(process.env.COMSPEC || 'cmd.exe', ['/d', '/s', '/c', command], { cwd, windowsVerbatimArguments: true })
288
- : spawn(process.env.SHELL || '/bin/sh', ['-c', command], { cwd, detached: true });
289
- const killTree = () => {
290
- try {
291
- if (!isWin && child.pid) process.kill(-child.pid, 'SIGKILL');
292
- else child.kill('SIGKILL');
293
- } catch {
294
- try {
295
- child.kill('SIGKILL');
296
- } catch {
297
- /* ignore */
298
- }
299
- }
300
- };
301
- const timer = setTimeout(() => {
302
- killed = true;
303
- killTree();
304
- }, timeout);
305
- const append = (d) => {
306
- if (out.length < BASH_MAX_OUTPUT) out += d.toString();
307
- };
308
- child.stdout.on('data', append);
309
- child.stderr.on('data', append);
310
- child.on('error', (e) => {
311
- clearTimeout(timer);
312
- resolve(`命令启动失败:${e.message}`);
313
- });
314
- child.on('close', (code) => {
315
- clearTimeout(timer);
316
- let text = out.length > BASH_MAX_OUTPUT ? out.slice(0, BASH_MAX_OUTPUT) + '\n…(输出已截断)' : out;
317
- if (text.trim() === '') text = '(无输出)';
318
- const status = killed ? `【超时被终止】` : `退出码 ${code}`;
319
- resolve(`${status}\n${text}`);
320
- });
321
- });
322
- }
323
-
324
- /** 抓取网页/接口并转为可读文本(HTML 去标签;JSON/纯文本原样)。仅 http/https GET。 */
325
- export async function fetchUrl(cwd, args) {
326
- const url = String(args.url || '').trim();
327
- if (!/^https?:\/\//i.test(url)) throw new Error('仅支持 http/https URL');
328
- let res;
329
- try {
330
- res = await fetch(url, {
331
- redirect: 'follow',
332
- signal: AbortSignal.timeout(Number.isFinite(args.timeout) ? Math.min(args.timeout, 120_000) : FETCH_TIMEOUT),
333
- headers: {
334
- 'User-Agent': 'huicai-cli',
335
- Accept: 'text/html,application/json;q=0.9,text/plain;q=0.8,*/*;q=0.5',
336
- },
337
- });
338
- } catch (e) {
339
- throw new Error(`请求失败:${e?.cause?.message || e.message}`);
340
- }
341
- const buf = await readBodyCapped(res, FETCH_MAX_BYTES);
342
- const ctype = (res.headers.get('content-type') || '').toLowerCase();
343
- if (isProbablyBinary(buf) && !/text|json|xml|html/.test(ctype)) {
344
- return `HTTP ${res.status} · ${ctype || '未知类型'} · ${buf.length} 字节(二进制内容未展示)`;
345
- }
346
- let text = buf.toString('utf8');
347
- if (ctype.includes('html') || /^\s*<(!doctype|html)/i.test(text)) text = htmlToText(text);
348
- text = text.trim();
349
- if (text.length > FETCH_MAX_CHARS) text = text.slice(0, FETCH_MAX_CHARS) + '\n…(内容过长已截断)';
350
- const head = `HTTP ${res.status}${res.url && res.url !== url ? ` · 重定向至 ${res.url}` : ''}`;
351
- if (!res.ok) return `${head}\n${text || '(无内容)'}`;
352
- return `${head}\n${text || '(无内容)'}`;
353
- }
354
-
355
- async function readBodyCapped(res, maxBytes) {
356
- if (!res.body) return Buffer.alloc(0);
357
- const reader = res.body.getReader();
358
- const chunks = [];
359
- let size = 0;
360
- while (true) {
361
- const { done, value } = await reader.read();
362
- if (done) break;
363
- chunks.push(value);
364
- size += value.length;
365
- if (size >= maxBytes) {
366
- reader.cancel().catch(() => {});
367
- break;
368
- }
369
- }
370
- return Buffer.concat(chunks.map((u) => Buffer.from(u)), Math.min(size, maxBytes));
371
- }
372
-
373
- /** 极简 HTML→文本:去 script/style,块级标签换行,去其余标签,解码常见实体,压缩空行。 */
374
- function htmlToText(html) {
375
- let s = String(html);
376
- s = s.replace(/<(script|style|noscript|svg|head)[\s\S]*?<\/\1>/gi, '');
377
- s = s.replace(/<!--[\s\S]*?-->/g, '');
378
- s = s.replace(/<br\s*\/?>/gi, '\n');
379
- s = s.replace(/<\/(p|div|li|tr|h[1-6]|section|article|blockquote|pre|ul|ol|table)>/gi, '\n');
380
- s = s.replace(/<li[^>]*>/gi, '- ');
381
- s = s.replace(/<[^>]+>/g, '');
382
- s = s
383
- .replace(/&nbsp;/g, ' ')
384
- .replace(/&lt;/g, '<')
385
- .replace(/&gt;/g, '>')
386
- .replace(/&quot;/g, '"')
387
- .replace(/&#39;|&apos;/g, "'")
388
- .replace(/&amp;/g, '&');
389
- s = s.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n');
390
- return s;
391
- }
1
+ import t from"node:fs";import e from"node:path";import{spawn as n}from"node:child_process";import*as r from"./ide.js";const i=3e4,o=new Set([".git","node_modules","dist","build","target","out",".next",".nuxt",".venv","venv","__pycache__",".idea",".gradle",".mvn","coverage",".cache"]);function s(t,n,r){if(!t||"string"!=typeof t)throw new Error("工作目录未设置:请在项目目录中运行(VS Code 中需先「打开文件夹」再对话)");if(!n||"string"!=typeof n)throw new Error("缺少路径 path(工具参数里必须提供 path 字段)");const i=e.resolve(t,n);if(r){const r=e.relative(t,i);if(""===r||r.startsWith("..")||e.isAbsolute(r))throw new Error(`路径超出项目目录(写操作仅限当前项目内):${n}`)}return i}function l(t,n){const r=e.relative(t,n);return""===r?".":r}function c(t){const e=Math.min(t.length,4096);for(let n=0;n<e;n++)if(0===t[n])return!0;return!1}function*a(n,r=0,i=14){let s;try{s=t.readdirSync(n,{withFileTypes:!0})}catch{return}for(const t of s){if(o.has(t.name))continue;const s=e.join(n,t.name);t.isDirectory()?r<i&&(yield*a(s,r+1,i)):t.isFile()&&(yield s)}}function u(t){let e="";for(let n=0;n<t.length;n++){const r=t[n];"*"===r?"*"===t[n+1]?(e+=".*",n++,"/"===t[n+1]&&n++):e+="[^/]*":"?"===r?e+="[^/]":"\\^$+.()|{}[]".includes(r)?e+="\\"+r:e+=r}return new RegExp("^"+e+"$")}export function readFile(e,n){const r=s(e,n.path,!1),i=t.statSync(r);if(i.isDirectory())throw new Error(`是目录而非文件:${n.path}`);if(i.size>16777216)throw new Error(`文件过大(${Math.round(i.size/1024/1024)}MB):请用 grep 检索,或用 run_bash 的 head/tail/sed 分段查看`);const o=t.readFileSync(r);if(c(o))return`(二进制文件,${i.size} 字节,未展示内容)`;const l=o.toString("utf8");if(""===l)return"(空文件)";const a=l.split("\n");a.length>1&&""===a[a.length-1]&&a.pop();const u=a.length,p=Math.max(0,(Number.isFinite(n.offset)?n.offset:1)-1),f=Number.isFinite(n.limit)&&n.limit>0?n.limit:2e3,h=Math.min(u,p+f);if(p>=u)return`(起始行 ${p+1} 超出文件总行数 ${u})`;const g=String(h).length,d=[];for(let t=p;t<h;t++){let e=a[t];e.length>2e3&&(e=e.slice(0,2e3)+"…(行过长已截断)"),d.push(`${String(t+1).padStart(g)}→${e}`)}let m=d.join("\n");return(p>0||h<u)&&(m+=`\n…(文件共 ${u} 行,本次显示第 ${p+1}-${h} 行,可用 offset/limit 继续读取)`),m}export async function writeFile(n,i){const o=s(n,i.path,!0),c=null==i.content?"":String(i.content);if(r.isActive()){const t=await r.proposeWrite(o,c,n);if(t.decided)return t.applied?`已写入 ${l(n,o)}(经 IDE 审阅应用)`:`用户在 IDE 中拒绝了对 ${l(n,o)} 的修改${t.reason?":"+t.reason:""}`}return t.mkdirSync(e.dirname(o),{recursive:!0}),t.writeFileSync(o,c,"utf8"),`已写入 ${l(n,o)}(${Buffer.byteLength(c)} 字节)`+(r.isActive()?"(IDE 未响应,已直接写入)":"")}export async function editFile(e,n){const i=s(e,n.path,!0),o=null==n.old_string?"":String(n.old_string),c=null==n.new_string?"":String(n.new_string);if(""===o)throw new Error("edit_file 需要非空 old_string");const a=t.readFileSync(i,"utf8"),u=a.split(o).length-1;if(0===u)throw new Error("未找到要替换的内容(old_string 未出现在文件中)。注意:old_string 必须是文件原文,不能包含 read_file 显示的「行号→」前缀");if(u>1&&!n.replace_all)throw new Error(`old_string 在文件中出现 ${u} 处,不唯一;请补充上下文使其唯一,或设 replace_all=true`);const p=n.replace_all?a.split(o).join(c):a.replace(o,c),f=n.replace_all?u:1;if(r.isActive()){const t=await r.proposeWrite(i,p,e);if(t.decided)return t.applied?`已编辑 ${l(e,i)}(经 IDE 审阅应用,替换 ${f} 处)`:`用户在 IDE 中拒绝了对 ${l(e,i)} 的修改${t.reason?":"+t.reason:""}`}return t.writeFileSync(i,p,"utf8"),`已编辑 ${l(e,i)},替换 ${f} 处`+(r.isActive()?"(IDE 未响应,已直接写入)":"")}export function listFiles(n,r){const i=s(n,r.path||".",!1),l=[];let c=0;const a=(n,r)=>{if(c>=400)return;let s;try{s=t.readdirSync(n,{withFileTypes:!0})}catch{return}s.sort((t,e)=>t.name.localeCompare(e.name));for(const t of s){if(c>=400)break;if(o.has(t.name))continue;const s=e.join(n,t.name),u=e.relative(i,s);t.isDirectory()?(l.push(u+"/"),c++,r<6&&a(s,r+1)):(l.push(u),c++)}};return a(i,0),0===l.length?"(空目录)":l.join("\n")+(c>=400?"\n…(条目已截断)":"")}export function glob(t,n){if(!n.pattern)throw new Error("缺少 pattern");const r=u(n.pattern),i=[];for(const n of a(t)){const o=e.relative(t,n).split(e.sep).join("/");if(r.test(o)&&(i.push(o),i.length>=300))break}return 0===i.length?"(无匹配文件)":i.join("\n")}export function grep(n,r){if(!r.pattern)throw new Error("缺少 pattern");let i;try{i=new RegExp(r.pattern,r.ignore_case?"i":void 0)}catch(t){throw new Error(`无效的正则:${t.message}`)}const o=r.glob?u(r.glob):null,l=s(n,r.path||".",!1),p=Number.isFinite(r.max_results)&&r.max_results>0?Math.min(r.max_results,500):200,f=[],h=[];for(const s of a(l)){if(f.length>=p)break;const l=e.relative(n,s).split(e.sep).join("/");if(o&&!o.test(l))continue;let a;try{a=t.readFileSync(s)}catch{continue}if(a.length>262144||c(a))continue;const u=a.toString("utf8").split("\n");let g=!1;for(let t=0;t<u.length;t++)if(i.test(u[t])){if(g=!0,r.files_only)break;if(f.push(`${l}:${t+1}: ${u[t].slice(0,240)}`),f.length>=p)break}if(g&&(h.push(l),r.files_only&&h.length>=p))break}return r.files_only?h.length?h.join("\n"):"(无匹配)":0===f.length?"(无匹配)":f.join("\n")+(f.length>=p?"\n…(结果已截断)":"")}export function runBash(t,e){const r=null==e.command?"":String(e.command);if(""===r.trim())return Promise.resolve("(未提供命令)");const o=Number.isFinite(e.timeout)?Math.min(e.timeout,6e5):12e4,s="win32"===process.platform;return new Promise(e=>{let l="",c=!1;const a=s?n(process.env.COMSPEC||"cmd.exe",["/d","/s","/c",r],{cwd:t,windowsVerbatimArguments:!0}):n(process.env.SHELL||"/bin/sh",["-c",r],{cwd:t,detached:!0}),u=setTimeout(()=>{c=!0,(()=>{try{!s&&a.pid?process.kill(-a.pid,"SIGKILL"):a.kill("SIGKILL")}catch{try{a.kill("SIGKILL")}catch{}}})()},o),p=t=>{l.length<i&&(l+=t.toString())};a.stdout.on("data",p),a.stderr.on("data",p),a.on("error",t=>{clearTimeout(u),e(`命令启动失败:${t.message}`)}),a.on("close",t=>{clearTimeout(u);let n=l.length>i?l.slice(0,i)+"\n…(输出已截断)":l;""===n.trim()&&(n="(无输出)");e(`${c?"【超时被终止】":`退出码 ${t}`}\n${n}`)})})}export async function fetchUrl(t,e){const n=String(e.url||"").trim();if(!/^https?:\/\//i.test(n))throw new Error("仅支持 http/https URL");let r;try{r=await fetch(n,{redirect:"follow",signal:AbortSignal.timeout(Number.isFinite(e.timeout)?Math.min(e.timeout,12e4):3e4),headers:{"User-Agent":"huicai-cli",Accept:"text/html,application/json;q=0.9,text/plain;q=0.8,*/*;q=0.5"}})}catch(t){throw new Error(`请求失败:${t?.cause?.message||t.message}`)}const i=await async function(t,e){if(!t.body)return Buffer.alloc(0);const n=t.body.getReader(),r=[];let i=0;for(;;){const{done:t,value:o}=await n.read();if(t)break;if(r.push(o),i+=o.length,i>=e){n.cancel().catch(()=>{});break}}return Buffer.concat(r.map(t=>Buffer.from(t)),Math.min(i,e))}(r,2097152),o=(r.headers.get("content-type")||"").toLowerCase();if(c(i)&&!/text|json|xml|html/.test(o))return`HTTP ${r.status} · ${o||"未知类型"} · ${i.length} 字节(二进制内容未展示)`;let s=i.toString("utf8");(o.includes("html")||/^\s*<(!doctype|html)/i.test(s))&&(s=function(t){let e=String(t);return e=e.replace(/<(script|style|noscript|svg|head)[\s\S]*?<\/\1>/gi,""),e=e.replace(/<!--[\s\S]*?-->/g,""),e=e.replace(/<br\s*\/?>/gi,"\n"),e=e.replace(/<\/(p|div|li|tr|h[1-6]|section|article|blockquote|pre|ul|ol|table)>/gi,"\n"),e=e.replace(/<li[^>]*>/gi,"- "),e=e.replace(/<[^>]+>/g,""),e=e.replace(/&nbsp;/g," ").replace(/&lt;/g,"<").replace(/&gt;/g,">").replace(/&quot;/g,'"').replace(/&#39;|&apos;/g,"'").replace(/&amp;/g,"&"),e=e.replace(/[ \t]+\n/g,"\n").replace(/\n{3,}/g,"\n\n"),e}(s)),s=s.trim(),s.length>8e4&&(s=s.slice(0,8e4)+"\n…(内容过长已截断)");const l=`HTTP ${r.status}${r.url&&r.url!==n?` · 重定向至 ${r.url}`:""}`;return r.ok,`${l}\n${s||"(无内容)"}`}
package/src/forks.js CHANGED
@@ -1,59 +1 @@
1
- // /fork + /tasks:派生后台智能体(detached 子进程跑 `huicai resume <copyId> -p ... --yolo --plain`),
2
- // 输出写日志文件,本进程内登记以便 /tasks 查看/终止。仅在能定位 huicai 可执行文件时可用(通常即 CLI)。
3
-
4
- import { spawn } from 'node:child_process';
5
- import fs from 'node:fs';
6
- import path from 'node:path';
7
- import os from 'node:os';
8
-
9
- const forks = []; // 本进程登记的后台任务 {pid, prompt, cwd, log, started}
10
-
11
- /** 定位当前 huicai 可执行入口(CLI 下即 process.argv[1]);无法定位返回 null。 */
12
- export function huicaiBin() {
13
- const a = process.argv[1] || '';
14
- return /huicai/.test(a) ? a : null;
15
- }
16
-
17
- /** 派生后台智能体。@param {{prompt:string, cwd:string, sessionId?:string}} p @returns 登记记录 */
18
- export function forkAgent({ prompt, cwd, sessionId }) {
19
- const bin = huicaiBin();
20
- if (!bin) throw new Error('无法定位 huicai 可执行文件(请在 CLI 中使用 /fork)');
21
- const logDir = path.join(os.homedir(), '.huicai', 'forks');
22
- fs.mkdirSync(logDir, { recursive: true });
23
- const logFile = path.join(logDir, Date.now().toString(36) + '.log');
24
- const out = fs.openSync(logFile, 'a');
25
- const args = [bin];
26
- if (sessionId) args.push('resume', sessionId); // 继承对话副本
27
- args.push('-p', prompt, '--yolo', '--plain'); // 非交互需 --yolo 自动放行
28
- const child = spawn(process.execPath, args, { cwd, detached: true, stdio: ['ignore', out, out], env: process.env });
29
- child.unref();
30
- const rec = { pid: child.pid, prompt: prompt.replace(/\s+/g, ' ').slice(0, 80), cwd, log: logFile, started: Date.now() };
31
- forks.push(rec);
32
- return rec;
33
- }
34
-
35
- function isAlive(pid) {
36
- try {
37
- process.kill(pid, 0);
38
- return true;
39
- } catch {
40
- return false;
41
- }
42
- }
43
-
44
- /** 列出本会话派生的后台任务(附存活状态)。 */
45
- export function listForks() {
46
- return forks.map((f) => ({ ...f, alive: isAlive(f.pid) }));
47
- }
48
-
49
- /** 终止某后台任务(仅限本会话派生的)。 */
50
- export function killFork(pid) {
51
- const rec = forks.find((f) => f.pid === pid);
52
- if (!rec) return { ok: false, reason: '不是本会话派生的任务' };
53
- try {
54
- process.kill(pid, 'SIGTERM');
55
- return { ok: true };
56
- } catch (e) {
57
- return { ok: false, reason: e.message };
58
- }
59
- }
1
+ import{spawn as o}from"node:child_process";import r from"node:fs";import e from"node:path";import n from"node:os";const t=[];export function huicaiBin(){const o=process.argv[1]||"";return/huicai/.test(o)?o:null}export function forkAgent({prompt:i,cwd:s,sessionId:c}){const p=huicaiBin();if(!p)throw new Error("无法定位 huicai 可执行文件(请在 CLI 中使用 /fork)");const u=e.join(n.homedir(),".huicai","forks");r.mkdirSync(u,{recursive:!0});const a=e.join(u,Date.now().toString(36)+".log"),d=r.openSync(a,"a"),f=[p];c&&f.push("resume",c),f.push("-p",i,"--yolo","--plain");const l=o(process.execPath,f,{cwd:s,detached:!0,stdio:["ignore",d,d],env:process.env});l.unref();const h={pid:l.pid,prompt:i.replace(/\s+/g," ").slice(0,80),cwd:s,log:a,started:Date.now()};return t.push(h),h}function i(o){try{return process.kill(o,0),!0}catch{return!1}}export function listForks(){return t.map(o=>({...o,alive:i(o.pid)}))}export function killFork(o){if(!t.find(r=>r.pid===o))return{ok:!1,reason:"不是本会话派生的任务"};try{return process.kill(o,"SIGTERM"),{ok:!0}}catch(o){return{ok:!1,reason:o.message}}}
package/src/gitflow.js CHANGED
@@ -1,140 +1 @@
1
- // Git/PR 全自动化:暂存改动 → LLM 生成规范 commit 信息 → 若在主分支先建分支 → 提交 → push → gh 开 PR。
2
- // 每步优雅降级:非 git 仓库/无 remote/未装 gh 都给清晰结果而非报错。绝不 force-push、绝不直接提交到 main。
3
-
4
- import fs from 'node:fs';
5
- import os from 'node:os';
6
- import path from 'node:path';
7
- import { execSync } from 'node:child_process';
8
- import { streamChat } from './api.js';
9
- import { DEFAULT_MODEL } from './config.js';
10
-
11
- const PSEUDO = /^(agent|workflow|datahuman)-/;
12
- const DEFAULT_BRANCHES = new Set(['main', 'master', 'develop', 'dev']);
13
-
14
- function git(cwd, args) {
15
- return execSync('git ' + args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
16
- }
17
- function gitQuiet(cwd, args) {
18
- try {
19
- return git(cwd, args);
20
- } catch {
21
- return '';
22
- }
23
- }
24
-
25
- /** 仓库状态:是否 git 仓库、当前分支、是否有改动、是否有 remote、是否装了 gh。 */
26
- export function gitInfo(cwd) {
27
- const info = { isRepo: false, branch: '', dirty: false, hasRemote: false, hasGh: false };
28
- try {
29
- info.isRepo = git(cwd, 'rev-parse --is-inside-work-tree').trim() === 'true';
30
- } catch {
31
- return info;
32
- }
33
- info.branch = gitQuiet(cwd, 'rev-parse --abbrev-ref HEAD').trim();
34
- info.dirty = gitQuiet(cwd, 'status --porcelain').trim().length > 0;
35
- info.hasRemote = gitQuiet(cwd, 'remote').trim().length > 0;
36
- try {
37
- execSync('gh --version', { stdio: 'ignore' });
38
- info.hasGh = true;
39
- } catch { /* gh 未安装 */ }
40
- return info;
41
- }
42
-
43
- /** 由 git diff 生成一条 Conventional Commits 提交信息(直连原始模型,避开伪模型编排)。 */
44
- export async function generateCommitMessage(cwd, config) {
45
- let diff = gitQuiet(cwd, 'diff --staged');
46
- if (!diff.trim()) diff = gitQuiet(cwd, 'diff');
47
- diff = diff.slice(0, 12000);
48
- if (!diff.trim()) return 'chore: update';
49
- const model = PSEUDO.test(String(config.model || '')) ? (config.compactModel || DEFAULT_MODEL) : config.model;
50
- try {
51
- const r = await streamChat({
52
- baseUrl: config.baseUrl, apiKey: config.apiKey, model,
53
- messages: [
54
- { role: 'system', content: '你是提交信息生成器。根据 git diff 生成一条规范的 Conventional Commits 提交信息:'
55
- + '第一行 `<type>(<scope>): <简述>`(简述≤50字,type 用 feat/fix/refactor/docs/test/chore/perf/style 之一);'
56
- + '改动较大可空行后加 2-4 条正文要点。只输出提交信息本身,不要解释、不要代码块围栏。' },
57
- { role: 'user', content: diff },
58
- ],
59
- });
60
- const msg = (r.content || '').trim().replace(/^```[a-z]*\n?|\n?```$/g, '').trim();
61
- return msg || 'chore: update';
62
- } catch {
63
- return 'chore: update';
64
- }
65
- }
66
-
67
- function slug(msg) {
68
- return String(msg).split('\n')[0]
69
- .replace(/^[a-z]+(\([^)]*\))?!?:\s*/i, '')
70
- .replace(/[^a-zA-Z0-9一-鿿]+/g, '-')
71
- .replace(/^-+|-+$/g, '')
72
- .toLowerCase()
73
- .slice(0, 32) || 'change';
74
- }
75
-
76
- /**
77
- * 执行 PR 全流程。
78
- * @param {{cwd, config, emit?:(t:string)=>void, push?:boolean, openPr?:boolean}} p
79
- * @returns {Promise<{ok, error?, branch?, pr?, note?, committed?, pushed?}>}
80
- */
81
- export async function runPrFlow({ cwd, config, emit = () => {}, push = true, openPr = true }) {
82
- const info = gitInfo(cwd);
83
- if (!info.isRepo) return { ok: false, error: '当前目录不是 git 仓库' };
84
- const onDefault = DEFAULT_BRANCHES.has(info.branch);
85
-
86
- if (info.dirty) {
87
- git(cwd, 'add -A');
88
- emit('已暂存全部改动');
89
- }
90
- const stagedStat = gitQuiet(cwd, 'diff --staged --stat').trim();
91
-
92
- let msg = '';
93
- if (stagedStat) {
94
- emit('正在生成提交信息…');
95
- msg = await generateCommitMessage(cwd, config);
96
- emit('提交信息:' + msg.split('\n')[0]);
97
- }
98
-
99
- // 在主分支上:先建分支,绝不直接提交到 main/master
100
- let target = info.branch;
101
- if (onDefault) {
102
- target = 'huicai/' + slug(msg || 'change') + '-' + Date.now().toString(36).slice(-4);
103
- git(cwd, `checkout -b ${target}`);
104
- emit('已从 ' + info.branch + ' 创建分支 ' + target);
105
- }
106
-
107
- let committed = false;
108
- if (stagedStat) {
109
- const f = path.join(os.tmpdir(), 'huicai-commit-' + Date.now() + '.txt');
110
- fs.writeFileSync(f, msg);
111
- try {
112
- git(cwd, `commit -F "${f}"`);
113
- committed = true;
114
- emit('已提交');
115
- } finally {
116
- try { fs.unlinkSync(f); } catch { /* ignore */ }
117
- }
118
- } else {
119
- emit('无新暂存改动,跳过提交');
120
- }
121
-
122
- if (!push) return { ok: true, branch: target, committed };
123
- if (!info.hasRemote) return { ok: true, branch: target, committed, note: '无 remote,未推送' };
124
- try {
125
- git(cwd, `push -u origin ${target}`);
126
- emit('已推送 origin/' + target);
127
- } catch (e) {
128
- return { ok: false, error: 'push 失败:' + String(e.message).split('\n')[0], branch: target };
129
- }
130
-
131
- if (!openPr) return { ok: true, branch: target, committed, pushed: true };
132
- if (!info.hasGh) return { ok: true, branch: target, committed, pushed: true, note: '未装 gh CLI,PR 请手动开' };
133
- try {
134
- const out = execSync('gh pr create --fill', { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
135
- emit('已开 PR:' + out);
136
- return { ok: true, branch: target, committed, pr: out };
137
- } catch (e) {
138
- return { ok: true, branch: target, committed, pushed: true, note: 'gh pr create 失败(可手动开):' + String(e.message).split('\n')[0] };
139
- }
140
- }
1
+ import t from"node:fs";import e from"node:os";import r from"node:path";import{execSync as o}from"node:child_process";import{streamChat as n}from"./api.js";import{DEFAULT_MODEL as i}from"./config.js";const c=/^(agent|workflow|datahuman)-/,a=new Set(["main","master","develop","dev"]);function s(t,e){return o("git "+e,{cwd:t,encoding:"utf8",stdio:["ignore","pipe","pipe"]})}function m(t,e){try{return s(t,e)}catch{return""}}export function gitInfo(t){const e={isRepo:!1,branch:"",dirty:!1,hasRemote:!1,hasGh:!1};try{e.isRepo="true"===s(t,"rev-parse --is-inside-work-tree").trim()}catch{return e}e.branch=m(t,"rev-parse --abbrev-ref HEAD").trim(),e.dirty=m(t,"status --porcelain").trim().length>0,e.hasRemote=m(t,"remote").trim().length>0;try{o("gh --version",{stdio:"ignore"}),e.hasGh=!0}catch{}return e}export async function generateCommitMessage(t,e){let r=m(t,"diff --staged");if(r.trim()||(r=m(t,"diff")),r=r.slice(0,12e3),!r.trim())return"chore: update";const o=c.test(String(e.model||""))?e.compactModel||i:e.model;try{const t=await n({baseUrl:e.baseUrl,apiKey:e.apiKey,model:o,messages:[{role:"system",content:"你是提交信息生成器。根据 git diff 生成一条规范的 Conventional Commits 提交信息:第一行 `<type>(<scope>): <简述>`(简述≤50字,type 用 feat/fix/refactor/docs/test/chore/perf/style 之一);改动较大可空行后加 2-4 条正文要点。只输出提交信息本身,不要解释、不要代码块围栏。"},{role:"user",content:r}]});return(t.content||"").trim().replace(/^```[a-z]*\n?|\n?```$/g,"").trim()||"chore: update"}catch{return"chore: update"}}export async function runPrFlow({cwd:n,config:i,emit:c=()=>{},push:p=!0,openPr:h=!0}){const d=gitInfo(n);if(!d.isRepo)return{ok:!1,error:"当前目录不是 git 仓库"};const u=a.has(d.branch);d.dirty&&(s(n,"add -A"),c("已暂存全部改动"));const f=m(n,"diff --staged --stat").trim();let g="";f&&(c("正在生成提交信息…"),g=await generateCommitMessage(n,i),c("提交信息:"+g.split("\n")[0]));let l=d.branch;u&&(l="huicai/"+function(t){return String(t).split("\n")[0].replace(/^[a-z]+(\([^)]*\))?!?:\s*/i,"").replace(/[^a-zA-Z0-9一-鿿]+/g,"-").replace(/^-+|-+$/g,"").toLowerCase().slice(0,32)||"change"}(g||"change")+"-"+Date.now().toString(36).slice(-4),s(n,`checkout -b ${l}`),c("已从 "+d.branch+" 创建分支 "+l));let y=!1;if(f){const o=r.join(e.tmpdir(),"huicai-commit-"+Date.now()+".txt");t.writeFileSync(o,g);try{s(n,`commit -F "${o}"`),y=!0,c("已提交")}finally{try{t.unlinkSync(o)}catch{}}}else c("无新暂存改动,跳过提交");if(!p)return{ok:!0,branch:l,committed:y};if(!d.hasRemote)return{ok:!0,branch:l,committed:y,note:"无 remote,未推送"};try{s(n,`push -u origin ${l}`),c("已推送 origin/"+l)}catch(t){return{ok:!1,error:"push 失败:"+String(t.message).split("\n")[0],branch:l}}if(!h)return{ok:!0,branch:l,committed:y,pushed:!0};if(!d.hasGh)return{ok:!0,branch:l,committed:y,pushed:!0,note:"未装 gh CLI,PR 请手动开"};try{const t=o("gh pr create --fill",{cwd:n,encoding:"utf8",stdio:["ignore","pipe","pipe"]}).trim();return c("已开 PR:"+t),{ok:!0,branch:l,committed:y,pr:t}}catch(t){return{ok:!0,branch:l,committed:y,pushed:!0,note:"gh pr create 失败(可手动开):"+String(t.message).split("\n")[0]}}}