zen-gitsync 2.15.33 → 2.16.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/README.md +25 -0
- package/package.json +2 -1
- package/scripts/run-tests.cjs +1 -0
- package/src/cli/ai/agent.js +845 -0
- package/src/cli/ai/agent.test.js +114 -0
- package/src/cli/ai/images.js +211 -0
- package/src/cli/ai/images.test.js +98 -0
- package/src/cli/ai/safety.js +215 -0
- package/src/cli/ai/safety.test.js +158 -0
- package/src/cli/ai/streamFilter.js +82 -0
- package/src/cli/ai/streamFilter.test.js +88 -0
- package/src/cli/ai/termui.js +378 -0
- package/src/cli/ai/termui.test.js +215 -0
- package/src/cli/ai/tools.js +482 -0
- package/src/cli/ai/tools.test.js +190 -0
- package/src/gitCommit.js +9 -0
- package/src/ui/public/assets/AppVersionBadge-CS7Trvp-.css +1 -0
- package/src/ui/public/assets/AppVersionBadge-DJyfXR_Q.js +2 -0
- package/src/ui/public/assets/BranchSelector-B0eLkAdv.css +1 -0
- package/src/ui/public/assets/BranchSelector-ClnVPC0Q.js +1 -0
- package/src/ui/public/assets/CommitForm-BihM26_4.js +6 -0
- package/src/ui/public/assets/CommitForm-DdK8dymc.css +1 -0
- package/src/ui/public/assets/CommonDialog-CYoIF2fg.js +1 -0
- package/src/ui/public/assets/CommonDialog-ChSKsEz2.css +1 -0
- package/src/ui/public/assets/EditorView-BVod-rjn.css +1 -0
- package/src/ui/public/assets/EditorView-DYJQT-Xj.js +0 -0
- package/src/ui/public/assets/FlowExecutionViewer-CPblwVA1.js +1 -0
- package/src/ui/public/assets/FlowExecutionViewer-jNHI5zdm.css +1 -0
- package/src/ui/public/assets/FlowOrchestrationWorkspace-CYGp-PqS.css +1 -0
- package/src/ui/public/assets/FlowOrchestrationWorkspace-Cc-Tnhng.js +21 -0
- package/src/ui/public/assets/LogList-CeFFytjB.css +1 -0
- package/src/ui/public/assets/LogList-va6ANtbL.js +15 -0
- package/src/ui/public/assets/MindmapView-3rMfOoNm.css +1 -0
- package/src/ui/public/assets/MindmapView-C_9P5ijq.js +1 -0
- package/src/ui/public/assets/MonitorView-DRPECOVW.js +1 -0
- package/src/ui/public/assets/MonitorView-TaS4kL0Y.css +1 -0
- package/src/ui/public/assets/ProjectStartupButton-DSovbKPE.js +1 -0
- package/src/ui/public/assets/ProjectStartupButton-QLPltCK5.css +1 -0
- package/src/ui/public/assets/RemoteRepoCard-BRGNlbeO.css +1 -0
- package/src/ui/public/assets/RemoteRepoCard-C_cBtHcN.js +5 -0
- package/src/ui/public/assets/SourceMapView-BNRjvgzH.css +1 -0
- package/src/ui/public/assets/SourceMapView-ZiCL8mS8.js +3 -0
- package/src/ui/public/assets/SvgIcon-C9YQSvGU.js +1 -0
- package/src/ui/public/assets/{SvgIcon-BQ6bR7l0.css → SvgIcon-CDNsk4-Q.css} +1 -1
- package/src/ui/public/assets/UserInputNode-BVgQkB2l.css +1 -0
- package/src/ui/public/assets/UserInputNode-CR6o8zJG.js +1 -0
- package/src/ui/public/assets/WorkbenchView-CJJrBqBH.js +28 -0
- package/src/ui/public/assets/{WorkbenchView-CQfjF0Gr.css → WorkbenchView-DbF4hH5s.css} +1 -1
- package/src/ui/public/assets/{_plugin-vue_export-helper-CzpRIaQR.js → _plugin-vue_export-helper-CHQqCylN.js} +1 -1
- package/src/ui/public/assets/{configStore-DaWjJLTW.js → configStore-Bw9U4AHx.js} +1 -1
- package/src/ui/public/assets/element-plus-CyGhXd7X.js +25 -0
- package/src/ui/public/assets/{flow-mindmap-2CcykUk_.js → flow-mindmap-CUvJaYn2.js} +1 -1
- package/src/ui/public/assets/index-BzNpkvm5.js +68 -0
- package/src/ui/public/assets/{index-CWoujklW.css → index-CaIu73UM.css} +1 -1
- package/src/ui/public/assets/socket-io-BSgn8Jx2.js +1 -0
- package/src/ui/public/assets/{vendor-CG4K5XlB.js → vendor-CU5NYUKJ.js} +44 -37
- package/src/ui/public/assets/vue-flow-CWmfpfPN.js +39 -0
- package/src/ui/public/index.html +12 -12
- package/src/utils/index.js +5 -0
- package/src/ui/public/assets/AppVersionBadge-Ckwa7eM9.js +0 -2
- package/src/ui/public/assets/AppVersionBadge-Dj6H729N.css +0 -1
- package/src/ui/public/assets/BranchSelector-T_rJm-JY.css +0 -1
- package/src/ui/public/assets/BranchSelector-e0465kxE.js +0 -1
- package/src/ui/public/assets/CommitForm-862NAe0q.js +0 -6
- package/src/ui/public/assets/CommitForm-CUvl0VcF.css +0 -1
- package/src/ui/public/assets/CommonDialog-BVPvUoQ0.js +0 -1
- package/src/ui/public/assets/CommonDialog-Cyh3xu0p.css +0 -1
- package/src/ui/public/assets/EditorView-CRYGVw_r.css +0 -1
- package/src/ui/public/assets/EditorView-sZoyx-nK.js +0 -0
- package/src/ui/public/assets/FlowExecutionViewer-B5kh7VxS.css +0 -1
- package/src/ui/public/assets/FlowExecutionViewer-CowPPIpm.js +0 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-BAQDU9Jd.css +0 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-Dno2fn7O.js +0 -21
- package/src/ui/public/assets/LogList-DJYpDUTZ.css +0 -1
- package/src/ui/public/assets/LogList-Dy42jDbI.js +0 -7
- package/src/ui/public/assets/MindmapView-CWoqfZRL.css +0 -1
- package/src/ui/public/assets/MindmapView-Ds2XDHgS.js +0 -1
- package/src/ui/public/assets/MonitorView-DxFogQIt.css +0 -1
- package/src/ui/public/assets/MonitorView-Dy158z7E.js +0 -1
- package/src/ui/public/assets/ProjectStartupButton-B7zvqRj_.css +0 -1
- package/src/ui/public/assets/ProjectStartupButton-eozWTyGl.js +0 -1
- package/src/ui/public/assets/RemoteRepoCard-CTfNrrx4.js +0 -1
- package/src/ui/public/assets/RemoteRepoCard-d0klonVb.css +0 -1
- package/src/ui/public/assets/SourceMapView-Bl5jEKf6.js +0 -3
- package/src/ui/public/assets/SourceMapView-DG_a_076.css +0 -1
- package/src/ui/public/assets/SvgIcon-D7FdXvWv.js +0 -1
- package/src/ui/public/assets/UserInputNode-Bcxe_5-C.js +0 -1
- package/src/ui/public/assets/UserInputNode-DDtvv9Iq.css +0 -1
- package/src/ui/public/assets/WorkbenchView-CZSPrO3Q.js +0 -12
- package/src/ui/public/assets/element-plus-d0668myq.js +0 -17
- package/src/ui/public/assets/index-CG24iOR5.js +0 -38
- package/src/ui/public/assets/socket-io-B1foIbJS.js +0 -1
- package/src/ui/public/assets/vue-flow-CVRzDji7.js +0 -39
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// g ai 智能体的工具层 — OpenAI function calling 格式的工具定义 + 执行器。
|
|
16
|
+
//
|
|
17
|
+
// 权限模型(用户在需求里明确授权):
|
|
18
|
+
// - 文件读写 / 命令执行不设目录白名单,工作目录与其他目录都可以操作
|
|
19
|
+
// - 唯一限制来自 safety.js 的系统级红线(格式化、删根目录、关机等)
|
|
20
|
+
//
|
|
21
|
+
// 输出约束:
|
|
22
|
+
// - 所有工具结果都是字符串,直接作为 role:'tool' 消息回喂给模型
|
|
23
|
+
// - 大输出在源头截断(头 + 尾保留),避免撑爆上下文窗口
|
|
24
|
+
// - 工具内部异常一律 catch 成字符串返回,不抛给 agent 循环 ——
|
|
25
|
+
// 让模型看到错误信息自己修正,而不是中断整轮对话
|
|
26
|
+
|
|
27
|
+
import { exec } from 'node:child_process'
|
|
28
|
+
import { promises as fs } from 'node:fs'
|
|
29
|
+
import path from 'node:path'
|
|
30
|
+
import iconv from 'iconv-lite'
|
|
31
|
+
import { trackChild } from '../cleanup.js'
|
|
32
|
+
import { checkDangerousCommand } from './safety.js'
|
|
33
|
+
|
|
34
|
+
// ──────────────────────────────────────────────
|
|
35
|
+
// 常量
|
|
36
|
+
// ──────────────────────────────────────────────
|
|
37
|
+
const MAX_CMD_OUTPUT = 8000 // run_command 输出截断预算(字符)
|
|
38
|
+
const MAX_READ_LINES = 2000 // read_file 单次最多返回行数
|
|
39
|
+
const MAX_LINE_WIDTH = 2000 // 单行截断宽度
|
|
40
|
+
const MAX_LIST_ENTRIES = 400 // list_files 最多条目
|
|
41
|
+
const MAX_SEARCH_HITS = 100 // search_text 最多命中
|
|
42
|
+
const MAX_SEARCH_FILE_SIZE = 1024 * 1024 // search_text 跳过大文件(1MB)
|
|
43
|
+
const CMD_TIMEOUT_DEFAULT = 120 // run_command 默认超时(秒)
|
|
44
|
+
const CMD_TIMEOUT_MAX = 600 // run_command 超时上限(秒)
|
|
45
|
+
|
|
46
|
+
// 目录遍历时跳过的目录名(产物/依赖/VCS)
|
|
47
|
+
const SKIP_DIRS = new Set([
|
|
48
|
+
'node_modules', '.git', '.svn', '.hg', 'dist', 'build', 'out',
|
|
49
|
+
'coverage', '.next', '.nuxt', '.cache', 'target', 'vendor',
|
|
50
|
+
'__pycache__', '.idea', '.vscode',
|
|
51
|
+
])
|
|
52
|
+
|
|
53
|
+
// search_text 跳过的二进制/资源扩展名
|
|
54
|
+
const BINARY_EXTS = new Set([
|
|
55
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.bmp', '.tiff',
|
|
56
|
+
'.mp4', '.mov', '.avi', '.mkv', '.webm', '.mp3', '.wav', '.ogg', '.flac',
|
|
57
|
+
'.woff', '.woff2', '.ttf', '.otf', '.eot', '.pdf', '.zip', '.tar', '.gz',
|
|
58
|
+
'.7z', '.rar', '.exe', '.dll', '.so', '.dylib', '.bin', '.lock', '.min.js',
|
|
59
|
+
'.map', '.jar', '.class', '.pyc',
|
|
60
|
+
])
|
|
61
|
+
|
|
62
|
+
// ──────────────────────────────────────────────
|
|
63
|
+
// 工具 schema(OpenAI function calling 格式)
|
|
64
|
+
// ──────────────────────────────────────────────
|
|
65
|
+
export const TOOL_DEFINITIONS = [
|
|
66
|
+
{
|
|
67
|
+
type: 'function',
|
|
68
|
+
function: {
|
|
69
|
+
name: 'run_command',
|
|
70
|
+
description: '在系统默认 shell 执行一条命令(Windows 为 cmd.exe,POSIX 为 /bin/sh)。可用于构建、测试、安装依赖、git 操作(add/commit/push)、查看系统信息等。命令以当前用户权限运行;会把系统搞崩的命令(格式化、删根目录、关机等)会被安全守卫拒绝。',
|
|
71
|
+
parameters: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
command: { type: 'string', description: '要执行的完整命令行' },
|
|
75
|
+
cwd: { type: 'string', description: '执行目录,默认是智能体启动目录;可传任意绝对/相对路径' },
|
|
76
|
+
timeout_seconds: { type: 'number', description: `超时秒数,默认 ${CMD_TIMEOUT_DEFAULT},最大 ${CMD_TIMEOUT_MAX}` },
|
|
77
|
+
},
|
|
78
|
+
required: ['command'],
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
type: 'function',
|
|
84
|
+
function: {
|
|
85
|
+
name: 'read_file',
|
|
86
|
+
description: '读取文本文件内容,带行号返回。大文件用 offset/limit 分段读取。',
|
|
87
|
+
parameters: {
|
|
88
|
+
type: 'object',
|
|
89
|
+
properties: {
|
|
90
|
+
path: { type: 'string', description: '文件路径(相对智能体启动目录或绝对路径)' },
|
|
91
|
+
offset: { type: 'number', description: '起始行(1 起始),默认 1' },
|
|
92
|
+
limit: { type: 'number', description: `最多读取行数,默认 ${MAX_READ_LINES}` },
|
|
93
|
+
},
|
|
94
|
+
required: ['path'],
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
type: 'function',
|
|
100
|
+
function: {
|
|
101
|
+
name: 'write_file',
|
|
102
|
+
description: '创建或整体覆盖写入一个文件(父目录不存在会自动创建)。适合新建文件或小文件整体改写;大文件局部修改请用 edit_file。',
|
|
103
|
+
parameters: {
|
|
104
|
+
type: 'object',
|
|
105
|
+
properties: {
|
|
106
|
+
path: { type: 'string', description: '文件路径' },
|
|
107
|
+
content: { type: 'string', description: '完整文件内容' },
|
|
108
|
+
},
|
|
109
|
+
required: ['path', 'content'],
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
type: 'function',
|
|
115
|
+
function: {
|
|
116
|
+
name: 'edit_file',
|
|
117
|
+
description: '对文件做精确字符串替换:old_string 必须与文件内容完全一致(含缩进/换行)且默认要求唯一匹配。修改前先 read_file 确认原文。',
|
|
118
|
+
parameters: {
|
|
119
|
+
type: 'object',
|
|
120
|
+
properties: {
|
|
121
|
+
path: { type: 'string', description: '文件路径' },
|
|
122
|
+
old_string: { type: 'string', description: '要被替换的原始文本(精确匹配)' },
|
|
123
|
+
new_string: { type: 'string', description: '替换后的新文本' },
|
|
124
|
+
replace_all: { type: 'boolean', description: '为 true 时替换所有出现位置;默认 false,要求 old_string 唯一' },
|
|
125
|
+
},
|
|
126
|
+
required: ['path', 'old_string', 'new_string'],
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
type: 'function',
|
|
132
|
+
function: {
|
|
133
|
+
name: 'list_files',
|
|
134
|
+
description: '递归列出目录内容(跳过 node_modules/.git/dist 等产物目录),返回带缩进的树状列表。用于快速了解项目结构。',
|
|
135
|
+
parameters: {
|
|
136
|
+
type: 'object',
|
|
137
|
+
properties: {
|
|
138
|
+
path: { type: 'string', description: '目录路径,默认智能体启动目录' },
|
|
139
|
+
depth: { type: 'number', description: '递归深度,默认 2,最大 6' },
|
|
140
|
+
},
|
|
141
|
+
required: [],
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
type: 'function',
|
|
147
|
+
function: {
|
|
148
|
+
name: 'search_text',
|
|
149
|
+
description: '搜索正则匹配的行,返回 文件:行号: 内容。path 可以是目录(递归搜全部文本文件)或单个文件。用于定位符号、关键字、调用点。',
|
|
150
|
+
parameters: {
|
|
151
|
+
type: 'object',
|
|
152
|
+
properties: {
|
|
153
|
+
pattern: { type: 'string', description: '正则表达式(JavaScript 语法)' },
|
|
154
|
+
path: { type: 'string', description: '搜索目录或单个文件路径,默认智能体启动目录' },
|
|
155
|
+
ext: { type: 'string', description: '限定扩展名过滤,如 "js" 或 "js,ts,vue";不传则搜全部文本文件' },
|
|
156
|
+
ignore_case: { type: 'boolean', description: '忽略大小写,默认 false' },
|
|
157
|
+
},
|
|
158
|
+
required: ['pattern'],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
// ──────────────────────────────────────────────
|
|
165
|
+
// 内部工具函数
|
|
166
|
+
// ──────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
// 相对 ctx.cwd 解析路径;绝对路径原样保留(用户授权了任意目录访问)
|
|
169
|
+
function resolvePath(ctx, p) {
|
|
170
|
+
if (!p || typeof p !== 'string') return ctx.cwd
|
|
171
|
+
return path.resolve(ctx.cwd, p)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 头 + 尾截断:超过 budget 时保留前 head 字符 + 后 tail 字符
|
|
175
|
+
function truncateMiddle(text, budget) {
|
|
176
|
+
if (!text || text.length <= budget) return text || ''
|
|
177
|
+
const head = Math.floor(budget * 0.4)
|
|
178
|
+
const tail = budget - head
|
|
179
|
+
const omitted = text.length - head - tail
|
|
180
|
+
return `${text.slice(0, head)}\n\n... [中间省略 ${omitted} 字符] ...\n\n${text.slice(text.length - tail)}`
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 子进程输出解码:Windows cmd/PowerShell 的本地化消息(错误提示等)是
|
|
184
|
+
// GBK(CP936) 字节流,直接按 UTF-8 解会变成 '����' 乱码(实测踩坑)。
|
|
185
|
+
// 策略:先严格 UTF-8 解码(node/git 等现代工具的输出都能过),
|
|
186
|
+
// 失败(含非法字节序列,GBK 中文几乎必挂)再按 GBK 兜底。
|
|
187
|
+
function decodeOutput(buf) {
|
|
188
|
+
if (!buf || buf.length === 0) return ''
|
|
189
|
+
try {
|
|
190
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(buf)
|
|
191
|
+
} catch {
|
|
192
|
+
return iconv.decode(buf, 'gbk')
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 包装 exec 为 Promise,stdout/stderr 合并返回;出错(非零退出)也正常返回输出
|
|
197
|
+
// encoding:'buffer' 拿原始字节,由 decodeOutput 决定真实编码
|
|
198
|
+
function execCommand(command, options) {
|
|
199
|
+
return new Promise((resolve) => {
|
|
200
|
+
const child = exec(command, { ...options, encoding: 'buffer' }, (err, stdout, stderr) => {
|
|
201
|
+
resolve({
|
|
202
|
+
code: err ? (typeof err.code === 'number' ? err.code : 1) : 0,
|
|
203
|
+
killed: !!err?.killed,
|
|
204
|
+
stdout: decodeOutput(stdout),
|
|
205
|
+
stderr: decodeOutput(stderr),
|
|
206
|
+
errorMessage: err && !('code' in err) ? err.message : null,
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
trackChild(child)
|
|
210
|
+
// 把 child 暴露给 agent 循环,支持用户 Ctrl+C 时立即中止当前命令
|
|
211
|
+
if (typeof options.onChild === 'function') options.onChild(child)
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ──────────────────────────────────────────────
|
|
216
|
+
// 各工具实现
|
|
217
|
+
// ──────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
async function toolRunCommand(args, ctx) {
|
|
220
|
+
const command = String(args.command || '').trim()
|
|
221
|
+
if (!command) return '错误: command 不能为空'
|
|
222
|
+
|
|
223
|
+
// 红线检查 — 唯一硬性限制
|
|
224
|
+
const danger = checkDangerousCommand(command)
|
|
225
|
+
if (danger.blocked) {
|
|
226
|
+
return `已拒绝执行(安全守卫): ${danger.reason}\n命令: ${command}\n如果你确认需要类似效果,请换一种不破坏系统的方式,或明确告知用户需要他手动执行。`
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const cwd = resolvePath(ctx, args.cwd)
|
|
230
|
+
let timeoutSec = Number(args.timeout_seconds) || CMD_TIMEOUT_DEFAULT
|
|
231
|
+
timeoutSec = Math.max(1, Math.min(CMD_TIMEOUT_MAX, timeoutSec))
|
|
232
|
+
|
|
233
|
+
// 验证 cwd 存在,避免 exec 抛模糊错误
|
|
234
|
+
try {
|
|
235
|
+
const st = await fs.stat(cwd)
|
|
236
|
+
if (!st.isDirectory()) return `错误: 执行目录不是目录: ${cwd}`
|
|
237
|
+
} catch {
|
|
238
|
+
return `错误: 执行目录不存在: ${cwd}`
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const result = await execCommand(command, {
|
|
242
|
+
cwd,
|
|
243
|
+
timeout: timeoutSec * 1000,
|
|
244
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
245
|
+
windowsHide: true,
|
|
246
|
+
env: { ...process.env, FORCE_COLOR: '0' },
|
|
247
|
+
onChild: ctx.onChild,
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
const parts = []
|
|
251
|
+
if (result.killed) parts.push(`[命令超时被终止(>${timeoutSec}s)]`)
|
|
252
|
+
if (result.stdout) parts.push(result.stdout.trimEnd())
|
|
253
|
+
if (result.stderr) parts.push(`[stderr]\n${result.stderr.trimEnd()}`)
|
|
254
|
+
if (result.errorMessage) parts.push(`[执行错误] ${result.errorMessage}`)
|
|
255
|
+
const output = parts.join('\n').trim()
|
|
256
|
+
|
|
257
|
+
return [
|
|
258
|
+
`$ ${command}`,
|
|
259
|
+
`(exit ${result.code})`,
|
|
260
|
+
output ? truncateMiddle(output, MAX_CMD_OUTPUT) : '(无输出)',
|
|
261
|
+
].join('\n')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function toolReadFile(args, ctx) {
|
|
265
|
+
const filePath = resolvePath(ctx, args.path)
|
|
266
|
+
let raw
|
|
267
|
+
try {
|
|
268
|
+
raw = await fs.readFile(filePath, 'utf-8')
|
|
269
|
+
} catch (err) {
|
|
270
|
+
return `错误: 无法读取 ${filePath}: ${err.message}`
|
|
271
|
+
}
|
|
272
|
+
const lines = raw.split('\n')
|
|
273
|
+
const total = lines.length
|
|
274
|
+
const offset = Math.max(1, Number(args.offset) || 1)
|
|
275
|
+
const limit = Math.max(1, Math.min(MAX_READ_LINES, Number(args.limit) || MAX_READ_LINES))
|
|
276
|
+
const slice = lines.slice(offset - 1, offset - 1 + limit)
|
|
277
|
+
const numbered = slice.map((line, i) => {
|
|
278
|
+
const text = line.length > MAX_LINE_WIDTH ? line.slice(0, MAX_LINE_WIDTH) + ' ...[行截断]' : line
|
|
279
|
+
return `${offset + i}→${text}`
|
|
280
|
+
})
|
|
281
|
+
const header = total > limit
|
|
282
|
+
? `# ${filePath} (共 ${total} 行, 显示 ${offset}-${offset + slice.length - 1})`
|
|
283
|
+
: `# ${filePath} (共 ${total} 行)`
|
|
284
|
+
return `${header}\n${numbered.join('\n')}`
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function toolWriteFile(args, ctx) {
|
|
288
|
+
const filePath = resolvePath(ctx, args.path)
|
|
289
|
+
const content = String(args.content ?? '')
|
|
290
|
+
try {
|
|
291
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
|
292
|
+
await fs.writeFile(filePath, content, 'utf-8')
|
|
293
|
+
return `已写入 ${filePath} (${content.length} 字符)`
|
|
294
|
+
} catch (err) {
|
|
295
|
+
return `错误: 写入 ${filePath} 失败: ${err.message}`
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function toolEditFile(args, ctx) {
|
|
300
|
+
const filePath = resolvePath(ctx, args.path)
|
|
301
|
+
const oldStr = String(args.old_string ?? '')
|
|
302
|
+
const newStr = String(args.new_string ?? '')
|
|
303
|
+
if (!oldStr) return '错误: old_string 不能为空'
|
|
304
|
+
if (oldStr === newStr) return '错误: old_string 与 new_string 相同,无需修改'
|
|
305
|
+
|
|
306
|
+
let raw
|
|
307
|
+
try {
|
|
308
|
+
raw = await fs.readFile(filePath, 'utf-8')
|
|
309
|
+
} catch (err) {
|
|
310
|
+
return `错误: 无法读取 ${filePath}: ${err.message}`
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 统计出现次数(手写 indexOf 循环,避免正则转义问题)
|
|
314
|
+
let count = 0
|
|
315
|
+
let idx = raw.indexOf(oldStr)
|
|
316
|
+
while (idx !== -1) {
|
|
317
|
+
count++
|
|
318
|
+
idx = raw.indexOf(oldStr, idx + oldStr.length)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (count === 0) {
|
|
322
|
+
return `错误: 在 ${filePath} 中找不到 old_string。请先用 read_file 确认文件的精确内容(注意缩进/换行/空白必须完全一致)。`
|
|
323
|
+
}
|
|
324
|
+
if (count > 1 && !args.replace_all) {
|
|
325
|
+
return `错误: old_string 在 ${filePath} 中出现 ${count} 次,不唯一。请提供更多上下文让它唯一,或显式传 replace_all=true 全部替换。`
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const updated = args.replace_all ? raw.split(oldStr).join(newStr) : raw.replace(oldStr, newStr)
|
|
329
|
+
try {
|
|
330
|
+
await fs.writeFile(filePath, updated, 'utf-8')
|
|
331
|
+
return `已修改 ${filePath} (替换 ${args.replace_all ? count : 1} 处)`
|
|
332
|
+
} catch (err) {
|
|
333
|
+
return `错误: 写入 ${filePath} 失败: ${err.message}`
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function toolListFiles(args, ctx) {
|
|
338
|
+
const dir = resolvePath(ctx, args.path)
|
|
339
|
+
const maxDepth = Math.max(1, Math.min(6, Number(args.depth) || 2))
|
|
340
|
+
const entries = []
|
|
341
|
+
let overflow = false
|
|
342
|
+
|
|
343
|
+
async function walk(current, depth, prefix) {
|
|
344
|
+
if (entries.length >= MAX_LIST_ENTRIES) { overflow = true; return }
|
|
345
|
+
let items
|
|
346
|
+
try {
|
|
347
|
+
items = await fs.readdir(current, { withFileTypes: true })
|
|
348
|
+
} catch (err) {
|
|
349
|
+
entries.push(`${prefix}[无法读取: ${err.message}]`)
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
// 目录在前,文件在后,各自按名字排序
|
|
353
|
+
items.sort((a, b) => (Number(b.isDirectory()) - Number(a.isDirectory())) || a.name.localeCompare(b.name))
|
|
354
|
+
for (const item of items) {
|
|
355
|
+
if (entries.length >= MAX_LIST_ENTRIES) { overflow = true; return }
|
|
356
|
+
if (item.isDirectory()) {
|
|
357
|
+
// 跳过产物/依赖/隐藏目录,避免列表被 node_modules 之类淹没
|
|
358
|
+
if (SKIP_DIRS.has(item.name) || item.name.startsWith('.')) continue
|
|
359
|
+
entries.push(`${prefix}${item.name}/`)
|
|
360
|
+
if (depth < maxDepth) await walk(path.join(current, item.name), depth + 1, `${prefix} `)
|
|
361
|
+
} else {
|
|
362
|
+
entries.push(`${prefix}${item.name}`)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
const st = await fs.stat(dir)
|
|
369
|
+
if (!st.isDirectory()) return `错误: 不是目录: ${dir}`
|
|
370
|
+
} catch {
|
|
371
|
+
return `错误: 目录不存在: ${dir}`
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
await walk(dir, 1, '')
|
|
375
|
+
const suffix = overflow ? `\n... [超过 ${MAX_LIST_ENTRIES} 条,已截断,请缩小 depth 或指定子目录]` : ''
|
|
376
|
+
return `# ${dir}\n${entries.join('\n')}${suffix}`
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function toolSearchText(args, ctx) {
|
|
380
|
+
const dir = resolvePath(ctx, args.path)
|
|
381
|
+
let regex
|
|
382
|
+
try {
|
|
383
|
+
regex = new RegExp(String(args.pattern), args.ignore_case ? 'i' : '')
|
|
384
|
+
} catch (err) {
|
|
385
|
+
return `错误: 正则无效: ${err.message}`
|
|
386
|
+
}
|
|
387
|
+
const extFilter = args.ext
|
|
388
|
+
? new Set(String(args.ext).split(',').map(e => e.trim().replace(/^\./, '').toLowerCase()).filter(Boolean))
|
|
389
|
+
: null
|
|
390
|
+
|
|
391
|
+
const hits = []
|
|
392
|
+
let scanned = 0
|
|
393
|
+
let overflow = false
|
|
394
|
+
|
|
395
|
+
// 在单个文件内匹配各行(path 直接指向文件时走这个分支 ——
|
|
396
|
+
// 模型经常拿文件路径来搜,兼容掉,别让它吃"不是目录"的错)
|
|
397
|
+
async function searchOneFile(full) {
|
|
398
|
+
let text
|
|
399
|
+
try { text = await fs.readFile(full, 'utf-8') } catch { return }
|
|
400
|
+
scanned++
|
|
401
|
+
const lines = text.split('\n')
|
|
402
|
+
for (let i = 0; i < lines.length && hits.length < MAX_SEARCH_HITS; i++) {
|
|
403
|
+
regex.lastIndex = 0
|
|
404
|
+
if (regex.test(lines[i])) {
|
|
405
|
+
hits.push(`${path.relative(ctx.cwd, full) || full}:${i + 1}: ${lines[i].trim().slice(0, 300)}`)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function walk(current, depth) {
|
|
411
|
+
if (hits.length >= MAX_SEARCH_HITS || depth > 12) { if (depth > 12) return; overflow = true; return }
|
|
412
|
+
let items
|
|
413
|
+
try { items = await fs.readdir(current, { withFileTypes: true }) } catch { return }
|
|
414
|
+
for (const item of items) {
|
|
415
|
+
if (hits.length >= MAX_SEARCH_HITS) { overflow = true; return }
|
|
416
|
+
const full = path.join(current, item.name)
|
|
417
|
+
if (item.isDirectory()) {
|
|
418
|
+
if (SKIP_DIRS.has(item.name) || item.name.startsWith('.')) continue
|
|
419
|
+
await walk(full, depth + 1)
|
|
420
|
+
} else {
|
|
421
|
+
const ext = path.extname(item.name).toLowerCase()
|
|
422
|
+
if (BINARY_EXTS.has(ext) || BINARY_EXTS.has(item.name.toLowerCase())) continue
|
|
423
|
+
if (extFilter && !extFilter.has(ext.replace('.', ''))) continue
|
|
424
|
+
let st
|
|
425
|
+
try { st = await fs.stat(full) } catch { continue }
|
|
426
|
+
if (st.size > MAX_SEARCH_FILE_SIZE) continue
|
|
427
|
+
await searchOneFile(full)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
let st
|
|
433
|
+
try {
|
|
434
|
+
st = await fs.stat(dir)
|
|
435
|
+
} catch {
|
|
436
|
+
return `错误: 路径不存在: ${dir}`
|
|
437
|
+
}
|
|
438
|
+
// 文件:直接搜它自己(忽略 ext 过滤 — 用户/模型明确指名了这个文件)
|
|
439
|
+
if (!st.isDirectory()) {
|
|
440
|
+
if (st.size > MAX_SEARCH_FILE_SIZE) return `错误: 文件过大(${(st.size / 1024 / 1024).toFixed(1)}MB),请用 read_file 分段查看`
|
|
441
|
+
await searchOneFile(dir)
|
|
442
|
+
if (hits.length === 0) return `未找到匹配 "${args.pattern}" 的内容(已扫描 1 个文件)`
|
|
443
|
+
return hits.join('\n')
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
await walk(dir, 1)
|
|
447
|
+
if (hits.length === 0) return `未找到匹配 "${args.pattern}" 的内容(已扫描 ${scanned} 个文件)`
|
|
448
|
+
const suffix = overflow ? `\n... [超过 ${MAX_SEARCH_HITS} 条命中,已截断,请缩小范围或加 ext 过滤]` : ''
|
|
449
|
+
return `${hits.join('\n')}${suffix}`
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ──────────────────────────────────────────────
|
|
453
|
+
// 工具分发
|
|
454
|
+
// ──────────────────────────────────────────────
|
|
455
|
+
const TOOL_HANDLERS = {
|
|
456
|
+
run_command: toolRunCommand,
|
|
457
|
+
read_file: toolReadFile,
|
|
458
|
+
write_file: toolWriteFile,
|
|
459
|
+
edit_file: toolEditFile,
|
|
460
|
+
list_files: toolListFiles,
|
|
461
|
+
search_text: toolSearchText,
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* 执行一个工具调用,返回字符串结果(直接作为 role:'tool' 消息内容)。
|
|
466
|
+
* 所有异常都在内部消化成字符串,不向外抛。
|
|
467
|
+
*
|
|
468
|
+
* @param {string} name - 工具名
|
|
469
|
+
* @param {object} args - 已解析的参数对象
|
|
470
|
+
* @param {{ cwd: string, onChild?: (child: object) => void }} ctx
|
|
471
|
+
*/
|
|
472
|
+
export async function executeTool(name, args, ctx) {
|
|
473
|
+
const handler = TOOL_HANDLERS[name]
|
|
474
|
+
if (!handler) return `错误: 未知工具 "${name}",可用工具: ${Object.keys(TOOL_HANDLERS).join(', ')}`
|
|
475
|
+
try {
|
|
476
|
+
return await handler(args || {}, ctx)
|
|
477
|
+
} catch (err) {
|
|
478
|
+
return `错误: 工具 ${name} 执行异常: ${err.message}`
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export default { TOOL_DEFINITIONS, executeTool }
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// src/cli/ai/tools.js 单元测试 — 在系统临时目录里做真实的文件读写,
|
|
16
|
+
// 覆盖 write/read/edit/list/search 的往返与 run_command 的安全守卫联动。
|
|
17
|
+
import { test, before, after } from 'node:test'
|
|
18
|
+
import assert from 'node:assert/strict'
|
|
19
|
+
import { promises as fs } from 'node:fs'
|
|
20
|
+
import os from 'node:os'
|
|
21
|
+
import path from 'node:path'
|
|
22
|
+
import { TOOL_DEFINITIONS, executeTool } from './tools.js'
|
|
23
|
+
|
|
24
|
+
let tmpDir
|
|
25
|
+
const ctx = { cwd: null, onChild: null }
|
|
26
|
+
|
|
27
|
+
before(async () => {
|
|
28
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'g-ai-tools-'))
|
|
29
|
+
ctx.cwd = tmpDir
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
after(async () => {
|
|
33
|
+
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// ========== schema 基本形态 ==========
|
|
37
|
+
|
|
38
|
+
test('TOOL_DEFINITIONS 符合 OpenAI function calling 格式', () => {
|
|
39
|
+
assert.ok(Array.isArray(TOOL_DEFINITIONS))
|
|
40
|
+
assert.ok(TOOL_DEFINITIONS.length >= 5)
|
|
41
|
+
for (const t of TOOL_DEFINITIONS) {
|
|
42
|
+
assert.equal(t.type, 'function')
|
|
43
|
+
assert.ok(t.function.name, '工具要有名字')
|
|
44
|
+
assert.ok(t.function.description, '工具要有描述')
|
|
45
|
+
assert.equal(t.function.parameters.type, 'object')
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// ========== write_file / read_file 往返 ==========
|
|
50
|
+
|
|
51
|
+
test('write_file 创建嵌套文件,read_file 带行号读回', async () => {
|
|
52
|
+
const w = await executeTool('write_file', { path: 'src/deep/hello.txt', content: 'line1\nline2\nline3' }, ctx)
|
|
53
|
+
assert.match(w, /已写入/)
|
|
54
|
+
|
|
55
|
+
const r = await executeTool('read_file', { path: 'src/deep/hello.txt' }, ctx)
|
|
56
|
+
assert.match(r, /1→line1/)
|
|
57
|
+
assert.match(r, /3→line3/)
|
|
58
|
+
assert.match(r, /共 3 行/)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('read_file 支持 offset/limit 分段', async () => {
|
|
62
|
+
const r = await executeTool('read_file', { path: 'src/deep/hello.txt', offset: 2, limit: 1 }, ctx)
|
|
63
|
+
assert.match(r, /2→line2/)
|
|
64
|
+
assert.doesNotMatch(r, /1→line1/)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('read_file 不存在的文件返回错误字符串(不抛异常)', async () => {
|
|
68
|
+
const r = await executeTool('read_file', { path: 'no-such-file.txt' }, ctx)
|
|
69
|
+
assert.match(r, /^错误:/)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// ========== edit_file ==========
|
|
73
|
+
|
|
74
|
+
test('edit_file 唯一匹配替换成功', async () => {
|
|
75
|
+
const r = await executeTool('edit_file', { path: 'src/deep/hello.txt', old_string: 'line2', new_string: 'LINE-2' }, ctx)
|
|
76
|
+
assert.match(r, /已修改/)
|
|
77
|
+
const content = await fs.readFile(path.join(tmpDir, 'src/deep/hello.txt'), 'utf-8')
|
|
78
|
+
assert.ok(content.includes('LINE-2'))
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('edit_file 找不到 old_string 时报错并提示先 read_file', async () => {
|
|
82
|
+
const r = await executeTool('edit_file', { path: 'src/deep/hello.txt', old_string: 'not-exist', new_string: 'x' }, ctx)
|
|
83
|
+
assert.match(r, /找不到 old_string/)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('edit_file 多处匹配默认拒绝,replace_all 放行', async () => {
|
|
87
|
+
await executeTool('write_file', { path: 'multi.txt', content: 'a=a\nb=a' }, ctx)
|
|
88
|
+
const dup = await executeTool('edit_file', { path: 'multi.txt', old_string: 'a', new_string: 'z' }, ctx)
|
|
89
|
+
assert.match(dup, /不唯一/)
|
|
90
|
+
|
|
91
|
+
const all = await executeTool('edit_file', { path: 'multi.txt', old_string: 'a', new_string: 'z', replace_all: true }, ctx)
|
|
92
|
+
assert.match(all, /替换 3 处/)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// ========== list_files ==========
|
|
96
|
+
|
|
97
|
+
test('list_files 列出结构并跳过 node_modules', async () => {
|
|
98
|
+
await fs.mkdir(path.join(tmpDir, 'node_modules/pkg'), { recursive: true })
|
|
99
|
+
await fs.writeFile(path.join(tmpDir, 'node_modules/pkg/x.js'), 'x')
|
|
100
|
+
const r = await executeTool('list_files', { depth: 3 }, ctx)
|
|
101
|
+
assert.match(r, /src\//)
|
|
102
|
+
assert.match(r, /hello\.txt/)
|
|
103
|
+
assert.doesNotMatch(r, /node_modules/)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// ========== search_text ==========
|
|
107
|
+
|
|
108
|
+
test('search_text 能找到匹配行并带行号', async () => {
|
|
109
|
+
const r = await executeTool('search_text', { pattern: 'LINE-2' }, ctx)
|
|
110
|
+
assert.match(r, /hello\.txt:2:/)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('search_text 支持 ext 过滤与 ignore_case', async () => {
|
|
114
|
+
const miss = await executeTool('search_text', { pattern: 'LINE-2', ext: 'md' }, ctx)
|
|
115
|
+
assert.match(miss, /未找到/)
|
|
116
|
+
const hit = await executeTool('search_text', { pattern: 'line-2', ignore_case: true }, ctx)
|
|
117
|
+
assert.match(hit, /hello\.txt/)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('search_text 非法正则不抛异常', async () => {
|
|
121
|
+
const r = await executeTool('search_text', { pattern: '([' }, ctx)
|
|
122
|
+
assert.match(r, /正则无效/)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// ========== run_command ==========
|
|
126
|
+
|
|
127
|
+
test('run_command 执行成功并返回输出与退出码', async () => {
|
|
128
|
+
const r = await executeTool('run_command', { command: 'echo hello-g-ai' }, ctx)
|
|
129
|
+
assert.match(r, /\(exit 0\)/)
|
|
130
|
+
assert.match(r, /hello-g-ai/)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('run_command 非零退出也正常返回(不抛异常)', async () => {
|
|
134
|
+
const r = await executeTool('run_command', { command: 'exit 3' }, ctx)
|
|
135
|
+
assert.match(r, /\(exit 3\)/)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('run_command 被安全守卫拦截系统级毁灭命令', async () => {
|
|
139
|
+
const r = await executeTool('run_command', { command: 'rm -rf /' }, ctx)
|
|
140
|
+
assert.match(r, /已拒绝执行/)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('run_command 放行项目内删除操作(不被守卫拦截)', async () => {
|
|
144
|
+
await fs.mkdir(path.join(tmpDir, 'junk'), { recursive: true })
|
|
145
|
+
// 注意不断言 exit 0:Windows 默认 cmd.exe 没有 rm 命令,删不删得掉是 shell 的事,
|
|
146
|
+
// 这里只验证安全守卫**不拦截**项目内删除(那是模型的正常工作流)
|
|
147
|
+
const r = await executeTool('run_command', { command: 'rm -rf junk' }, ctx)
|
|
148
|
+
assert.doesNotMatch(r, /已拒绝执行/)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('run_command 不存在的 cwd 返回错误', async () => {
|
|
152
|
+
const r = await executeTool('run_command', { command: 'echo x', cwd: 'no-such-dir-xyz' }, ctx)
|
|
153
|
+
assert.match(r, /目录不存在/)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
// ========== 未知工具 ==========
|
|
157
|
+
|
|
158
|
+
test('未知工具返回错误字符串并列出可用工具', async () => {
|
|
159
|
+
const r = await executeTool('nope_tool', {}, ctx)
|
|
160
|
+
assert.match(r, /未知工具/)
|
|
161
|
+
assert.match(r, /run_command/)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// ========== search_text: 文件路径直接搜索 ==========
|
|
165
|
+
|
|
166
|
+
test('search_text 支持 path 直接指向单个文件', async () => {
|
|
167
|
+
const r = await executeTool('search_text', { pattern: 'LINE-2', path: 'src/deep/hello.txt' }, ctx)
|
|
168
|
+
assert.match(r, /hello\.txt:2:/)
|
|
169
|
+
const miss = await executeTool('search_text', { pattern: 'not-in-file-xyz', path: 'src/deep/hello.txt' }, ctx)
|
|
170
|
+
assert.match(miss, /未找到/)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// ========== run_command: 输出编码(GBK 乱码回归) ==========
|
|
174
|
+
|
|
175
|
+
test('run_command UTF-8 中文输出不乱码', async () => {
|
|
176
|
+
// node 自身 stdout 是 UTF-8,跨平台一致
|
|
177
|
+
const r = await executeTool('run_command', { command: 'node -e "console.log(\'中文输出测试\')"' }, ctx)
|
|
178
|
+
assert.match(r, /中文输出测试/)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('run_command 本地化错误消息解码正确(Windows GBK 回归)', async () => {
|
|
182
|
+
const r = await executeTool('run_command', { command: 'nonexistent-cmd-xyz-123' }, ctx)
|
|
183
|
+
if (process.platform === 'win32') {
|
|
184
|
+
// cmd.exe 的"不是内部或外部命令"是 GBK 字节流,修复前会显示为 '����' 乱码
|
|
185
|
+
assert.match(r, /不是内部或外部命令/)
|
|
186
|
+
assert.doesNotMatch(r, /�/)
|
|
187
|
+
} else {
|
|
188
|
+
assert.match(r, /not found|未找到/)
|
|
189
|
+
}
|
|
190
|
+
})
|
package/src/gitCommit.js
CHANGED
|
@@ -255,6 +255,15 @@ async function main() {
|
|
|
255
255
|
return;
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
// 检查是否是 AI 智能体命令(g ai)
|
|
259
|
+
// 只认 argv[2] === 'ai':精确匹配子命令位置,避免 `g -m ai` 这种
|
|
260
|
+
// 把 "ai" 当提交信息的场景被误判。动态 import,普通提交路径零加载成本。
|
|
261
|
+
if (process.argv[2] === 'ai') {
|
|
262
|
+
const { runAiAgent } = await import('./cli/ai/agent.js');
|
|
263
|
+
await runAiAgent(process.argv.slice(3));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
258
267
|
// 检查是否是添加脚本命令
|
|
259
268
|
if (process.argv.includes('addScript')) {
|
|
260
269
|
await addScriptToPackageJson();
|