@ww_nero/mini-cli 1.0.77 → 1.0.78
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 +16 -0
- package/package.json +1 -1
- package/src/chat.js +37 -1
- package/src/tools/bash.js +1 -1
- package/src/tools/index.js +2 -1
- package/src/tools/skills.js +49 -0
- package/src/utils/skills.js +250 -0
package/README.md
CHANGED
|
@@ -22,3 +22,19 @@
|
|
|
22
22
|
## 会话历史
|
|
23
23
|
- 每个工作目录的历史记录保存在 `~/.mini/cli/<workspace-hash>/YYYYMMDDHHMMSS.json` 中。
|
|
24
24
|
- 可通过 `mini --resume` 自动载入最近一次会话,或在对话中使用 `/resume` 按序号恢复。
|
|
25
|
+
|
|
26
|
+
## Skills 支持
|
|
27
|
+
- `mini` 会自动扫描 `~/.mini/skills/*/SKILL.md` 作为可用 skills。
|
|
28
|
+
- 当你在提问中明确提到某个 skill 名称,或任务明显匹配某个 skill 描述时,模型会优先读取并遵循对应 skill。
|
|
29
|
+
- 运行时可通过内置 `skills` 工具读取指定 skill 下的文件(默认 `SKILL.md`)。
|
|
30
|
+
- 推荐目录结构示例:
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
~/.mini/skills/
|
|
34
|
+
algorithmic-art/
|
|
35
|
+
SKILL.md
|
|
36
|
+
assets/
|
|
37
|
+
scripts/
|
|
38
|
+
docx/
|
|
39
|
+
SKILL.md
|
|
40
|
+
```
|
package/package.json
CHANGED
package/src/chat.js
CHANGED
|
@@ -38,6 +38,7 @@ const {
|
|
|
38
38
|
refreshHistoryFilePath
|
|
39
39
|
} = require('./utils/history');
|
|
40
40
|
const { selectFromList } = require('./utils/menu');
|
|
41
|
+
const { discoverSkills, buildSkillsSystemPrompt } = require('./utils/skills');
|
|
41
42
|
|
|
42
43
|
const CURSOR_STYLE_CODES = {
|
|
43
44
|
default: '\u001B[0 q',
|
|
@@ -81,6 +82,25 @@ const appendMiniInstructions = (baseContent, workspaceRoot) => {
|
|
|
81
82
|
return `${baseContent}\n\n${sections.join('\n\n')}`;
|
|
82
83
|
};
|
|
83
84
|
|
|
85
|
+
const appendSkillsInstructions = (baseContent) => {
|
|
86
|
+
const snapshot = discoverSkills();
|
|
87
|
+
const skillsSection = buildSkillsSystemPrompt(snapshot);
|
|
88
|
+
|
|
89
|
+
if (!skillsSection) {
|
|
90
|
+
return {
|
|
91
|
+
content: baseContent,
|
|
92
|
+
snapshot,
|
|
93
|
+
enabled: false
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
content: `${baseContent}\n\n${skillsSection}`,
|
|
99
|
+
snapshot,
|
|
100
|
+
enabled: true
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
84
104
|
const formatWriteOutput = (result) => {
|
|
85
105
|
if (!result || typeof result !== 'object' || !result.success) {
|
|
86
106
|
return null;
|
|
@@ -254,7 +274,9 @@ const startChatSession = async ({
|
|
|
254
274
|
if (initialCommitCreated) {
|
|
255
275
|
console.log(chalk.gray('已创建初始提交:init project。'));
|
|
256
276
|
}
|
|
257
|
-
const
|
|
277
|
+
const basePromptContent = appendMiniInstructions(toolSystemPrompt, workspaceRoot);
|
|
278
|
+
const skillsPromptResult = appendSkillsInstructions(basePromptContent);
|
|
279
|
+
const systemPromptContent = skillsPromptResult.content;
|
|
258
280
|
|
|
259
281
|
// Load settings to get toolOutputTokenLimit and compactTokenThreshold
|
|
260
282
|
const { settings } = loadSettings();
|
|
@@ -557,6 +579,15 @@ const startChatSession = async ({
|
|
|
557
579
|
console.log(chalk.gray('未启用任何 MCP 服务器'));
|
|
558
580
|
}
|
|
559
581
|
|
|
582
|
+
if (skillsPromptResult.snapshot && skillsPromptResult.snapshot.error) {
|
|
583
|
+
console.log(chalk.yellow(`skills 初始化失败: ${skillsPromptResult.snapshot.error}`));
|
|
584
|
+
} else if (skillsPromptResult.enabled) {
|
|
585
|
+
const skillNames = (skillsPromptResult.snapshot.skills || []).map((item) => item.name);
|
|
586
|
+
console.log(chalk.gray(`可用 skills (${skillNames.length}): ${skillNames.join(', ')}`));
|
|
587
|
+
} else {
|
|
588
|
+
console.log(chalk.gray('未发现可用 skills(可在 ~/.mini/skills/<skill>/SKILL.md 添加)'));
|
|
589
|
+
}
|
|
590
|
+
|
|
560
591
|
const startupOptions = (Array.isArray(cliOptions) && cliOptions.length > 0) ? cliOptions : CLI_OPTIONS;
|
|
561
592
|
if (startupOptions.length > 0) {
|
|
562
593
|
console.log(chalk.gray('启动参数:'));
|
|
@@ -976,6 +1007,11 @@ const startChatSession = async ({
|
|
|
976
1007
|
if (toolContent) {
|
|
977
1008
|
console.log(chalk.gray(` ${toolContent}`));
|
|
978
1009
|
}
|
|
1010
|
+
} else if (functionName === 'skills') {
|
|
1011
|
+
const preview = truncateForDisplay(toolContent, 200);
|
|
1012
|
+
if (preview) {
|
|
1013
|
+
console.log(chalk.gray(` ${preview}`));
|
|
1014
|
+
}
|
|
979
1015
|
} else {
|
|
980
1016
|
// For other tools, show preview in gray
|
|
981
1017
|
const preview = truncateForDisplay(toolContent, 100);
|
package/src/tools/bash.js
CHANGED
|
@@ -106,7 +106,7 @@ const executeCommand = async ({ command, workingDirectory = '.', isService = fal
|
|
|
106
106
|
|
|
107
107
|
const createBashToolSchema = () => {
|
|
108
108
|
const descriptionParts = ['在指定目录运行 bash 命令,支持使用 && / || 连接多个命令。'];
|
|
109
|
-
descriptionParts.push('isService=true 时在后台运行服务,等待 5
|
|
109
|
+
descriptionParts.push('isService=true 时在后台运行服务,等待 5 秒返回初始输出,之后服务进程继续运行;默认等待命令执行完成,超时为 300 秒。');
|
|
110
110
|
|
|
111
111
|
return {
|
|
112
112
|
type: 'function',
|
package/src/tools/index.js
CHANGED
|
@@ -3,10 +3,11 @@ const read = require('./read');
|
|
|
3
3
|
const write = require('./write');
|
|
4
4
|
const replace = require('./replace');
|
|
5
5
|
const todos = require('./todos');
|
|
6
|
+
const skills = require('./skills');
|
|
6
7
|
const { createMcpManager } = require('./mcp');
|
|
7
8
|
const { loadSettings } = require('../config');
|
|
8
9
|
|
|
9
|
-
const TOOL_MODULES = [bash, read, write, replace, todos];
|
|
10
|
+
const TOOL_MODULES = [bash, read, write, replace, todos, skills];
|
|
10
11
|
|
|
11
12
|
const createToolRuntime = async (workspaceRoot, options = {}) => {
|
|
12
13
|
const defaultToolNames = TOOL_MODULES.map((tool) => tool.name);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const { readSkillFile, toDisplayPath, SKILL_ENTRY_FILE } = require('../utils/skills');
|
|
2
|
+
|
|
3
|
+
const handler = async ({ skillName, filePath } = {}) => {
|
|
4
|
+
try {
|
|
5
|
+
const result = readSkillFile({
|
|
6
|
+
skillName,
|
|
7
|
+
filePath
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
success: true,
|
|
12
|
+
skillsRoot: toDisplayPath(result.skillsRoot),
|
|
13
|
+
skillName: result.skillName,
|
|
14
|
+
filePath: result.filePath,
|
|
15
|
+
absolutePath: result.absoluteFilePath,
|
|
16
|
+
content: result.content
|
|
17
|
+
};
|
|
18
|
+
} catch (error) {
|
|
19
|
+
return `读取 skill 失败:${error.message}`;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const schema = {
|
|
24
|
+
type: 'function',
|
|
25
|
+
function: {
|
|
26
|
+
name: 'skills',
|
|
27
|
+
description: '读取 ~/.mini/skills 下指定 skill 的文件内容(默认入口文件为 SKILL.md)',
|
|
28
|
+
parameters: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
skillName: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
description: 'skill 名称'
|
|
34
|
+
},
|
|
35
|
+
filePath: {
|
|
36
|
+
type: 'string',
|
|
37
|
+
description: `相对于 skill 目录的文件路径,默认 ${SKILL_ENTRY_FILE}`
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
required: ['skillName']
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
module.exports = {
|
|
46
|
+
name: 'skills',
|
|
47
|
+
schema,
|
|
48
|
+
handler
|
|
49
|
+
};
|
|
@@ -0,0 +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
|
+
};
|