@ww_nero/mini-cli 1.0.76 → 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/config.js +0 -12
- package/src/tools/bash.js +2 -99
- package/src/tools/index.js +2 -3
- package/src/tools/skills.js +49 -0
- package/src/utils/settings.js +3 -16
- 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/config.js
CHANGED
|
@@ -12,12 +12,6 @@ const FILE_NAMES = {
|
|
|
12
12
|
mini: 'MINI.md'
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
-
const DEFAULT_ALLOWED_COMMANDS = [
|
|
16
|
-
'rm', 'rmdir', 'touch', 'mkdir', 'cd', 'cp', 'mv', 'node', 'npm', 'pkill', 'kill',
|
|
17
|
-
'curl', 'ls', 'pwd', 'grep', 'cat', 'echo', 'sed', 'head', 'tail', 'find', 'true',
|
|
18
|
-
'false', 'pip', 'python', 'ps', 'lsof', 'git', 'pandoc'
|
|
19
|
-
];
|
|
20
|
-
|
|
21
15
|
const DEFAULT_TOOL_RESPONSE_MAX_TOKENS = 65536;
|
|
22
16
|
const DEFAULT_COMPACT_TOKEN_THRESHOLD = 65536;
|
|
23
17
|
const DEFAULT_MCP_TOOL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
@@ -91,7 +85,6 @@ const DEFAULT_SETTINGS = {
|
|
|
91
85
|
commands: [],
|
|
92
86
|
maxToolTokens: DEFAULT_TOOL_RESPONSE_MAX_TOKENS,
|
|
93
87
|
compactTokenThreshold: DEFAULT_COMPACT_TOKEN_THRESHOLD,
|
|
94
|
-
allowedCommands: [...DEFAULT_ALLOWED_COMMANDS],
|
|
95
88
|
mcpToolTimeout: DEFAULT_MCP_TOOL_TIMEOUT_MS,
|
|
96
89
|
outputMaxLength: DEFAULT_OUTPUT_MAX_LENGTH,
|
|
97
90
|
executionTimeout: DEFAULT_EXECUTION_TIMEOUT,
|
|
@@ -345,10 +338,6 @@ const loadSettings = ({ defaultTools = [] } = {}) => {
|
|
|
345
338
|
parsed.maxToolTokens,
|
|
346
339
|
DEFAULT_TOOL_RESPONSE_MAX_TOKENS
|
|
347
340
|
),
|
|
348
|
-
allowedCommands: (() => {
|
|
349
|
-
const list = ensureArrayOfStrings(parsed.allowedCommands);
|
|
350
|
-
return list.length ? Array.from(new Set(list)) : [...DEFAULT_ALLOWED_COMMANDS];
|
|
351
|
-
})(),
|
|
352
341
|
compactTokenThreshold: normalizePositiveInteger(
|
|
353
342
|
parsed.compactTokenThreshold,
|
|
354
343
|
DEFAULT_COMPACT_TOKEN_THRESHOLD
|
|
@@ -392,7 +381,6 @@ module.exports = {
|
|
|
392
381
|
loadSettings,
|
|
393
382
|
ensureConfigFiles,
|
|
394
383
|
getConfigPath,
|
|
395
|
-
DEFAULT_ALLOWED_COMMANDS,
|
|
396
384
|
DEFAULT_TOOL_RESPONSE_MAX_TOKENS,
|
|
397
385
|
DEFAULT_COMPACT_TOKEN_THRESHOLD,
|
|
398
386
|
COMPACT_SUMMARY_PROMPT
|
package/src/tools/bash.js
CHANGED
|
@@ -1,76 +1,5 @@
|
|
|
1
1
|
const { spawn } = require('child_process');
|
|
2
2
|
const { resolveWorkspacePath } = require('../utils/helpers');
|
|
3
|
-
const { DEFAULT_ALLOWED_COMMANDS } = require('../config');
|
|
4
|
-
|
|
5
|
-
// Git 只读命令白名单
|
|
6
|
-
const GIT_READONLY_COMMANDS = ['show', 'diff', 'log', 'status', 'branch', 'tag', 'ls-files', 'ls-tree', 'rev-parse', 'reflog', 'blame', 'shortlog', 'describe', 'config --get', 'config --list', 'remote', 'ls-remote', 'fetch --dry-run', 'grep'];
|
|
7
|
-
|
|
8
|
-
// Git 禁止的命令(会修改状态的命令)
|
|
9
|
-
const GIT_FORBIDDEN_COMMANDS = ['add', 'commit', 'push', 'pull', 'merge', 'rebase', 'reset', 'revert', 'cherry-pick', 'apply', 'stash', 'clean', 'rm', 'mv', 'checkout', 'switch', 'restore', 'tag -a', 'tag -d', 'branch -d', 'branch -D', 'config --add', 'config --unset', 'submodule', 'clone', 'init'];
|
|
10
|
-
|
|
11
|
-
const splitShellCommands = (commandString = '') => {
|
|
12
|
-
const operators = ['&&', '||'];
|
|
13
|
-
const pattern = '(' + operators.map(op => op.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')';
|
|
14
|
-
return commandString
|
|
15
|
-
.split(new RegExp(pattern))
|
|
16
|
-
.map(part => part.trim())
|
|
17
|
-
.filter(part => part && !operators.includes(part));
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
const validateSingleCommand = (commandString = '', allowedCommands = []) => {
|
|
21
|
-
const parts = commandString.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
|
|
22
|
-
if (parts.length === 0) {
|
|
23
|
-
return { isValid: false, reason: '命令为空' };
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const command = parts[0];
|
|
27
|
-
if (!allowedCommands.includes(command)) {
|
|
28
|
-
return { isValid: false, reason: `命令 ${command} 不在允许名单内` };
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
if ((command === 'rm' || command === 'rmdir') && parts.slice(1).some(arg => !arg.startsWith('-') && arg.includes('.git'))) {
|
|
32
|
-
return { isValid: false, reason: '禁止对 .git 目录执行删除操作' };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Git 命令额外验证
|
|
36
|
-
if (command === 'git') {
|
|
37
|
-
if (parts.length < 2) {
|
|
38
|
-
return { isValid: false, reason: 'git 命令缺少子命令' };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const gitSubCommand = parts[1];
|
|
42
|
-
const gitArgs = parts.slice(2).join(' ');
|
|
43
|
-
const fullGitCommand = gitSubCommand + (gitArgs ? ' ' + gitArgs : '');
|
|
44
|
-
|
|
45
|
-
// 检查是否是禁止的命令
|
|
46
|
-
for (const forbidden of GIT_FORBIDDEN_COMMANDS) {
|
|
47
|
-
if (gitSubCommand === forbidden || fullGitCommand.startsWith(forbidden)) {
|
|
48
|
-
return {
|
|
49
|
-
isValid: false,
|
|
50
|
-
reason: `禁止执行会修改仓库状态的 git 命令: ${forbidden}。只允许执行只读命令如: ${GIT_READONLY_COMMANDS.join(', ')}`
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// 检查是否是允许的只读命令
|
|
56
|
-
let isReadOnly = false;
|
|
57
|
-
for (const readonly of GIT_READONLY_COMMANDS) {
|
|
58
|
-
if (gitSubCommand === readonly || fullGitCommand.startsWith(readonly)) {
|
|
59
|
-
isReadOnly = true;
|
|
60
|
-
break;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (!isReadOnly) {
|
|
65
|
-
return {
|
|
66
|
-
isValid: false,
|
|
67
|
-
reason: `git 子命令 ${gitSubCommand} 不在只读命令白名单内。允许的命令: ${GIT_READONLY_COMMANDS.join(', ')}`
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
return { isValid: true, reason: '' };
|
|
73
|
-
};
|
|
74
3
|
|
|
75
4
|
const executeCommand = async ({ command, workingDirectory = '.', isService = false } = {}, context = {}) => {
|
|
76
5
|
if (!command || typeof command !== 'string' || !command.trim()) {
|
|
@@ -79,30 +8,9 @@ const executeCommand = async ({ command, workingDirectory = '.', isService = fal
|
|
|
79
8
|
|
|
80
9
|
const normalizedCommand = command.replace(/\bpython3\b/g, 'python');
|
|
81
10
|
|
|
82
|
-
const allowedCommands = Array.isArray(context.allowedCommands) && context.allowedCommands.length > 0
|
|
83
|
-
? context.allowedCommands
|
|
84
|
-
: DEFAULT_ALLOWED_COMMANDS;
|
|
85
|
-
|
|
86
11
|
const outputMaxLength = context.outputMaxLength || 12000;
|
|
87
12
|
const executionTimeout = context.executionTimeout || 300000;
|
|
88
13
|
const serviceBootWindow = context.serviceBootWindow || 5000;
|
|
89
|
-
const commands = splitShellCommands(normalizedCommand);
|
|
90
|
-
if (commands.length === 0) {
|
|
91
|
-
return '未找到有效的命令';
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
for (let i = 0; i < commands.length; i += 1) {
|
|
95
|
-
const validation = validateSingleCommand(commands[i], allowedCommands);
|
|
96
|
-
if (!validation.isValid) {
|
|
97
|
-
return [
|
|
98
|
-
'命令未通过安全校验',
|
|
99
|
-
`第 ${i + 1} 个指令: ${commands[i]}`,
|
|
100
|
-
`原因: ${validation.reason}`,
|
|
101
|
-
'允许的命令: ' + allowedCommands.join(', '),
|
|
102
|
-
'附加限制: rm/rmdir 禁止修改`.git`目录'
|
|
103
|
-
].join('\n');
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
14
|
|
|
107
15
|
let cwd;
|
|
108
16
|
try {
|
|
@@ -196,14 +104,9 @@ const executeCommand = async ({ command, workingDirectory = '.', isService = fal
|
|
|
196
104
|
});
|
|
197
105
|
};
|
|
198
106
|
|
|
199
|
-
const createBashToolSchema = (
|
|
200
|
-
const allowedCommands = Array.isArray(context.allowedCommands) && context.allowedCommands.length > 0
|
|
201
|
-
? context.allowedCommands
|
|
202
|
-
: DEFAULT_ALLOWED_COMMANDS;
|
|
107
|
+
const createBashToolSchema = () => {
|
|
203
108
|
const descriptionParts = ['在指定目录运行 bash 命令,支持使用 && / || 连接多个命令。'];
|
|
204
|
-
descriptionParts.push(
|
|
205
|
-
descriptionParts.push('git 命令仅支持只读操作(show/diff/log/status 等),严禁执行 add/commit/push/reset 等会修改仓库状态的命令。');
|
|
206
|
-
descriptionParts.push('isService=true 时在后台运行服务,等待 5 秒返回初始输出,进程继续运行并持续捕获输出;默认等待命令执行完成,超时为 300 秒。');
|
|
109
|
+
descriptionParts.push('isService=true 时在后台运行服务,等待 5 秒返回初始输出,之后服务进程继续运行;默认等待命令执行完成,超时为 300 秒。');
|
|
207
110
|
|
|
208
111
|
return {
|
|
209
112
|
type: 'function',
|
package/src/tools/index.js
CHANGED
|
@@ -3,21 +3,20 @@ 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);
|
|
13
14
|
const { settings } = loadSettings({ defaultTools: defaultToolNames });
|
|
14
15
|
const enabledTools = new Set(settings.tools && settings.tools.length > 0 ? settings.tools : defaultToolNames);
|
|
15
16
|
const enabledMcps = Array.isArray(settings.mcps) ? settings.mcps : [];
|
|
16
|
-
const allowedCommands = settings.allowedCommands;
|
|
17
17
|
|
|
18
18
|
const context = {
|
|
19
19
|
workspaceRoot,
|
|
20
|
-
allowedCommands,
|
|
21
20
|
outputMaxLength: settings.outputMaxLength,
|
|
22
21
|
executionTimeout: settings.executionTimeout,
|
|
23
22
|
serviceBootWindow: settings.serviceBootWindow,
|
|
@@ -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
|
+
};
|
package/src/utils/settings.js
CHANGED
|
@@ -5,11 +5,6 @@ const path = require('path');
|
|
|
5
5
|
const MINI_DIR_NAME = '.mini';
|
|
6
6
|
const SETTINGS_FILE_NAME = 'settings.json';
|
|
7
7
|
const DEFAULT_TOOL_OUTPUT_TOKEN_LIMIT = 32768;
|
|
8
|
-
const DEFAULT_ALLOWED_COMMANDS = [
|
|
9
|
-
'rm', 'rmdir', 'touch', 'mkdir', 'cd', 'cp', 'mv', 'node', 'npm', 'ls',
|
|
10
|
-
'grep', 'cat', 'echo', 'sed', 'head', 'tail', 'find', 'true', 'false',
|
|
11
|
-
'pkill', 'kill', 'curl', 'ps', 'lsof', 'git', 'pip', 'python', 'pandoc'
|
|
12
|
-
];
|
|
13
8
|
|
|
14
9
|
const ensureArrayOfStrings = (value, fallback = []) => {
|
|
15
10
|
if (!Array.isArray(value)) {
|
|
@@ -29,8 +24,7 @@ const loadSettings = ({ defaultTools = [] } = {}) => {
|
|
|
29
24
|
const defaultSettings = {
|
|
30
25
|
mcps: [],
|
|
31
26
|
tools: [...defaultTools],
|
|
32
|
-
toolOutputTokenLimit: DEFAULT_TOOL_OUTPUT_TOKEN_LIMIT
|
|
33
|
-
allowedCommands: [...DEFAULT_ALLOWED_COMMANDS]
|
|
27
|
+
toolOutputTokenLimit: DEFAULT_TOOL_OUTPUT_TOKEN_LIMIT
|
|
34
28
|
};
|
|
35
29
|
|
|
36
30
|
let parsed = null;
|
|
@@ -50,11 +44,6 @@ const loadSettings = ({ defaultTools = [] } = {}) => {
|
|
|
50
44
|
parsed.toolOutputTokenLimit = DEFAULT_TOOL_OUTPUT_TOKEN_LIMIT;
|
|
51
45
|
needsUpdate = true;
|
|
52
46
|
}
|
|
53
|
-
|
|
54
|
-
if (!Array.isArray(parsed.allowedCommands)) {
|
|
55
|
-
parsed.allowedCommands = [...DEFAULT_ALLOWED_COMMANDS];
|
|
56
|
-
needsUpdate = true;
|
|
57
|
-
}
|
|
58
47
|
} catch (error) {
|
|
59
48
|
parsed = defaultSettings;
|
|
60
49
|
}
|
|
@@ -65,8 +54,7 @@ const loadSettings = ({ defaultTools = [] } = {}) => {
|
|
|
65
54
|
tools: ensureArrayOfStrings(parsed.tools, defaultSettings.tools),
|
|
66
55
|
toolOutputTokenLimit: typeof parsed.toolOutputTokenLimit === 'number' && parsed.toolOutputTokenLimit > 0
|
|
67
56
|
? parsed.toolOutputTokenLimit
|
|
68
|
-
: DEFAULT_TOOL_OUTPUT_TOKEN_LIMIT
|
|
69
|
-
allowedCommands: ensureArrayOfStrings(parsed.allowedCommands, DEFAULT_ALLOWED_COMMANDS)
|
|
57
|
+
: DEFAULT_TOOL_OUTPUT_TOKEN_LIMIT
|
|
70
58
|
};
|
|
71
59
|
|
|
72
60
|
if (needsUpdate) {
|
|
@@ -85,6 +73,5 @@ const loadSettings = ({ defaultTools = [] } = {}) => {
|
|
|
85
73
|
|
|
86
74
|
module.exports = {
|
|
87
75
|
loadSettings,
|
|
88
|
-
DEFAULT_TOOL_OUTPUT_TOKEN_LIMIT
|
|
89
|
-
DEFAULT_ALLOWED_COMMANDS
|
|
76
|
+
DEFAULT_TOOL_OUTPUT_TOKEN_LIMIT
|
|
90
77
|
};
|
|
@@ -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
|
+
};
|