zen-gitsync 2.17.8 → 2.17.10
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 +6 -0
- package/package.json +2 -1
- package/src/cli/ai/agent.js +210 -58
- package/src/cli/ai/context.js +58 -0
- package/src/cli/ai/context.test.js +117 -0
- package/src/cli/ai/extensions.js +131 -0
- package/src/cli/ai/extensions.test.js +158 -0
- package/src/cli/ai/mcp.js +498 -0
- package/src/cli/ai/mcp.test.js +275 -0
- package/src/cli/ai/runtime.test.js +32 -0
- package/src/cli/ai/skills.js +207 -0
- package/src/cli/ai/skills.test.js +126 -0
- package/src/cli/ai/termui.js +1 -0
- package/src/cli/ai/tools.js +94 -3
- package/src/cli/ai/tools.test.js +82 -0
- package/src/cli/ai/transport.js +18 -4
- package/src/cli/ai/turn.js +21 -5
- package/src/config.js +27 -3
- package/src/paths.js +11 -0
- package/src/ui/public/assets/AgentView-BV15UtEF.css +1 -0
- package/src/ui/public/assets/AgentView-C-AJo7KS.js +5 -0
- package/src/ui/public/assets/AppVersionBadge-B6R6eN2N.js +2 -0
- package/src/ui/public/assets/BranchSelector-Cj3kMWON.js +1 -0
- package/src/ui/public/assets/CommitForm-DpXuFG-F.js +6 -0
- package/src/ui/public/assets/CommonDialog-BLPHDsn0.js +1 -0
- package/src/ui/public/assets/EditorView-B1OnoXXf.js +1 -0
- package/src/ui/public/assets/FileDiffViewer-BQl0JcWd.js +14 -0
- package/src/ui/public/assets/{FlowExecutionViewer-uXrzZjsW.js → FlowExecutionViewer-BgEt5_Hz.js} +1 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-CbLmsSF8.js +21 -0
- package/src/ui/public/assets/IconButton-aM3vaSIN.js +1 -0
- package/src/ui/public/assets/LogList-UjoVFZ-H.js +7 -0
- package/src/ui/public/assets/MindmapView-D_04v0n4.js +1 -0
- package/src/ui/public/assets/MonitorView-BD1_uzq7.js +1 -0
- package/src/ui/public/assets/ProjectStartupButton-BTYlVvzh.js +1 -0
- package/src/ui/public/assets/RemoteManagerDialog-20mpQMwu.js +1 -0
- package/src/ui/public/assets/RemoteRepoCard-DOrm17Xd.js +1 -0
- package/src/ui/public/assets/SourceMapView-nUU9bYPh.js +3 -0
- package/src/ui/public/assets/SvgIcon-LUynX3Iq.js +1 -0
- package/src/ui/public/assets/TemplateManager-Bo3Q7J9Y.js +1 -0
- package/src/ui/public/assets/UserInputNode-D5tSXiJz.js +1 -0
- package/src/ui/public/assets/{WorkbenchView-v3QFn6Ln.css → WorkbenchView-C_Gmzvj1.css} +1 -1
- package/src/ui/public/assets/WorkbenchView-UGC334JL.js +13 -0
- package/src/ui/public/assets/_plugin-vue_export-helper-D-D9qkRz.js +19 -0
- package/src/ui/public/assets/configStore-BLRcYJdf.js +1 -0
- package/src/ui/public/assets/{element-plus-KTmRV-so.js → element-plus-DcZ-TVp6.js} +3 -3
- package/src/ui/public/assets/flow-mindmap-D1VTkZh3.js +139 -0
- package/src/ui/public/assets/{gitStore-CpcS-MzM.js → gitStore-DMEDMZMk.js} +4 -4
- package/src/ui/public/assets/index-CW3xs-af.css +1 -0
- package/src/ui/public/assets/index-S0_gRyiO.js +26 -0
- package/src/ui/public/assets/{office-docx-BfSBpdBv.js → office-docx-C0g5Oepv.js} +5 -5
- package/src/ui/public/assets/{office-excel-S95W2mv4.js → office-excel-D-uYLKaA.js} +1 -1
- package/src/ui/public/assets/{office-pptx-DFFarGrk.js → office-pptx-De3fnc6M.js} +1 -1
- package/src/ui/public/assets/previewSandbox-DXAxqDVu.js +0 -0
- package/src/ui/public/assets/toolsStore-CpImRUhI.js +1 -0
- package/src/ui/public/assets/{useThemeObserver-B01D-Vl6.js → useThemeObserver-CG8k8U18.js} +1 -1
- package/src/ui/public/assets/{vendor-dO4B7wQf.js → vendor-BIgZEtkx.js} +27 -27
- package/src/ui/public/assets/vue-flow-CPCFgenl.js +39 -0
- package/src/ui/public/index.html +17 -17
- package/src/ui/server/index.js +4 -0
- package/src/ui/server/routes/config.js +16 -3
- package/src/ui/server/routes/exec.js +19 -11
- package/src/ui/server/routes/fileOpen.js +142 -2
- package/src/ui/server/routes/remoteRepos.js +604 -0
- package/src/ui/server/routes/remoteRepos.test.js +386 -0
- package/src/ui/server/routes/workbench/agentChat.js +86 -180
- package/src/ui/server/routes/workbench/agentChatShared.test.js +151 -0
- package/src/ui/server/routes/workbench/agentMarketplace.js +986 -0
- package/src/ui/server/routes/workbench/agentMarketplace.test.js +306 -0
- package/src/ui/server/routes/workbench/agentParity.test.js +167 -0
- package/src/ui/server/routes/workbench/agentRoutes.js +86 -1
- package/src/ui/server/routes/workbench/agentRoutes.test.js +50 -1
- package/src/ui/server/routes/workbench/jobStore.js +17 -3
- package/src/ui/server/routes/workbench/orchestratorStore.js +42 -2
- package/src/ui/server/routes/workbench/projectRegistry.js +6 -0
- package/src/ui/server/routes/workbench/projectRegistry.test.js +70 -0
- package/src/ui/server/routes/workbench/projectTool.js +198 -0
- package/src/ui/server/routes/workbench/projectTool.test.js +152 -0
- package/src/ui/server/routes/workbench/remoteCliPromptParity.test.js +177 -0
- package/src/ui/server/routes/workbench/taskRunner.js +269 -12
- package/src/ui/server/routes/workbench/taskRunner.toolcalls.test.js +307 -0
- package/src/ui/server/socket/registerUiSocketHandlers.js +18 -11
- package/src/utils/shellPath.js +253 -0
- package/src/utils/shellPath.test.js +221 -0
- package/src/ui/public/assets/AgentView-CdJaeRh0.js +0 -5
- package/src/ui/public/assets/AgentView-DAY4S5WN.css +0 -1
- package/src/ui/public/assets/AppVersionBadge-CiijSYfM.js +0 -2
- package/src/ui/public/assets/BranchSelector-BS7ajwvv.js +0 -1
- package/src/ui/public/assets/CommitForm-CEOSqXCA.js +0 -6
- package/src/ui/public/assets/CommonDialog-CAaEi-4T.js +0 -1
- package/src/ui/public/assets/EditorView-DyaFyRDD.js +0 -1
- package/src/ui/public/assets/FileDiffViewer-BvX9cQjR.js +0 -14
- package/src/ui/public/assets/FlowOrchestrationWorkspace-DIBpUSt_.js +0 -21
- package/src/ui/public/assets/IconButton-CnoW2on6.js +0 -1
- package/src/ui/public/assets/LogList-D2espSVo.js +0 -7
- package/src/ui/public/assets/MindmapView-CZF-rZVs.js +0 -1
- package/src/ui/public/assets/MonitorView-DjLr7qOG.js +0 -1
- package/src/ui/public/assets/ProjectStartupButton-DzsU-QIO.js +0 -1
- package/src/ui/public/assets/RemoteManagerDialog-CuuG2ucT.js +0 -1
- package/src/ui/public/assets/RemoteRepoCard-BGf8L0of.js +0 -1
- package/src/ui/public/assets/SourceMapView-CFb02m6z.js +0 -3
- package/src/ui/public/assets/SvgIcon-SRAeYjnN.js +0 -1
- package/src/ui/public/assets/TemplateManager-gedqAXl8.js +0 -1
- package/src/ui/public/assets/UserInputNode-BOCuMQW6.js +0 -1
- package/src/ui/public/assets/WorkbenchView-CRoR21LQ.js +0 -13
- package/src/ui/public/assets/_plugin-vue_export-helper-C8OE7U9H.js +0 -19
- package/src/ui/public/assets/configStore-CuMElTgV.js +0 -1
- package/src/ui/public/assets/flow-mindmap-CurCIiCp.js +0 -139
- package/src/ui/public/assets/index-cApp6w0b.css +0 -1
- package/src/ui/public/assets/index-yXm9JLGv.js +0 -24
- package/src/ui/public/assets/previewSandbox-DkXYEpLP.js +0 -0
- package/src/ui/public/assets/toolsStore-DGxlRe3Y.js +0 -1
- package/src/ui/public/assets/vue-flow-CsvDD38k.js +0 -39
|
@@ -0,0 +1,275 @@
|
|
|
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
|
+
// MCP stdio 客户端的回归测试。
|
|
16
|
+
//
|
|
17
|
+
// 这里最值钱的一条是 buildSpawnSpec 的转义用例 —— Windows 上
|
|
18
|
+
// `spawn(cmd, args, { shell: true })` 会把含空格的参数拆碎(实测 C:\a b\proj
|
|
19
|
+
// 会变成 "C:a" + "bproj"),而直接 spawn .cmd 又会被 Node 拒绝(EINVAL)。
|
|
20
|
+
// 项目路径带空格是常态,这两条任一回归都会让"装了 MCP 但连不上"变成玄学问题。
|
|
21
|
+
|
|
22
|
+
import { test } from 'node:test';
|
|
23
|
+
import assert from 'node:assert/strict';
|
|
24
|
+
import { promises as fs } from 'node:fs';
|
|
25
|
+
import { spawn } from 'node:child_process';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
|
|
29
|
+
const sandboxHome = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-mcp-home-'));
|
|
30
|
+
const sandboxProject = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-mcp-project-'));
|
|
31
|
+
process.env.USERPROFILE = sandboxHome;
|
|
32
|
+
process.env.HOME = sandboxHome;
|
|
33
|
+
delete process.env.HOMEDRIVE;
|
|
34
|
+
delete process.env.HOMEPATH;
|
|
35
|
+
|
|
36
|
+
const { buildSpawnSpec, loadMcpServers, McpManager } = await import('./mcp.js');
|
|
37
|
+
const { AI_MCP_FILE } = await import('../../paths.js');
|
|
38
|
+
|
|
39
|
+
const IS_WIN = process.platform === 'win32';
|
|
40
|
+
|
|
41
|
+
// ── buildSpawnSpec ────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
test('buildSpawnSpec: 解析到可执行文件时不经 shell,参数原样保留', () => {
|
|
44
|
+
const spec = buildSpawnSpec(process.execPath, ['-e', 'console.log(1)', 'C:\\a b\\proj'], {});
|
|
45
|
+
assert.equal(spec.file, process.execPath);
|
|
46
|
+
assert.deepEqual(spec.argv, ['-e', 'console.log(1)', 'C:\\a b\\proj']);
|
|
47
|
+
assert.notEqual(spec.options.shell, true, '绝不能用 shell:true —— 含空格的参数会被拆碎');
|
|
48
|
+
assert.equal(spec.options.windowsVerbatimArguments, undefined, '直接 spawn 时不应开启 verbatim');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('buildSpawnSpec: 批处理走 cmd.exe,反斜杠与元字符按 cmd 规则转义', { skip: !IS_WIN }, () => {
|
|
52
|
+
const spec = buildSpawnSpec('npx', ['-y', 'pkg', 'C:\\a b\\proj', 'trail\\', 'a&b'], {});
|
|
53
|
+
assert.match(spec.file, /cmd\.exe$/i, '批处理必须交给 cmd.exe');
|
|
54
|
+
assert.deepEqual(spec.argv.slice(0, 3), ['/d', '/s', '/c']);
|
|
55
|
+
assert.equal(spec.options.windowsVerbatimArguments, true, '整行由我们自己转义,不能再让 Node 加引号');
|
|
56
|
+
|
|
57
|
+
const line = spec.argv[3];
|
|
58
|
+
assert.match(line, /"C:\\a b\\proj"/, '含空格的路径必须整体加引号,否则会被拆成两段');
|
|
59
|
+
assert.match(line, /"trail\\\\"/, '行尾反斜杠必须加倍,否则会被 cmd 当转义符吃掉');
|
|
60
|
+
assert.match(line, /a\^&b/, '元字符 & 必须加 ^ 前缀,否则会被当成管道/顺序执行');
|
|
61
|
+
// 整行外层再加一层引号,配合 /s 的"去掉首尾引号"规则
|
|
62
|
+
assert.ok(line.startsWith('""') && line.endsWith('""'), `整行需要被引号包裹: ${line}`);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('buildSpawnSpec: 含空格的 .cmd 实参能原样到达子进程(端到端)', { skip: !IS_WIN }, async () => {
|
|
66
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-mcp-cmd-'));
|
|
67
|
+
const script = path.join(dir, 'echo-args.cmd');
|
|
68
|
+
// %* 会把收到的参数原样回显,是最直接的"参数有没有被拆碎"探针
|
|
69
|
+
await fs.writeFile(script, '@echo off\r\necho ARGS:%*\r\n', 'utf8');
|
|
70
|
+
|
|
71
|
+
const spaced = 'C:\\a b\\proj';
|
|
72
|
+
const spec = buildSpawnSpec(script, [spaced, 'plain'], {});
|
|
73
|
+
const output = await new Promise((resolve, reject) => {
|
|
74
|
+
const child = spawn(spec.file, spec.argv, { ...spec.options });
|
|
75
|
+
let text = '';
|
|
76
|
+
child.stdout.on('data', chunk => { text += chunk; });
|
|
77
|
+
child.on('error', reject);
|
|
78
|
+
child.on('exit', () => resolve(text.trim()));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
assert.match(output, /ARGS:.*a b.*proj/, `参数被拆碎了,实际情况: ${output}`);
|
|
82
|
+
assert.match(output, /plain/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ── loadMcpServers ────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
test('loadMcpServers: 项目级覆盖全局同名 server,坏的 JSON 只当没有配置', async () => {
|
|
88
|
+
const project = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-mcp-merge-'));
|
|
89
|
+
await fs.mkdir(path.dirname(AI_MCP_FILE), { recursive: true });
|
|
90
|
+
await fs.writeFile(AI_MCP_FILE, JSON.stringify({
|
|
91
|
+
mcpServers: {
|
|
92
|
+
shared: { command: 'npx', args: ['global'] },
|
|
93
|
+
onlyGlobal: { command: 'npx', args: ['g'] },
|
|
94
|
+
broken: { args: ['no-command'] },
|
|
95
|
+
},
|
|
96
|
+
}), 'utf8');
|
|
97
|
+
await fs.writeFile(path.join(project, '.mcp.json'), JSON.stringify({
|
|
98
|
+
mcpServers: { shared: { command: 'npx', args: ['project'] } },
|
|
99
|
+
}), 'utf8');
|
|
100
|
+
|
|
101
|
+
const { servers, sources } = await loadMcpServers({ cwd: project });
|
|
102
|
+
assert.deepEqual(servers.shared.args, ['project'], '项目级应覆盖全局级');
|
|
103
|
+
assert.ok(servers.onlyGlobal);
|
|
104
|
+
assert.equal(servers.broken, undefined, '没有 command 的条目应被丢弃');
|
|
105
|
+
assert.equal(sources.length, 2);
|
|
106
|
+
|
|
107
|
+
// 坏文件:整个文件当空配置,不抛错
|
|
108
|
+
await fs.writeFile(path.join(project, '.mcp.json'), '{ this is not json', 'utf8');
|
|
109
|
+
const broken = await loadMcpServers({ cwd: project });
|
|
110
|
+
assert.deepEqual(broken.servers.shared.args, ['global'], '坏的项目配置应回落到全局配置');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ── 真实 stdio 往返 ───────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
// 一个最小可用的 MCP server:换行分隔 JSON-RPC,只实现握手 + tools/list + tools/call。
|
|
116
|
+
const FAKE_SERVER = `
|
|
117
|
+
let buffer = '';
|
|
118
|
+
const send = (message) => process.stdout.write(JSON.stringify(message) + '\\n');
|
|
119
|
+
process.stdin.setEncoding('utf8');
|
|
120
|
+
process.stdin.on('data', chunk => {
|
|
121
|
+
buffer += chunk;
|
|
122
|
+
let index;
|
|
123
|
+
while ((index = buffer.indexOf('\\n')) >= 0) {
|
|
124
|
+
const line = buffer.slice(0, index).trim();
|
|
125
|
+
buffer = buffer.slice(index + 1);
|
|
126
|
+
if (!line) continue;
|
|
127
|
+
const message = JSON.parse(line);
|
|
128
|
+
if (message.method === 'initialize') {
|
|
129
|
+
send({ jsonrpc: '2.0', id: message.id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'fake', version: '0' } } });
|
|
130
|
+
} else if (message.method === 'tools/list') {
|
|
131
|
+
send({ jsonrpc: '2.0', id: message.id, result: { tools: [
|
|
132
|
+
{ name: 'echo', description: '回显输入', inputSchema: { type: 'object', properties: { text: { type: 'string' } } } },
|
|
133
|
+
{ name: 'weird-name/with spaces', description: '', inputSchema: { type: 'object' } },
|
|
134
|
+
{ name: 'boom', description: '永远失败', inputSchema: { type: 'object' } },
|
|
135
|
+
] } });
|
|
136
|
+
} else if (message.method === 'tools/call') {
|
|
137
|
+
if (message.params.name === 'boom') {
|
|
138
|
+
send({ jsonrpc: '2.0', id: message.id, result: { isError: true, content: [{ type: 'text', text: '炸了' }] } });
|
|
139
|
+
} else if (message.params.name === 'weird-name/with spaces') {
|
|
140
|
+
send({ jsonrpc: '2.0', id: message.id, result: { content: [{ type: 'image', mimeType: 'image/png' }] } });
|
|
141
|
+
} else {
|
|
142
|
+
send({ jsonrpc: '2.0', id: message.id, result: { content: [{ type: 'text', text: 'echo:' + (message.params.arguments?.text ?? '') }] } });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
`;
|
|
148
|
+
|
|
149
|
+
async function startFakeManager({ servers }) {
|
|
150
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-mcp-fake-'));
|
|
151
|
+
const script = path.join(dir, 'fake-mcp.mjs');
|
|
152
|
+
await fs.writeFile(script, FAKE_SERVER, 'utf8');
|
|
153
|
+
return await McpManager.create({
|
|
154
|
+
cwd: sandboxProject,
|
|
155
|
+
servers: servers ?? { fake: { command: process.execPath, args: [script] } },
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
test('McpManager: 握手后把工具挂成 mcp__<server>__<tool>,名字收敛到安全字符集', async () => {
|
|
160
|
+
const manager = await startFakeManager({});
|
|
161
|
+
try {
|
|
162
|
+
assert.deepEqual(manager.status().map(s => s.error), [null], 'server 应连接成功');
|
|
163
|
+
const names = manager.toolDefinitions.map(tool => tool.function.name);
|
|
164
|
+
assert.ok(names.includes('mcp__fake__echo'));
|
|
165
|
+
// OpenAI function name 只允许 [A-Za-z0-9_-],空格与斜杠必须被替换
|
|
166
|
+
assert.ok(names.some(name => name === 'mcp__fake__weird-name_with_spaces'), names.join(','));
|
|
167
|
+
for (const tool of manager.toolDefinitions) {
|
|
168
|
+
assert.match(tool.function.name, /^[a-zA-Z0-9_-]+$/);
|
|
169
|
+
assert.ok(tool.function.parameters, '每个工具都要带 inputSchema');
|
|
170
|
+
}
|
|
171
|
+
} finally {
|
|
172
|
+
await manager.close();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('McpManager: tools/call 的 content 数组被拍平成字符串回喂给模型', async () => {
|
|
177
|
+
const manager = await startFakeManager({});
|
|
178
|
+
try {
|
|
179
|
+
assert.equal(await manager.call('mcp__fake__echo', { text: '你好' }), 'echo:你好');
|
|
180
|
+
// isError 的结果也要变成可读字符串,而不是把异常抛进 agent 循环
|
|
181
|
+
assert.match(await manager.call('mcp__fake__boom', {}), /^错误: 炸了/);
|
|
182
|
+
// 非文本 part 用占位符表示,不能让模型看到 undefined
|
|
183
|
+
assert.match(await manager.call('mcp__fake__weird-name_with_spaces', {}), /\[image image\/png/);
|
|
184
|
+
// 不认识的名字返回 null,由调用方回落到内置工具
|
|
185
|
+
assert.equal(await manager.call('mcp__nope__nope', {}), null);
|
|
186
|
+
} finally {
|
|
187
|
+
await manager.close();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('McpManager: 未知工具不进入 registry,describe 只描述真实工具数', async () => {
|
|
192
|
+
const manager = await startFakeManager({});
|
|
193
|
+
try {
|
|
194
|
+
assert.equal(manager.has('mcp__fake__echo'), true);
|
|
195
|
+
assert.equal(manager.has('read_file'), false, '内置工具不该被扩展层接管');
|
|
196
|
+
assert.equal(manager.toolCount, 3);
|
|
197
|
+
|
|
198
|
+
const zh = manager.describe({ locale: 'zh-CN' });
|
|
199
|
+
assert.match(zh, /已接入的 MCP 服务/);
|
|
200
|
+
assert.match(zh, /fake: 3 个工具/);
|
|
201
|
+
assert.doesNotMatch(manager.describe({ locale: 'en' }), /已接入/);
|
|
202
|
+
} finally {
|
|
203
|
+
await manager.close();
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('McpManager: 启动失败只记录错误,不抛异常也不拖垮其他 server', async () => {
|
|
208
|
+
const manager = await McpManager.create({
|
|
209
|
+
cwd: sandboxProject,
|
|
210
|
+
servers: {
|
|
211
|
+
good: { command: process.execPath, args: ['-e', 'process.stdin.resume()'] },
|
|
212
|
+
missing: { command: IS_WIN ? 'definitely-not-a-real-command-xyz' : 'definitely-not-a-real-command-xyz', args: [] },
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
try {
|
|
216
|
+
const status = manager.status();
|
|
217
|
+
assert.equal(status.length, 2);
|
|
218
|
+
const missing = status.find(item => item.id === 'missing');
|
|
219
|
+
assert.ok(missing.error, '起不来的 server 必须有可读错误');
|
|
220
|
+
assert.equal(missing.tools, 0);
|
|
221
|
+
} finally {
|
|
222
|
+
await manager.close();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('McpManager: server 中途退出时调用立刻失败,而不是干等到超时', async () => {
|
|
227
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-mcp-die-'));
|
|
228
|
+
const script = path.join(dir, 'die.mjs');
|
|
229
|
+
// 握完手就退出,模拟 server 崩溃
|
|
230
|
+
await fs.writeFile(script, `
|
|
231
|
+
let buffer = '';
|
|
232
|
+
process.stdin.setEncoding('utf8');
|
|
233
|
+
process.stdin.on('data', chunk => {
|
|
234
|
+
buffer += chunk;
|
|
235
|
+
let index;
|
|
236
|
+
while ((index = buffer.indexOf('\\n')) >= 0) {
|
|
237
|
+
const line = buffer.slice(0, index).trim();
|
|
238
|
+
buffer = buffer.slice(index + 1);
|
|
239
|
+
if (!line) continue;
|
|
240
|
+
const message = JSON.parse(line);
|
|
241
|
+
if (message.method === 'initialize') {
|
|
242
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { protocolVersion: '2024-11-05', capabilities: {}, serverInfo: { name: 'die', version: '0' } } }) + '\\n');
|
|
243
|
+
} else if (message.method === 'tools/list') {
|
|
244
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { tools: [{ name: 'later', description: '', inputSchema: { type: 'object' } }] } }) + '\\n');
|
|
245
|
+
setTimeout(() => process.exit(1), 30);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
`, 'utf8');
|
|
250
|
+
|
|
251
|
+
const manager = await McpManager.create({ cwd: sandboxProject, servers: { die: { command: process.execPath, args: [script] } } });
|
|
252
|
+
try {
|
|
253
|
+
assert.equal(manager.toolCount, 1);
|
|
254
|
+
await new Promise(resolve => setTimeout(resolve, 120)); // 等子进程真的死掉
|
|
255
|
+
const started = Date.now();
|
|
256
|
+
const result = await manager.call('mcp__die__later', {});
|
|
257
|
+
assert.ok(Date.now() - started < 5000, '应在子进程退出后立即返回,不该等满 120s 超时');
|
|
258
|
+
assert.match(result, /错误: MCP 工具/);
|
|
259
|
+
assert.match(String(result), /已退出|不可写/, `错误信息应说明原因,实际: ${result}`);
|
|
260
|
+
} finally {
|
|
261
|
+
await manager.close();
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('McpManager: 没有配置任何 server 时是空管理器,不影响内置工具', async () => {
|
|
266
|
+
const manager = await McpManager.create({ cwd: sandboxProject, servers: {} });
|
|
267
|
+
try {
|
|
268
|
+
assert.equal(manager.toolCount, 0);
|
|
269
|
+
assert.deepEqual(manager.toolDefinitions, []);
|
|
270
|
+
assert.equal(manager.describe({ locale: 'zh-CN' }), '', '没有连接成功任何 server 时不应往提示词里塞标题');
|
|
271
|
+
assert.equal(await manager.call('mcp__x__y', {}), null);
|
|
272
|
+
} finally {
|
|
273
|
+
await manager.close();
|
|
274
|
+
}
|
|
275
|
+
});
|
|
@@ -78,6 +78,10 @@ test('timeout and explicit cancellation have different outcomes', async () => {
|
|
|
78
78
|
})
|
|
79
79
|
|
|
80
80
|
const call = n => ({ id: `call${n}`, type: 'function', function: { name: 'read_file', arguments: '{}' } })
|
|
81
|
+
const askCall = (id = 'ask1') => ({ id, type: 'function', function: {
|
|
82
|
+
name: 'ask_user',
|
|
83
|
+
arguments: JSON.stringify({ question: 'Which option?', options: ['A', 'B'], allow_free_text: false }),
|
|
84
|
+
} })
|
|
81
85
|
const strings = { waiting: '', toolRunning: x => x, toolIterLimit: n => String(n), llmError: x => x, emptyResponse: 'empty' }
|
|
82
86
|
const noop = () => {}
|
|
83
87
|
const quiet = { startSpinner: () => ({ stop: noop }), createAssistantWriter: () => ({ writeContent: noop, writeThinking: noop, finish: noop }),
|
|
@@ -133,6 +137,34 @@ test('turn aggregates all model calls, persists progress and records response ti
|
|
|
133
137
|
assert.equal(s.sessionStats.usage.totalTokens, 24)
|
|
134
138
|
})
|
|
135
139
|
|
|
140
|
+
test('ask_user pauses a turn until the answer arrives, then continues the model loop', async () => {
|
|
141
|
+
const s = state()
|
|
142
|
+
let resolveAnswer
|
|
143
|
+
let asked
|
|
144
|
+
s.ctx.askUser = args => {
|
|
145
|
+
asked = args
|
|
146
|
+
return new Promise(resolve => { resolveAnswer = resolve })
|
|
147
|
+
}
|
|
148
|
+
let round = 0
|
|
149
|
+
const turnPromise = runAgentTurn(s, 'task', strings, [], {
|
|
150
|
+
ui: quiet,
|
|
151
|
+
chat: async () => (++round === 1
|
|
152
|
+
? { content: '', toolCalls: [askCall()] }
|
|
153
|
+
: { content: 'finished', toolCalls: [] }),
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
157
|
+
assert.deepEqual(asked, { question: 'Which option?', options: ['A', 'B'], allowFreeText: false })
|
|
158
|
+
assert.equal(round, 1)
|
|
159
|
+
resolveAnswer('B')
|
|
160
|
+
|
|
161
|
+
const stats = await turnPromise
|
|
162
|
+
assert.equal(stats.status, 'completed')
|
|
163
|
+
assert.equal(round, 2)
|
|
164
|
+
assert.equal(s.messages.at(-1).content, 'finished')
|
|
165
|
+
assert.equal(s.messages.find(message => message.role === 'tool').content, 'B')
|
|
166
|
+
})
|
|
167
|
+
|
|
136
168
|
test('cancellation during a tool skips the remaining batch and keeps valid tool history', async () => {
|
|
137
169
|
const s = state(), executed = []
|
|
138
170
|
const stats = await runAgentTurn(s, 'task', strings, [], { ui: quiet,
|
|
@@ -0,0 +1,207 @@
|
|
|
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 的 Skill 加载层。
|
|
16
|
+
//
|
|
17
|
+
// Skill 的形态就是「一个目录 + 内含 SKILL.md(YAML frontmatter: name / description)」,
|
|
18
|
+
// 与 Claude Code、Cursor 等工具共用同一套约定 —— 所以智能体页面里从广场装下来的
|
|
19
|
+
// skill 不需要任何转换就能被 g ai 直接读。
|
|
20
|
+
//
|
|
21
|
+
// 两级作用域(与安装目标一一对应):
|
|
22
|
+
// - 项目级 <cwd>/.claude/skills/<id>/SKILL.md 仅该项目可见
|
|
23
|
+
// - 全局级 ~/.zen-gitsync/ai/skills/<id>/SKILL.md 所有项目可见
|
|
24
|
+
// 同名时**项目级覆盖全局级**(与本仓库其他"项目配置优先"的口径一致)。
|
|
25
|
+
//
|
|
26
|
+
// 渐进式披露(progressive disclosure):
|
|
27
|
+
// 这里只把「名字 + 描述 + SKILL.md 路径」塞进系统提示词(每个 skill 约 30~50 token),
|
|
28
|
+
// 不注入正文。模型判断某个 skill 与当前任务相关时,自己用 read_file 去读那个路径。
|
|
29
|
+
// 这样装 50 个 skill 也不会把上下文撑爆,而且是本仓库已有工具集的零成本复用。
|
|
30
|
+
|
|
31
|
+
import { promises as fs } from 'node:fs';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import { AI_SKILLS_DIR } from '../../paths.js';
|
|
34
|
+
|
|
35
|
+
// 单个 skill 目录下必须存在的入口文件名
|
|
36
|
+
export const SKILL_ENTRY = 'SKILL.md';
|
|
37
|
+
|
|
38
|
+
// 名字/描述的长度上限 —— 防止某个仓库里的 SKILL.md 写了 500 字的 description
|
|
39
|
+
// 把系统提示词挤爆。截断而不是丢弃,保证 skill 至少还能被看到。
|
|
40
|
+
const MAX_NAME_CHARS = 80;
|
|
41
|
+
const MAX_DESCRIPTION_CHARS = 400;
|
|
42
|
+
// 一次注入的最大 skill 数。超出的按 name 排序截断,并在提示词里说明还有多少个。
|
|
43
|
+
const MAX_SKILLS_IN_PROMPT = 60;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 极简 frontmatter 解析。
|
|
47
|
+
*
|
|
48
|
+
* 只用支持 SKILL.md 里实际出现的形状:
|
|
49
|
+
* ---
|
|
50
|
+
* name: pdf
|
|
51
|
+
* description: "带引号的描述"
|
|
52
|
+
* description: 不带引号的描述
|
|
53
|
+
* description: >-
|
|
54
|
+
* 折叠成一行
|
|
55
|
+
* 的长描述
|
|
56
|
+
* ---
|
|
57
|
+
* 刻意不引入 yaml 依赖(本包 dependencies 保持精简),
|
|
58
|
+
* 也对不合法 frontmatter 保持宽容:解析不出来就退回空对象,由调用方用目录名兜底。
|
|
59
|
+
*
|
|
60
|
+
* @param {string} raw - 文件全文
|
|
61
|
+
* @returns {{ attrs: Record<string,string>, body: string }}
|
|
62
|
+
*/
|
|
63
|
+
export function parseFrontmatter(raw) {
|
|
64
|
+
const text = String(raw || '').replace(/^\uFEFF/, '');
|
|
65
|
+
const match = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
|
|
66
|
+
if (!match) return { attrs: {}, body: text };
|
|
67
|
+
|
|
68
|
+
const attrs = {};
|
|
69
|
+
const lines = match[1].split(/\r?\n/);
|
|
70
|
+
for (let i = 0; i < lines.length; i++) {
|
|
71
|
+
const kv = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(lines[i]);
|
|
72
|
+
if (!kv) continue;
|
|
73
|
+
const key = kv[1].toLowerCase();
|
|
74
|
+
let value = kv[2].trim();
|
|
75
|
+
|
|
76
|
+
// 折叠标量(> 或 >-):把后续所有缩进行用空格拼起来
|
|
77
|
+
if (/^[>|][+-]?$/.test(value)) {
|
|
78
|
+
const parts = [];
|
|
79
|
+
while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) {
|
|
80
|
+
parts.push(lines[++i].trim());
|
|
81
|
+
}
|
|
82
|
+
value = parts.join(value.startsWith('|') ? '\n' : ' ');
|
|
83
|
+
} else {
|
|
84
|
+
// 行内可能是个多行序列的开头(description: 后直接换行缩进),同样收进来
|
|
85
|
+
const parts = [value];
|
|
86
|
+
while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1]) && !/^\s*[A-Za-z_][A-Za-z0-9_-]*\s*:/.test(lines[i + 1])) {
|
|
87
|
+
parts.push(lines[++i].trim());
|
|
88
|
+
}
|
|
89
|
+
value = parts.filter(Boolean).join(' ').trim();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 去掉包裹的引号
|
|
93
|
+
value = value.replace(/^(['"])([\s\S]*)\1$/, '$2').trim();
|
|
94
|
+
if (value) attrs[key] = value;
|
|
95
|
+
}
|
|
96
|
+
return { attrs, body: text.slice(match[0].length) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function clamp(value, max) {
|
|
100
|
+
const text = String(value || '').trim();
|
|
101
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 目录名兜底转可读名:my-cool-skill → my cool skill
|
|
105
|
+
function prettifyDirName(dirName) {
|
|
106
|
+
return String(dirName || '').replace(/[-_]+/g, ' ').trim();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 读取一个 skills 根目录下的所有 skill。
|
|
111
|
+
* 单个 skill 读失败只跳过它,不影响同目录下其他 skill(一个坏文件不该让整批消失)。
|
|
112
|
+
*
|
|
113
|
+
* @param {string} root - skills 根目录(其下每个子目录是一个 skill)
|
|
114
|
+
* @param {'project'|'global'} scope
|
|
115
|
+
* @returns {Promise<Array<object>>}
|
|
116
|
+
*/
|
|
117
|
+
async function readSkillRoot(root, scope) {
|
|
118
|
+
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
119
|
+
const found = [];
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
if (!entry.isDirectory()) continue;
|
|
122
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
123
|
+
const file = path.join(root, entry.name, SKILL_ENTRY);
|
|
124
|
+
const raw = await fs.readFile(file, 'utf8').catch(() => '');
|
|
125
|
+
if (!raw.trim()) continue;
|
|
126
|
+
const { attrs } = parseFrontmatter(raw);
|
|
127
|
+
found.push({
|
|
128
|
+
id: entry.name,
|
|
129
|
+
name: clamp(attrs.name || prettifyDirName(entry.name) || entry.name, MAX_NAME_CHARS),
|
|
130
|
+
description: clamp(attrs.description || '', MAX_DESCRIPTION_CHARS),
|
|
131
|
+
dir: path.join(root, entry.name),
|
|
132
|
+
file,
|
|
133
|
+
scope,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return found;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 加载项目级 + 全局级 skill,项目级覆盖同名全局 skill。
|
|
141
|
+
*
|
|
142
|
+
* @param {{ cwd?: string }} [options]
|
|
143
|
+
* @returns {Promise<{ skills: Array<object>, errors: Array<{ root: string, message: string }> }>}
|
|
144
|
+
*/
|
|
145
|
+
export async function loadSkills({ cwd } = {}) {
|
|
146
|
+
const roots = [];
|
|
147
|
+
if (cwd) roots.push({ root: path.join(path.resolve(cwd), '.claude', 'skills'), scope: 'project' });
|
|
148
|
+
roots.push({ root: AI_SKILLS_DIR, scope: 'global' });
|
|
149
|
+
|
|
150
|
+
const skills = [];
|
|
151
|
+
const errors = [];
|
|
152
|
+
for (const { root, scope } of roots) {
|
|
153
|
+
try {
|
|
154
|
+
skills.push(...await readSkillRoot(root, scope));
|
|
155
|
+
} catch (err) {
|
|
156
|
+
errors.push({ root, message: err.message });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 项目级优先:全局的先放,项目级后放覆盖同 id 的
|
|
161
|
+
const byId = new Map();
|
|
162
|
+
for (const skill of skills) {
|
|
163
|
+
const existing = byId.get(skill.id);
|
|
164
|
+
if (!existing || skill.scope === 'project') byId.set(skill.id, skill);
|
|
165
|
+
}
|
|
166
|
+
const merged = [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
167
|
+
return { skills: merged, errors };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 把已安装的 skill 渲染成系统提示词片段。
|
|
172
|
+
* 没有 skill 时返回空串,调用方直接拼接到提示词末尾即可(不会留下空标题)。
|
|
173
|
+
*
|
|
174
|
+
* @param {Array<object>} skills
|
|
175
|
+
* @param {{ locale?: string }} [options]
|
|
176
|
+
* @returns {string}
|
|
177
|
+
*/
|
|
178
|
+
export function buildSkillsPrompt(skills, { locale } = {}) {
|
|
179
|
+
if (!Array.isArray(skills) || skills.length === 0) return '';
|
|
180
|
+
const zh = !String(locale || '').startsWith('en');
|
|
181
|
+
const shown = skills.slice(0, MAX_SKILLS_IN_PROMPT);
|
|
182
|
+
const omitted = skills.length - shown.length;
|
|
183
|
+
|
|
184
|
+
const lines = shown.map(skill => {
|
|
185
|
+
const scope = skill.scope === 'project' ? (zh ? '项目' : 'project') : (zh ? '全局' : 'global');
|
|
186
|
+
const desc = skill.description || (zh ? '(无描述)' : '(no description)');
|
|
187
|
+
return `- **${skill.name}** [${scope}] — ${desc}\n ${zh ? '说明书' : 'instructions'}: ${skill.file}`;
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
if (zh) {
|
|
191
|
+
return `
|
|
192
|
+
|
|
193
|
+
# 已安装的 Skill(技能说明书)
|
|
194
|
+
用户可以随时给 g ai 装新的 Skill。下面是当前可用的清单,**只列出了名字、用途和说明书路径**。
|
|
195
|
+
当某个 Skill 明显贴合当前任务时,先用 read_file 读它的说明书,再严格按说明书的步骤执行 —— 不要凭名字猜测它的做法。
|
|
196
|
+
${lines.join('\n')}${omitted > 0 ? `\n(还有 ${omitted} 个 Skill 未列出,用户可以缩小范围或自行告知)` : ''}`
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return `
|
|
200
|
+
|
|
201
|
+
# Installed skills
|
|
202
|
+
The user can install new skills for g ai at any time. Below is the current list — **name, purpose and the path to its instructions only**.
|
|
203
|
+
When a skill clearly matches the current task, read its instruction file with read_file first, then follow it literally. Never guess what a skill does from its name.
|
|
204
|
+
${lines.join('\n')}${omitted > 0 ? `\n(${omitted} more skills are not listed; the user can narrow it down or name one directly)` : ''}`
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export default { loadSkills, buildSkillsPrompt, parseFrontmatter, SKILL_ENTRY };
|
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
import { test } from 'node:test';
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { promises as fs } from 'node:fs';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
// paths.js 在加载时求值 homedir,必须在 import 之前把 HOME 指到沙箱
|
|
22
|
+
const sandboxHome = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-skills-home-'));
|
|
23
|
+
const sandboxProject = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-skills-project-'));
|
|
24
|
+
process.env.USERPROFILE = sandboxHome;
|
|
25
|
+
process.env.HOME = sandboxHome;
|
|
26
|
+
delete process.env.HOMEDRIVE;
|
|
27
|
+
delete process.env.HOMEPATH;
|
|
28
|
+
|
|
29
|
+
const { loadSkills, buildSkillsPrompt, parseFrontmatter } = await import('./skills.js');
|
|
30
|
+
const { AI_SKILLS_DIR } = await import('../../paths.js');
|
|
31
|
+
|
|
32
|
+
async function writeSkill(root, name, body) {
|
|
33
|
+
await fs.mkdir(path.join(root, name), { recursive: true });
|
|
34
|
+
await fs.writeFile(path.join(root, name, 'SKILL.md'), body, 'utf8');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test('parseFrontmatter: 覆盖 SKILL.md 里实际会出现的几种写法', () => {
|
|
38
|
+
assert.deepEqual(
|
|
39
|
+
parseFrontmatter('---\nname: pdf\ndescription: 处理 PDF\n---\n# body\n').attrs,
|
|
40
|
+
{ name: 'pdf', description: '处理 PDF' },
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// 带引号
|
|
44
|
+
assert.equal(
|
|
45
|
+
parseFrontmatter('---\ndescription: "带: 冒号的描述"\n---\n').attrs.description,
|
|
46
|
+
'带: 冒号的描述',
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// 折叠标量(>):续行拼成一行
|
|
50
|
+
const folded = parseFrontmatter('---\ndescription: >-\n 第一行\n 第二行\nname: x\n---\n');
|
|
51
|
+
assert.equal(folded.attrs.description, '第一行 第二行');
|
|
52
|
+
assert.equal(folded.attrs.name, 'x', '折叠块之后的键仍要能解析出来');
|
|
53
|
+
|
|
54
|
+
// 没有 frontmatter:整体当正文,不报错
|
|
55
|
+
const none = parseFrontmatter('# just markdown\n');
|
|
56
|
+
assert.deepEqual(none.attrs, {});
|
|
57
|
+
assert.equal(none.body, '# just markdown\n');
|
|
58
|
+
|
|
59
|
+
// 坏 frontmatter(没闭合)不应抛错
|
|
60
|
+
assert.deepEqual(parseFrontmatter('---\nname: x\n').attrs, {});
|
|
61
|
+
|
|
62
|
+
// BOM 不能挡住 frontmatter 识别
|
|
63
|
+
assert.equal(parseFrontmatter('\uFEFF---\nname: bom\n---\n').attrs.name, 'bom');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('loadSkills: 项目级覆盖同名全局级,并保留 scope 标记', async () => {
|
|
67
|
+
await writeSkill(AI_SKILLS_DIR, 'shared', '---\nname: 全局版\ndescription: from global\n---\n');
|
|
68
|
+
await writeSkill(AI_SKILLS_DIR, 'only-global', '---\nname: 只有全局\ndescription: g\n---\n');
|
|
69
|
+
await writeSkill(path.join(sandboxProject, '.claude', 'skills'), 'shared', '---\nname: 项目版\ndescription: from project\n---\n');
|
|
70
|
+
await writeSkill(path.join(sandboxProject, '.claude', 'skills'), 'only-project', '---\nname: 只有项目\ndescription: p\n---\n');
|
|
71
|
+
|
|
72
|
+
const { skills } = await loadSkills({ cwd: sandboxProject });
|
|
73
|
+
const byId = new Map(skills.map(skill => [skill.id, skill]));
|
|
74
|
+
|
|
75
|
+
assert.equal(byId.size, 3, 'shared 只应出现一次(被项目级覆盖)');
|
|
76
|
+
assert.equal(byId.get('shared').name, '项目版');
|
|
77
|
+
assert.equal(byId.get('shared').scope, 'project');
|
|
78
|
+
assert.equal(byId.get('only-global').scope, 'global');
|
|
79
|
+
assert.equal(byId.get('only-project').scope, 'project');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('loadSkills: 没有 frontmatter 时用目录名兜底,坏目录不影响其他 skill', async () => {
|
|
83
|
+
await writeSkill(AI_SKILLS_DIR, 'no-frontmatter', '# 只有正文\n');
|
|
84
|
+
await writeSkill(AI_SKILLS_DIR, 'my-cool-skill', '---\ndescription: d\n---\n');
|
|
85
|
+
// 有目录但没有 SKILL.md:应被跳过而不是产生一条空记录
|
|
86
|
+
await fs.mkdir(path.join(AI_SKILLS_DIR, 'empty-dir'), { recursive: true });
|
|
87
|
+
|
|
88
|
+
const { skills } = await loadSkills({ cwd: sandboxProject });
|
|
89
|
+
const byId = new Map(skills.map(skill => [skill.id, skill]));
|
|
90
|
+
|
|
91
|
+
assert.equal(byId.get('no-frontmatter').name, 'no frontmatter', '无 frontmatter 时用目录名兜底并转成可读形式');
|
|
92
|
+
assert.equal(byId.get('my-cool-skill').name, 'my cool skill', '目录名应转成可读形式');
|
|
93
|
+
assert.equal(byId.has('empty-dir'), false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('loadSkills: 没有配置任何 skill 时返回空数组而不是抛错', async () => {
|
|
97
|
+
const empty = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-skills-empty-'));
|
|
98
|
+
const { skills, errors } = await loadSkills({ cwd: empty });
|
|
99
|
+
// 全局目录里上面已经写了 skill,所以这里只断言"不抛错且结构正确"
|
|
100
|
+
assert.ok(Array.isArray(skills));
|
|
101
|
+
assert.ok(Array.isArray(errors));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('buildSkillsPrompt: 没有 skill 时返回空串(拼到提示词里不留空标题)', () => {
|
|
105
|
+
assert.equal(buildSkillsPrompt([], { locale: 'zh-CN' }), '');
|
|
106
|
+
assert.equal(buildSkillsPrompt(undefined, { locale: 'en' }), '');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('buildSkillsPrompt: 只注入名字/描述/路径,不注入正文(渐进式披露)', () => {
|
|
110
|
+
const skills = [
|
|
111
|
+
{ id: 'a', name: 'pdf', description: '处理 PDF', file: 'C:/x/pdf/SKILL.md', scope: 'project' },
|
|
112
|
+
{ id: 'b', name: 'docx', description: '', file: 'C:/y/docx/SKILL.md', scope: 'global' },
|
|
113
|
+
];
|
|
114
|
+
const zh = buildSkillsPrompt(skills, { locale: 'zh-CN' });
|
|
115
|
+
assert.match(zh, /已安装的 Skill/);
|
|
116
|
+
assert.match(zh, /pdf/);
|
|
117
|
+
assert.match(zh, /\[项目\]/);
|
|
118
|
+
assert.match(zh, /\[全局\]/);
|
|
119
|
+
assert.match(zh, /C:\/x\/pdf\/SKILL\.md/);
|
|
120
|
+
assert.match(zh, /\(无描述\)/, '空描述要有占位,不能让模型以为描述丢了');
|
|
121
|
+
|
|
122
|
+
const en = buildSkillsPrompt(skills, { locale: 'en-US' });
|
|
123
|
+
assert.match(en, /Installed skills/);
|
|
124
|
+
assert.match(en, /project/);
|
|
125
|
+
assert.doesNotMatch(en, /已安装/);
|
|
126
|
+
});
|
package/src/cli/ai/termui.js
CHANGED
|
@@ -156,6 +156,7 @@ export const SLASH_COMMANDS = [
|
|
|
156
156
|
{ cmd: '/think compact', descZh: '预览前 12 行思考', descEn: 'Preview the first 12 thinking lines' },
|
|
157
157
|
{ cmd: '/think off', descZh: '隐藏思考(不影响模型推理)', descEn: 'Hide thinking (model still reasons)' },
|
|
158
158
|
{ cmd: '/tools', descZh: '切换工具结果:精简 / 完整', descEn: 'Toggle compact / full tool output' },
|
|
159
|
+
{ cmd: '/skills', descZh: '查看已装 Skill 与 MCP', descEn: 'List installed skills / MCP' },
|
|
159
160
|
{ cmd: '/stats', descZh: '查看耗时与 Token 用量', descEn: 'Show timing and token usage' },
|
|
160
161
|
{ cmd: '/new', descZh: '开启新对话', descEn: 'Start a new chat' },
|
|
161
162
|
{ cmd: '/resume', descZh: '恢复之前的对话', descEn: 'Resume a previous chat' },
|