@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ww_nero/mini-cli",
3
- "version": "1.0.77",
3
+ "version": "1.0.78",
4
4
  "description": "极简的 AI 命令行助手",
5
5
  "bin": {
6
6
  "mini": "bin/mini.js"
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 systemPromptContent = appendMiniInstructions(toolSystemPrompt, workspaceRoot);
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 秒返回初始输出,进程继续运行并持续捕获输出;默认等待命令执行完成,超时为 300 秒。');
109
+ descriptionParts.push('isService=true 时在后台运行服务,等待 5 秒返回初始输出,之后服务进程继续运行;默认等待命令执行完成,超时为 300 秒。');
110
110
 
111
111
  return {
112
112
  type: 'function',
@@ -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
+ };