@ww_nero/mini-cli 1.0.81 → 1.0.82
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 +40 -40
- package/package.json +38 -38
- package/src/chat.js +1150 -1150
- package/src/config.js +342 -342
- package/src/index.js +39 -39
- package/src/llm.js +147 -147
- package/src/prompt/tool.js +18 -18
- package/src/request.js +338 -328
- package/src/tools/bash.js +144 -144
- package/src/tools/edit.js +137 -137
- package/src/tools/index.js +74 -74
- package/src/tools/mcp.js +503 -503
- package/src/tools/read.js +44 -44
- package/src/tools/skills.js +49 -49
- package/src/tools/todos.js +90 -90
- package/src/tools/write.js +66 -66
- package/src/utils/cliOptions.js +9 -9
- package/src/utils/git.js +89 -89
- package/src/utils/history.js +181 -181
- package/src/utils/model.js +131 -131
- package/src/utils/output.js +75 -75
- package/src/utils/renderer.js +102 -102
- package/src/utils/settings.js +77 -77
- package/src/utils/skills.js +250 -250
- package/src/utils/think.js +214 -214
package/src/utils/settings.js
CHANGED
|
@@ -1,77 +1,77 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const os = require('os');
|
|
3
|
-
const path = require('path');
|
|
4
|
-
|
|
5
|
-
const MINI_DIR_NAME = '.mini';
|
|
6
|
-
const SETTINGS_FILE_NAME = 'settings.json';
|
|
7
|
-
const DEFAULT_TOOL_OUTPUT_TOKEN_LIMIT = 32768;
|
|
8
|
-
|
|
9
|
-
const ensureArrayOfStrings = (value, fallback = []) => {
|
|
10
|
-
if (!Array.isArray(value)) {
|
|
11
|
-
return [...fallback];
|
|
12
|
-
}
|
|
13
|
-
return value.map(String).filter(Boolean);
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
const loadSettings = ({ defaultTools = [] } = {}) => {
|
|
17
|
-
const settingsPath = path.join(os.homedir(), MINI_DIR_NAME, SETTINGS_FILE_NAME);
|
|
18
|
-
const dir = path.dirname(settingsPath);
|
|
19
|
-
|
|
20
|
-
if (!fs.existsSync(dir)) {
|
|
21
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const defaultSettings = {
|
|
25
|
-
mcps: [],
|
|
26
|
-
tools: [...defaultTools],
|
|
27
|
-
toolOutputTokenLimit: DEFAULT_TOOL_OUTPUT_TOKEN_LIMIT
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
let parsed = null;
|
|
31
|
-
let created = false;
|
|
32
|
-
let needsUpdate = false;
|
|
33
|
-
|
|
34
|
-
if (!fs.existsSync(settingsPath)) {
|
|
35
|
-
fs.writeFileSync(settingsPath, `${JSON.stringify(defaultSettings, null, 2)}\n`, 'utf8');
|
|
36
|
-
parsed = defaultSettings;
|
|
37
|
-
created = true;
|
|
38
|
-
} else {
|
|
39
|
-
try {
|
|
40
|
-
const raw = fs.readFileSync(settingsPath, 'utf8') || '{}';
|
|
41
|
-
parsed = JSON.parse(raw);
|
|
42
|
-
|
|
43
|
-
if (typeof parsed.toolOutputTokenLimit !== 'number') {
|
|
44
|
-
parsed.toolOutputTokenLimit = DEFAULT_TOOL_OUTPUT_TOKEN_LIMIT;
|
|
45
|
-
needsUpdate = true;
|
|
46
|
-
}
|
|
47
|
-
} catch (error) {
|
|
48
|
-
parsed = defaultSettings;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const settings = {
|
|
53
|
-
mcps: ensureArrayOfStrings(parsed.mcps, defaultSettings.mcps),
|
|
54
|
-
tools: ensureArrayOfStrings(parsed.tools, defaultSettings.tools),
|
|
55
|
-
toolOutputTokenLimit: typeof parsed.toolOutputTokenLimit === 'number' && parsed.toolOutputTokenLimit > 0
|
|
56
|
-
? parsed.toolOutputTokenLimit
|
|
57
|
-
: DEFAULT_TOOL_OUTPUT_TOKEN_LIMIT
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
if (needsUpdate) {
|
|
61
|
-
try {
|
|
62
|
-
fs.writeFileSync(settingsPath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf8');
|
|
63
|
-
} catch (error) {
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return {
|
|
68
|
-
settingsPath,
|
|
69
|
-
settings,
|
|
70
|
-
created
|
|
71
|
-
};
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
module.exports = {
|
|
75
|
-
loadSettings,
|
|
76
|
-
DEFAULT_TOOL_OUTPUT_TOKEN_LIMIT
|
|
77
|
-
};
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const MINI_DIR_NAME = '.mini';
|
|
6
|
+
const SETTINGS_FILE_NAME = 'settings.json';
|
|
7
|
+
const DEFAULT_TOOL_OUTPUT_TOKEN_LIMIT = 32768;
|
|
8
|
+
|
|
9
|
+
const ensureArrayOfStrings = (value, fallback = []) => {
|
|
10
|
+
if (!Array.isArray(value)) {
|
|
11
|
+
return [...fallback];
|
|
12
|
+
}
|
|
13
|
+
return value.map(String).filter(Boolean);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const loadSettings = ({ defaultTools = [] } = {}) => {
|
|
17
|
+
const settingsPath = path.join(os.homedir(), MINI_DIR_NAME, SETTINGS_FILE_NAME);
|
|
18
|
+
const dir = path.dirname(settingsPath);
|
|
19
|
+
|
|
20
|
+
if (!fs.existsSync(dir)) {
|
|
21
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const defaultSettings = {
|
|
25
|
+
mcps: [],
|
|
26
|
+
tools: [...defaultTools],
|
|
27
|
+
toolOutputTokenLimit: DEFAULT_TOOL_OUTPUT_TOKEN_LIMIT
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
let parsed = null;
|
|
31
|
+
let created = false;
|
|
32
|
+
let needsUpdate = false;
|
|
33
|
+
|
|
34
|
+
if (!fs.existsSync(settingsPath)) {
|
|
35
|
+
fs.writeFileSync(settingsPath, `${JSON.stringify(defaultSettings, null, 2)}\n`, 'utf8');
|
|
36
|
+
parsed = defaultSettings;
|
|
37
|
+
created = true;
|
|
38
|
+
} else {
|
|
39
|
+
try {
|
|
40
|
+
const raw = fs.readFileSync(settingsPath, 'utf8') || '{}';
|
|
41
|
+
parsed = JSON.parse(raw);
|
|
42
|
+
|
|
43
|
+
if (typeof parsed.toolOutputTokenLimit !== 'number') {
|
|
44
|
+
parsed.toolOutputTokenLimit = DEFAULT_TOOL_OUTPUT_TOKEN_LIMIT;
|
|
45
|
+
needsUpdate = true;
|
|
46
|
+
}
|
|
47
|
+
} catch (error) {
|
|
48
|
+
parsed = defaultSettings;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const settings = {
|
|
53
|
+
mcps: ensureArrayOfStrings(parsed.mcps, defaultSettings.mcps),
|
|
54
|
+
tools: ensureArrayOfStrings(parsed.tools, defaultSettings.tools),
|
|
55
|
+
toolOutputTokenLimit: typeof parsed.toolOutputTokenLimit === 'number' && parsed.toolOutputTokenLimit > 0
|
|
56
|
+
? parsed.toolOutputTokenLimit
|
|
57
|
+
: DEFAULT_TOOL_OUTPUT_TOKEN_LIMIT
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (needsUpdate) {
|
|
61
|
+
try {
|
|
62
|
+
fs.writeFileSync(settingsPath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf8');
|
|
63
|
+
} catch (error) {
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
settingsPath,
|
|
69
|
+
settings,
|
|
70
|
+
created
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
module.exports = {
|
|
75
|
+
loadSettings,
|
|
76
|
+
DEFAULT_TOOL_OUTPUT_TOKEN_LIMIT
|
|
77
|
+
};
|
package/src/utils/skills.js
CHANGED
|
@@ -1,250 +1,250 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const os = require('os');
|
|
3
|
-
const path = require('path');
|
|
4
|
-
|
|
5
|
-
const MINI_DIR_NAME = '.mini';
|
|
6
|
-
const SKILLS_DIR_NAME = 'skills';
|
|
7
|
-
const SKILL_ENTRY_FILE = 'SKILL.md';
|
|
8
|
-
const MAX_DESCRIPTION_LENGTH = 160;
|
|
9
|
-
|
|
10
|
-
const getSkillsRoot = () => path.join(os.homedir(), MINI_DIR_NAME, SKILLS_DIR_NAME);
|
|
11
|
-
|
|
12
|
-
const toDisplayPath = (targetPath = '') => {
|
|
13
|
-
const absolutePath = path.resolve(String(targetPath || ''));
|
|
14
|
-
const homePath = path.resolve(os.homedir());
|
|
15
|
-
const relativePath = path.relative(homePath, absolutePath);
|
|
16
|
-
|
|
17
|
-
if (relativePath === '') {
|
|
18
|
-
return '~';
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
if (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)) {
|
|
22
|
-
return `~/${relativePath.split(path.sep).join('/')}`;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
return absolutePath;
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
const isPathInside = (basePath, targetPath) => {
|
|
29
|
-
const relativePath = path.relative(path.resolve(basePath), path.resolve(targetPath));
|
|
30
|
-
return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const trimDescription = (text = '') => {
|
|
34
|
-
const normalized = String(text || '').replace(/\s+/g, ' ').trim();
|
|
35
|
-
if (!normalized) {
|
|
36
|
-
return '';
|
|
37
|
-
}
|
|
38
|
-
if (normalized.length <= MAX_DESCRIPTION_LENGTH) {
|
|
39
|
-
return normalized;
|
|
40
|
-
}
|
|
41
|
-
return `${normalized.slice(0, MAX_DESCRIPTION_LENGTH)}...`;
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
const extractDescription = (content = '') => {
|
|
45
|
-
const normalized = String(content || '').replace(/\r/g, '');
|
|
46
|
-
if (!normalized.trim()) {
|
|
47
|
-
return '';
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const taggedPattern = /^\s*(?:description|desc|简介|说明)\s*[::]\s*(.+)$/im;
|
|
51
|
-
const taggedMatch = normalized.match(taggedPattern);
|
|
52
|
-
if (taggedMatch && taggedMatch[1]) {
|
|
53
|
-
return trimDescription(taggedMatch[1]);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const lines = normalized.split('\n');
|
|
57
|
-
let inCodeBlock = false;
|
|
58
|
-
|
|
59
|
-
for (const rawLine of lines) {
|
|
60
|
-
const line = rawLine.trim();
|
|
61
|
-
|
|
62
|
-
if (line.startsWith('```')) {
|
|
63
|
-
inCodeBlock = !inCodeBlock;
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (!line || inCodeBlock) {
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (
|
|
72
|
-
line.startsWith('#')
|
|
73
|
-
|| line.startsWith('-')
|
|
74
|
-
|| line.startsWith('*')
|
|
75
|
-
|| line.startsWith('>')
|
|
76
|
-
|| /^\d+[.)]\s/.test(line)
|
|
77
|
-
) {
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return trimDescription(line);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return '';
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
const discoverSkills = () => {
|
|
88
|
-
const skillsRoot = getSkillsRoot();
|
|
89
|
-
|
|
90
|
-
if (!fs.existsSync(skillsRoot)) {
|
|
91
|
-
return {
|
|
92
|
-
skillsRoot,
|
|
93
|
-
skills: []
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
let entries = [];
|
|
98
|
-
|
|
99
|
-
try {
|
|
100
|
-
entries = fs.readdirSync(skillsRoot, { withFileTypes: true });
|
|
101
|
-
} catch (error) {
|
|
102
|
-
return {
|
|
103
|
-
skillsRoot,
|
|
104
|
-
skills: [],
|
|
105
|
-
error: `读取 skills 目录失败:${error.message}`
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const skills = entries
|
|
110
|
-
.filter((entry) => entry && entry.isDirectory() && !entry.name.startsWith('.'))
|
|
111
|
-
.map((entry) => {
|
|
112
|
-
const skillDirPath = path.join(skillsRoot, entry.name);
|
|
113
|
-
const entryFilePath = path.join(skillDirPath, SKILL_ENTRY_FILE);
|
|
114
|
-
|
|
115
|
-
if (!fs.existsSync(entryFilePath)) {
|
|
116
|
-
return null;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
let description = '';
|
|
120
|
-
try {
|
|
121
|
-
const content = fs.readFileSync(entryFilePath, 'utf8');
|
|
122
|
-
description = extractDescription(content);
|
|
123
|
-
} catch (_) {
|
|
124
|
-
description = '';
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return {
|
|
128
|
-
name: entry.name,
|
|
129
|
-
description,
|
|
130
|
-
dirPath: skillDirPath,
|
|
131
|
-
entryFilePath
|
|
132
|
-
};
|
|
133
|
-
})
|
|
134
|
-
.filter(Boolean)
|
|
135
|
-
.sort((left, right) => left.name.localeCompare(right.name));
|
|
136
|
-
|
|
137
|
-
return {
|
|
138
|
-
skillsRoot,
|
|
139
|
-
skills
|
|
140
|
-
};
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
const resolveSkillDirectory = (skillsRoot, skillName) => {
|
|
144
|
-
if (typeof skillName !== 'string' || !skillName.trim()) {
|
|
145
|
-
throw new Error('skillName 参数不能为空');
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const normalizedName = skillName.trim();
|
|
149
|
-
const skillDirPath = path.resolve(skillsRoot, normalizedName);
|
|
150
|
-
|
|
151
|
-
if (!isPathInside(skillsRoot, skillDirPath)) {
|
|
152
|
-
throw new Error('skillName 非法,不能超出 skills 目录');
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
if (path.resolve(path.dirname(skillDirPath)) !== path.resolve(skillsRoot)) {
|
|
156
|
-
throw new Error('skillName 必须是 skills 目录下的直接子目录名');
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (!fs.existsSync(skillDirPath) || !fs.statSync(skillDirPath).isDirectory()) {
|
|
160
|
-
throw new Error(`skill 不存在: ${normalizedName}`);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
return {
|
|
164
|
-
skillName: normalizedName,
|
|
165
|
-
skillDirPath
|
|
166
|
-
};
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
const resolveSkillFilePath = (skillDirPath, filePath = SKILL_ENTRY_FILE) => {
|
|
170
|
-
const normalizedFilePath = typeof filePath === 'string' && filePath.trim()
|
|
171
|
-
? filePath.trim()
|
|
172
|
-
: SKILL_ENTRY_FILE;
|
|
173
|
-
|
|
174
|
-
if (path.isAbsolute(normalizedFilePath)) {
|
|
175
|
-
throw new Error('filePath 必须是相对于 skill 目录的路径');
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const absoluteFilePath = path.resolve(skillDirPath, normalizedFilePath);
|
|
179
|
-
|
|
180
|
-
if (!isPathInside(skillDirPath, absoluteFilePath)) {
|
|
181
|
-
throw new Error('filePath 非法,不能超出 skill 目录');
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
if (!fs.existsSync(absoluteFilePath) || !fs.statSync(absoluteFilePath).isFile()) {
|
|
185
|
-
throw new Error(`文件不存在: ${normalizedFilePath}`);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const relativeFilePath = path.relative(skillDirPath, absoluteFilePath).split(path.sep).join('/');
|
|
189
|
-
|
|
190
|
-
return {
|
|
191
|
-
absoluteFilePath,
|
|
192
|
-
relativeFilePath
|
|
193
|
-
};
|
|
194
|
-
};
|
|
195
|
-
|
|
196
|
-
const readSkillFile = ({ skillName, filePath } = {}) => {
|
|
197
|
-
const skillsRoot = getSkillsRoot();
|
|
198
|
-
if (!fs.existsSync(skillsRoot)) {
|
|
199
|
-
throw new Error(`skills 目录不存在: ${toDisplayPath(skillsRoot)}`);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const { skillName: normalizedSkillName, skillDirPath } = resolveSkillDirectory(skillsRoot, skillName);
|
|
203
|
-
const { absoluteFilePath, relativeFilePath } = resolveSkillFilePath(skillDirPath, filePath);
|
|
204
|
-
const content = fs.readFileSync(absoluteFilePath, 'utf8');
|
|
205
|
-
|
|
206
|
-
return {
|
|
207
|
-
skillsRoot,
|
|
208
|
-
skillName: normalizedSkillName,
|
|
209
|
-
skillDirPath,
|
|
210
|
-
filePath: relativeFilePath,
|
|
211
|
-
absoluteFilePath,
|
|
212
|
-
content
|
|
213
|
-
};
|
|
214
|
-
};
|
|
215
|
-
|
|
216
|
-
const buildSkillsSystemPrompt = (snapshot = {}) => {
|
|
217
|
-
const skills = Array.isArray(snapshot.skills) ? snapshot.skills : [];
|
|
218
|
-
if (skills.length === 0) {
|
|
219
|
-
return '';
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const lines = [
|
|
223
|
-
'<skills>',
|
|
224
|
-
`skills 目录: ${toDisplayPath(snapshot.skillsRoot || getSkillsRoot())}`,
|
|
225
|
-
'当用户明确提到某个 skill,或任务明显匹配某个 skill 描述时,优先使用该 skill。',
|
|
226
|
-
'skill 使用流程:',
|
|
227
|
-
'1. 先调用 skills 工具读取对应 skill 的 SKILL.md。',
|
|
228
|
-
'2. 若 SKILL.md 引用其他文件,再调用 skills 工具按需读取。',
|
|
229
|
-
'3. 仅加载当前任务需要的内容,避免无关读取。',
|
|
230
|
-
'可用 skills:'
|
|
231
|
-
];
|
|
232
|
-
|
|
233
|
-
skills.forEach((skill) => {
|
|
234
|
-
const description = skill.description || '(暂无描述)';
|
|
235
|
-
lines.push(`- ${skill.name}: ${description}`);
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
lines.push('</skills>');
|
|
239
|
-
|
|
240
|
-
return lines.join('\n');
|
|
241
|
-
};
|
|
242
|
-
|
|
243
|
-
module.exports = {
|
|
244
|
-
SKILL_ENTRY_FILE,
|
|
245
|
-
getSkillsRoot,
|
|
246
|
-
discoverSkills,
|
|
247
|
-
readSkillFile,
|
|
248
|
-
toDisplayPath,
|
|
249
|
-
buildSkillsSystemPrompt
|
|
250
|
-
};
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const MINI_DIR_NAME = '.mini';
|
|
6
|
+
const SKILLS_DIR_NAME = 'skills';
|
|
7
|
+
const SKILL_ENTRY_FILE = 'SKILL.md';
|
|
8
|
+
const MAX_DESCRIPTION_LENGTH = 160;
|
|
9
|
+
|
|
10
|
+
const getSkillsRoot = () => path.join(os.homedir(), MINI_DIR_NAME, SKILLS_DIR_NAME);
|
|
11
|
+
|
|
12
|
+
const toDisplayPath = (targetPath = '') => {
|
|
13
|
+
const absolutePath = path.resolve(String(targetPath || ''));
|
|
14
|
+
const homePath = path.resolve(os.homedir());
|
|
15
|
+
const relativePath = path.relative(homePath, absolutePath);
|
|
16
|
+
|
|
17
|
+
if (relativePath === '') {
|
|
18
|
+
return '~';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)) {
|
|
22
|
+
return `~/${relativePath.split(path.sep).join('/')}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return absolutePath;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const isPathInside = (basePath, targetPath) => {
|
|
29
|
+
const relativePath = path.relative(path.resolve(basePath), path.resolve(targetPath));
|
|
30
|
+
return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const trimDescription = (text = '') => {
|
|
34
|
+
const normalized = String(text || '').replace(/\s+/g, ' ').trim();
|
|
35
|
+
if (!normalized) {
|
|
36
|
+
return '';
|
|
37
|
+
}
|
|
38
|
+
if (normalized.length <= MAX_DESCRIPTION_LENGTH) {
|
|
39
|
+
return normalized;
|
|
40
|
+
}
|
|
41
|
+
return `${normalized.slice(0, MAX_DESCRIPTION_LENGTH)}...`;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const extractDescription = (content = '') => {
|
|
45
|
+
const normalized = String(content || '').replace(/\r/g, '');
|
|
46
|
+
if (!normalized.trim()) {
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const taggedPattern = /^\s*(?:description|desc|简介|说明)\s*[::]\s*(.+)$/im;
|
|
51
|
+
const taggedMatch = normalized.match(taggedPattern);
|
|
52
|
+
if (taggedMatch && taggedMatch[1]) {
|
|
53
|
+
return trimDescription(taggedMatch[1]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const lines = normalized.split('\n');
|
|
57
|
+
let inCodeBlock = false;
|
|
58
|
+
|
|
59
|
+
for (const rawLine of lines) {
|
|
60
|
+
const line = rawLine.trim();
|
|
61
|
+
|
|
62
|
+
if (line.startsWith('```')) {
|
|
63
|
+
inCodeBlock = !inCodeBlock;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!line || inCodeBlock) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (
|
|
72
|
+
line.startsWith('#')
|
|
73
|
+
|| line.startsWith('-')
|
|
74
|
+
|| line.startsWith('*')
|
|
75
|
+
|| line.startsWith('>')
|
|
76
|
+
|| /^\d+[.)]\s/.test(line)
|
|
77
|
+
) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return trimDescription(line);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return '';
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const discoverSkills = () => {
|
|
88
|
+
const skillsRoot = getSkillsRoot();
|
|
89
|
+
|
|
90
|
+
if (!fs.existsSync(skillsRoot)) {
|
|
91
|
+
return {
|
|
92
|
+
skillsRoot,
|
|
93
|
+
skills: []
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let entries = [];
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
entries = fs.readdirSync(skillsRoot, { withFileTypes: true });
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return {
|
|
103
|
+
skillsRoot,
|
|
104
|
+
skills: [],
|
|
105
|
+
error: `读取 skills 目录失败:${error.message}`
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const skills = entries
|
|
110
|
+
.filter((entry) => entry && entry.isDirectory() && !entry.name.startsWith('.'))
|
|
111
|
+
.map((entry) => {
|
|
112
|
+
const skillDirPath = path.join(skillsRoot, entry.name);
|
|
113
|
+
const entryFilePath = path.join(skillDirPath, SKILL_ENTRY_FILE);
|
|
114
|
+
|
|
115
|
+
if (!fs.existsSync(entryFilePath)) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let description = '';
|
|
120
|
+
try {
|
|
121
|
+
const content = fs.readFileSync(entryFilePath, 'utf8');
|
|
122
|
+
description = extractDescription(content);
|
|
123
|
+
} catch (_) {
|
|
124
|
+
description = '';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
name: entry.name,
|
|
129
|
+
description,
|
|
130
|
+
dirPath: skillDirPath,
|
|
131
|
+
entryFilePath
|
|
132
|
+
};
|
|
133
|
+
})
|
|
134
|
+
.filter(Boolean)
|
|
135
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
skillsRoot,
|
|
139
|
+
skills
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const resolveSkillDirectory = (skillsRoot, skillName) => {
|
|
144
|
+
if (typeof skillName !== 'string' || !skillName.trim()) {
|
|
145
|
+
throw new Error('skillName 参数不能为空');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const normalizedName = skillName.trim();
|
|
149
|
+
const skillDirPath = path.resolve(skillsRoot, normalizedName);
|
|
150
|
+
|
|
151
|
+
if (!isPathInside(skillsRoot, skillDirPath)) {
|
|
152
|
+
throw new Error('skillName 非法,不能超出 skills 目录');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (path.resolve(path.dirname(skillDirPath)) !== path.resolve(skillsRoot)) {
|
|
156
|
+
throw new Error('skillName 必须是 skills 目录下的直接子目录名');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!fs.existsSync(skillDirPath) || !fs.statSync(skillDirPath).isDirectory()) {
|
|
160
|
+
throw new Error(`skill 不存在: ${normalizedName}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
skillName: normalizedName,
|
|
165
|
+
skillDirPath
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const resolveSkillFilePath = (skillDirPath, filePath = SKILL_ENTRY_FILE) => {
|
|
170
|
+
const normalizedFilePath = typeof filePath === 'string' && filePath.trim()
|
|
171
|
+
? filePath.trim()
|
|
172
|
+
: SKILL_ENTRY_FILE;
|
|
173
|
+
|
|
174
|
+
if (path.isAbsolute(normalizedFilePath)) {
|
|
175
|
+
throw new Error('filePath 必须是相对于 skill 目录的路径');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const absoluteFilePath = path.resolve(skillDirPath, normalizedFilePath);
|
|
179
|
+
|
|
180
|
+
if (!isPathInside(skillDirPath, absoluteFilePath)) {
|
|
181
|
+
throw new Error('filePath 非法,不能超出 skill 目录');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!fs.existsSync(absoluteFilePath) || !fs.statSync(absoluteFilePath).isFile()) {
|
|
185
|
+
throw new Error(`文件不存在: ${normalizedFilePath}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const relativeFilePath = path.relative(skillDirPath, absoluteFilePath).split(path.sep).join('/');
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
absoluteFilePath,
|
|
192
|
+
relativeFilePath
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const readSkillFile = ({ skillName, filePath } = {}) => {
|
|
197
|
+
const skillsRoot = getSkillsRoot();
|
|
198
|
+
if (!fs.existsSync(skillsRoot)) {
|
|
199
|
+
throw new Error(`skills 目录不存在: ${toDisplayPath(skillsRoot)}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const { skillName: normalizedSkillName, skillDirPath } = resolveSkillDirectory(skillsRoot, skillName);
|
|
203
|
+
const { absoluteFilePath, relativeFilePath } = resolveSkillFilePath(skillDirPath, filePath);
|
|
204
|
+
const content = fs.readFileSync(absoluteFilePath, 'utf8');
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
skillsRoot,
|
|
208
|
+
skillName: normalizedSkillName,
|
|
209
|
+
skillDirPath,
|
|
210
|
+
filePath: relativeFilePath,
|
|
211
|
+
absoluteFilePath,
|
|
212
|
+
content
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const buildSkillsSystemPrompt = (snapshot = {}) => {
|
|
217
|
+
const skills = Array.isArray(snapshot.skills) ? snapshot.skills : [];
|
|
218
|
+
if (skills.length === 0) {
|
|
219
|
+
return '';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const lines = [
|
|
223
|
+
'<skills>',
|
|
224
|
+
`skills 目录: ${toDisplayPath(snapshot.skillsRoot || getSkillsRoot())}`,
|
|
225
|
+
'当用户明确提到某个 skill,或任务明显匹配某个 skill 描述时,优先使用该 skill。',
|
|
226
|
+
'skill 使用流程:',
|
|
227
|
+
'1. 先调用 skills 工具读取对应 skill 的 SKILL.md。',
|
|
228
|
+
'2. 若 SKILL.md 引用其他文件,再调用 skills 工具按需读取。',
|
|
229
|
+
'3. 仅加载当前任务需要的内容,避免无关读取。',
|
|
230
|
+
'可用 skills:'
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
skills.forEach((skill) => {
|
|
234
|
+
const description = skill.description || '(暂无描述)';
|
|
235
|
+
lines.push(`- ${skill.name}: ${description}`);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
lines.push('</skills>');
|
|
239
|
+
|
|
240
|
+
return lines.join('\n');
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
module.exports = {
|
|
244
|
+
SKILL_ENTRY_FILE,
|
|
245
|
+
getSkillsRoot,
|
|
246
|
+
discoverSkills,
|
|
247
|
+
readSkillFile,
|
|
248
|
+
toDisplayPath,
|
|
249
|
+
buildSkillsSystemPrompt
|
|
250
|
+
};
|