@huicai/cli 2026.711.0 → 2026.711.24616
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/LICENSE +43 -21
- package/package.json +4 -3
- package/src/advisor.js +1 -46
- package/src/agent.js +1 -518
- package/src/api.js +1 -359
- package/src/autofix.js +1 -90
- package/src/banner.js +1 -83
- package/src/checkpoints.js +1 -99
- package/src/clipboard.js +1 -34
- package/src/codeindex.js +1 -229
- package/src/colors.js +1 -47
- package/src/commands.js +1 -1294
- package/src/compaction.js +1 -144
- package/src/config.js +1 -86
- package/src/consult.js +1 -65
- package/src/context-report.js +1 -119
- package/src/context.js +1 -152
- package/src/cron.js +1 -64
- package/src/daemon.js +1 -62
- package/src/device.js +1 -88
- package/src/diffview.js +1 -115
- package/src/doctor.js +1 -72
- package/src/executors.js +1 -391
- package/src/forks.js +1 -59
- package/src/gitflow.js +1 -140
- package/src/hooks.js +1 -207
- package/src/ide.js +1 -53
- package/src/input.js +1 -182
- package/src/insights.js +1 -102
- package/src/kbsync.js +1 -141
- package/src/markdown.js +0 -0
- package/src/markup-filter.js +1 -85
- package/src/mcp.js +1 -207
- package/src/memory.js +1 -161
- package/src/permissions.js +1 -120
- package/src/render.js +1 -138
- package/src/repl.js +1 -271
- package/src/scan.js +1 -84
- package/src/schedule.js +1 -48
- package/src/session.js +1 -140
- package/src/settings.js +0 -0
- package/src/skills.js +1 -248
- package/src/subagent.js +1 -183
- package/src/todo.js +1 -75
- package/src/tools.js +1 -147
- package/src/transcript.js +1 -94
- package/src/tui/app.js +1 -666
- package/src/usage.js +1 -43
- package/src/watch.js +1 -74
package/src/permissions.js
CHANGED
|
@@ -1,120 +1 @@
|
|
|
1
|
-
|
|
2
|
-
// 另支持 settings.json 允许规则(全局 ~/.huicai + 项目 .huicai,见 settings.js)。
|
|
3
|
-
// yolo 模式全放行;非交互(无 ask)且非 yolo 时拒绝写/执行并把原因回灌模型,让它调整策略。
|
|
4
|
-
|
|
5
|
-
import { c } from './colors.js';
|
|
6
|
-
import { toolLabel, renderEditDiff, indent } from './render.js';
|
|
7
|
-
import { loadAllowRules, loadDenyRules, matchAllowRules, suggestRule, addProjectAllowRule, riskWarning } from './settings.js';
|
|
8
|
-
|
|
9
|
-
// 计划模式下写/执行类工具被拒绝时的统一说明(REPL 与 TUI 共用同一文案)。
|
|
10
|
-
export const PLAN_DENY_REASON =
|
|
11
|
-
'当前处于计划模式:只做只读调研,不执行任何改动。请先产出完整方案交用户确认;'
|
|
12
|
-
+ '用户 /plan 关闭计划模式后再执行改动。';
|
|
13
|
-
|
|
14
|
-
// 沙箱模式下 run_bash / fetch_url 被拒绝时的统一说明。
|
|
15
|
-
export const SANDBOX_DENY_REASON =
|
|
16
|
-
'当前处于沙箱模式:禁止执行 shell 命令(run_bash)与联网(fetch_url)。'
|
|
17
|
-
+ '可继续读写本地文件;如需执行/联网,请用户 /sandbox off 关闭沙箱。';
|
|
18
|
-
|
|
19
|
-
export function createPermissions({ yolo = false, ask = null, cwd = null } = {}) {
|
|
20
|
-
const sessionAllowed = new Set();
|
|
21
|
-
let autoAll = !!yolo;
|
|
22
|
-
let planMode = false;
|
|
23
|
-
let sandbox = false;
|
|
24
|
-
// 仅在给定 cwd 时读取 settings 规则(测试等场景不受用户本机全局配置影响)
|
|
25
|
-
let rules = cwd ? loadAllowRules(cwd) : [];
|
|
26
|
-
let denyRules = cwd ? loadDenyRules(cwd) : [];
|
|
27
|
-
|
|
28
|
-
// readOnly 由调用方判定(内置工具查 READONLY_TOOLS;MCP 工具看 readOnlyHint;子智能体只读)
|
|
29
|
-
async function request(name, args, _cwd, readOnly = false) {
|
|
30
|
-
// 拒绝规则优先于一切(含只读;可用于挡住读取密钥文件等)
|
|
31
|
-
if (denyRules.length && matchAllowRules(denyRules, name, args)) {
|
|
32
|
-
return { allow: false, reason: '该操作被拒绝规则阻止(/permissions 查看或移除)。' };
|
|
33
|
-
}
|
|
34
|
-
// 计划模式:只放行只读,写/执行一律拦下并回灌原因(模型据此改为产出方案)
|
|
35
|
-
if (planMode && !readOnly) return { allow: false, reason: PLAN_DENY_REASON };
|
|
36
|
-
// 沙箱模式:禁 shell 与联网(仍可读写本地文件)
|
|
37
|
-
if (sandbox && (name === 'run_bash' || name === 'fetch_url')) return { allow: false, reason: SANDBOX_DENY_REASON };
|
|
38
|
-
if (readOnly) return { allow: true };
|
|
39
|
-
if (autoAll || sessionAllowed.has(name)) return { allow: true };
|
|
40
|
-
if (rules.length && matchAllowRules(rules, name, args)) return { allow: true };
|
|
41
|
-
if (!ask) {
|
|
42
|
-
return { allow: false, reason: '当前为非交互模式,未授权写/执行操作(加 --yolo 可自动放行)。' };
|
|
43
|
-
}
|
|
44
|
-
printPreview(name, args);
|
|
45
|
-
const raw = (await ask(
|
|
46
|
-
`${c.yellow('允许执行?')} [y]是 · [a]本会话该工具 · [A]永久允许(写入项目设置) · [N]否(可附原因): `,
|
|
47
|
-
)) || '';
|
|
48
|
-
const answer = raw.trim();
|
|
49
|
-
if (answer === 'a') {
|
|
50
|
-
sessionAllowed.add(name);
|
|
51
|
-
return { allow: true };
|
|
52
|
-
}
|
|
53
|
-
if (answer === 'A') {
|
|
54
|
-
sessionAllowed.add(name);
|
|
55
|
-
if (cwd) {
|
|
56
|
-
const rule = suggestRule(name, args);
|
|
57
|
-
try {
|
|
58
|
-
const p = addProjectAllowRule(cwd, rule);
|
|
59
|
-
rules = loadAllowRules(cwd);
|
|
60
|
-
process.stdout.write(c.gray(` 已写入规则 ${rule} → ${p}`) + '\n');
|
|
61
|
-
} catch (e) {
|
|
62
|
-
process.stdout.write(c.yellow(` 规则写入失败(本会话仍生效):${e.message}`) + '\n');
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return { allow: true };
|
|
66
|
-
}
|
|
67
|
-
const lower = answer.toLowerCase();
|
|
68
|
-
if (lower === 'y' || lower === 'yes') return { allow: true };
|
|
69
|
-
if (answer === '' || lower === 'n' || lower === 'no') {
|
|
70
|
-
return { allow: false, reason: '用户拒绝了该操作。' };
|
|
71
|
-
}
|
|
72
|
-
return { allow: false, reason: `用户拒绝并说明:${answer}` };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return {
|
|
76
|
-
request,
|
|
77
|
-
setYolo(v) {
|
|
78
|
-
autoAll = !!v;
|
|
79
|
-
},
|
|
80
|
-
isYolo() {
|
|
81
|
-
return autoAll;
|
|
82
|
-
},
|
|
83
|
-
setPlanMode(v) {
|
|
84
|
-
planMode = !!v;
|
|
85
|
-
},
|
|
86
|
-
isPlanMode() {
|
|
87
|
-
return planMode;
|
|
88
|
-
},
|
|
89
|
-
setSandbox(v) {
|
|
90
|
-
sandbox = !!v;
|
|
91
|
-
},
|
|
92
|
-
isSandbox() {
|
|
93
|
-
return sandbox;
|
|
94
|
-
},
|
|
95
|
-
/** 规则文件被 /permissions 修改后重新加载(本会话即时生效)。 */
|
|
96
|
-
reloadRules() {
|
|
97
|
-
rules = cwd ? loadAllowRules(cwd) : [];
|
|
98
|
-
denyRules = cwd ? loadDenyRules(cwd) : [];
|
|
99
|
-
},
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function printPreview(name, args) {
|
|
104
|
-
process.stdout.write('\n' + c.bold(c.yellow('● 需要授权:')) + toolLabel(name, args) + '\n');
|
|
105
|
-
const risk = riskWarning(name, args);
|
|
106
|
-
if (risk) process.stdout.write(' ' + c.bold(c.red('⚠ ' + risk)) + '\n');
|
|
107
|
-
if (name === 'run_bash') {
|
|
108
|
-
process.stdout.write(c.gray(' $ ') + String(args.command ?? '') + '\n');
|
|
109
|
-
} else if (name === 'write_file') {
|
|
110
|
-
const preview = String(args.content ?? '')
|
|
111
|
-
.split('\n')
|
|
112
|
-
.slice(0, 12)
|
|
113
|
-
.join('\n');
|
|
114
|
-
process.stdout.write(c.gray(` 将写入 ${args.path}:`) + '\n' + indent(preview) + '\n');
|
|
115
|
-
} else if (name === 'edit_file') {
|
|
116
|
-
process.stdout.write(c.gray(` 编辑 ${args.path}:`) + '\n' + indent(renderEditDiff(args.old_string, args.new_string)) + '\n');
|
|
117
|
-
} else if (name === 'fetch_url') {
|
|
118
|
-
process.stdout.write(c.gray(' GET ') + String(args.url ?? '') + '\n');
|
|
119
|
-
}
|
|
120
|
-
}
|
|
1
|
+
import{c as o}from"./colors.js";import{toolLabel as t,renderEditDiff as r,indent as e}from"./render.js";import{loadAllowRules as s,loadDenyRules as n,matchAllowRules as l,suggestRule as a,addProjectAllowRule as i,riskWarning as u}from"./settings.js";export const PLAN_DENY_REASON="当前处于计划模式:只做只读调研,不执行任何改动。请先产出完整方案交用户确认;用户 /plan 关闭计划模式后再执行改动。";export const SANDBOX_DENY_REASON="当前处于沙箱模式:禁止执行 shell 命令(run_bash)与联网(fetch_url)。可继续读写本地文件;如需执行/联网,请用户 /sandbox off 关闭沙箱。";export function createPermissions({yolo:c=!1,ask:w=null,cwd:d=null}={}){const f=new Set;let p=!!c,_=!1,y=!1,g=d?s(d):[],h=d?n(d):[];return{request:async function(n,c,N,m=!1){if(h.length&&l(h,n,c))return{allow:!1,reason:"该操作被拒绝规则阻止(/permissions 查看或移除)。"};if(_&&!m)return{allow:!1,reason:PLAN_DENY_REASON};if(y&&("run_bash"===n||"fetch_url"===n))return{allow:!1,reason:SANDBOX_DENY_REASON};if(m)return{allow:!0};if(p||f.has(n))return{allow:!0};if(g.length&&l(g,n,c))return{allow:!0};if(!w)return{allow:!1,reason:"当前为非交互模式,未授权写/执行操作(加 --yolo 可自动放行)。"};!function(s,n){process.stdout.write("\n"+o.bold(o.yellow("● 需要授权:"))+t(s,n)+"\n");const l=u(s,n);l&&process.stdout.write(" "+o.bold(o.red("⚠ "+l))+"\n");if("run_bash"===s)process.stdout.write(o.gray(" $ ")+String(n.command??"")+"\n");else if("write_file"===s){const t=String(n.content??"").split("\n").slice(0,12).join("\n");process.stdout.write(o.gray(` 将写入 ${n.path}:`)+"\n"+e(t)+"\n")}else"edit_file"===s?process.stdout.write(o.gray(` 编辑 ${n.path}:`)+"\n"+e(r(n.old_string,n.new_string))+"\n"):"fetch_url"===s&&process.stdout.write(o.gray(" GET ")+String(n.url??"")+"\n")}(n,c);const S=(await w(`${o.yellow("允许执行?")} [y]是 · [a]本会话该工具 · [A]永久允许(写入项目设置) · [N]否(可附原因): `)||"").trim();if("a"===S)return f.add(n),{allow:!0};if("A"===S){if(f.add(n),d){const t=a(n,c);try{const r=i(d,t);g=s(d),process.stdout.write(o.gray(` 已写入规则 ${t} → ${r}`)+"\n")}catch(t){process.stdout.write(o.yellow(` 规则写入失败(本会话仍生效):${t.message}`)+"\n")}}return{allow:!0}}const A=S.toLowerCase();return"y"===A||"yes"===A?{allow:!0}:""===S||"n"===A||"no"===A?{allow:!1,reason:"用户拒绝了该操作。"}:{allow:!1,reason:`用户拒绝并说明:${S}`}},setYolo(o){p=!!o},isYolo:()=>p,setPlanMode(o){_=!!o},isPlanMode:()=>_,setSandbox(o){y=!!o},isSandbox:()=>y,reloadRules(){g=d?s(d):[],h=d?n(d):[]}}}
|
package/src/render.js
CHANGED
|
@@ -1,138 +1 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import { c } from './colors.js';
|
|
4
|
-
|
|
5
|
-
/** 把一次工具调用渲染成简洁的一行标签。 */
|
|
6
|
-
export function toolLabel(name, args = {}) {
|
|
7
|
-
switch (name) {
|
|
8
|
-
case 'read_file':
|
|
9
|
-
return `read_file ${args.path ?? ''}`;
|
|
10
|
-
case 'list_files':
|
|
11
|
-
return `list_files ${args.path ?? '.'}`;
|
|
12
|
-
case 'glob':
|
|
13
|
-
return `glob ${args.pattern ?? ''}`;
|
|
14
|
-
case 'grep':
|
|
15
|
-
return `grep ${JSON.stringify(args.pattern ?? '')}${args.path ? ' in ' + args.path : ''}`;
|
|
16
|
-
case 'search_code':
|
|
17
|
-
return `search_code ${JSON.stringify(args.query ?? '')}`;
|
|
18
|
-
case 'write_file':
|
|
19
|
-
return `write_file ${args.path ?? ''}`;
|
|
20
|
-
case 'edit_file':
|
|
21
|
-
return `edit_file ${args.path ?? ''}`;
|
|
22
|
-
case 'run_bash':
|
|
23
|
-
return `run_bash: ${String(args.command ?? '').split('\n')[0]}`;
|
|
24
|
-
case 'fetch_url':
|
|
25
|
-
return `fetch_url ${args.url ?? ''}`;
|
|
26
|
-
case 'todo_write':
|
|
27
|
-
return 'todo_write(更新任务清单)';
|
|
28
|
-
case 'dispatch_agent': {
|
|
29
|
-
const o = String(args.objective ?? '').split('\n')[0];
|
|
30
|
-
return `dispatch_agent: ${o.length > 60 ? o.slice(0, 60) + '…' : o}`;
|
|
31
|
-
}
|
|
32
|
-
case 'dispatch_agents':
|
|
33
|
-
return `dispatch_agents(并行 ${Array.isArray(args.tasks) ? args.tasks.length : '?'} 个子任务)`;
|
|
34
|
-
default:
|
|
35
|
-
return `${name}(${Object.keys(args).join(', ')})`;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** edit_file 的简易 diff:old 红、new 绿。 */
|
|
40
|
-
export function renderEditDiff(oldStr, newStr) {
|
|
41
|
-
const out = [];
|
|
42
|
-
for (const l of String(oldStr ?? '').split('\n')) out.push(c.red('- ' + l));
|
|
43
|
-
for (const l of String(newStr ?? '').split('\n')) out.push(c.green('+ ' + l));
|
|
44
|
-
return out.join('\n');
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* 逐行 LCS diff:返回 [{type:' '|'-'|'+', text}]。超大文本退化为「全删+全增」以防 O(n·m) 爆内存。
|
|
49
|
-
*/
|
|
50
|
-
export function diffLines(oldText, newText) {
|
|
51
|
-
// 空文本视为「零行」(新建文件时旧内容为空,不应显示一条幽灵空行删除)
|
|
52
|
-
const split = (t) => (t == null || t === '' ? [] : String(t).split('\n'));
|
|
53
|
-
const a = split(oldText);
|
|
54
|
-
const b = split(newText);
|
|
55
|
-
const n = a.length, m = b.length;
|
|
56
|
-
if (n * m > 4_000_000) {
|
|
57
|
-
return [...a.map((t) => ({ type: '-', text: t })), ...b.map((t) => ({ type: '+', text: t }))];
|
|
58
|
-
}
|
|
59
|
-
// dp[i][j] = a[i..] 与 b[j..] 的最长公共子序列长度
|
|
60
|
-
const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
|
61
|
-
for (let i = n - 1; i >= 0; i--) {
|
|
62
|
-
for (let j = m - 1; j >= 0; j--) {
|
|
63
|
-
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
const ops = [];
|
|
67
|
-
let i = 0, j = 0;
|
|
68
|
-
while (i < n && j < m) {
|
|
69
|
-
if (a[i] === b[j]) { ops.push({ type: ' ', text: a[i] }); i++; j++; }
|
|
70
|
-
else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ type: '-', text: a[i] }); i++; }
|
|
71
|
-
else { ops.push({ type: '+', text: b[j] }); j++; }
|
|
72
|
-
}
|
|
73
|
-
while (i < n) ops.push({ type: '-', text: a[i++] });
|
|
74
|
-
while (j < m) ops.push({ type: '+', text: b[j++] });
|
|
75
|
-
return ops;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* 把一次文件更改渲染为「带行号的统一 diff」(对标 Claude Code 编辑后展示更改的行):
|
|
80
|
-
* 增行绿色 `+`、删行红色 `-`、上下文灰色;仅显示变更行及其上下 context 行,中间大段用 ⋯ 省略。
|
|
81
|
-
* 无变化返回空串。返回的是带 ANSI 的字符串(可直接 write,也可放进 ink <Text>)。
|
|
82
|
-
* @param {string} oldText
|
|
83
|
-
* @param {string} newText
|
|
84
|
-
* @param {{path?:string, context?:number, maxLines?:number}} [opts]
|
|
85
|
-
*/
|
|
86
|
-
export function renderFileDiff(oldText, newText, opts = {}) {
|
|
87
|
-
const { path = '', context = 3, maxLines = 80 } = opts;
|
|
88
|
-
const ops = diffLines(oldText, newText);
|
|
89
|
-
let adds = 0, dels = 0;
|
|
90
|
-
for (const o of ops) { if (o.type === '+') adds++; else if (o.type === '-') dels++; }
|
|
91
|
-
if (adds === 0 && dels === 0) return '';
|
|
92
|
-
|
|
93
|
-
// 标注每行的旧/新行号
|
|
94
|
-
const rows = [];
|
|
95
|
-
let oldNo = 0, newNo = 0;
|
|
96
|
-
for (const o of ops) {
|
|
97
|
-
if (o.type === ' ') { oldNo++; newNo++; rows.push({ type: ' ', text: o.text, no: oldNo }); }
|
|
98
|
-
else if (o.type === '-') { oldNo++; rows.push({ type: '-', text: o.text, no: oldNo }); }
|
|
99
|
-
else { newNo++; rows.push({ type: '+', text: o.text, no: newNo }); }
|
|
100
|
-
}
|
|
101
|
-
// 需展示的行 = 变更行及其上下 context 行
|
|
102
|
-
const show = new Array(rows.length).fill(false);
|
|
103
|
-
rows.forEach((r, idx) => {
|
|
104
|
-
if (r.type === ' ') return;
|
|
105
|
-
for (let k = Math.max(0, idx - context); k <= Math.min(rows.length - 1, idx + context); k++) show[k] = true;
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
const width = String(Math.max(oldNo, newNo)).length;
|
|
109
|
-
const pad = (no) => String(no == null ? '' : no).padStart(width);
|
|
110
|
-
const out = [(path ? c.bold(path) + ' ' : '') + c.green('+' + adds) + ' ' + c.red('−' + dels)];
|
|
111
|
-
let elided = false, shown = 0, truncated = false;
|
|
112
|
-
for (let idx = 0; idx < rows.length; idx++) {
|
|
113
|
-
if (!show[idx]) { if (!elided) { out.push(c.gray(' ' + ' '.repeat(width) + ' ⋯')); elided = true; } continue; }
|
|
114
|
-
elided = false;
|
|
115
|
-
if (shown >= maxLines) { truncated = true; break; }
|
|
116
|
-
const r = rows[idx];
|
|
117
|
-
if (r.type === '+') out.push(c.green(' ' + pad(r.no) + ' + ') + c.green(r.text));
|
|
118
|
-
else if (r.type === '-') out.push(c.red(' ' + pad(r.no) + ' - ') + c.red(r.text));
|
|
119
|
-
else out.push(c.gray(' ' + pad(r.no) + ' ' + r.text));
|
|
120
|
-
shown++;
|
|
121
|
-
}
|
|
122
|
-
if (truncated) out.push(c.gray(` …(差异较大,仅显示前 ${maxLines} 行;共 +${adds} −${dels})`));
|
|
123
|
-
return out.join('\n');
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export function indent(s, pad = ' ') {
|
|
127
|
-
return String(s)
|
|
128
|
-
.split('\n')
|
|
129
|
-
.map((l) => pad + l)
|
|
130
|
-
.join('\n');
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** 结果摘要(首行 + 行数提示),避免把整段工具输出刷屏。 */
|
|
134
|
-
export function summarize(text, maxLines = 6) {
|
|
135
|
-
const lines = String(text).split('\n');
|
|
136
|
-
if (lines.length <= maxLines) return text;
|
|
137
|
-
return lines.slice(0, maxLines).join('\n') + c.gray(`\n …(共 ${lines.length} 行)`);
|
|
138
|
-
}
|
|
1
|
+
import{c as t}from"./colors.js";export function toolLabel(t,e={}){switch(t){case"read_file":return`read_file ${e.path??""}`;case"list_files":return`list_files ${e.path??"."}`;case"glob":return`glob ${e.pattern??""}`;case"grep":return`grep ${JSON.stringify(e.pattern??"")}${e.path?" in "+e.path:""}`;case"search_code":return`search_code ${JSON.stringify(e.query??"")}`;case"write_file":return`write_file ${e.path??""}`;case"edit_file":return`edit_file ${e.path??""}`;case"run_bash":return`run_bash: ${String(e.command??"").split("\n")[0]}`;case"fetch_url":return`fetch_url ${e.url??""}`;case"todo_write":return"todo_write(更新任务清单)";case"dispatch_agent":{const t=String(e.objective??"").split("\n")[0];return`dispatch_agent: ${t.length>60?t.slice(0,60)+"…":t}`}case"dispatch_agents":return`dispatch_agents(并行 ${Array.isArray(e.tasks)?e.tasks.length:"?"} 个子任务)`;default:return`${t}(${Object.keys(e).join(", ")})`}}export function renderEditDiff(e,n){const r=[];for(const n of String(e??"").split("\n"))r.push(t.red("- "+n));for(const e of String(n??"").split("\n"))r.push(t.green("+ "+e));return r.join("\n")}export function diffLines(t,e){const n=t=>null==t||""===t?[]:String(t).split("\n"),r=n(t),s=n(e),i=r.length,o=s.length;if(i*o>4e6)return[...r.map(t=>({type:"-",text:t})),...s.map(t=>({type:"+",text:t}))];const a=Array.from({length:i+1},()=>new Uint32Array(o+1));for(let t=i-1;t>=0;t--)for(let e=o-1;e>=0;e--)a[t][e]=r[t]===s[e]?a[t+1][e+1]+1:Math.max(a[t+1][e],a[t][e+1]);const p=[];let l=0,c=0;for(;l<i&&c<o;)r[l]===s[c]?(p.push({type:" ",text:r[l]}),l++,c++):a[l+1][c]>=a[l][c+1]?(p.push({type:"-",text:r[l]}),l++):(p.push({type:"+",text:s[c]}),c++);for(;l<i;)p.push({type:"-",text:r[l++]});for(;c<o;)p.push({type:"+",text:s[c++]});return p}export function renderFileDiff(e,n,r={}){const{path:s="",context:i=3,maxLines:o=80}=r,a=diffLines(e,n);let p=0,l=0;for(const t of a)"+"===t.type?p++:"-"===t.type&&l++;if(0===p&&0===l)return"";const c=[];let u=0,f=0;for(const t of a)" "===t.type?(u++,f++,c.push({type:" ",text:t.text,no:u})):"-"===t.type?(u++,c.push({type:"-",text:t.text,no:u})):(f++,c.push({type:"+",text:t.text,no:f}));const h=new Array(c.length).fill(!1);c.forEach((t,e)=>{if(" "!==t.type)for(let t=Math.max(0,e-i);t<=Math.min(c.length-1,e+i);t++)h[t]=!0});const g=String(Math.max(u,f)).length,y=t=>String(null==t?"":t).padStart(g),x=[(s?t.bold(s)+" ":"")+t.green("+"+p)+" "+t.red("−"+l)];let d=!1,_=0,$=!1;for(let e=0;e<c.length;e++){if(!h[e]){d||(x.push(t.gray(" "+" ".repeat(g)+" ⋯")),d=!0);continue}if(d=!1,_>=o){$=!0;break}const n=c[e];"+"===n.type?x.push(t.green(" "+y(n.no)+" + ")+t.green(n.text)):"-"===n.type?x.push(t.red(" "+y(n.no)+" - ")+t.red(n.text)):x.push(t.gray(" "+y(n.no)+" "+n.text)),_++}return $&&x.push(t.gray(` …(差异较大,仅显示前 ${o} 行;共 +${p} −${l})`)),x.join("\n")}export function indent(t,e=" "){return String(t).split("\n").map(t=>e+t).join("\n")}export function summarize(e,n=6){const r=String(e).split("\n");return r.length<=n?e:r.slice(0,n).join("\n")+t.gray(`\n …(共 ${r.length} 行)`)}
|
package/src/repl.js
CHANGED
|
@@ -1,271 +1 @@
|
|
|
1
|
-
|
|
2
|
-
// 斜杠命令走 commands.js 统一注册表(与 ink TUI 共用);支持 @文件 引用与 !命令 直通。
|
|
3
|
-
|
|
4
|
-
import readline from 'node:readline';
|
|
5
|
-
import { c } from './colors.js';
|
|
6
|
-
import { createPermissions } from './permissions.js';
|
|
7
|
-
import { runTurn } from './agent.js';
|
|
8
|
-
import { createSession, saveSession, turnCount } from './session.js';
|
|
9
|
-
import { createCheckpoints } from './checkpoints.js';
|
|
10
|
-
import { buildSystemPrompt } from './context.js';
|
|
11
|
-
import { runVerifyLoop } from './autofix.js';
|
|
12
|
-
import { loadMcpTools, closeMcp } from './mcp.js';
|
|
13
|
-
import { makeSubagentTool, makeDispatchAgentsTool } from './subagent.js';
|
|
14
|
-
import { makeConsultTool } from './consult.js';
|
|
15
|
-
import { makeAdvisorTool } from './advisor.js';
|
|
16
|
-
import { makeRecallTool, readMemoryRaw } from './memory.js';
|
|
17
|
-
import { detectScanners, makeScanTool } from './scan.js';
|
|
18
|
-
import { runSlash } from './commands.js';
|
|
19
|
-
import { expandAtMentions, buildUserContent, runBangCommand, loadHistory, appendHistory } from './input.js';
|
|
20
|
-
import { makeSkillTools } from './skills.js';
|
|
21
|
-
import { renderWelcome } from './banner.js';
|
|
22
|
-
|
|
23
|
-
/** 汇集动态工具:本地技能(load_skill)+ 本地 MCP 工具 + dispatch_agent 子智能体。返回 {extraTools, clients}。 */
|
|
24
|
-
export async function buildExtraTools(config, cwd) {
|
|
25
|
-
let mcp = { tools: [], clients: [] };
|
|
26
|
-
try {
|
|
27
|
-
mcp = await loadMcpTools();
|
|
28
|
-
} catch (e) {
|
|
29
|
-
process.stdout.write(c.yellow('MCP 初始化失败(忽略):' + e.message) + '\n');
|
|
30
|
-
}
|
|
31
|
-
// 本地技能工具(.huicai/skills 与 ~/.huicai/skills;无技能时为空数组,零开销)
|
|
32
|
-
const extraTools = [...makeSkillTools(cwd), ...mcp.tools];
|
|
33
|
-
const dispatch = makeSubagentTool({ config, cwd, extraTools: mcp.tools, depth: 0 });
|
|
34
|
-
if (dispatch) extraTools.push(dispatch);
|
|
35
|
-
// 多智能体编排:并行批量子智能体(一次调用→N 并行→聚合)
|
|
36
|
-
const dispatchBatch = makeDispatchAgentsTool({ config, cwd, extraTools: mcp.tools, depth: 0 });
|
|
37
|
-
if (dispatchBatch) extraTools.push(dispatchBatch);
|
|
38
|
-
// 混合模式:配置了 --consult 平台伪模型时,暴露 consult_platform 工具(本机驱动 + 平台编排咨询)
|
|
39
|
-
const consult = makeConsultTool({ config });
|
|
40
|
-
if (consult) extraTools.push(consult);
|
|
41
|
-
// 会决策:设置了顾问模型 → ask_advisor;有长期记忆 → recall_memory(跨会话经验主动召回)
|
|
42
|
-
const advisor = makeAdvisorTool({ config });
|
|
43
|
-
if (advisor) extraTools.push(advisor);
|
|
44
|
-
if (config.memoryEnabled !== false && readMemoryRaw(cwd)) extraTools.push(makeRecallTool({ cwd }));
|
|
45
|
-
// 提质:检测到可用扫描器 → security_scan(真扫描,供安全审查/提交前自检)
|
|
46
|
-
if (detectScanners(cwd).length) extraTools.push(makeScanTool({ cwd }));
|
|
47
|
-
return { extraTools, clients: mcp.clients };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function banner(config, cwd, permissions, version) {
|
|
51
|
-
process.stdout.write(renderWelcome({
|
|
52
|
-
model: config.model,
|
|
53
|
-
cwd,
|
|
54
|
-
version,
|
|
55
|
-
platform: /^(agent|workflow|datahuman)-/.test(String(config.model || '')),
|
|
56
|
-
yolo: permissions.isYolo(),
|
|
57
|
-
consult: config.consult ? (config.consultLabel || config.consult) : null,
|
|
58
|
-
}) + '\n');
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export async function startRepl({ config, cwd, yolo, initialPrompt, session, version }) {
|
|
62
|
-
const rl = readline.createInterface({
|
|
63
|
-
input: process.stdin,
|
|
64
|
-
output: process.stdout,
|
|
65
|
-
history: loadHistory(cwd).reverse(), // readline 的 history 最近的在前
|
|
66
|
-
historySize: 200,
|
|
67
|
-
});
|
|
68
|
-
const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
69
|
-
let permissions = createPermissions({ yolo, ask, cwd }); // /cd 换目录时会重建
|
|
70
|
-
const usage = { prompt: 0, completion: 0 };
|
|
71
|
-
const todoState = { list: [] };
|
|
72
|
-
const checkpoints = createCheckpoints(); // /rewind 检查点(本会话内存态)
|
|
73
|
-
let currentAbort = null;
|
|
74
|
-
let loopTimer = null; // /loop 定时器
|
|
75
|
-
// ctx 持有可变的 session(/clear 会替换为新会话),各处统一读 ctx.session
|
|
76
|
-
const ctx = { config, cwd, permissions, usage, session, version };
|
|
77
|
-
|
|
78
|
-
rl.on('SIGINT', () => {
|
|
79
|
-
if (currentAbort) currentAbort.abort();
|
|
80
|
-
else process.stdout.write('\n' + c.gray('输入 /exit 退出。') + '\n');
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
banner(config, cwd, permissions, version);
|
|
84
|
-
echoResumed(session);
|
|
85
|
-
let { extraTools, clients } = await buildExtraTools(config, cwd); // 连接 MCP(若已配置);/cd 换目录时会重建
|
|
86
|
-
|
|
87
|
-
const doTurn = async (text) => {
|
|
88
|
-
const { text: expanded, mentions, images } = await expandAtMentions(text, cwd, config);
|
|
89
|
-
if (mentions.length) process.stdout.write(c.gray(' · 已展开引用:' + mentions.join('、')
|
|
90
|
-
+ (images.length ? `(含 ${images.length} 张图片)` : '')) + '\n');
|
|
91
|
-
// 回合前记录检查点(对话位置 + 本回合文件原文经 fileJournal 累积),供 /rewind 回滚
|
|
92
|
-
const journal = checkpoints.snapshot({ session: ctx.session, label: text });
|
|
93
|
-
ctx.session.messages.push({ role: 'user', content: buildUserContent(expanded, images) });
|
|
94
|
-
currentAbort = new AbortController();
|
|
95
|
-
if (!ctx.session.compaction) ctx.session.compaction = {};
|
|
96
|
-
config.sessionId = ctx.session.id; // 对话落库审计:当前会话号(/clear 换新会话后随之更新)
|
|
97
|
-
const r = await runTurn({
|
|
98
|
-
config,
|
|
99
|
-
messages: ctx.session.messages,
|
|
100
|
-
permissions,
|
|
101
|
-
cwd,
|
|
102
|
-
signal: currentAbort.signal,
|
|
103
|
-
extraTools,
|
|
104
|
-
todoState,
|
|
105
|
-
compaction: ctx.session.compaction,
|
|
106
|
-
fileJournal: journal,
|
|
107
|
-
});
|
|
108
|
-
if (r?.usage) {
|
|
109
|
-
usage.prompt += r.usage.prompt;
|
|
110
|
-
usage.completion += r.usage.completion;
|
|
111
|
-
}
|
|
112
|
-
if (r?.lastPromptTokens) ctx.session.lastPromptTokens = r.lastPromptTokens;
|
|
113
|
-
currentAbort = null;
|
|
114
|
-
saveSession(ctx.session); // 每轮结束自动保存,可随时 --resume 恢复
|
|
115
|
-
return r;
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
const cmdCtx = () => ({
|
|
119
|
-
...ctx,
|
|
120
|
-
session: ctx.session,
|
|
121
|
-
extraTools,
|
|
122
|
-
checkpoints,
|
|
123
|
-
print: (t) => process.stdout.write(String(t).endsWith('\n') ? String(t) : String(t) + '\n'),
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
if (initialPrompt) {
|
|
127
|
-
process.stdout.write(c.cyan('\nhuicai> ') + initialPrompt + '\n');
|
|
128
|
-
await doTurn(initialPrompt);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
while (true) {
|
|
132
|
-
let input;
|
|
133
|
-
try {
|
|
134
|
-
input = (await ask(c.cyan('\nhuicai> '))).trim();
|
|
135
|
-
} catch {
|
|
136
|
-
break; // stdin 关闭
|
|
137
|
-
}
|
|
138
|
-
if (input === '') continue;
|
|
139
|
-
appendHistory(cwd, input);
|
|
140
|
-
if (input === '/exit' || input === '/quit') break;
|
|
141
|
-
|
|
142
|
-
if (input.startsWith('!') && input.length > 1) {
|
|
143
|
-
// shell 直通:本机执行,输出加入上下文(不消耗模型调用)
|
|
144
|
-
const command = input.slice(1).trim();
|
|
145
|
-
const { output, record } = await runBangCommand(command, cwd);
|
|
146
|
-
process.stdout.write(c.gray(output.split('\n').map((l) => ' ' + l).join('\n')) + '\n');
|
|
147
|
-
ctx.session.messages.push({ role: 'user', content: record });
|
|
148
|
-
saveSession(ctx.session);
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (input.startsWith('/')) {
|
|
153
|
-
const r = await runSlash(input, cmdCtx());
|
|
154
|
-
if (r.action === 'exit') break;
|
|
155
|
-
if (r.action === 'clear') {
|
|
156
|
-
ctx.session = createSession({ cwd, model: config.model, messages: [ctx.session.messages[0]] });
|
|
157
|
-
todoState.list = [];
|
|
158
|
-
checkpoints.list.length = 0;
|
|
159
|
-
process.stdout.write(c.gray('已开启新会话(旧会话已保存,可用 huicai --resume 恢复)。\n'));
|
|
160
|
-
}
|
|
161
|
-
if (r.action === 'refresh') echoResumed(ctx.session); // /rewind 回退对话后重新回显,帮助对齐
|
|
162
|
-
if (r.action === 'cd') {
|
|
163
|
-
// 切换工作目录:重建权限/工具/会话(在新目录开启新会话,旧会话已保存)
|
|
164
|
-
cwd = r.cwd;
|
|
165
|
-
ctx.cwd = cwd;
|
|
166
|
-
permissions = createPermissions({ yolo: permissions.isYolo(), ask, cwd });
|
|
167
|
-
ctx.permissions = permissions;
|
|
168
|
-
closeMcp(clients);
|
|
169
|
-
({ extraTools, clients } = await buildExtraTools(config, cwd));
|
|
170
|
-
ctx.session = createSession({
|
|
171
|
-
cwd, model: config.model,
|
|
172
|
-
messages: [{ role: 'system', content: buildSystemPrompt(cwd, { memory: config.memoryEnabled !== false }) }],
|
|
173
|
-
});
|
|
174
|
-
todoState.list = [];
|
|
175
|
-
checkpoints.list.length = 0;
|
|
176
|
-
config.sessionId = ctx.session.id;
|
|
177
|
-
process.stdout.write(c.gray('已切换到 ' + cwd + '(新会话,旧会话已保存)。\n'));
|
|
178
|
-
}
|
|
179
|
-
if (r.action === 'autofix') {
|
|
180
|
-
// 修到通过闭环:跑验证命令,失败自动让智能体修复后重跑
|
|
181
|
-
await runVerifyLoop({
|
|
182
|
-
command: r.command,
|
|
183
|
-
maxIters: r.maxIters,
|
|
184
|
-
cwd,
|
|
185
|
-
runTurnOnce: (p) => doTurn(p),
|
|
186
|
-
emit: (t) => process.stdout.write(c.gray(t) + '\n'),
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
if (r.action === 'loop') {
|
|
190
|
-
if (loopTimer) {
|
|
191
|
-
clearInterval(loopTimer);
|
|
192
|
-
loopTimer = null;
|
|
193
|
-
}
|
|
194
|
-
if (r.stop) {
|
|
195
|
-
process.stdout.write(c.gray('已停止循环。\n'));
|
|
196
|
-
} else {
|
|
197
|
-
process.stdout.write(c.gray(`已开始循环:每 ${r.seconds}s 运行「${r.prompt}」(/loop off 停止)。\n`));
|
|
198
|
-
loopTimer = setInterval(() => {
|
|
199
|
-
if (!currentAbort) void doTurn(r.prompt);
|
|
200
|
-
}, r.seconds * 1000);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
if (r.prompt) await doTurn(r.prompt);
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
|
-
await doTurn(input);
|
|
207
|
-
}
|
|
208
|
-
if (loopTimer) clearInterval(loopTimer);
|
|
209
|
-
rl.close();
|
|
210
|
-
closeMcp(clients);
|
|
211
|
-
process.stdout.write(c.gray('\n再见。\n'));
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/** 恢复会话时的提示:标题 + 轮数 + 上一条助手回复摘要,帮用户续上下文。 */
|
|
215
|
-
function echoResumed(session) {
|
|
216
|
-
const turns = turnCount(session.messages);
|
|
217
|
-
if (turns === 0) return;
|
|
218
|
-
process.stdout.write(c.gray(` 已恢复会话「${session.title || ''}」(${turns} 轮)`) + '\n');
|
|
219
|
-
const lastAssistant = [...session.messages].reverse().find((m) => m.role === 'assistant' && m.content);
|
|
220
|
-
if (lastAssistant) {
|
|
221
|
-
const text = String(lastAssistant.content).trim();
|
|
222
|
-
const snippet = text.length > 400 ? text.slice(0, 400) + '…' : text;
|
|
223
|
-
process.stdout.write(c.gray(' 上次回复:') + snippet.replace(/\n/g, '\n ') + '\n');
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* 一次性(非交互)模式:跑一个指令即退出。无 ask → 写/执行需 --yolo 才放行。
|
|
229
|
-
* persist=true(配合 --continue/--resume)时把本轮追加保存回会话,供后续续接。
|
|
230
|
-
* outputFormat='json' 时静默过程输出,最后打印一行结构化 JSON(脚本/CI 集成)。
|
|
231
|
-
*/
|
|
232
|
-
export async function runOnce({ config, cwd, yolo, prompt, session, persist, outputFormat, maxTurns }) {
|
|
233
|
-
if (!prompt) {
|
|
234
|
-
process.stderr.write('用法: huicai -p "你的指令"\n');
|
|
235
|
-
process.exit(1);
|
|
236
|
-
}
|
|
237
|
-
const json = outputFormat === 'json';
|
|
238
|
-
const permissions = createPermissions({ yolo, ask: null, cwd });
|
|
239
|
-
const { extraTools, clients } = await buildExtraTools(config, cwd);
|
|
240
|
-
const { text: expanded, images } = await expandAtMentions(prompt, cwd, config);
|
|
241
|
-
session.messages.push({ role: 'user', content: buildUserContent(expanded, images) });
|
|
242
|
-
if (!session.compaction) session.compaction = {};
|
|
243
|
-
config.sessionId = session.id; // 对话落库审计:会话号
|
|
244
|
-
const r = await runTurn({
|
|
245
|
-
config,
|
|
246
|
-
messages: session.messages,
|
|
247
|
-
permissions,
|
|
248
|
-
cwd,
|
|
249
|
-
extraTools,
|
|
250
|
-
maxIters: maxTurns,
|
|
251
|
-
compaction: session.compaction,
|
|
252
|
-
io: json ? { write: () => {} } : undefined,
|
|
253
|
-
});
|
|
254
|
-
if (r?.lastPromptTokens) session.lastPromptTokens = r.lastPromptTokens;
|
|
255
|
-
if (persist) saveSession(session);
|
|
256
|
-
closeMcp(clients);
|
|
257
|
-
if (json) {
|
|
258
|
-
const lastAssistant = [...session.messages].reverse().find((m) => m.role === 'assistant' && m.content);
|
|
259
|
-
process.stdout.write(JSON.stringify({
|
|
260
|
-
type: 'result',
|
|
261
|
-
is_error: !!r?.error,
|
|
262
|
-
...(r?.error ? { error: r.error } : {}),
|
|
263
|
-
result: lastAssistant ? String(lastAssistant.content) : '',
|
|
264
|
-
usage: { input_tokens: r?.usage?.prompt ?? 0, output_tokens: r?.usage?.completion ?? 0 },
|
|
265
|
-
session_id: session.id,
|
|
266
|
-
model: config.model,
|
|
267
|
-
num_turns: turnCount(session.messages),
|
|
268
|
-
}) + '\n');
|
|
269
|
-
}
|
|
270
|
-
if (r?.error) process.exit(1);
|
|
271
|
-
}
|
|
1
|
+
import s from"node:readline";import{c as o}from"./colors.js";import{createPermissions as t}from"./permissions.js";import{runTurn as e}from"./agent.js";import{createSession as n,saveSession as r,turnCount as i}from"./session.js";import{createCheckpoints as a}from"./checkpoints.js";import{buildSystemPrompt as c}from"./context.js";import{runVerifyLoop as l}from"./autofix.js";import{loadMcpTools as m,closeMcp as p}from"./mcp.js";import{makeSubagentTool as u,makeDispatchAgentsTool as d}from"./subagent.js";import{makeConsultTool as g}from"./consult.js";import{makeAdvisorTool as w}from"./advisor.js";import{makeRecallTool as f,readMemoryRaw as h}from"./memory.js";import{detectScanners as y,makeScanTool as x}from"./scan.js";import{runSlash as T}from"./commands.js";import{expandAtMentions as j,buildUserContent as k,runBangCommand as b,loadHistory as I,appendHistory as v}from"./input.js";import{makeSkillTools as S}from"./skills.js";import{renderWelcome as P}from"./banner.js";export async function buildExtraTools(s,t){let e={tools:[],clients:[]};try{e=await m()}catch(s){process.stdout.write(o.yellow("MCP 初始化失败(忽略):"+s.message)+"\n")}const n=[...S(t),...e.tools],r=u({config:s,cwd:t,extraTools:e.tools,depth:0});r&&n.push(r);const i=d({config:s,cwd:t,extraTools:e.tools,depth:0});i&&n.push(i);const a=g({config:s});a&&n.push(a);const c=w({config:s});return c&&n.push(c),!1!==s.memoryEnabled&&h(t)&&n.push(f({cwd:t})),y(t).length&&n.push(x({cwd:t})),{extraTools:n,clients:e.clients}}export async function startRepl({config:i,cwd:m,yolo:u,initialPrompt:d,session:g,version:w}){const f=s.createInterface({input:process.stdin,output:process.stdout,history:I(m).reverse(),historySize:200}),h=s=>new Promise(o=>f.question(s,o));let y=t({yolo:u,ask:h,cwd:m});const x={prompt:0,completion:0},S={list:[]},$=a();let _=null,O=null;const W={config:i,cwd:m,permissions:y,usage:x,session:g,version:w};f.on("SIGINT",()=>{_?_.abort():process.stdout.write("\n"+o.gray("输入 /exit 退出。")+"\n")}),function(s,o,t,e){process.stdout.write(P({model:s.model,cwd:o,version:e,platform:/^(agent|workflow|datahuman)-/.test(String(s.model||"")),yolo:t.isYolo(),consult:s.consult?s.consultLabel||s.consult:null})+"\n")}(i,m,y,w),E(g);let{extraTools:q,clients:C}=await buildExtraTools(i,m);const J=async s=>{const{text:t,mentions:n,images:a}=await j(s,m,i);n.length&&process.stdout.write(o.gray(" · 已展开引用:"+n.join("、")+(a.length?`(含 ${a.length} 张图片)`:""))+"\n");const c=$.snapshot({session:W.session,label:s});W.session.messages.push({role:"user",content:k(t,a)}),_=new AbortController,W.session.compaction||(W.session.compaction={}),i.sessionId=W.session.id;const l=await e({config:i,messages:W.session.messages,permissions:y,cwd:m,signal:_.signal,extraTools:q,todoState:S,compaction:W.session.compaction,fileJournal:c});return l?.usage&&(x.prompt+=l.usage.prompt,x.completion+=l.usage.completion),l?.lastPromptTokens&&(W.session.lastPromptTokens=l.lastPromptTokens),_=null,r(W.session),l},N=()=>({...W,session:W.session,extraTools:q,checkpoints:$,print:s=>process.stdout.write(String(s).endsWith("\n")?String(s):String(s)+"\n")});for(d&&(process.stdout.write(o.cyan("\nhuicai> ")+d+"\n"),await J(d));;){let s;try{s=(await h(o.cyan("\nhuicai> "))).trim()}catch{break}if(""!==s){if(v(m,s),"/exit"===s||"/quit"===s)break;if(s.startsWith("!")&&s.length>1){const t=s.slice(1).trim(),{output:e,record:n}=await b(t,m);process.stdout.write(o.gray(e.split("\n").map(s=>" "+s).join("\n"))+"\n"),W.session.messages.push({role:"user",content:n}),r(W.session);continue}if(s.startsWith("/")){const e=await T(s,N());if("exit"===e.action)break;"clear"===e.action&&(W.session=n({cwd:m,model:i.model,messages:[W.session.messages[0]]}),S.list=[],$.list.length=0,process.stdout.write(o.gray("已开启新会话(旧会话已保存,可用 huicai --resume 恢复)。\n"))),"refresh"===e.action&&E(W.session),"cd"===e.action&&(m=e.cwd,W.cwd=m,y=t({yolo:y.isYolo(),ask:h,cwd:m}),W.permissions=y,p(C),({extraTools:q,clients:C}=await buildExtraTools(i,m)),W.session=n({cwd:m,model:i.model,messages:[{role:"system",content:c(m,{memory:!1!==i.memoryEnabled})}]}),S.list=[],$.list.length=0,i.sessionId=W.session.id,process.stdout.write(o.gray("已切换到 "+m+"(新会话,旧会话已保存)。\n"))),"autofix"===e.action&&await l({command:e.command,maxIters:e.maxIters,cwd:m,runTurnOnce:s=>J(s),emit:s=>process.stdout.write(o.gray(s)+"\n")}),"loop"===e.action&&(O&&(clearInterval(O),O=null),e.stop?process.stdout.write(o.gray("已停止循环。\n")):(process.stdout.write(o.gray(`已开始循环:每 ${e.seconds}s 运行「${e.prompt}」(/loop off 停止)。\n`)),O=setInterval(()=>{_||J(e.prompt)},1e3*e.seconds))),e.prompt&&await J(e.prompt);continue}await J(s)}}O&&clearInterval(O),f.close(),p(C),process.stdout.write(o.gray("\n再见。\n"))}function E(s){const t=i(s.messages);if(0===t)return;process.stdout.write(o.gray(` 已恢复会话「${s.title||""}」(${t} 轮)`)+"\n");const e=[...s.messages].reverse().find(s=>"assistant"===s.role&&s.content);if(e){const s=String(e.content).trim(),t=s.length>400?s.slice(0,400)+"…":s;process.stdout.write(o.gray(" 上次回复:")+t.replace(/\n/g,"\n ")+"\n")}}export async function runOnce({config:s,cwd:o,yolo:n,prompt:a,session:c,persist:l,outputFormat:m,maxTurns:u}){a||(process.stderr.write('用法: huicai -p "你的指令"\n'),process.exit(1));const d="json"===m,g=t({yolo:n,ask:null,cwd:o}),{extraTools:w,clients:f}=await buildExtraTools(s,o),{text:h,images:y}=await j(a,o,s);c.messages.push({role:"user",content:k(h,y)}),c.compaction||(c.compaction={}),s.sessionId=c.id;const x=await e({config:s,messages:c.messages,permissions:g,cwd:o,extraTools:w,maxIters:u,compaction:c.compaction,io:d?{write:()=>{}}:void 0});if(x?.lastPromptTokens&&(c.lastPromptTokens=x.lastPromptTokens),l&&r(c),p(f),d){const o=[...c.messages].reverse().find(s=>"assistant"===s.role&&s.content);process.stdout.write(JSON.stringify({type:"result",is_error:!!x?.error,...x?.error?{error:x.error}:{},result:o?String(o.content):"",usage:{input_tokens:x?.usage?.prompt??0,output_tokens:x?.usage?.completion??0},session_id:c.id,model:s.model,num_turns:i(c.messages)})+"\n")}x?.error&&process.exit(1)}
|
package/src/scan.js
CHANGED
|
@@ -1,84 +1 @@
|
|
|
1
|
-
|
|
2
|
-
// (依赖漏洞 / SAST / 密钥泄漏),把「发现」交给智能体去修——从「发提示词」变「真扫描 + 修复」。
|
|
3
|
-
// 暴露为 security_scan 工具(智能体可在安全审查/提交前自检时调用)+ /scan 命令。
|
|
4
|
-
|
|
5
|
-
import fs from 'node:fs';
|
|
6
|
-
import path from 'node:path';
|
|
7
|
-
import { execSync } from 'node:child_process';
|
|
8
|
-
import { captureCommand } from './autofix.js';
|
|
9
|
-
|
|
10
|
-
function has(cwd, file) {
|
|
11
|
-
try {
|
|
12
|
-
return fs.existsSync(path.join(cwd, file));
|
|
13
|
-
} catch {
|
|
14
|
-
return false;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** 命令是否可用(本机已安装)。 */
|
|
19
|
-
function cmdExists(cmd) {
|
|
20
|
-
try {
|
|
21
|
-
execSync(process.platform === 'win32' ? `where ${cmd}` : `command -v ${cmd}`, { stdio: 'ignore' });
|
|
22
|
-
return true;
|
|
23
|
-
} catch {
|
|
24
|
-
return false;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** 检测本项目适用且已安装的扫描器。返回 [{name, command, why}]。 */
|
|
29
|
-
export function detectScanners(cwd) {
|
|
30
|
-
const s = [];
|
|
31
|
-
if (has(cwd, 'package.json') && cmdExists('npm')) {
|
|
32
|
-
s.push({ name: 'npm audit', command: 'npm audit --audit-level=high', why: 'Node 依赖漏洞' });
|
|
33
|
-
}
|
|
34
|
-
if ((has(cwd, 'requirements.txt') || has(cwd, 'pyproject.toml')) && cmdExists('pip-audit')) {
|
|
35
|
-
s.push({ name: 'pip-audit', command: 'pip-audit', why: 'Python 依赖漏洞' });
|
|
36
|
-
}
|
|
37
|
-
if (has(cwd, 'go.mod') && cmdExists('govulncheck')) {
|
|
38
|
-
s.push({ name: 'govulncheck', command: 'govulncheck ./...', why: 'Go 漏洞' });
|
|
39
|
-
}
|
|
40
|
-
if (has(cwd, 'Cargo.toml') && cmdExists('cargo-audit')) {
|
|
41
|
-
s.push({ name: 'cargo audit', command: 'cargo audit', why: 'Rust 依赖漏洞' });
|
|
42
|
-
}
|
|
43
|
-
if (cmdExists('semgrep')) {
|
|
44
|
-
s.push({ name: 'semgrep', command: 'semgrep --config auto --error -q .', why: '代码静态安全(SAST)' });
|
|
45
|
-
}
|
|
46
|
-
if (cmdExists('gitleaks')) {
|
|
47
|
-
s.push({ name: 'gitleaks', command: 'gitleaks detect --no-banner -r /dev/stdout', why: '密钥泄漏' });
|
|
48
|
-
}
|
|
49
|
-
if (cmdExists('trivy')) {
|
|
50
|
-
s.push({ name: 'trivy fs', command: 'trivy fs --scanners vuln,secret -q .', why: '依赖漏洞+密钥' });
|
|
51
|
-
}
|
|
52
|
-
return s;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** 依次运行扫描器(各限时 3 分钟)。返回 [{name, why, code, output}](code!==0 视为有发现/高危)。 */
|
|
56
|
-
export async function runScanners(cwd, scanners) {
|
|
57
|
-
const results = [];
|
|
58
|
-
for (const sc of scanners) {
|
|
59
|
-
const { code, output } = await captureCommand(cwd, sc.command, 180000);
|
|
60
|
-
results.push({ name: sc.name, why: sc.why, command: sc.command, code, output });
|
|
61
|
-
}
|
|
62
|
-
return results;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** 构造 security_scan 工具(智能体可调用):自动检测+运行,返回发现。 */
|
|
66
|
-
export function makeScanTool({ cwd }) {
|
|
67
|
-
const spec = {
|
|
68
|
-
type: 'function',
|
|
69
|
-
function: {
|
|
70
|
-
name: 'security_scan',
|
|
71
|
-
description:
|
|
72
|
-
'自动检测并运行本项目适用的安全/质量扫描器(npm audit / semgrep / trivy / gitleaks / 依赖体检等,仅跑本机已装的),返回发现的问题。'
|
|
73
|
-
+ '用于安全审查或提交前自检;发现问题后你应逐条修复再重扫确认。',
|
|
74
|
-
parameters: { type: 'object', properties: {}, required: [] },
|
|
75
|
-
},
|
|
76
|
-
};
|
|
77
|
-
const run = async () => {
|
|
78
|
-
const scanners = detectScanners(cwd);
|
|
79
|
-
if (!scanners.length) return '未检测到可用的扫描器(可安装 semgrep/trivy/gitleaks,或项目含 package.json 等)。';
|
|
80
|
-
const results = await runScanners(cwd, scanners);
|
|
81
|
-
return results.map((r) => `## ${r.name}(${r.code === 0 ? '无高危' : '有发现'})\n${r.output.slice(0, 4000)}`).join('\n\n');
|
|
82
|
-
};
|
|
83
|
-
return { readOnly: true, spec, run };
|
|
84
|
-
}
|
|
1
|
+
import n from"node:fs";import e from"node:path";import{execSync as t}from"node:child_process";import{captureCommand as o}from"./autofix.js";function r(t,o){try{return n.existsSync(e.join(t,o))}catch{return!1}}function a(n){try{return t("win32"===process.platform?`where ${n}`:`command -v ${n}`,{stdio:"ignore"}),!0}catch{return!1}}export function detectScanners(n){const e=[];return r(n,"package.json")&&a("npm")&&e.push({name:"npm audit",command:"npm audit --audit-level=high",why:"Node 依赖漏洞"}),(r(n,"requirements.txt")||r(n,"pyproject.toml"))&&a("pip-audit")&&e.push({name:"pip-audit",command:"pip-audit",why:"Python 依赖漏洞"}),r(n,"go.mod")&&a("govulncheck")&&e.push({name:"govulncheck",command:"govulncheck ./...",why:"Go 漏洞"}),r(n,"Cargo.toml")&&a("cargo-audit")&&e.push({name:"cargo audit",command:"cargo audit",why:"Rust 依赖漏洞"}),a("semgrep")&&e.push({name:"semgrep",command:"semgrep --config auto --error -q .",why:"代码静态安全(SAST)"}),a("gitleaks")&&e.push({name:"gitleaks",command:"gitleaks detect --no-banner -r /dev/stdout",why:"密钥泄漏"}),a("trivy")&&e.push({name:"trivy fs",command:"trivy fs --scanners vuln,secret -q .",why:"依赖漏洞+密钥"}),e}export async function runScanners(n,e){const t=[];for(const r of e){const{code:e,output:a}=await o(n,r.command,18e4);t.push({name:r.name,why:r.why,command:r.command,code:e,output:a})}return t}export function makeScanTool({cwd:n}){return{readOnly:!0,spec:{type:"function",function:{name:"security_scan",description:"自动检测并运行本项目适用的安全/质量扫描器(npm audit / semgrep / trivy / gitleaks / 依赖体检等,仅跑本机已装的),返回发现的问题。用于安全审查或提交前自检;发现问题后你应逐条修复再重扫确认。",parameters:{type:"object",properties:{},required:[]}}},run:async()=>{const e=detectScanners(n);if(!e.length)return"未检测到可用的扫描器(可安装 semgrep/trivy/gitleaks,或项目含 package.json 等)。";return(await runScanners(n,e)).map(n=>`## ${n.name}(${0===n.code?"无高危":"有发现"})\n${n.output.slice(0,4e3)}`).join("\n\n")}}}
|