@huicai/cli 0.5.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/src/device.js ADDED
@@ -0,0 +1,88 @@
1
+ // 设备授权码流(浏览器授权登录):发起 → 引导用户在浏览器确认 → 轮询换取授权登录令牌。
2
+ // 服务端在用户确认时签发带时限的登录令牌(JWT,默认 7 天)——不再为每次登录铸造 API 令牌,
3
+ // 不占用会员 API 令牌数量额度;令牌过期或改密后需重新授权。响应字段名保留 apiKey 以兼容旧版。
4
+ // 全程只用平台网关的 /v1/device/*(免签名/免登录),CLI 无需预置任何凭据。
5
+
6
+ import { spawn } from 'node:child_process';
7
+ import { c } from './colors.js';
8
+
9
+ /** 尽力打开系统浏览器(失败静默,用户可手动打开)。HUICAI_NO_BROWSER 置位时跳过(无头/测试环境)。 */
10
+ function openBrowser(url) {
11
+ if (!url || process.env.HUICAI_NO_BROWSER) return;
12
+ const platform = process.platform;
13
+ const cmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
14
+ const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
15
+ try {
16
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
17
+ child.on('error', () => {});
18
+ child.unref();
19
+ } catch {
20
+ /* 用户可手动打开 */
21
+ }
22
+ }
23
+
24
+ async function postJson(baseUrl, path, body) {
25
+ const res = await fetch(`${baseUrl}${path}`, {
26
+ method: 'POST',
27
+ headers: { 'Content-Type': 'application/json' },
28
+ body: JSON.stringify(body || {}),
29
+ });
30
+ const text = await res.text();
31
+ let json;
32
+ try {
33
+ json = JSON.parse(text);
34
+ } catch {
35
+ throw new Error(`服务返回异常(${res.status}):${text.slice(0, 200)}`);
36
+ }
37
+ if (!res.ok) throw new Error(json?.message || `HTTP ${res.status}`);
38
+ // 平台统一包装 {code,message,data}
39
+ if (json && typeof json === 'object' && 'data' in json && 'code' in json) {
40
+ if (json.code !== 0 && json.code !== 200) throw new Error(json.message || '请求失败');
41
+ return json.data;
42
+ }
43
+ return json;
44
+ }
45
+
46
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
47
+
48
+ /**
49
+ * 执行设备授权流,成功返回授权登录令牌(JWT,服务端响应字段仍名 apiKey);失败/超时抛错。
50
+ * @param {string} baseUrl 平台网关地址
51
+ */
52
+ export async function deviceLogin(baseUrl) {
53
+ const start = await postJson(baseUrl, '/v1/device/code', { client_name: `huicai-cli (${process.platform})` });
54
+ const { deviceCode, userCode, verificationUri, verificationUriComplete } = start;
55
+ const interval = Math.max(2, start.interval || 5);
56
+ const expiresIn = start.expiresIn || 600;
57
+
58
+ // 授权页地址:优先服务端返回;否则按同域部署由平台地址推导 {baseUrl}/device
59
+ const vUri = verificationUri || baseUrl + '/device';
60
+ const vComplete = verificationUriComplete || vUri + (vUri.includes('?') ? '&' : '?') + 'code=' + encodeURIComponent(userCode);
61
+
62
+ process.stdout.write('\n' + c.bold('请在浏览器中完成授权:') + '\n');
63
+ process.stdout.write(' 1) 打开 ' + c.cyan(vUri) + '\n');
64
+ process.stdout.write(' 2) 确认授权码 ' + c.bold(c.yellow(userCode)) + '\n');
65
+ process.stdout.write(c.gray(' (已尝试自动打开浏览器;未弹出请手动访问上面的地址)') + '\n');
66
+ openBrowser(vComplete);
67
+ process.stdout.write(c.gray(`\n等待授权中…(有效期约 ${Math.round(expiresIn / 60)} 分钟,Ctrl+C 取消)`) + '\n');
68
+
69
+ const deadline = Date.now() + expiresIn * 1000;
70
+ while (Date.now() < deadline) {
71
+ await sleep(interval * 1000);
72
+ let r;
73
+ try {
74
+ r = await postJson(baseUrl, '/v1/device/token', { device_code: deviceCode });
75
+ } catch (e) {
76
+ process.stdout.write(c.gray(` 轮询出错(将重试):${e.message}`) + '\n');
77
+ continue;
78
+ }
79
+ if (r.status === 'approved' && r.apiKey) {
80
+ process.stdout.write(c.green('\n✓ 授权成功') + '\n');
81
+ return r.apiKey;
82
+ }
83
+ if (r.status === 'denied') throw new Error('用户在浏览器中拒绝了授权');
84
+ if (r.status === 'expired') throw new Error('授权码已过期,请重新登录');
85
+ process.stdout.write(c.gray(' · 仍在等待浏览器确认…') + '\n');
86
+ }
87
+ throw new Error('授权超时,请重新运行 huicai login');
88
+ }
@@ -0,0 +1,391 @@
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
+ }
package/src/ide.js ADDED
@@ -0,0 +1,53 @@
1
+ // IDE 桥接客户端:当运行在 huicai VS Code 扩展启动的终端里(扩展经环境变量注入本地服务端口+令牌)时,
2
+ // 把 write_file/edit_file 的「拟写入内容」发给扩展做「内联 diff 审阅 + 应用」,而非 CLI 直接落盘。
3
+ // 未在 IDE 环境(无 HUICAI_IDE_PORT)则本模块不介入,CLI 照常直接写文件。
4
+
5
+ import http from 'node:http';
6
+
7
+ const APPLY_TIMEOUT = 120_000; // 用户审阅可能较久
8
+
9
+ /** 是否处于 IDE 集成环境(由扩展注入端口)。 */
10
+ export function isActive() {
11
+ return !!process.env.HUICAI_IDE_PORT;
12
+ }
13
+
14
+ /**
15
+ * 把拟写入内容提交给 IDE 扩展做内联 diff 审阅。
16
+ * @returns {Promise<{decided:boolean, applied?:boolean, reason?:string}>}
17
+ * decided=false 表示传输失败(扩展未响应)——调用方应回退为直接写盘。
18
+ */
19
+ export function proposeWrite(absPath, content, cwd) {
20
+ return new Promise((resolve) => {
21
+ const port = process.env.HUICAI_IDE_PORT;
22
+ const token = process.env.HUICAI_IDE_TOKEN || '';
23
+ const body = JSON.stringify({ path: absPath, content, cwd, token });
24
+ const req = http.request(
25
+ {
26
+ host: '127.0.0.1',
27
+ port,
28
+ path: '/apply',
29
+ method: 'POST',
30
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
31
+ },
32
+ (res) => {
33
+ let d = '';
34
+ res.on('data', (c) => (d += c));
35
+ res.on('end', () => {
36
+ try {
37
+ const j = JSON.parse(d);
38
+ resolve({ decided: true, applied: !!j.applied, reason: j.reason });
39
+ } catch {
40
+ resolve({ decided: false });
41
+ }
42
+ });
43
+ },
44
+ );
45
+ req.on('error', () => resolve({ decided: false }));
46
+ req.setTimeout(APPLY_TIMEOUT, () => {
47
+ req.destroy();
48
+ resolve({ decided: false });
49
+ });
50
+ req.write(body);
51
+ req.end();
52
+ });
53
+ }
package/src/input.js ADDED
@@ -0,0 +1,118 @@
1
+ // 输入预处理与历史:
2
+ // - @文件 引用:消息中的 @路径 自动展开为文件内容(附在消息末尾),目录则展开为清单;
3
+ // - !命令 直通:以 ! 开头的输入直接在本机执行 shell 命令,输出加入对话上下文(不消耗模型调用);
4
+ // - 输入历史:按项目目录持久化到 ~/.huicai/history.json,供 REPL/TUI 上下键翻阅。
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import os from 'node:os';
9
+ import { CONFIG_DIR } from './config.js';
10
+ import { runBash } from './executors.js';
11
+
12
+ const MAX_MENTIONS = 5;
13
+ const MENTION_FILE_CHARS = 12_000;
14
+ const HISTORY_PATH = path.join(CONFIG_DIR, 'history.json');
15
+ const HISTORY_CAP = 1000;
16
+
17
+ /**
18
+ * 展开消息中的 @文件/@目录 引用:原文保留,内容以引用块追加在末尾(模型可见全文)。
19
+ * @returns {{text:string, mentions:string[]}}
20
+ */
21
+ export function expandAtMentions(text, cwd) {
22
+ const re = /(^|[\s("'`(【,,::])@([~A-Za-z0-9_./\\-]+)/g;
23
+ const seen = new Set();
24
+ const blocks = [];
25
+ let m;
26
+ while ((m = re.exec(text)) && seen.size < MAX_MENTIONS) {
27
+ const raw = m[2].replace(/[.,;:。,;]+$/, ''); // 去句尾标点
28
+ if (!raw || seen.has(raw)) continue;
29
+ const abs = raw.startsWith('~') ? path.join(os.homedir(), raw.slice(1)) : path.resolve(cwd, raw);
30
+ let st;
31
+ try {
32
+ st = fs.statSync(abs);
33
+ } catch {
34
+ continue; // 不存在的路径按普通文本处理
35
+ }
36
+ seen.add(raw);
37
+ if (st.isDirectory()) {
38
+ try {
39
+ const entries = fs.readdirSync(abs).slice(0, 100).join('\n');
40
+ blocks.push(`【引用目录 @${raw}】\n${entries || '(空目录)'}`);
41
+ } catch {
42
+ /* ignore */
43
+ }
44
+ continue;
45
+ }
46
+ if (st.size > 512 * 1024) {
47
+ blocks.push(`【引用文件 @${raw}】\n(文件过大:${Math.round(st.size / 1024)}KB,未展开;可让我用 read_file 分段读取)`);
48
+ continue;
49
+ }
50
+ try {
51
+ const buf = fs.readFileSync(abs);
52
+ if (isBinary(buf)) {
53
+ blocks.push(`【引用文件 @${raw}】\n(二进制文件,未展开)`);
54
+ continue;
55
+ }
56
+ let content = buf.toString('utf8');
57
+ if (content.length > MENTION_FILE_CHARS) content = content.slice(0, MENTION_FILE_CHARS) + '\n…(已截断)';
58
+ blocks.push(`【引用文件 @${raw}】\n\`\`\`\n${content}\n\`\`\``);
59
+ } catch {
60
+ /* ignore */
61
+ }
62
+ }
63
+ if (!blocks.length) return { text, mentions: [] };
64
+ return { text: text + '\n\n' + blocks.join('\n\n'), mentions: [...seen] };
65
+ }
66
+
67
+ function isBinary(buf) {
68
+ const n = Math.min(buf.length, 4096);
69
+ for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
70
+ return false;
71
+ }
72
+
73
+ /**
74
+ * 执行 !命令 直通:在 cwd 下跑 shell 命令,返回展示文本与写入对话的记录文本。
75
+ * @returns {Promise<{output:string, record:string}>}
76
+ */
77
+ export async function runBangCommand(command, cwd) {
78
+ const output = await runBash(cwd, { command });
79
+ const capped = output.length > 8000 ? output.slice(0, 8000) + '\n…(已截断)' : output;
80
+ return {
81
+ output,
82
+ record: `我在终端直接执行了命令:$ ${command}\n${capped}`,
83
+ };
84
+ }
85
+
86
+ function readHistoryFile() {
87
+ try {
88
+ const j = JSON.parse(fs.readFileSync(HISTORY_PATH, 'utf8'));
89
+ return Array.isArray(j?.entries) ? j.entries : [];
90
+ } catch {
91
+ return [];
92
+ }
93
+ }
94
+
95
+ /** 读取某目录的输入历史(旧→新)。 */
96
+ export function loadHistory(cwd, limit = 200) {
97
+ return readHistoryFile()
98
+ .filter((e) => e && e.cwd === cwd && typeof e.line === 'string')
99
+ .slice(-limit)
100
+ .map((e) => e.line);
101
+ }
102
+
103
+ /** 追加一条输入历史(连续重复不记)。best-effort,失败静默。 */
104
+ export function appendHistory(cwd, line) {
105
+ const t = String(line || '').trim();
106
+ if (!t) return;
107
+ try {
108
+ let entries = readHistoryFile();
109
+ const last = [...entries].reverse().find((e) => e.cwd === cwd);
110
+ if (last && last.line === t) return;
111
+ entries.push({ cwd, line: t, ts: Date.now() });
112
+ if (entries.length > HISTORY_CAP) entries = entries.slice(-HISTORY_CAP);
113
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
114
+ fs.writeFileSync(HISTORY_PATH, JSON.stringify({ entries }), { mode: 0o600 });
115
+ } catch {
116
+ /* ignore */
117
+ }
118
+ }