computer_mcp 1.0.1
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 +168 -0
- package/package.json +26 -0
- package/server.js +168 -0
- package/tools/bash.js +147 -0
- package/tools/file.js +188 -0
- package/tools/interactive.js +232 -0
- package/tools/skills.js +147 -0
package/README.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# Computer MCP Server
|
|
2
|
+
|
|
3
|
+
一个基于 [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) 的 Streamable HTTP 服务器,提供文件操作和命令执行功能。
|
|
4
|
+
|
|
5
|
+
## 功能特性
|
|
6
|
+
|
|
7
|
+
- ✅ **执行命令** - 运行系统命令并实时返回输出
|
|
8
|
+
- ✅ **读取文件** - 读取文本文件内容
|
|
9
|
+
- ✅ **写入文件** - 创建或覆盖文本文件
|
|
10
|
+
- ✅ **修改文件** - 搜索替换文件内容(支持正则表达式)
|
|
11
|
+
- ✅ **实时通知** - 命令执行过程中发送 MCP 通知消息
|
|
12
|
+
- ✅ **Streamable HTTP** - 使用 HTTP 流式传输协议
|
|
13
|
+
|
|
14
|
+
## 安装
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## 启动服务器
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# 生产模式
|
|
24
|
+
npm start
|
|
25
|
+
|
|
26
|
+
# 开发模式(自动重启)
|
|
27
|
+
npm run dev
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
服务器默认运行在端口 `3000`,可以通过环境变量 `PORT` 修改:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
PORT=8080 npm start
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 端点
|
|
37
|
+
|
|
38
|
+
- **MCP 端点**: `POST http://localhost:3000/mcp`
|
|
39
|
+
- **健康检查**: `GET http://localhost:3000/health`
|
|
40
|
+
|
|
41
|
+
## 工具说明
|
|
42
|
+
|
|
43
|
+
### 1. execute_command - 执行命令
|
|
44
|
+
|
|
45
|
+
执行系统命令并返回结果。执行过程中会发送多条通知消息。
|
|
46
|
+
|
|
47
|
+
**参数:**
|
|
48
|
+
- `command` (必需): 要执行的命令
|
|
49
|
+
- `args` (可选): 命令参数数组
|
|
50
|
+
- `cwd` (可选): 工作目录
|
|
51
|
+
- `timeout` (可选): 超时时间(毫秒,默认 30000)
|
|
52
|
+
|
|
53
|
+
**示例:**
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"command": "ls",
|
|
57
|
+
"args": ["-la"],
|
|
58
|
+
"cwd": "/home/user"
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**返回:**
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"exitCode": 0,
|
|
66
|
+
"stdout": "...",
|
|
67
|
+
"stderr": "...",
|
|
68
|
+
"success": true
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### 2. read_file - 读取文件
|
|
73
|
+
|
|
74
|
+
读取文本文件的内容。
|
|
75
|
+
|
|
76
|
+
**参数:**
|
|
77
|
+
- `path` (必需): 文件路径
|
|
78
|
+
- `encoding` (可选): 文件编码(默认 utf-8)
|
|
79
|
+
|
|
80
|
+
**示例:**
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"path": "/path/to/file.txt",
|
|
84
|
+
"encoding": "utf-8"
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 3. write_file - 写入文件
|
|
89
|
+
|
|
90
|
+
写入内容到文本文件,如果文件已存在会被覆盖。
|
|
91
|
+
|
|
92
|
+
**参数:**
|
|
93
|
+
- `path` (必需): 文件路径
|
|
94
|
+
- `content` (必需): 要写入的内容
|
|
95
|
+
- `encoding` (可选): 文件编码(默认 utf-8)
|
|
96
|
+
- `create_dirs` (可选): 是否创建目录(默认 false)
|
|
97
|
+
|
|
98
|
+
**示例:**
|
|
99
|
+
```json
|
|
100
|
+
{
|
|
101
|
+
"path": "/path/to/file.txt",
|
|
102
|
+
"content": "Hello, World!",
|
|
103
|
+
"create_dirs": true
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### 4. edit_file - 修改文件
|
|
108
|
+
|
|
109
|
+
修改文本文件的内容,支持搜索替换。
|
|
110
|
+
|
|
111
|
+
**参数:**
|
|
112
|
+
- `path` (必需): 文件路径
|
|
113
|
+
- `search` (必需): 要搜索的文本
|
|
114
|
+
- `replace` (必需): 替换后的文本
|
|
115
|
+
- `regex` (可选): 是否使用正则表达式(默认 false)
|
|
116
|
+
- `all` (可选): 是否替换所有匹配项(默认 true)
|
|
117
|
+
- `encoding` (可选): 文件编码(默认 utf-8)
|
|
118
|
+
|
|
119
|
+
**示例:**
|
|
120
|
+
```json
|
|
121
|
+
{
|
|
122
|
+
"path": "/path/to/file.txt",
|
|
123
|
+
"search": "old text",
|
|
124
|
+
"replace": "new text",
|
|
125
|
+
"all": true
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## 通知消息
|
|
130
|
+
|
|
131
|
+
在执行命令时,服务器会发送多条 MCP 通知消息:
|
|
132
|
+
|
|
133
|
+
- **info**: 命令开始执行、执行完成
|
|
134
|
+
- **debug**: 标准输出内容
|
|
135
|
+
- **warning**: 标准错误输出、超时警告
|
|
136
|
+
- **error**: 执行错误
|
|
137
|
+
|
|
138
|
+
## Docker 部署
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
# 构建镜像
|
|
142
|
+
./dockerBuild.sh
|
|
143
|
+
|
|
144
|
+
# 运行容器
|
|
145
|
+
docker run -d -p 3000:3000 computer-mcp-server
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## 测试
|
|
149
|
+
|
|
150
|
+
使用 `curl` 测试健康检查:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
curl http://localhost:3000/health
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
测试 MCP 端点需要发送符合 MCP 协议的 JSON-RPC 请求。
|
|
157
|
+
|
|
158
|
+
## 技术栈
|
|
159
|
+
|
|
160
|
+
- Node.js
|
|
161
|
+
- Express.js
|
|
162
|
+
- @modelcontextprotocol/sdk
|
|
163
|
+
- ES Modules
|
|
164
|
+
|
|
165
|
+
## 许可证
|
|
166
|
+
|
|
167
|
+
ISC
|
|
168
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "computer_mcp",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "MCP Streamable HTTP Server with file and command execution tools",
|
|
5
|
+
"main": "server.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"computer-mcp": "./server.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"server.js",
|
|
12
|
+
"tools"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"start": "node server.js",
|
|
16
|
+
"dev": "node --watch server.js",
|
|
17
|
+
"test": "node test_mcp.js"
|
|
18
|
+
},
|
|
19
|
+
"author": "",
|
|
20
|
+
"license": "ISC",
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@modelcontextprotocol/sdk": "^1.24.1",
|
|
23
|
+
"cors": "^2.8.5",
|
|
24
|
+
"express": "^5.2.1"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/server.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
5
|
+
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/index.js";
|
|
6
|
+
import express from "express";
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { registerBashTool } from './tools/bash.js';
|
|
9
|
+
import { registerFileTools } from './tools/file.js';
|
|
10
|
+
import { registerInteractiveBashTool } from './tools/interactive.js';
|
|
11
|
+
import { loadSkills, getSkillDetails } from './tools/skills.js';
|
|
12
|
+
|
|
13
|
+
// 创建 MCP 服务器
|
|
14
|
+
const server = new McpServer(
|
|
15
|
+
{
|
|
16
|
+
name: 'computer-mcp-server',
|
|
17
|
+
version: '1.0.0'
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
capabilities: {
|
|
21
|
+
logging: {},
|
|
22
|
+
tools: {}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
// 注册工具
|
|
28
|
+
registerInteractiveBashTool(server);
|
|
29
|
+
// registerBashTool(server);
|
|
30
|
+
registerFileTools(server);
|
|
31
|
+
|
|
32
|
+
// // 创建 Express 应用
|
|
33
|
+
// const app = createMcpExpressApp();
|
|
34
|
+
const app = express();
|
|
35
|
+
|
|
36
|
+
// 健康检查端点
|
|
37
|
+
app.get('/health', (req, res) => {
|
|
38
|
+
res.json({
|
|
39
|
+
status: 'ok',
|
|
40
|
+
server: 'computer-mcp-server',
|
|
41
|
+
version: '1.0.0',
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// 技能列表端点
|
|
46
|
+
app.get('/skills', async (req, res) => {
|
|
47
|
+
try {
|
|
48
|
+
// 从 query 参数中获取 ids,支持 ?ids=id1,id2,id3 或 ?ids[]=id1&ids[]=id2
|
|
49
|
+
let ids = [];
|
|
50
|
+
if (req.query.ids) {
|
|
51
|
+
if (Array.isArray(req.query.ids)) {
|
|
52
|
+
ids = req.query.ids;
|
|
53
|
+
} else if (typeof req.query.ids === 'string') {
|
|
54
|
+
// 支持逗号分隔的 ID
|
|
55
|
+
ids = req.query.ids.split(',').map(id => id.trim()).filter(id => id);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const skills = await loadSkills(ids);
|
|
60
|
+
res.json({
|
|
61
|
+
success: true,
|
|
62
|
+
skills: skills
|
|
63
|
+
});
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error('Error loading skills:', error);
|
|
66
|
+
res.status(500).json({
|
|
67
|
+
success: false,
|
|
68
|
+
error: error.message
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// 获取单个技能详情
|
|
74
|
+
app.get('/skills/:name', async (req, res) => {
|
|
75
|
+
try {
|
|
76
|
+
const skillName = req.params.name;
|
|
77
|
+
|
|
78
|
+
// 从 query 参数中获取 ids
|
|
79
|
+
let ids = [];
|
|
80
|
+
if (req.query.ids) {
|
|
81
|
+
if (Array.isArray(req.query.ids)) {
|
|
82
|
+
ids = req.query.ids;
|
|
83
|
+
} else if (typeof req.query.ids === 'string') {
|
|
84
|
+
// 支持逗号分隔的 ID
|
|
85
|
+
ids = req.query.ids.split(',').map(id => id.trim()).filter(id => id);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const skill = await getSkillDetails(skillName, ids);
|
|
90
|
+
|
|
91
|
+
if (!skill) {
|
|
92
|
+
res.status(404).json({
|
|
93
|
+
success: false,
|
|
94
|
+
error: 'Skill not found'
|
|
95
|
+
});
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
res.json({
|
|
100
|
+
success: true,
|
|
101
|
+
skill: skill
|
|
102
|
+
});
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.error('Error getting skill details:', error);
|
|
105
|
+
res.status(500).json({
|
|
106
|
+
success: false,
|
|
107
|
+
error: error.message
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// 管理传输层的 Map
|
|
113
|
+
const transports = new Map();
|
|
114
|
+
|
|
115
|
+
// MCP 端点处理
|
|
116
|
+
app.all('/mcp', async (req, res) => {
|
|
117
|
+
try {
|
|
118
|
+
// 重用现有传输层或创建新的
|
|
119
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
120
|
+
let transport = sessionId ? transports.get(sessionId) : undefined;
|
|
121
|
+
|
|
122
|
+
if (!transport) {
|
|
123
|
+
transport = new StreamableHTTPServerTransport({
|
|
124
|
+
sessionIdGenerator: () => randomUUID(),
|
|
125
|
+
retryInterval: 2000, // 默认重试间隔用于 priming events
|
|
126
|
+
onsessioninitialized: id => {
|
|
127
|
+
console.log(`[${id}] Session initialized`);
|
|
128
|
+
transports.set(id, transport);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
await server.connect(transport);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
await transport.handleRequest(req, res, req.body);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
console.error('Error handling MCP request:', error);
|
|
137
|
+
if (!res.headersSent) {
|
|
138
|
+
res.status(500).json({
|
|
139
|
+
jsonrpc: '2.0',
|
|
140
|
+
error: {
|
|
141
|
+
code: -32603,
|
|
142
|
+
message: 'Internal server error'
|
|
143
|
+
},
|
|
144
|
+
id: null
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// 启动服务器
|
|
151
|
+
const PORT = process.env.PORT || 8000;
|
|
152
|
+
app.listen(PORT, error => {
|
|
153
|
+
if (error) {
|
|
154
|
+
console.error('Failed to start server:', error);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
console.log(`MCP Streamable HTTP 服务器运行在端口 ${PORT}`);
|
|
158
|
+
console.log(`端点: http://localhost:${PORT}/mcp`);
|
|
159
|
+
console.log(`健康检查: http://localhost:${PORT}/health`);
|
|
160
|
+
console.log(`技能列表: http://localhost:${PORT}/skills`);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// 处理服务器关闭
|
|
164
|
+
process.on('SIGINT', async () => {
|
|
165
|
+
console.log('Shutting down server...');
|
|
166
|
+
process.exit(0);
|
|
167
|
+
});
|
|
168
|
+
|
package/tools/bash.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import * as z from 'zod/v4';
|
|
3
|
+
|
|
4
|
+
const MAX_OUTPUT_LINES = 100;
|
|
5
|
+
const MAX_OUTPUT_CHARS = 5000;
|
|
6
|
+
|
|
7
|
+
function trimCommandOutput(output) {
|
|
8
|
+
if (!output) {
|
|
9
|
+
return {
|
|
10
|
+
text: '',
|
|
11
|
+
truncated: false,
|
|
12
|
+
reasons: [],
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const lines = output.split(/\r?\n/);
|
|
17
|
+
const lineTruncated = lines.length > MAX_OUTPUT_LINES;
|
|
18
|
+
const tailLines = lines.slice(-MAX_OUTPUT_LINES).join('\n');
|
|
19
|
+
const charTruncated = tailLines.length > MAX_OUTPUT_CHARS;
|
|
20
|
+
|
|
21
|
+
let text = tailLines;
|
|
22
|
+
if (charTruncated) {
|
|
23
|
+
text = tailLines.slice(-MAX_OUTPUT_CHARS);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const reasons = [];
|
|
27
|
+
if (lineTruncated) {
|
|
28
|
+
reasons.push(`已截断为最后 ${MAX_OUTPUT_LINES} 行`);
|
|
29
|
+
}
|
|
30
|
+
if (charTruncated) {
|
|
31
|
+
reasons.push(`已截断为最后 ${MAX_OUTPUT_CHARS} 字符`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (reasons.length > 0) {
|
|
35
|
+
const notice = `[输出已截断] ${reasons.join(',')}\n`;
|
|
36
|
+
text = `${notice}${text}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
text,
|
|
41
|
+
truncated: reasons.length > 0,
|
|
42
|
+
reasons,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 注册 bash 命令执行工具
|
|
48
|
+
* @param {McpServer} server - MCP 服务器实例
|
|
49
|
+
*/
|
|
50
|
+
export function registerBashTool(server) {
|
|
51
|
+
server.tool(
|
|
52
|
+
'bash',
|
|
53
|
+
'执行系统命令。可以运行任何 shell 命令并返回输出结果。',
|
|
54
|
+
{
|
|
55
|
+
command: z.string().describe('要执行的命令'),
|
|
56
|
+
args: z.array(z.string()).describe('命令参数(可选)').optional(),
|
|
57
|
+
cwd: z.string().describe('工作目录(可选)').optional()
|
|
58
|
+
},
|
|
59
|
+
async ({ command, args = [], cwd = process.cwd() }, extra) => {
|
|
60
|
+
// 发送通知的函数
|
|
61
|
+
const sendNotification = async (params) => {
|
|
62
|
+
try {
|
|
63
|
+
await server.sendLoggingMessage(
|
|
64
|
+
{
|
|
65
|
+
level: params.level,
|
|
66
|
+
logger: params.logger || 'bash',
|
|
67
|
+
data: params.data,
|
|
68
|
+
},
|
|
69
|
+
extra.sessionId
|
|
70
|
+
);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
console.error('Error sending notification:', error);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
await sendNotification({
|
|
77
|
+
level: 'info',
|
|
78
|
+
data: `开始执行命令: ${command} ${args.join(' ')}`,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const child = spawn(command, args, {
|
|
83
|
+
cwd,
|
|
84
|
+
shell: true,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
let stdout = '';
|
|
88
|
+
let stderr = '';
|
|
89
|
+
|
|
90
|
+
child.stdout.on('data', async (data) => {
|
|
91
|
+
const output = data.toString();
|
|
92
|
+
stdout += output;
|
|
93
|
+
await sendNotification({
|
|
94
|
+
level: 'debug',
|
|
95
|
+
data: `[stdout] ${output.trim()}`,
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
child.stderr.on('data', async (data) => {
|
|
100
|
+
const output = data.toString();
|
|
101
|
+
stderr += output;
|
|
102
|
+
await sendNotification({
|
|
103
|
+
level: 'warning',
|
|
104
|
+
data: `[stderr] ${output.trim()}`,
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
child.on('close', async (code) => {
|
|
109
|
+
await sendNotification({
|
|
110
|
+
level: code === 0 ? 'info' : 'error',
|
|
111
|
+
data: `命令执行完成,退出码: ${code}`,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const trimmedStdout = trimCommandOutput(stdout);
|
|
115
|
+
const trimmedStderr = trimCommandOutput(stderr);
|
|
116
|
+
|
|
117
|
+
resolve({
|
|
118
|
+
content: [
|
|
119
|
+
{
|
|
120
|
+
type: 'text',
|
|
121
|
+
text: JSON.stringify(
|
|
122
|
+
{
|
|
123
|
+
exitCode: code,
|
|
124
|
+
stdout: trimmedStdout.text,
|
|
125
|
+
stderr: trimmedStderr.text,
|
|
126
|
+
success: code === 0,
|
|
127
|
+
},
|
|
128
|
+
null,
|
|
129
|
+
2
|
|
130
|
+
),
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
child.on('error', async (error) => {
|
|
137
|
+
await sendNotification({
|
|
138
|
+
level: 'error',
|
|
139
|
+
data: `命令执行错误: ${error.message}`,
|
|
140
|
+
});
|
|
141
|
+
reject(error);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
package/tools/file.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import * as z from 'zod/v4';
|
|
4
|
+
|
|
5
|
+
// 文件大小限制:100万字符
|
|
6
|
+
const MAX_FILE_SIZE = 1000000;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 检查文件大小是否超过限制
|
|
10
|
+
* @param {string} filePath - 文件路径
|
|
11
|
+
* @param {string} encoding - 文件编码
|
|
12
|
+
* @returns {Promise<{valid: boolean, size: number, message?: string}>}
|
|
13
|
+
*/
|
|
14
|
+
async function checkFileSize(filePath, encoding = 'utf-8') {
|
|
15
|
+
try {
|
|
16
|
+
const stats = await fs.stat(filePath);
|
|
17
|
+
const sizeInBytes = stats.size;
|
|
18
|
+
|
|
19
|
+
// 粗略估算字符数(根据编码)
|
|
20
|
+
// UTF-8: 平均每个字符约1-3字节,这里使用保守估计
|
|
21
|
+
let estimatedChars;
|
|
22
|
+
if (encoding === 'utf-8') {
|
|
23
|
+
estimatedChars = sizeInBytes; // 最坏情况:每字节一个字符
|
|
24
|
+
} else if (encoding === 'utf16le') {
|
|
25
|
+
estimatedChars = sizeInBytes / 2;
|
|
26
|
+
} else {
|
|
27
|
+
estimatedChars = sizeInBytes;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (estimatedChars > MAX_FILE_SIZE) {
|
|
31
|
+
return {
|
|
32
|
+
valid: false,
|
|
33
|
+
size: Math.round(estimatedChars),
|
|
34
|
+
message: `文件太大,不能处理。文件大小约为 ${Math.round(estimatedChars).toLocaleString()} 字符,超过了 ${MAX_FILE_SIZE.toLocaleString()} 字符的限制。`
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
valid: true,
|
|
40
|
+
size: Math.round(estimatedChars)
|
|
41
|
+
};
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw new Error(`检查文件大小失败: ${error.message}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 注册文件操作工具
|
|
49
|
+
* @param {McpServer} server - MCP 服务器实例
|
|
50
|
+
*/
|
|
51
|
+
export function registerFileTools(server) {
|
|
52
|
+
// 注册读取文件工具
|
|
53
|
+
server.tool(
|
|
54
|
+
'read_file',
|
|
55
|
+
'读取文本文件的内容',
|
|
56
|
+
{
|
|
57
|
+
path: z.string().describe('文件路径'),
|
|
58
|
+
encoding: z.enum(['utf-8', 'ascii', 'utf16le', 'latin1']).describe('文件编码(可选,默认 utf-8)').optional().default('utf-8')
|
|
59
|
+
},
|
|
60
|
+
async ({ path: filePath, encoding = 'utf-8' }) => {
|
|
61
|
+
try {
|
|
62
|
+
// 先检查文件大小
|
|
63
|
+
const sizeCheck = await checkFileSize(filePath, encoding);
|
|
64
|
+
if (!sizeCheck.valid) {
|
|
65
|
+
throw new Error(sizeCheck.message);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const content = await fs.readFile(filePath, encoding);
|
|
69
|
+
|
|
70
|
+
// 再次检查实际字符数
|
|
71
|
+
if (content.length > MAX_FILE_SIZE) {
|
|
72
|
+
throw new Error(`文件太大,不能处理。文件包含 ${content.length.toLocaleString()} 字符,超过了 ${MAX_FILE_SIZE.toLocaleString()} 字符的限制。`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
content: [
|
|
77
|
+
{
|
|
78
|
+
type: 'text',
|
|
79
|
+
text: content,
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
} catch (error) {
|
|
84
|
+
throw new Error(`读取文件失败: ${error.message}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// 注册写入文件工具
|
|
90
|
+
server.tool(
|
|
91
|
+
'write_file',
|
|
92
|
+
'写入内容到文本文件。如果文件已存在会被覆盖。',
|
|
93
|
+
{
|
|
94
|
+
path: z.string().describe('文件路径'),
|
|
95
|
+
content: z.string().describe('要写入的内容'),
|
|
96
|
+
encoding: z.enum(['utf-8', 'ascii', 'utf16le', 'latin1']).describe('文件编码(可选,默认 utf-8)').optional().default('utf-8'),
|
|
97
|
+
create_dirs: z.boolean().describe('如果目录不存在是否创建(可选,默认 false)').optional().default(false)
|
|
98
|
+
},
|
|
99
|
+
async ({ path: filePath, content, encoding = 'utf-8', create_dirs = false }) => {
|
|
100
|
+
try {
|
|
101
|
+
if (create_dirs) {
|
|
102
|
+
const dir = path.dirname(filePath);
|
|
103
|
+
await fs.mkdir(dir, { recursive: true });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
await fs.writeFile(filePath, content, encoding);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
content: [
|
|
110
|
+
{
|
|
111
|
+
type: 'text',
|
|
112
|
+
text: `文件写入成功: ${filePath}\n写入字节数: ${Buffer.byteLength(content, encoding)}`,
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
} catch (error) {
|
|
117
|
+
throw new Error(`写入文件失败: ${error.message}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// 注册修改文件工具
|
|
123
|
+
server.tool(
|
|
124
|
+
'edit_file',
|
|
125
|
+
'修改文本文件的内容。可以进行搜索替换操作。',
|
|
126
|
+
{
|
|
127
|
+
path: z.string().describe('文件路径'),
|
|
128
|
+
search: z.string().describe('要搜索的文本(支持正则表达式)'),
|
|
129
|
+
replace: z.string().describe('替换后的文本'),
|
|
130
|
+
regex: z.boolean().describe('是否使用正则表达式(可选,默认 false)').optional().default(false),
|
|
131
|
+
all: z.boolean().describe('是否替换所有匹配项(可选,默认 true)').optional().default(true),
|
|
132
|
+
encoding: z.enum(['utf-8', 'ascii', 'utf16le', 'latin1']).describe('文件编码(可选,默认 utf-8)').optional().default('utf-8')
|
|
133
|
+
},
|
|
134
|
+
async ({ path: filePath, search, replace, regex = false, all = true, encoding = 'utf-8' }) => {
|
|
135
|
+
try {
|
|
136
|
+
// 先检查文件大小
|
|
137
|
+
const sizeCheck = await checkFileSize(filePath, encoding);
|
|
138
|
+
if (!sizeCheck.valid) {
|
|
139
|
+
throw new Error(sizeCheck.message);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let content = await fs.readFile(filePath, encoding);
|
|
143
|
+
|
|
144
|
+
// 再次检查实际字符数
|
|
145
|
+
if (content.length > MAX_FILE_SIZE) {
|
|
146
|
+
throw new Error(`文件太大,不能处理。文件包含 ${content.length.toLocaleString()} 字符,超过了 ${MAX_FILE_SIZE.toLocaleString()} 字符的限制。`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let searchPattern;
|
|
150
|
+
if (regex) {
|
|
151
|
+
searchPattern = new RegExp(search, all ? 'g' : '');
|
|
152
|
+
} else {
|
|
153
|
+
const escapedSearch = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
154
|
+
searchPattern = new RegExp(escapedSearch, all ? 'g' : '');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const matches = content.match(searchPattern);
|
|
158
|
+
const matchCount = matches ? matches.length : 0;
|
|
159
|
+
|
|
160
|
+
if (matchCount === 0) {
|
|
161
|
+
return {
|
|
162
|
+
content: [
|
|
163
|
+
{
|
|
164
|
+
type: 'text',
|
|
165
|
+
text: `未找到匹配的内容: "${search}"`,
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const newContent = content.replace(searchPattern, replace);
|
|
172
|
+
await fs.writeFile(filePath, newContent, encoding);
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
content: [
|
|
176
|
+
{
|
|
177
|
+
type: 'text',
|
|
178
|
+
text: `文件修改成功: ${filePath}\n替换次数: ${matchCount}`,
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
};
|
|
182
|
+
} catch (error) {
|
|
183
|
+
throw new Error(`修改文件失败: ${error.message}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { tmpdir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { unlink, readFile, access, writeFile, chmod } from "fs/promises";
|
|
6
|
+
import * as z from 'zod/v4';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 注册交互式命令执行工具
|
|
10
|
+
* @param {McpServer} server - MCP 服务器实例
|
|
11
|
+
*/
|
|
12
|
+
export function registerInteractiveBashTool(server) {
|
|
13
|
+
server.tool(
|
|
14
|
+
'bash',
|
|
15
|
+
'执行交互式命令。使用 xfce4-terminal 启动 script 命令记录完整的交互式会话,包括用户输入和程序输出。会在图形界面中打开 xfce4-terminal 窗口供用户交互。适用于需要交互式输入的命令或脚本,当然也支持普通命令。',
|
|
16
|
+
{
|
|
17
|
+
script: z.string().describe('要执行的 shell 脚本内容或命令'),
|
|
18
|
+
cwd: z.string().describe('工作目录(可选)').optional()
|
|
19
|
+
},
|
|
20
|
+
async ({ script, cwd = process.cwd() }, extra) => {
|
|
21
|
+
// 发送通知的函数
|
|
22
|
+
const sendNotification = async (params) => {
|
|
23
|
+
try {
|
|
24
|
+
await server.sendLoggingMessage(
|
|
25
|
+
{
|
|
26
|
+
level: params.level,
|
|
27
|
+
logger: params.logger || 'interactive',
|
|
28
|
+
data: params.data,
|
|
29
|
+
},
|
|
30
|
+
extra.sessionId
|
|
31
|
+
);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.error('Error sending notification:', error);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// 创建临时文件
|
|
38
|
+
const scriptFile = join(tmpdir(), `script_${randomUUID()}.sh`);
|
|
39
|
+
const logFile = join(tmpdir(), `interactive_${randomUUID()}.log`);
|
|
40
|
+
const doneFlag = `${logFile}.done`;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
// 将脚本内容写入临时文件
|
|
44
|
+
await writeFile(scriptFile, script, 'utf-8');
|
|
45
|
+
// 设置脚本文件为可执行
|
|
46
|
+
await chmod(scriptFile, 0o755);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
await sendNotification({
|
|
49
|
+
level: 'error',
|
|
50
|
+
data: `创建脚本文件失败: ${error.message}`,
|
|
51
|
+
});
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await sendNotification({
|
|
56
|
+
level: 'info',
|
|
57
|
+
data: `开始执行交互式命令\n脚本文件: ${scriptFile}\n日志文件: ${logFile}`,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
// 构建完整的命令:使用 xfce4-terminal 启动 script 来记录会话
|
|
62
|
+
// xfce4-terminal --hold -e "bash -c \"script ... && touch done_flag\""
|
|
63
|
+
|
|
64
|
+
// 转义单引号以在 bash -c 中使用
|
|
65
|
+
const escapedScriptFile = scriptFile.replace(/'/g, "'\\''");
|
|
66
|
+
const escapedLogFile = logFile.replace(/'/g, "'\\''");
|
|
67
|
+
const escapedDoneFlag = doneFlag.replace(/'/g, "'\\''");
|
|
68
|
+
|
|
69
|
+
// 构建内部命令:运行 script,结束后 touch 标记文件
|
|
70
|
+
const innerCommand = `script -q -f -c 'bash ${escapedScriptFile}' '${escapedLogFile}' && touch '${escapedDoneFlag}'`;
|
|
71
|
+
|
|
72
|
+
const terminalArgs = [
|
|
73
|
+
// '--hold', // 命令结束后保持窗口打开
|
|
74
|
+
'-e', // 执行命令
|
|
75
|
+
`bash -c "${innerCommand}"` // 使用 bash -c 执行复合命令
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
// 启动终端,不捕获其 stdio(避免 DBus 警告污染)
|
|
79
|
+
const child = spawn('xfce4-terminal', terminalArgs, {
|
|
80
|
+
cwd,
|
|
81
|
+
shell: false,
|
|
82
|
+
stdio: ['ignore', 'ignore', 'ignore'], // 忽略终端自身的输出
|
|
83
|
+
detached: true // 使终端独立运行
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// 分离子进程,避免父进程等待
|
|
87
|
+
child.unref();
|
|
88
|
+
|
|
89
|
+
// 异步等待完成标记文件出现
|
|
90
|
+
const waitForCompletion = async () => {
|
|
91
|
+
await sendNotification({
|
|
92
|
+
level: 'info',
|
|
93
|
+
data: '等待交互式程序运行完成...',
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const maxWaitTime = 6 * 3600000; // 6 小时超时
|
|
97
|
+
const pollInterval = 200; // 每 200ms 检查一次
|
|
98
|
+
const maxAttempts = maxWaitTime / pollInterval;
|
|
99
|
+
let attempts = 0;
|
|
100
|
+
|
|
101
|
+
// 记录开始时间和上次通知时间
|
|
102
|
+
const startTime = Date.now();
|
|
103
|
+
let lastNotifyTime = startTime;
|
|
104
|
+
const notifyInterval = 1000; // 1秒发送一次
|
|
105
|
+
|
|
106
|
+
while (attempts < maxAttempts) {
|
|
107
|
+
try {
|
|
108
|
+
// 检查标记文件是否存在
|
|
109
|
+
await access(doneFlag);
|
|
110
|
+
// 文件存在,脚本已完成
|
|
111
|
+
break;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
// 文件不存在,继续等待
|
|
114
|
+
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
115
|
+
attempts++;
|
|
116
|
+
|
|
117
|
+
// 检查是否需要发送时间通知
|
|
118
|
+
const currentTime = Date.now();
|
|
119
|
+
if (currentTime - lastNotifyTime >= notifyInterval) {
|
|
120
|
+
const elapsedSeconds = Math.floor((currentTime - startTime) / 1000);
|
|
121
|
+
const minutes = Math.floor(elapsedSeconds / 60);
|
|
122
|
+
const seconds = elapsedSeconds % 60;
|
|
123
|
+
const timeStr = minutes > 0
|
|
124
|
+
? `${minutes}分${seconds}秒`
|
|
125
|
+
: `${seconds}秒`;
|
|
126
|
+
|
|
127
|
+
await sendNotification({
|
|
128
|
+
level: 'info',
|
|
129
|
+
data: `已运行时间: ${timeStr}\r`,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
lastNotifyTime = currentTime;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (attempts >= maxAttempts) {
|
|
138
|
+
console.error('等待脚本执行超时6小时');
|
|
139
|
+
throw new Error('等待脚本执行超时');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 读取日志文件内容
|
|
143
|
+
let sessionOutput = '';
|
|
144
|
+
try {
|
|
145
|
+
sessionOutput = await readFile(logFile, 'utf-8');
|
|
146
|
+
// 限制返回大小,只保留最后5000个字符
|
|
147
|
+
const maxOutputLength = 5000;
|
|
148
|
+
if (sessionOutput.length > maxOutputLength) {
|
|
149
|
+
const truncated = sessionOutput.length - maxOutputLength;
|
|
150
|
+
sessionOutput = `... [已截断前面 ${truncated} 个字符] ...\n\n` + sessionOutput.slice(-maxOutputLength);
|
|
151
|
+
}
|
|
152
|
+
} catch (readError) {
|
|
153
|
+
await sendNotification({
|
|
154
|
+
level: 'warning',
|
|
155
|
+
data: `无法读取日志文件: ${readError.message}`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 清理临时文件
|
|
160
|
+
try {
|
|
161
|
+
await unlink(scriptFile);
|
|
162
|
+
} catch (unlinkError) {
|
|
163
|
+
console.warn(`无法删除脚本文件 ${scriptFile}:`, unlinkError);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
await unlink(logFile);
|
|
168
|
+
} catch (unlinkError) {
|
|
169
|
+
console.warn(`无法删除临时文件 ${logFile}:`, unlinkError);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
await unlink(doneFlag);
|
|
174
|
+
} catch (unlinkError) {
|
|
175
|
+
console.warn(`无法删除标记文件 ${doneFlag}:`, unlinkError);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
await sendNotification({
|
|
179
|
+
level: 'info',
|
|
180
|
+
data: '交互式命令执行完成',
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
content: [
|
|
185
|
+
{
|
|
186
|
+
type: 'text',
|
|
187
|
+
text: sessionOutput,
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// 执行等待逻辑
|
|
194
|
+
waitForCompletion()
|
|
195
|
+
.then(resolve)
|
|
196
|
+
.catch(async (error) => {
|
|
197
|
+
// 清理临时文件
|
|
198
|
+
try {
|
|
199
|
+
await unlink(scriptFile);
|
|
200
|
+
} catch (unlinkError) {
|
|
201
|
+
// 忽略删除错误
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
await unlink(logFile);
|
|
205
|
+
} catch (unlinkError) {
|
|
206
|
+
// 忽略删除错误
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
await unlink(doneFlag);
|
|
210
|
+
} catch (unlinkError) {
|
|
211
|
+
// 忽略删除错误
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
await sendNotification({
|
|
215
|
+
level: 'error',
|
|
216
|
+
data: `交互式命令执行错误: ${error.message}`,
|
|
217
|
+
});
|
|
218
|
+
reject(error);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
child.on('error', async (error) => {
|
|
222
|
+
await sendNotification({
|
|
223
|
+
level: 'error',
|
|
224
|
+
data: `启动终端失败: ${error.message}`,
|
|
225
|
+
});
|
|
226
|
+
reject(error);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
package/tools/skills.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 解析 SKILL.md 文件的 YAML front matter
|
|
6
|
+
* @param {string} content - 文件内容
|
|
7
|
+
* @returns {object} 包含 name 和 description 的对象
|
|
8
|
+
*/
|
|
9
|
+
function parseSkillMd(content) {
|
|
10
|
+
const frontMatterRegex = /^---\s*\n([\s\S]*?)\n---/;
|
|
11
|
+
const match = content.match(frontMatterRegex);
|
|
12
|
+
|
|
13
|
+
if (!match) {
|
|
14
|
+
return { name: null, description: null };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const frontMatter = match[1];
|
|
18
|
+
const lines = frontMatter.split('\n');
|
|
19
|
+
const result = {};
|
|
20
|
+
|
|
21
|
+
for (const line of lines) {
|
|
22
|
+
const colonIndex = line.indexOf(':');
|
|
23
|
+
if (colonIndex > 0) {
|
|
24
|
+
const key = line.substring(0, colonIndex).trim();
|
|
25
|
+
const value = line.substring(colonIndex + 1).trim();
|
|
26
|
+
result[key] = value;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
name: result.name || null,
|
|
32
|
+
description: result.description || null,
|
|
33
|
+
// license: result.license || null
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 递归扫描目录查找所有包含 SKILL.md 的技能目录
|
|
39
|
+
* @param {string} dirPath - 要扫描的目录路径
|
|
40
|
+
* @param {Array} skills - 技能数组(用于收集结果)
|
|
41
|
+
* @returns {Promise<void>}
|
|
42
|
+
*/
|
|
43
|
+
async function scanSkillsRecursively(dirPath, skills) {
|
|
44
|
+
try {
|
|
45
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
46
|
+
|
|
47
|
+
// 检查当前目录是否包含 SKILL.md
|
|
48
|
+
const skillMdPath = path.join(dirPath, 'SKILL.md');
|
|
49
|
+
let hasSkillMd = false;
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
await fs.access(skillMdPath);
|
|
53
|
+
hasSkillMd = true;
|
|
54
|
+
} catch {
|
|
55
|
+
// SKILL.md 不存在
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 如果当前目录包含 SKILL.md,加载技能信息
|
|
59
|
+
if (hasSkillMd) {
|
|
60
|
+
try {
|
|
61
|
+
const content = await fs.readFile(skillMdPath, 'utf-8');
|
|
62
|
+
const metadata = parseSkillMd(content);
|
|
63
|
+
const dirName = path.basename(dirPath);
|
|
64
|
+
|
|
65
|
+
skills.push({
|
|
66
|
+
name: metadata.name || dirName,
|
|
67
|
+
description: metadata.description,
|
|
68
|
+
path: dirPath,
|
|
69
|
+
// license: metadata.license || null
|
|
70
|
+
});
|
|
71
|
+
} catch (error) {
|
|
72
|
+
console.warn(`Failed to read SKILL.md at ${skillMdPath}:`, error.message);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 递归扫描所有子目录
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
if (entry.isDirectory()) {
|
|
79
|
+
const subDirPath = path.join(dirPath, entry.name);
|
|
80
|
+
await scanSkillsRecursively(subDirPath, skills);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.warn(`Failed to scan directory ${dirPath}:`, error.message);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 加载技能目录下的所有技能(递归扫描所有子目录)
|
|
90
|
+
* @param {Array<string>} ids - 可选的技能 ID 数组,用于加载 /readonly/skills/{id} 的技能
|
|
91
|
+
* @returns {Promise<Array>} 技能数组,每个元素包含 name, description, path
|
|
92
|
+
*/
|
|
93
|
+
export async function loadSkills(ids = []) {
|
|
94
|
+
const skills = [];
|
|
95
|
+
|
|
96
|
+
// 要扫描的目录列表
|
|
97
|
+
const dirsToScan = [
|
|
98
|
+
'/skills',
|
|
99
|
+
'/data/.skills',
|
|
100
|
+
'/data/skills',
|
|
101
|
+
'/data/.claude/skills',
|
|
102
|
+
'/root/.claude/skills',
|
|
103
|
+
'/data/.agents/skills',
|
|
104
|
+
'/root/.agents/skills',
|
|
105
|
+
'/data/.opencode/skills',
|
|
106
|
+
'/root/.opencode/skills'
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
// 如果提供了 ids,添加 /readonly/skills/{id} 目录
|
|
110
|
+
if (ids && ids.length > 0) {
|
|
111
|
+
for (const id of ids) {
|
|
112
|
+
dirsToScan.push(`/readonly/skills/${id}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
// 扫描所有目录
|
|
118
|
+
for (const dir of dirsToScan) {
|
|
119
|
+
try {
|
|
120
|
+
await fs.access(dir);
|
|
121
|
+
// 递归扫描目录及所有子目录
|
|
122
|
+
await scanSkillsRecursively(dir, skills);
|
|
123
|
+
console.log(`Scanned directory: ${dir}`);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.warn(`Skills directory ${dir} does not exist or is not accessible`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
console.log(`Loaded ${skills.length} skill(s) in total`);
|
|
130
|
+
return skills;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
console.error('Error loading skills:', error);
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 获取指定技能的详细信息
|
|
139
|
+
* @param {string} skillName - 技能名称
|
|
140
|
+
* @param {Array<string>} ids - 可选的技能 ID 数组
|
|
141
|
+
* @returns {Promise<object|null>} 技能详细信息
|
|
142
|
+
*/
|
|
143
|
+
export async function getSkillDetails(skillName, ids = []) {
|
|
144
|
+
const skills = await loadSkills(ids);
|
|
145
|
+
return skills.find(skill => skill.name === skillName) || null;
|
|
146
|
+
}
|
|
147
|
+
|