@open-skills-hub/mcp-server 1.0.0
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/CHANGELOG-PLATFORM.md +255 -0
- package/EXAMPLES.md +506 -0
- package/PLATFORM-SUPPORT.md +385 -0
- package/QUICK-REFERENCE-PLATFORMS.md +128 -0
- package/README.md +370 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1490 -0
- package/dist/index.js.map +1 -0
- package/dist/queue.d.ts +56 -0
- package/dist/queue.d.ts.map +1 -0
- package/dist/queue.js +232 -0
- package/dist/queue.js.map +1 -0
- package/package.json +45 -0
- package/src/index.ts +1756 -0
- package/src/queue.ts +278 -0
- package/tsconfig.json +13 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1490 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SkillsHub MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Model Context Protocol (MCP) server for AI Agent Skills management.
|
|
6
|
+
*
|
|
7
|
+
* This server enables AI agents to discover, retrieve, publish, and manage
|
|
8
|
+
* reusable skills through a standardized protocol. Think of it as "npm for AI agents"
|
|
9
|
+
* - a centralized hub for sharing and discovering agent capabilities.
|
|
10
|
+
*
|
|
11
|
+
* Features:
|
|
12
|
+
* - 🔍 Search skills by keywords, category, or author
|
|
13
|
+
* - 📥 Retrieve complete skill content with all attachments
|
|
14
|
+
* - 📤 Publish new skills or update existing ones
|
|
15
|
+
* - 🔒 Security scanning with built-in SkillsGuard rules
|
|
16
|
+
* - 💬 Feedback system for skill quality improvement
|
|
17
|
+
* - 💾 Local caching for offline usage
|
|
18
|
+
* - 📦 Install skills directly into projects
|
|
19
|
+
*
|
|
20
|
+
* Architecture:
|
|
21
|
+
* - Local-first: All data stored in SQLite, no cloud dependency
|
|
22
|
+
* - Secure: Automatic security scanning on publish
|
|
23
|
+
* - Versioned: Full semantic versioning support
|
|
24
|
+
* - Async feedback: Offline feedback queue with auto-retry
|
|
25
|
+
*
|
|
26
|
+
* @see https://github.com/OpenSkillsHub/open-skills-hub
|
|
27
|
+
* @see https://modelcontextprotocol.io
|
|
28
|
+
*/
|
|
29
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
30
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
31
|
+
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
|
|
32
|
+
import * as fs from 'fs';
|
|
33
|
+
import * as path from 'path';
|
|
34
|
+
import { parse as parseYaml } from 'yaml';
|
|
35
|
+
import { initConfig, getStorage, initScanner, getScanner, parseSkillFullName, buildSkillFullName, generateUUID, now, sha256, isValidSkillName, isValidSemver, logger, } from '@open-skills-hub/core';
|
|
36
|
+
import { FeedbackQueueProcessor } from './queue.js';
|
|
37
|
+
// Server info
|
|
38
|
+
const SERVER_NAME = 'open-skills-hub';
|
|
39
|
+
const SERVER_VERSION = '1.0.0';
|
|
40
|
+
// HTTP client for API reload
|
|
41
|
+
async function reloadApi(apiUrl) {
|
|
42
|
+
try {
|
|
43
|
+
// Remove trailing slash and ensure http:// prefix
|
|
44
|
+
const url = apiUrl.replace(/\/$/, '');
|
|
45
|
+
logger.info(`Attempting to reload API at ${url}/reload`);
|
|
46
|
+
const response = await fetch(`${url}/reload`, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: {
|
|
49
|
+
'Content-Type': 'application/json',
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
if (response.ok) {
|
|
53
|
+
logger.info('API reloaded successfully');
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
logger.warn(`Failed to reload API: ${response.status} ${response.statusText}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
// Don't fail the publish operation if reload fails
|
|
61
|
+
logger.warn('Failed to call API reload', { error });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Create and configure the MCP server
|
|
66
|
+
*/
|
|
67
|
+
async function createMcpServer() {
|
|
68
|
+
// Initialize config
|
|
69
|
+
const config = initConfig();
|
|
70
|
+
await config.load();
|
|
71
|
+
// Initialize storage
|
|
72
|
+
const storage = await getStorage();
|
|
73
|
+
await storage.initialize();
|
|
74
|
+
// Initialize scanner
|
|
75
|
+
initScanner({
|
|
76
|
+
enabled: config.get().scanner.enabled,
|
|
77
|
+
timeout: config.get().scanner.timeout,
|
|
78
|
+
maxFileSize: config.get().scanner.maxFileSize,
|
|
79
|
+
});
|
|
80
|
+
// Start feedback queue processor
|
|
81
|
+
const queueProcessor = new FeedbackQueueProcessor(storage, config.get().retry);
|
|
82
|
+
queueProcessor.start();
|
|
83
|
+
// Create MCP server
|
|
84
|
+
const server = new Server({
|
|
85
|
+
name: SERVER_NAME,
|
|
86
|
+
version: SERVER_VERSION,
|
|
87
|
+
}, {
|
|
88
|
+
capabilities: {
|
|
89
|
+
tools: {},
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
// ============================================================================
|
|
93
|
+
// MCP Tools Registration
|
|
94
|
+
// ============================================================================
|
|
95
|
+
//
|
|
96
|
+
// SkillsHub provides 7 core tools for managing AI Agent Skills:
|
|
97
|
+
//
|
|
98
|
+
// Discovery & Retrieval:
|
|
99
|
+
// - skills_search: Find skills by keywords, category, or author
|
|
100
|
+
// - skills_get: Retrieve complete skill content with attachments
|
|
101
|
+
// - skills_list_cached: View locally cached skills
|
|
102
|
+
//
|
|
103
|
+
// Publishing & Installation:
|
|
104
|
+
// - skills_publish: Upload skills to the hub
|
|
105
|
+
// - skills_install: Download and install skills to projects
|
|
106
|
+
//
|
|
107
|
+
// Quality & Safety:
|
|
108
|
+
// - skills_scan: Security scanning with SkillsGuard
|
|
109
|
+
// - skills_feedback: Submit ratings and feedback
|
|
110
|
+
//
|
|
111
|
+
// All tools use clear, explicit parameter names and provide detailed
|
|
112
|
+
// error messages to help AI agents use them correctly.
|
|
113
|
+
//
|
|
114
|
+
// ============================================================================
|
|
115
|
+
// Register tool handlers
|
|
116
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
117
|
+
tools: [
|
|
118
|
+
{
|
|
119
|
+
name: 'skills_search',
|
|
120
|
+
description: 'Search Agent Skills: Find skills by keywords, category, or author. Returns a list where each skill has a "Name" field (e.g., "@official/xlsx") that should be used as the "name" parameter in skills_install or skills_get.',
|
|
121
|
+
inputSchema: {
|
|
122
|
+
type: 'object',
|
|
123
|
+
properties: {
|
|
124
|
+
query: {
|
|
125
|
+
type: 'string',
|
|
126
|
+
description: 'Search query - can be skill name, description keywords, or feature characteristics. Leave EMPTY ("") to list all skills. DO NOT use wildcards like "*" or "%" - they will be treated as literal characters. Examples: "" (all skills), "calculator", "API", "design", "pdf"',
|
|
127
|
+
},
|
|
128
|
+
category: {
|
|
129
|
+
type: 'string',
|
|
130
|
+
description: 'Filter by category. Examples: "utility", "development", "design"',
|
|
131
|
+
},
|
|
132
|
+
author: {
|
|
133
|
+
type: 'string',
|
|
134
|
+
description: 'Filter by author or organization. Examples: "@official", "@user", "@example"',
|
|
135
|
+
},
|
|
136
|
+
limit: {
|
|
137
|
+
type: 'number',
|
|
138
|
+
description: 'Maximum number of results to return. Default: 10, Maximum: 100. Use higher limit (e.g., 50-100) when listing all skills.',
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: 'skills_get',
|
|
145
|
+
description: 'Get complete skill content: Retrieve SKILL.md documentation, all attachments, metadata, and version information. Specify a version or get the latest. Use this before using a skill to understand its functionality.',
|
|
146
|
+
inputSchema: {
|
|
147
|
+
type: 'object',
|
|
148
|
+
properties: {
|
|
149
|
+
name: {
|
|
150
|
+
type: 'string',
|
|
151
|
+
description: 'REQUIRED. Full skill name (use the "Name" field from skills_search results). Valid formats: "calculator", "@user/calculator", "@official/code-reviewer".',
|
|
152
|
+
},
|
|
153
|
+
version: {
|
|
154
|
+
type: 'string',
|
|
155
|
+
description: 'Optional. Specific version to retrieve in semver format. Examples: "1.0.0", "2.1.3". If omitted, returns latest version.',
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
required: ['name'],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: 'skills_scan',
|
|
163
|
+
description: 'Security scan: Check skill content for potential security risks including malicious code, dangerous commands, and sensitive information leaks. Returns a security score (0-100) and detailed report.',
|
|
164
|
+
inputSchema: {
|
|
165
|
+
type: 'object',
|
|
166
|
+
properties: {
|
|
167
|
+
content: {
|
|
168
|
+
type: 'string',
|
|
169
|
+
description: 'REQUIRED. Text content to scan - typically SKILL.md content or script code.',
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
required: ['content'],
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: 'skills_feedback',
|
|
177
|
+
description: 'Submit skill feedback: Help improve skill quality by reporting success, failure, suggestions, or bugs. Feedback is recorded and affects skill ratings.',
|
|
178
|
+
inputSchema: {
|
|
179
|
+
type: 'object',
|
|
180
|
+
properties: {
|
|
181
|
+
name: {
|
|
182
|
+
type: 'string',
|
|
183
|
+
description: 'REQUIRED. Full skill name with scope (same as from skills_search). Example: "@official/calculator"',
|
|
184
|
+
},
|
|
185
|
+
version: {
|
|
186
|
+
type: 'string',
|
|
187
|
+
description: 'Optional. Skill version. Example: "1.0.0". If omitted, feedback applies to latest version.',
|
|
188
|
+
},
|
|
189
|
+
type: {
|
|
190
|
+
type: 'string',
|
|
191
|
+
enum: ['success', 'failure', 'suggestion', 'bug'],
|
|
192
|
+
description: 'REQUIRED. Feedback type: "success" (worked well), "failure" (didn\'t work), "suggestion" (improvement idea), "bug" (found a bug)',
|
|
193
|
+
},
|
|
194
|
+
rating: {
|
|
195
|
+
type: 'number',
|
|
196
|
+
minimum: 1,
|
|
197
|
+
maximum: 5,
|
|
198
|
+
description: 'REQUIRED. Rating from 1-5 stars: 1=very poor, 2=poor, 3=average, 4=good, 5=excellent',
|
|
199
|
+
},
|
|
200
|
+
comment: {
|
|
201
|
+
type: 'string',
|
|
202
|
+
description: 'Optional. Detailed comment or suggestion to help the author understand specific issues or improvement directions.',
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
required: ['name', 'type', 'rating'],
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
name: 'skills_list_cached',
|
|
210
|
+
description: 'List locally cached skills: Show all skills downloaded to local cache with cache time, usage count, and other metadata. Useful for managing local storage.',
|
|
211
|
+
inputSchema: {
|
|
212
|
+
type: 'object',
|
|
213
|
+
properties: {},
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
name: 'skills_publish',
|
|
218
|
+
description: `Publish skill to hub: Upload a locally developed skill to Open Skills Hub, making it searchable and usable by others.
|
|
219
|
+
|
|
220
|
+
⚠️ RECOMMENDED METHOD: Use Shell tool to call CLI directly - more stable and faster:
|
|
221
|
+
cd /Users/george/Documents/AI项目/SkillsHub && node packages/cli/dist/index.js publish <path> -s <scope> -a <author> -v <version> -y
|
|
222
|
+
|
|
223
|
+
Example:
|
|
224
|
+
node packages/cli/dist/index.js publish "/Users/george/my-skills/calculator" -s @user -a "George" -v 1.0.0 -y
|
|
225
|
+
|
|
226
|
+
Arguments:
|
|
227
|
+
<path>: Skill directory path (required)
|
|
228
|
+
-s, --scope: Namespace (optional), e.g., @user, @official
|
|
229
|
+
-a, --author: Author name (required if not in frontmatter)
|
|
230
|
+
-v, --version: Version number (optional), e.g., 1.0.0
|
|
231
|
+
-y, --yes: Skip confirmation prompt
|
|
232
|
+
|
|
233
|
+
If you must use this MCP tool (not recommended), the directory must contain a SKILL.md file with "author" in frontmatter.`,
|
|
234
|
+
inputSchema: {
|
|
235
|
+
type: 'object',
|
|
236
|
+
properties: {
|
|
237
|
+
path: {
|
|
238
|
+
type: 'string',
|
|
239
|
+
description: 'REQUIRED. Absolute path to skill directory containing SKILL.md. Examples: "/Users/george/my-skills/calculator", "/tmp/my-new-skill"',
|
|
240
|
+
},
|
|
241
|
+
scope: {
|
|
242
|
+
type: 'string',
|
|
243
|
+
description: 'Optional. Namespace/scope for organizing skills and identifying ownership. Examples: "@user" (personal), "@official" (official), "@myorg" (organization). The @ prefix is optional.',
|
|
244
|
+
},
|
|
245
|
+
author: {
|
|
246
|
+
type: 'string',
|
|
247
|
+
description: 'REQUIRED (if not in frontmatter). Author name or organization. Examples: "George", "SkillsHub Team", "@mycompany". Will be saved as ownerId.',
|
|
248
|
+
},
|
|
249
|
+
version: {
|
|
250
|
+
type: 'string',
|
|
251
|
+
description: 'Optional. Semantic version number (e.g., "1.0.0"). If omitted, auto-increments patch version for updates, or defaults to "1.0.0" for new skills.',
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
required: ['path'],
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
name: 'skills_install',
|
|
259
|
+
description: 'Install skill to project: Download a skill from the hub and install it to a directory. Automatically detects the platform (Cursor, Codex, CodeBuddy, etc.) and uses appropriate default directory, or you can specify a custom path. Creates the skill folder with SKILL.md and all attachments (scripts, configs, docs, etc.). Directories are created automatically if they don\'t exist.',
|
|
260
|
+
inputSchema: {
|
|
261
|
+
type: 'object',
|
|
262
|
+
properties: {
|
|
263
|
+
name: {
|
|
264
|
+
type: 'string',
|
|
265
|
+
description: 'REQUIRED. Full skill name (use the "Name" field from skills_search results, NOT "skillId"). Valid formats: "calculator", "@user/calculator", "@official/xlsx".',
|
|
266
|
+
},
|
|
267
|
+
targetDir: {
|
|
268
|
+
type: 'string',
|
|
269
|
+
description: 'Optional. Absolute path to PARENT directory (NOT including skill name). System will create a subdirectory named after the skill. Example: use "/path/to/skills" NOT "/path/to/skills/xlsx". If omitted, uses platform defaults: Cursor (~/.cursor/skills), Codex (~/.codex/skills), CodeBuddy (~/.codebuddy/skills).',
|
|
270
|
+
},
|
|
271
|
+
platform: {
|
|
272
|
+
type: 'string',
|
|
273
|
+
enum: ['cursor', 'codex', 'codebuddy', 'claude', 'anthropic'],
|
|
274
|
+
description: 'Optional. Specify target platform to use its default directory. If omitted, platform is auto-detected. Examples: "cursor", "codex", "codebuddy"',
|
|
275
|
+
},
|
|
276
|
+
version: {
|
|
277
|
+
type: 'string',
|
|
278
|
+
description: 'Optional. Specific version to install in semver format. Examples: "1.0.0", "2.1.3". If omitted, installs latest version.',
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
required: ['name'],
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
}));
|
|
286
|
+
// Handle tool calls
|
|
287
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
288
|
+
const { name: toolName, arguments: args } = request.params;
|
|
289
|
+
try {
|
|
290
|
+
switch (toolName) {
|
|
291
|
+
case 'skills_search':
|
|
292
|
+
return await handleSearch(storage, args);
|
|
293
|
+
case 'skills_get':
|
|
294
|
+
return await handleGet(storage, args);
|
|
295
|
+
case 'skills_scan':
|
|
296
|
+
return await handleScan(args);
|
|
297
|
+
case 'skills_feedback':
|
|
298
|
+
return await handleFeedback(storage, queueProcessor, args);
|
|
299
|
+
case 'skills_list_cached':
|
|
300
|
+
return await handleListCached(storage);
|
|
301
|
+
case 'skills_publish':
|
|
302
|
+
return await handlePublish(storage, config, args);
|
|
303
|
+
case 'skills_install':
|
|
304
|
+
return await handleInstall(storage, args);
|
|
305
|
+
default:
|
|
306
|
+
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${toolName}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
if (error instanceof McpError)
|
|
311
|
+
throw error;
|
|
312
|
+
logger.error('Tool call error', { toolName, error });
|
|
313
|
+
throw new McpError(ErrorCode.InternalError, error instanceof Error ? error.message : 'Unknown error');
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
return server;
|
|
317
|
+
}
|
|
318
|
+
// ============================================================================
|
|
319
|
+
// Tool Handlers
|
|
320
|
+
// ============================================================================
|
|
321
|
+
//
|
|
322
|
+
// Each tool handler is responsible for:
|
|
323
|
+
// 1. Validating input parameters
|
|
324
|
+
// 2. Executing the requested operation
|
|
325
|
+
// 3. Formatting the response in MCP format
|
|
326
|
+
// 4. Providing clear error messages for AI agents
|
|
327
|
+
//
|
|
328
|
+
// All handlers throw McpError with appropriate error codes for failures.
|
|
329
|
+
// ============================================================================
|
|
330
|
+
// ============================================================================
|
|
331
|
+
// Platform Detection and Default Directories
|
|
332
|
+
// ============================================================================
|
|
333
|
+
//
|
|
334
|
+
// Different AI platforms store skills in different locations. This section
|
|
335
|
+
// provides platform detection and default directory resolution to improve
|
|
336
|
+
// the installation experience across platforms.
|
|
337
|
+
// ============================================================================
|
|
338
|
+
/**
|
|
339
|
+
* Platform-specific default skills directories
|
|
340
|
+
* Used when targetDir is not explicitly provided
|
|
341
|
+
*/
|
|
342
|
+
const PLATFORM_SKILLS_DIRS = {
|
|
343
|
+
cursor: '~/.cursor/skills',
|
|
344
|
+
codex: '~/.codex/skills',
|
|
345
|
+
codebuddy: '~/.codebuddy/skills',
|
|
346
|
+
claude: '~/.claude/skills',
|
|
347
|
+
anthropic: '~/.anthropic/skills',
|
|
348
|
+
};
|
|
349
|
+
/**
|
|
350
|
+
* Detect current platform based on multiple signals
|
|
351
|
+
* Priority: 0. CLI argument, 1. Process info, 2. Environment variables, 3. Directory timestamps, 4. Existing directories
|
|
352
|
+
* @returns Platform name or 'unknown'
|
|
353
|
+
*/
|
|
354
|
+
function detectPlatform() {
|
|
355
|
+
// PRIORITY 0: Use CLI-specified platform if provided
|
|
356
|
+
if (CLI_PLATFORM) {
|
|
357
|
+
logger.info(`Using CLI-specified platform: ${CLI_PLATFORM}`);
|
|
358
|
+
return CLI_PLATFORM;
|
|
359
|
+
}
|
|
360
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || '~';
|
|
361
|
+
// PRIORITY 1: Check parent process and application path
|
|
362
|
+
try {
|
|
363
|
+
const { execSync } = require('child_process');
|
|
364
|
+
const ppid = process.ppid;
|
|
365
|
+
if (ppid) {
|
|
366
|
+
// Check process name
|
|
367
|
+
const parentCmd = execSync(`ps -p ${ppid} -o comm=`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim().toLowerCase();
|
|
368
|
+
if (parentCmd.includes('cursor'))
|
|
369
|
+
return 'cursor';
|
|
370
|
+
if (parentCmd.includes('codebuddy'))
|
|
371
|
+
return 'codebuddy';
|
|
372
|
+
if (parentCmd.includes('codex'))
|
|
373
|
+
return 'codex';
|
|
374
|
+
if (parentCmd.includes('claude'))
|
|
375
|
+
return 'claude';
|
|
376
|
+
// Check full command/path (for apps that don't show name in comm)
|
|
377
|
+
try {
|
|
378
|
+
const fullArgs = execSync(`ps -p ${ppid} -o args=`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim().toLowerCase();
|
|
379
|
+
if (fullArgs.includes('codebuddy'))
|
|
380
|
+
return 'codebuddy';
|
|
381
|
+
if (fullArgs.includes('cursor'))
|
|
382
|
+
return 'cursor';
|
|
383
|
+
if (fullArgs.includes('codex'))
|
|
384
|
+
return 'codex';
|
|
385
|
+
}
|
|
386
|
+
catch (e2) {
|
|
387
|
+
// Ignore
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
catch (e) {
|
|
392
|
+
// Process detection failed, continue with other methods
|
|
393
|
+
}
|
|
394
|
+
// PRIORITY 2: Check environment variables and working directory
|
|
395
|
+
if (process.env.CURSOR_USER_DATA || process.env.CURSOR_AGENT)
|
|
396
|
+
return 'cursor';
|
|
397
|
+
if (process.env.CODEX_HOME)
|
|
398
|
+
return 'codex';
|
|
399
|
+
if (process.env.CODEBUDDY_HOME)
|
|
400
|
+
return 'codebuddy';
|
|
401
|
+
// Check if process was launched from CodeBuddy's Application Support
|
|
402
|
+
try {
|
|
403
|
+
const cwd = process.cwd();
|
|
404
|
+
if (cwd.includes('CodeBuddy'))
|
|
405
|
+
return 'codebuddy';
|
|
406
|
+
if (cwd.includes('Cursor'))
|
|
407
|
+
return 'cursor';
|
|
408
|
+
}
|
|
409
|
+
catch (e) {
|
|
410
|
+
// Ignore
|
|
411
|
+
}
|
|
412
|
+
// PRIORITY 3: Check most recently modified skills directory (user is likely using this platform)
|
|
413
|
+
try {
|
|
414
|
+
let latestPlatform = '';
|
|
415
|
+
let latestTime = 0;
|
|
416
|
+
for (const [platform, dir] of Object.entries(PLATFORM_SKILLS_DIRS)) {
|
|
417
|
+
const expandedDir = dir.replace('~', homeDir);
|
|
418
|
+
if (fs.existsSync(expandedDir)) {
|
|
419
|
+
const stats = fs.statSync(expandedDir);
|
|
420
|
+
if (stats.mtimeMs > latestTime) {
|
|
421
|
+
latestTime = stats.mtimeMs;
|
|
422
|
+
latestPlatform = platform;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (latestPlatform) {
|
|
427
|
+
return latestPlatform;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
catch (e) {
|
|
431
|
+
// Timestamp check failed, continue with simple existence check
|
|
432
|
+
}
|
|
433
|
+
// PRIORITY 4: Check for platform-specific directories in preferred order (fallback)
|
|
434
|
+
const priorityOrder = ['cursor', 'codex', 'codebuddy', 'claude', 'anthropic'];
|
|
435
|
+
for (const platform of priorityOrder) {
|
|
436
|
+
const dir = PLATFORM_SKILLS_DIRS[platform];
|
|
437
|
+
if (dir) {
|
|
438
|
+
const expandedDir = dir.replace('~', homeDir);
|
|
439
|
+
if (fs.existsSync(expandedDir)) {
|
|
440
|
+
return platform;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return 'unknown';
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Get default skills directory for a platform
|
|
448
|
+
* Priority: 1. Project directory, 2. User home directory
|
|
449
|
+
* @param platform Platform name (optional, auto-detected if not provided)
|
|
450
|
+
* @returns Expanded absolute path to skills directory
|
|
451
|
+
*/
|
|
452
|
+
function getDefaultSkillsDir(platform) {
|
|
453
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || '~';
|
|
454
|
+
const detectedPlatform = platform || detectPlatform();
|
|
455
|
+
// PRIORITY 1: Check if we should use project directory
|
|
456
|
+
try {
|
|
457
|
+
const cwd = process.cwd();
|
|
458
|
+
// Check if current directory is not the home directory or system directory
|
|
459
|
+
const isProjectDir = cwd !== homeDir &&
|
|
460
|
+
!cwd.startsWith('/usr') &&
|
|
461
|
+
!cwd.startsWith('/opt') &&
|
|
462
|
+
!cwd.startsWith('/System') &&
|
|
463
|
+
!cwd.includes('/Library/Application Support');
|
|
464
|
+
if (isProjectDir) {
|
|
465
|
+
// Use project-local skills directory based on platform
|
|
466
|
+
const platformDir = `.${detectedPlatform}/skills`;
|
|
467
|
+
const projectSkillsDir = path.join(cwd, platformDir);
|
|
468
|
+
logger.info(`Using project directory for skills: ${projectSkillsDir}`);
|
|
469
|
+
return projectSkillsDir;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
catch (e) {
|
|
473
|
+
logger.warn('Failed to detect project directory, falling back to user home', { error: e });
|
|
474
|
+
}
|
|
475
|
+
// PRIORITY 2: Use user home directory (original behavior)
|
|
476
|
+
const defaultDir = PLATFORM_SKILLS_DIRS[detectedPlatform];
|
|
477
|
+
if (defaultDir) {
|
|
478
|
+
return defaultDir.replace('~', homeDir);
|
|
479
|
+
}
|
|
480
|
+
// Fallback to generic location
|
|
481
|
+
return path.join(homeDir, '.skills');
|
|
482
|
+
}
|
|
483
|
+
// ============================================================================
|
|
484
|
+
// File Processing Constants
|
|
485
|
+
// ============================================================================
|
|
486
|
+
//
|
|
487
|
+
// These constants define which file types are supported for skill publishing
|
|
488
|
+
// and installation. Text files are read and stored as strings, while binary
|
|
489
|
+
// files are stored as base64-encoded data.
|
|
490
|
+
// ============================================================================
|
|
491
|
+
// File extensions and constants for publish/install
|
|
492
|
+
const TEXT_EXTENSIONS = new Set([
|
|
493
|
+
'.md', '.txt', '.py', '.js', '.ts', '.sh', '.bash', '.zsh',
|
|
494
|
+
'.json', '.yaml', '.yml', '.xml', '.xsd', '.html', '.css',
|
|
495
|
+
'.sql', '.r', '.rb', '.pl', '.lua', '.go', '.rs', '.java',
|
|
496
|
+
'.c', '.cpp', '.h', '.hpp', '.swift', '.kt', '.scala',
|
|
497
|
+
'.template', '.tpl', '.cfg', '.conf', '.ini', '.env',
|
|
498
|
+
'.csv', '.tsv', '.log',
|
|
499
|
+
]);
|
|
500
|
+
const BINARY_EXTENSIONS = new Set([
|
|
501
|
+
'.ttf', '.otf', '.woff', '.woff2',
|
|
502
|
+
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.webp',
|
|
503
|
+
'.pdf', '.zip', '.tar', '.gz',
|
|
504
|
+
]);
|
|
505
|
+
const SKIP_DIRS = new Set([
|
|
506
|
+
'node_modules', '.git', '.svn', '__pycache__', '.DS_Store',
|
|
507
|
+
'dist', 'build', '.cache', '.vscode', '.idea',
|
|
508
|
+
]);
|
|
509
|
+
const SKIP_FILES = new Set([
|
|
510
|
+
'.DS_Store', 'Thumbs.db', '.gitignore', '.npmignore',
|
|
511
|
+
]);
|
|
512
|
+
function isTextFile(filePath) {
|
|
513
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
514
|
+
return TEXT_EXTENSIONS.has(ext);
|
|
515
|
+
}
|
|
516
|
+
function isBinaryFile(filePath) {
|
|
517
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
518
|
+
return BINARY_EXTENSIONS.has(ext);
|
|
519
|
+
}
|
|
520
|
+
function shouldIncludeFile(filePath) {
|
|
521
|
+
const basename = path.basename(filePath);
|
|
522
|
+
if (SKIP_FILES.has(basename))
|
|
523
|
+
return false;
|
|
524
|
+
return isTextFile(filePath) || isBinaryFile(filePath);
|
|
525
|
+
}
|
|
526
|
+
function getMimeType(filePath) {
|
|
527
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
528
|
+
const mimeTypes = {
|
|
529
|
+
'.ttf': 'font/ttf', '.otf': 'font/otf', '.woff': 'font/woff', '.woff2': 'font/woff2',
|
|
530
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
|
|
531
|
+
'.svg': 'image/svg+xml', '.webp': 'image/webp', '.ico': 'image/x-icon',
|
|
532
|
+
'.pdf': 'application/pdf', '.zip': 'application/zip',
|
|
533
|
+
};
|
|
534
|
+
return mimeTypes[ext] || 'application/octet-stream';
|
|
535
|
+
}
|
|
536
|
+
function parseSkillMdFile(content) {
|
|
537
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
538
|
+
if (frontmatterMatch && frontmatterMatch[1] && frontmatterMatch[2] !== undefined) {
|
|
539
|
+
return {
|
|
540
|
+
frontmatter: parseYaml(frontmatterMatch[1]),
|
|
541
|
+
markdown: frontmatterMatch[2].trim(),
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
return { frontmatter: {}, markdown: content.trim() };
|
|
545
|
+
}
|
|
546
|
+
function collectFiles(dirPath, basePath = dirPath) {
|
|
547
|
+
const result = { skillMd: null, files: [], totalSize: 0 };
|
|
548
|
+
if (!dirPath || !fs.existsSync(dirPath))
|
|
549
|
+
return result;
|
|
550
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
551
|
+
for (const entry of entries) {
|
|
552
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
553
|
+
const relativePath = path.relative(basePath, fullPath);
|
|
554
|
+
if (entry.isDirectory()) {
|
|
555
|
+
if (SKIP_DIRS.has(entry.name))
|
|
556
|
+
continue;
|
|
557
|
+
const subResult = collectFiles(fullPath, basePath);
|
|
558
|
+
result.files.push(...subResult.files);
|
|
559
|
+
result.totalSize += subResult.totalSize;
|
|
560
|
+
if (subResult.skillMd && !result.skillMd) {
|
|
561
|
+
result.skillMd = subResult.skillMd;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
else if (entry.isFile()) {
|
|
565
|
+
if (entry.name.toUpperCase() === 'SKILL.MD') {
|
|
566
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
567
|
+
result.skillMd = { path: relativePath, content };
|
|
568
|
+
result.totalSize += content.length;
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
if (!shouldIncludeFile(entry.name))
|
|
572
|
+
continue;
|
|
573
|
+
const stat = fs.statSync(fullPath);
|
|
574
|
+
if (stat.size > 10 * 1024 * 1024)
|
|
575
|
+
continue; // Skip >10MB
|
|
576
|
+
let content;
|
|
577
|
+
if (isTextFile(fullPath)) {
|
|
578
|
+
content = fs.readFileSync(fullPath, 'utf-8');
|
|
579
|
+
}
|
|
580
|
+
else if (isBinaryFile(fullPath)) {
|
|
581
|
+
const buffer = fs.readFileSync(fullPath);
|
|
582
|
+
content = `data:${getMimeType(fullPath)};base64,${buffer.toString('base64')}`;
|
|
583
|
+
}
|
|
584
|
+
else {
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
result.files.push({ path: relativePath, content, size: stat.size });
|
|
588
|
+
result.totalSize += stat.size;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return result;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Handle skills_search tool call
|
|
595
|
+
*
|
|
596
|
+
* Searches the Open Skills Hub by keywords, category, or author.
|
|
597
|
+
* Returns a formatted list of matching skills with key metadata.
|
|
598
|
+
*
|
|
599
|
+
* @param storage - Storage interface for database operations
|
|
600
|
+
* @param args - Search parameters (query, category, author, limit)
|
|
601
|
+
* @returns MCP response with formatted skill list
|
|
602
|
+
*/
|
|
603
|
+
async function handleSearch(storage, args) {
|
|
604
|
+
const result = await storage.searchSkills({
|
|
605
|
+
query: args['query'],
|
|
606
|
+
category: args['category'],
|
|
607
|
+
author: args['author'],
|
|
608
|
+
limit: args['limit'] ?? 10,
|
|
609
|
+
});
|
|
610
|
+
const text = result.items.length === 0
|
|
611
|
+
? 'No skills found matching your query.'
|
|
612
|
+
: `Found ${result.items.length} skill(s):\n\n` + result.items.map((skill, index) => `${index + 1}. **Name:** ${skill.fullName}\n **Author:** ${skill.ownerId ?? 'unknown'}\n **Version:** ${skill.latestVersion}\n **Description:** ${skill.description.substring(0, 120)}${skill.description.length > 120 ? '...' : ''}\n **Security:** ${skill.securityLevel ?? 'unknown'} (${skill.securityScore ?? 'N/A'}) | **Uses:** ${skill.stats.totalUses}`).join('\n\n') +
|
|
613
|
+
`\n\n💡 To install a skill, use: skills_install with the "name" parameter (e.g., {"name": "${result.items[0]?.fullName}"})`;
|
|
614
|
+
return {
|
|
615
|
+
content: [{ type: 'text', text }],
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Handle skills_get tool call
|
|
620
|
+
*
|
|
621
|
+
* Retrieves complete skill content including SKILL.md, attachments, metadata,
|
|
622
|
+
* and version information. Results are cached locally for offline access.
|
|
623
|
+
*
|
|
624
|
+
* @param storage - Storage interface for database operations
|
|
625
|
+
* @param args - Parameters (name: required, version: optional)
|
|
626
|
+
* @returns MCP response with complete skill content
|
|
627
|
+
* @throws McpError if skill name is missing or skill not found
|
|
628
|
+
*/
|
|
629
|
+
async function handleGet(storage, args) {
|
|
630
|
+
const name = args['name'];
|
|
631
|
+
const requestedVersion = args['version'];
|
|
632
|
+
// Parse name
|
|
633
|
+
const { scope, name: skillName } = parseSkillFullName(name);
|
|
634
|
+
const fullName = buildSkillFullName(skillName, scope);
|
|
635
|
+
// Get skill
|
|
636
|
+
const skill = await storage.getSkillByName(fullName);
|
|
637
|
+
if (!skill) {
|
|
638
|
+
throw new McpError(ErrorCode.InvalidParams, `Skill '${fullName}' not found`);
|
|
639
|
+
}
|
|
640
|
+
// Get version
|
|
641
|
+
let version;
|
|
642
|
+
if (requestedVersion) {
|
|
643
|
+
version = await storage.getVersion(skill.id, requestedVersion);
|
|
644
|
+
if (!version) {
|
|
645
|
+
throw new McpError(ErrorCode.InvalidParams, `Version '${requestedVersion}' not found for '${fullName}'`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
version = await storage.getLatestVersion(skill.id);
|
|
650
|
+
if (!version) {
|
|
651
|
+
throw new McpError(ErrorCode.InvalidParams, `No versions found for '${fullName}'`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
// Record use
|
|
655
|
+
await storage.incrementSkillUses(skill.id);
|
|
656
|
+
await storage.incrementVersionUses(version.id);
|
|
657
|
+
const useRecord = {
|
|
658
|
+
id: generateUUID(),
|
|
659
|
+
skillId: skill.id,
|
|
660
|
+
versionId: version.id,
|
|
661
|
+
source: 'mcp',
|
|
662
|
+
cacheHit: false,
|
|
663
|
+
ip: '127.0.0.1',
|
|
664
|
+
createdAt: now(),
|
|
665
|
+
};
|
|
666
|
+
await storage.createUseRecord(useRecord);
|
|
667
|
+
// Build response
|
|
668
|
+
const content = version.content;
|
|
669
|
+
const frontmatterLines = Object.entries(content.frontmatter)
|
|
670
|
+
.filter(([_, v]) => v !== undefined)
|
|
671
|
+
.map(([k, v]) => {
|
|
672
|
+
if (Array.isArray(v)) {
|
|
673
|
+
return `${k}:\n${v.map(item => ` - ${item}`).join('\n')}`;
|
|
674
|
+
}
|
|
675
|
+
return `${k}: ${v}`;
|
|
676
|
+
});
|
|
677
|
+
const text = `# ${skill.displayName ?? skill.fullName}
|
|
678
|
+
|
|
679
|
+
## Metadata
|
|
680
|
+
- **Name:** ${skill.fullName} (use this in skills_install)
|
|
681
|
+
- **Author:** ${skill.ownerId ?? 'unknown'}
|
|
682
|
+
- **Version:** ${version.version}
|
|
683
|
+
- **Security Level:** ${skill.securityLevel ?? 'unknown'} (Score: ${skill.securityScore ?? 'N/A'})
|
|
684
|
+
- **Uses:** ${skill.stats.totalUses}
|
|
685
|
+
- **Rating:** ${skill.rating.average}/5 (${skill.rating.count} reviews)
|
|
686
|
+
|
|
687
|
+
## Frontmatter
|
|
688
|
+
\`\`\`yaml
|
|
689
|
+
${frontmatterLines.join('\n')}
|
|
690
|
+
\`\`\`
|
|
691
|
+
|
|
692
|
+
## Content
|
|
693
|
+
${content.markdown}`;
|
|
694
|
+
return {
|
|
695
|
+
content: [{ type: 'text', text }],
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Handle skills_scan tool call
|
|
700
|
+
*
|
|
701
|
+
* Performs security scanning on skill content using SkillsGuard with 16 built-in
|
|
702
|
+
* security rules. Returns a score (0-100) and detailed issue report.
|
|
703
|
+
*
|
|
704
|
+
* @param args - Parameters (content: required text to scan)
|
|
705
|
+
* @returns MCP response with security scan results
|
|
706
|
+
*/
|
|
707
|
+
async function handleScan(args) {
|
|
708
|
+
const content = args['content'];
|
|
709
|
+
const scanner = getScanner();
|
|
710
|
+
const result = await scanner.scan(content);
|
|
711
|
+
const issuesText = result.issues.length === 0
|
|
712
|
+
? 'No security issues found.'
|
|
713
|
+
: result.issues.map(issue => `[${issue.severity.toUpperCase()}] ${issue.ruleName} (${issue.ruleId})\n Line ${issue.line}: ${issue.content}\n ${issue.message}\n Suggestion: ${issue.suggestion ?? 'N/A'}`).join('\n\n');
|
|
714
|
+
const text = `## Security Scan Results
|
|
715
|
+
|
|
716
|
+
**Score:** ${result.summary.score}/100
|
|
717
|
+
**Level:** ${result.summary.level.toUpperCase()}
|
|
718
|
+
|
|
719
|
+
### Issue Summary
|
|
720
|
+
- High: ${result.summary.issueCount.high}
|
|
721
|
+
- Medium: ${result.summary.issueCount.medium}
|
|
722
|
+
- Low: ${result.summary.issueCount.low}
|
|
723
|
+
|
|
724
|
+
### Issues
|
|
725
|
+
${issuesText}
|
|
726
|
+
|
|
727
|
+
### Recommendations
|
|
728
|
+
${result.summary.recommendations.map(r => `• ${r}`).join('\n')}`;
|
|
729
|
+
return {
|
|
730
|
+
content: [{ type: 'text', text }],
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Handle skills_feedback tool call
|
|
735
|
+
*
|
|
736
|
+
* Submits feedback for a skill (success, failure, suggestion, or bug report).
|
|
737
|
+
* Feedback is queued locally and processed asynchronously with auto-retry.
|
|
738
|
+
*
|
|
739
|
+
* @param storage - Storage interface for database operations
|
|
740
|
+
* @param queueProcessor - Queue processor for async feedback handling
|
|
741
|
+
* @param args - Parameters (name, type, rating: required; version, comment: optional)
|
|
742
|
+
* @returns MCP response confirming feedback submission
|
|
743
|
+
* @throws McpError if required parameters are missing or invalid
|
|
744
|
+
*/
|
|
745
|
+
async function handleFeedback(storage, queueProcessor, args) {
|
|
746
|
+
const name = args['name'];
|
|
747
|
+
const feedbackType = args['type'];
|
|
748
|
+
const rating = args['rating'];
|
|
749
|
+
const comment = args['comment'];
|
|
750
|
+
const requestedVersion = args['version'];
|
|
751
|
+
// Parse name
|
|
752
|
+
const { scope, name: skillName } = parseSkillFullName(name);
|
|
753
|
+
const fullName = buildSkillFullName(skillName, scope);
|
|
754
|
+
// Get skill
|
|
755
|
+
const skill = await storage.getSkillByName(fullName);
|
|
756
|
+
if (!skill) {
|
|
757
|
+
throw new McpError(ErrorCode.InvalidParams, `Skill '${fullName}' not found`);
|
|
758
|
+
}
|
|
759
|
+
// Get version
|
|
760
|
+
let version;
|
|
761
|
+
if (requestedVersion) {
|
|
762
|
+
version = await storage.getVersion(skill.id, requestedVersion);
|
|
763
|
+
}
|
|
764
|
+
else {
|
|
765
|
+
version = await storage.getLatestVersion(skill.id);
|
|
766
|
+
}
|
|
767
|
+
if (!version) {
|
|
768
|
+
throw new McpError(ErrorCode.InvalidParams, 'Skill version not found');
|
|
769
|
+
}
|
|
770
|
+
// Create feedback
|
|
771
|
+
const feedback = {
|
|
772
|
+
id: generateUUID(),
|
|
773
|
+
skillId: skill.id,
|
|
774
|
+
skillVersion: version.version,
|
|
775
|
+
feedbackType,
|
|
776
|
+
rating,
|
|
777
|
+
comment,
|
|
778
|
+
context: {},
|
|
779
|
+
status: 'pending',
|
|
780
|
+
createdAt: now(),
|
|
781
|
+
};
|
|
782
|
+
try {
|
|
783
|
+
await storage.createFeedback(feedback);
|
|
784
|
+
// Update skill rating
|
|
785
|
+
const feedbacks = await storage.getFeedbacks(skill.id, { limit: 1000 });
|
|
786
|
+
const ratings = feedbacks.items.map(f => f.rating).filter((r) => r !== undefined);
|
|
787
|
+
const averageRating = ratings.length > 0
|
|
788
|
+
? ratings.reduce((sum, r) => sum + r, 0) / ratings.length
|
|
789
|
+
: 0;
|
|
790
|
+
await storage.updateSkill(skill.id, {
|
|
791
|
+
rating: {
|
|
792
|
+
average: Math.round(averageRating * 10) / 10,
|
|
793
|
+
count: ratings.length,
|
|
794
|
+
},
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
catch (error) {
|
|
798
|
+
// Queue for retry if failed
|
|
799
|
+
const queueItem = {
|
|
800
|
+
id: generateUUID(),
|
|
801
|
+
skillName: fullName,
|
|
802
|
+
skillVersion: version.version,
|
|
803
|
+
feedback: {
|
|
804
|
+
type: feedbackType,
|
|
805
|
+
rating,
|
|
806
|
+
comment,
|
|
807
|
+
context: {},
|
|
808
|
+
},
|
|
809
|
+
status: 'pending',
|
|
810
|
+
attempts: 0,
|
|
811
|
+
maxAttempts: 5,
|
|
812
|
+
baseDelay: 1000,
|
|
813
|
+
currentDelay: 1000,
|
|
814
|
+
maxDelay: 3600000,
|
|
815
|
+
createdAt: now(),
|
|
816
|
+
};
|
|
817
|
+
await queueProcessor.enqueue(queueItem);
|
|
818
|
+
return {
|
|
819
|
+
content: [{
|
|
820
|
+
type: 'text',
|
|
821
|
+
text: `Feedback queued for ${fullName}@${version.version}. Will be submitted when connection is restored.`,
|
|
822
|
+
}],
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
return {
|
|
826
|
+
content: [{
|
|
827
|
+
type: 'text',
|
|
828
|
+
text: `## ✅ Feedback Submitted Successfully!
|
|
829
|
+
|
|
830
|
+
**Name:** ${fullName}
|
|
831
|
+
**Version:** ${version.version}
|
|
832
|
+
**Type:** ${feedbackType}
|
|
833
|
+
**Rating:** ${'⭐'.repeat(rating)} (${rating}/5)${comment ? `\n**Comment:** ${comment}` : ''}
|
|
834
|
+
|
|
835
|
+
Thank you for helping improve this skill!`,
|
|
836
|
+
}],
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Handle skills_list_cached tool call
|
|
841
|
+
*
|
|
842
|
+
* Lists all skills currently stored in the local cache with metadata
|
|
843
|
+
* including cache time, usage count, and size.
|
|
844
|
+
*
|
|
845
|
+
* @param storage - Storage interface for database operations
|
|
846
|
+
* @returns MCP response with formatted list of cached skills
|
|
847
|
+
*/
|
|
848
|
+
async function handleListCached(storage) {
|
|
849
|
+
const cacheEntries = await storage.getAllCacheMetadata();
|
|
850
|
+
if (cacheEntries.length === 0) {
|
|
851
|
+
return {
|
|
852
|
+
content: [{ type: 'text', text: 'No skills currently cached locally.' }],
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
const text = `## Cached Skills (${cacheEntries.length})
|
|
856
|
+
|
|
857
|
+
${cacheEntries.map((entry, index) => `${index + 1}. **Name:** ${entry.skillName}\n **Version:** ${entry.version}\n **Cached:** ${entry.cachedAt.split('T')[0]}\n **Hits:** ${entry.hitCount}`).join('\n\n')}
|
|
858
|
+
|
|
859
|
+
💡 Use skills_install with the "name" parameter to reinstall or update a cached skill.`;
|
|
860
|
+
return {
|
|
861
|
+
content: [{ type: 'text', text }],
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Handle skills_publish tool call
|
|
866
|
+
*
|
|
867
|
+
* Publishes a skill to the hub by spawning the CLI tool as a subprocess.
|
|
868
|
+
* This approach is more stable than the direct implementation. The skill directory
|
|
869
|
+
* must contain a SKILL.md file. Automatically performs security scanning.
|
|
870
|
+
*
|
|
871
|
+
* @param storage - Storage interface for database operations
|
|
872
|
+
* @param config - Configuration instance for API URL
|
|
873
|
+
* @param args - Parameters (path: required; scope, version: optional)
|
|
874
|
+
* @returns MCP response with publish confirmation
|
|
875
|
+
* @throws McpError if path is missing, invalid, or publish fails
|
|
876
|
+
*/
|
|
877
|
+
async function handlePublish(storage, config, args) {
|
|
878
|
+
const inputPath = args['path'];
|
|
879
|
+
const scopeArg = args['scope'];
|
|
880
|
+
const authorArg = args['author'];
|
|
881
|
+
const versionArg = args['version'];
|
|
882
|
+
if (!inputPath) {
|
|
883
|
+
throw new McpError(ErrorCode.InvalidParams, 'Path is required');
|
|
884
|
+
}
|
|
885
|
+
const resolvedPath = path.resolve(inputPath);
|
|
886
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
887
|
+
throw new McpError(ErrorCode.InvalidParams, `Path not found: ${resolvedPath}`);
|
|
888
|
+
}
|
|
889
|
+
const stat = fs.statSync(resolvedPath);
|
|
890
|
+
if (!stat.isDirectory()) {
|
|
891
|
+
throw new McpError(ErrorCode.InvalidParams, 'Path must be a directory containing SKILL.md');
|
|
892
|
+
}
|
|
893
|
+
// Check if SKILL.md exists
|
|
894
|
+
const skillMdPath = path.join(resolvedPath, 'SKILL.md');
|
|
895
|
+
if (!fs.existsSync(skillMdPath)) {
|
|
896
|
+
throw new McpError(ErrorCode.InvalidParams, `SKILL.md not found in ${resolvedPath}`);
|
|
897
|
+
}
|
|
898
|
+
// Check if author is provided (either as arg or in frontmatter)
|
|
899
|
+
if (!authorArg) {
|
|
900
|
+
const skillMdContent = fs.readFileSync(skillMdPath, 'utf-8');
|
|
901
|
+
const parsed = parseSkillMdFile(skillMdContent);
|
|
902
|
+
const frontmatterAuthor = parsed.frontmatter['author']
|
|
903
|
+
?? parsed.frontmatter['metadata']?.['author'];
|
|
904
|
+
if (!frontmatterAuthor) {
|
|
905
|
+
throw new McpError(ErrorCode.InvalidParams, 'Author is required! Provide via --author argument or add "author: Your Name" to SKILL.md frontmatter.');
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// Build CLI command options
|
|
909
|
+
const commands = buildPublishCliCommands(resolvedPath, scopeArg, authorArg, versionArg);
|
|
910
|
+
// Return instructions for the agent
|
|
911
|
+
const text = `## 📦 如何发布 Skill
|
|
912
|
+
|
|
913
|
+
请使用 Shell 工具执行以下 CLI 命令来发布 skill(按优先级排序):
|
|
914
|
+
|
|
915
|
+
### 方式 1:使用全局安装的 CLI(推荐)
|
|
916
|
+
如果已通过 \`npm link\` 或 \`npm install -g\` 安装:
|
|
917
|
+
|
|
918
|
+
\`\`\`bash
|
|
919
|
+
${commands.global}
|
|
920
|
+
\`\`\`
|
|
921
|
+
|
|
922
|
+
### 方式 2:使用 npx(无需安装,发布到 npm 后可用)
|
|
923
|
+
\`\`\`bash
|
|
924
|
+
${commands.npx}
|
|
925
|
+
\`\`\`
|
|
926
|
+
|
|
927
|
+
### 方式 3:使用本地项目路径(开发模式)
|
|
928
|
+
如果有 SkillsHub 项目:
|
|
929
|
+
|
|
930
|
+
\`\`\`bash
|
|
931
|
+
${commands.local}
|
|
932
|
+
\`\`\`
|
|
933
|
+
|
|
934
|
+
---
|
|
935
|
+
|
|
936
|
+
### 命令说明:
|
|
937
|
+
- **路径**: ${resolvedPath}
|
|
938
|
+
${scopeArg ? `- **作用域**: ${scopeArg}` : '- **作用域**: 未指定(将使用默认)'}
|
|
939
|
+
${authorArg ? `- **作者**: ${authorArg}` : '- **作者**: 将从 SKILL.md frontmatter 读取'}
|
|
940
|
+
${versionArg ? `- **版本**: ${versionArg}` : '- **版本**: 未指定(将自动递增)'}
|
|
941
|
+
|
|
942
|
+
### 执行步骤:
|
|
943
|
+
1. 尝试方式 1,如果失败则尝试方式 2 或 3
|
|
944
|
+
2. 等待命令完成(通常需要 1-3 秒)
|
|
945
|
+
3. 检查输出确认发布成功
|
|
946
|
+
4. 如需在 Web UI 查看,执行: \`curl -X POST http://localhost:3001/reload\`
|
|
947
|
+
|
|
948
|
+
### 注意事项:
|
|
949
|
+
- 确保目录包含有效的 SKILL.md 文件
|
|
950
|
+
- 如果版本已存在,命令会失败并提示
|
|
951
|
+
- 发布成功后会自动进行安全扫描(评分 0-100)`;
|
|
952
|
+
return {
|
|
953
|
+
content: [{ type: 'text', text }],
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Build CLI commands for publishing a skill (multiple options)
|
|
958
|
+
*/
|
|
959
|
+
function buildPublishCliCommands(skillPath, scope, author, version) {
|
|
960
|
+
// Build argument list
|
|
961
|
+
const args = ['publish', `"${skillPath}"`];
|
|
962
|
+
if (scope) {
|
|
963
|
+
args.push('-s', scope);
|
|
964
|
+
}
|
|
965
|
+
if (author) {
|
|
966
|
+
args.push('-a', `"${author}"`);
|
|
967
|
+
}
|
|
968
|
+
if (version) {
|
|
969
|
+
args.push('-v', version);
|
|
970
|
+
}
|
|
971
|
+
args.push('-y'); // Skip confirmation
|
|
972
|
+
const argsStr = args.join(' ');
|
|
973
|
+
// Calculate local path for development
|
|
974
|
+
const mcpServerDir = path.dirname(decodeURIComponent(new URL(import.meta.url).pathname));
|
|
975
|
+
const projectRoot = path.resolve(mcpServerDir, '../../..'); // Go up to project root
|
|
976
|
+
const cliPath = path.join(projectRoot, 'packages/cli/dist/index.js');
|
|
977
|
+
return {
|
|
978
|
+
global: `skills ${argsStr}`,
|
|
979
|
+
npx: `npx open-skills-hub ${argsStr}`,
|
|
980
|
+
local: `node "${cliPath}" ${argsStr}`,
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Publish skill via CLI tool to avoid database concurrency issues
|
|
985
|
+
*/
|
|
986
|
+
async function handlePublishViaCli(skillPath, scope, version, config) {
|
|
987
|
+
const { spawn } = await import('child_process');
|
|
988
|
+
// Build CLI command - use absolute path from environment or relative to this file
|
|
989
|
+
// The MCP server is in packages/mcp-server/dist/index.js
|
|
990
|
+
// The CLI is in packages/cli/dist/index.js
|
|
991
|
+
const mcpServerDir = path.dirname(decodeURIComponent(new URL(import.meta.url).pathname));
|
|
992
|
+
const projectRoot = path.resolve(mcpServerDir, '../../..'); // Go up to project root
|
|
993
|
+
const cliPath = path.join(projectRoot, 'packages/cli/dist/index.js');
|
|
994
|
+
// Verify CLI exists
|
|
995
|
+
if (!fs.existsSync(cliPath)) {
|
|
996
|
+
logger.error('CLI tool not found', { cliPath, projectRoot, mcpServerDir });
|
|
997
|
+
throw new McpError(ErrorCode.InternalError, `CLI tool not found at ${cliPath}. Please ensure the CLI is built.`);
|
|
998
|
+
}
|
|
999
|
+
logger.info('Using CLI tool for publish', { cliPath, skillPath, scope, version });
|
|
1000
|
+
const args = ['publish', skillPath];
|
|
1001
|
+
if (scope) {
|
|
1002
|
+
args.push('-s', scope);
|
|
1003
|
+
}
|
|
1004
|
+
if (version) {
|
|
1005
|
+
args.push('-v', version);
|
|
1006
|
+
}
|
|
1007
|
+
args.push('-y'); // Auto-confirm
|
|
1008
|
+
return new Promise((resolve, reject) => {
|
|
1009
|
+
const child = spawn('node', [cliPath, ...args], {
|
|
1010
|
+
env: {
|
|
1011
|
+
...process.env,
|
|
1012
|
+
STORAGE_PATH: config.get().storagePath,
|
|
1013
|
+
STORAGE_MODE: config.get().mode,
|
|
1014
|
+
SKILLS_REGISTRY_URL: config.get().apiUrl || 'http://localhost:3001',
|
|
1015
|
+
},
|
|
1016
|
+
cwd: projectRoot, // Use project root as working directory
|
|
1017
|
+
});
|
|
1018
|
+
let stdout = '';
|
|
1019
|
+
let stderr = '';
|
|
1020
|
+
if (child.stdout) {
|
|
1021
|
+
child.stdout.on('data', (data) => {
|
|
1022
|
+
stdout += data.toString();
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
if (child.stderr) {
|
|
1026
|
+
child.stderr.on('data', (data) => {
|
|
1027
|
+
stderr += data.toString();
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
child.on('close', async (code) => {
|
|
1031
|
+
if (code === 0) {
|
|
1032
|
+
// Extract skill name and version from stdout
|
|
1033
|
+
const nameMatch = stdout.match(/Name:\s+(@?[\w-]+\/[\w-]+)/);
|
|
1034
|
+
const versionMatch = stdout.match(/Version:\s+([\d.]+)/);
|
|
1035
|
+
const actionMatch = stdout.match(/Action:\s+(.+)/);
|
|
1036
|
+
const skillName = nameMatch?.[1] || 'unknown';
|
|
1037
|
+
const skillVersion = versionMatch?.[1] || 'unknown';
|
|
1038
|
+
const action = actionMatch?.[1]?.trim() || 'Published';
|
|
1039
|
+
// Trigger API reload
|
|
1040
|
+
const apiUrl = config.get().apiUrl || 'http://localhost:3001';
|
|
1041
|
+
await reloadApi(apiUrl);
|
|
1042
|
+
const resultText = `## ✅ Published Successfully via MCP!
|
|
1043
|
+
|
|
1044
|
+
**Skill:** ${skillName}
|
|
1045
|
+
**Version:** ${skillVersion}
|
|
1046
|
+
**Action:** ${action}
|
|
1047
|
+
|
|
1048
|
+
The skill has been published and the API has been reloaded.
|
|
1049
|
+
You can now use it with \`skills_get\` or view it at http://localhost:8080
|
|
1050
|
+
|
|
1051
|
+
---
|
|
1052
|
+
Use \`skills_get ${skillName}\` to retrieve this skill.`;
|
|
1053
|
+
resolve({
|
|
1054
|
+
content: [{ type: 'text', text: resultText }],
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
else {
|
|
1058
|
+
// Extract error message from stderr or stdout
|
|
1059
|
+
const errorMsg = stderr || stdout || `CLI exited with code ${code}`;
|
|
1060
|
+
logger.error('CLI publish failed', { code, stderr, stdout });
|
|
1061
|
+
reject(new McpError(ErrorCode.InternalError, `Failed to publish skill: ${errorMsg}`));
|
|
1062
|
+
}
|
|
1063
|
+
});
|
|
1064
|
+
child.on('error', (error) => {
|
|
1065
|
+
logger.error('Failed to spawn CLI process', { error });
|
|
1066
|
+
reject(new McpError(ErrorCode.InternalError, `Failed to spawn CLI process: ${error.message}`));
|
|
1067
|
+
});
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
async function handlePublishOld(storage, config, args) {
|
|
1071
|
+
const inputPath = args['path'];
|
|
1072
|
+
const scopeArg = args['scope'];
|
|
1073
|
+
const versionArg = args['version'];
|
|
1074
|
+
if (!inputPath) {
|
|
1075
|
+
throw new McpError(ErrorCode.InvalidParams, 'Path is required');
|
|
1076
|
+
}
|
|
1077
|
+
const resolvedPath = path.resolve(inputPath);
|
|
1078
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
1079
|
+
throw new McpError(ErrorCode.InvalidParams, `Path not found: ${resolvedPath}`);
|
|
1080
|
+
}
|
|
1081
|
+
const stat = fs.statSync(resolvedPath);
|
|
1082
|
+
if (!stat.isDirectory()) {
|
|
1083
|
+
throw new McpError(ErrorCode.InvalidParams, 'Path must be a directory containing SKILL.md');
|
|
1084
|
+
}
|
|
1085
|
+
// Collect files
|
|
1086
|
+
const collected = collectFiles(resolvedPath);
|
|
1087
|
+
if (!collected.skillMd) {
|
|
1088
|
+
throw new McpError(ErrorCode.InvalidParams, 'SKILL.md not found in directory');
|
|
1089
|
+
}
|
|
1090
|
+
// Parse SKILL.md
|
|
1091
|
+
const parsed = parseSkillMdFile(collected.skillMd.content);
|
|
1092
|
+
const skillDirName = path.basename(resolvedPath);
|
|
1093
|
+
const skillName = parsed.frontmatter['name'] ?? skillDirName;
|
|
1094
|
+
if (!isValidSkillName(skillName)) {
|
|
1095
|
+
throw new McpError(ErrorCode.InvalidParams, `Invalid skill name: ${skillName}. Use lowercase letters, numbers, and hyphens.`);
|
|
1096
|
+
}
|
|
1097
|
+
const scope = scopeArg?.replace(/^@/, '');
|
|
1098
|
+
const fullName = buildSkillFullName(skillName, scope);
|
|
1099
|
+
// Check existing skill
|
|
1100
|
+
const existingSkill = await storage.getSkillByName(fullName);
|
|
1101
|
+
const isUpdate = !!existingSkill;
|
|
1102
|
+
// Determine version
|
|
1103
|
+
let version = versionArg ?? parsed.frontmatter['version'];
|
|
1104
|
+
if (!version) {
|
|
1105
|
+
if (isUpdate) {
|
|
1106
|
+
const [major, minor, patch] = existingSkill.latestVersion.split('.').map(Number);
|
|
1107
|
+
version = `${major}.${minor}.${(patch ?? 0) + 1}`;
|
|
1108
|
+
}
|
|
1109
|
+
else {
|
|
1110
|
+
version = '1.0.0';
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
if (!isValidSemver(version)) {
|
|
1114
|
+
throw new McpError(ErrorCode.InvalidParams, `Invalid version: ${version}. Use semantic versioning (e.g., 1.0.0).`);
|
|
1115
|
+
}
|
|
1116
|
+
// Check version doesn't exist
|
|
1117
|
+
if (isUpdate) {
|
|
1118
|
+
const existingVersion = await storage.getVersion(existingSkill.id, version);
|
|
1119
|
+
if (existingVersion) {
|
|
1120
|
+
logger.warn(`Version ${version} already exists for ${fullName}`);
|
|
1121
|
+
throw new McpError(ErrorCode.InvalidParams, `Version ${version} already exists for ${fullName}.`);
|
|
1122
|
+
}
|
|
1123
|
+
logger.info(`Creating new version ${version} for existing skill ${fullName}`);
|
|
1124
|
+
}
|
|
1125
|
+
else {
|
|
1126
|
+
logger.info(`Creating new skill ${fullName} with version ${version}`);
|
|
1127
|
+
}
|
|
1128
|
+
// Security scan
|
|
1129
|
+
const scanner = getScanner();
|
|
1130
|
+
const scanResult = await scanner.scan(collected.skillMd.content);
|
|
1131
|
+
for (const file of collected.files) {
|
|
1132
|
+
if (file.path.startsWith('scripts/') && (file.path.endsWith('.py') || file.path.endsWith('.sh') || file.path.endsWith('.js'))) {
|
|
1133
|
+
const fileScanResult = await scanner.scan(file.content);
|
|
1134
|
+
if (fileScanResult && fileScanResult.issues) {
|
|
1135
|
+
scanResult.issues.push(...fileScanResult.issues);
|
|
1136
|
+
}
|
|
1137
|
+
if (fileScanResult && fileScanResult.summary) {
|
|
1138
|
+
scanResult.summary.score = Math.min(scanResult.summary.score, fileScanResult.summary.score);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
// Build skill content
|
|
1143
|
+
const skillContent = {
|
|
1144
|
+
frontmatter: {
|
|
1145
|
+
name: skillName,
|
|
1146
|
+
description: parsed.frontmatter['description'] ?? '',
|
|
1147
|
+
allowedTools: parsed.frontmatter['allowedTools'],
|
|
1148
|
+
argumentHint: parsed.frontmatter['argumentHint'],
|
|
1149
|
+
disableModelInvocation: parsed.frontmatter['disableModelInvocation'],
|
|
1150
|
+
userInvocable: parsed.frontmatter['userInvocable'],
|
|
1151
|
+
model: parsed.frontmatter['model'],
|
|
1152
|
+
license: parsed.frontmatter['license'],
|
|
1153
|
+
compatibility: parsed.frontmatter['compatibility'],
|
|
1154
|
+
metadata: parsed.frontmatter['metadata'],
|
|
1155
|
+
},
|
|
1156
|
+
markdown: parsed.markdown,
|
|
1157
|
+
files: collected.files.length > 0 ? collected.files : undefined,
|
|
1158
|
+
};
|
|
1159
|
+
const contentString = JSON.stringify(skillContent);
|
|
1160
|
+
const contentHash = sha256(contentString);
|
|
1161
|
+
const timestamp = now();
|
|
1162
|
+
const versionId = generateUUID();
|
|
1163
|
+
if (isUpdate) {
|
|
1164
|
+
// Create new version
|
|
1165
|
+
const versionRecord = {
|
|
1166
|
+
id: versionId,
|
|
1167
|
+
skillId: existingSkill.id,
|
|
1168
|
+
version,
|
|
1169
|
+
tag: 'latest',
|
|
1170
|
+
content: skillContent,
|
|
1171
|
+
packageUrl: `local://${fullName}/${version}`,
|
|
1172
|
+
packageSize: contentString.length,
|
|
1173
|
+
packageHash: contentHash,
|
|
1174
|
+
status: 'published',
|
|
1175
|
+
uses: 0,
|
|
1176
|
+
createdAt: timestamp,
|
|
1177
|
+
publishedAt: timestamp,
|
|
1178
|
+
};
|
|
1179
|
+
await storage.createVersion(versionRecord);
|
|
1180
|
+
logger.info(`Version ${version} created for skill ${fullName}`);
|
|
1181
|
+
// Update previous latest tag
|
|
1182
|
+
const versions = await storage.getVersions(existingSkill.id, { limit: 100 });
|
|
1183
|
+
for (const v of versions.items) {
|
|
1184
|
+
if (v.id !== versionId && v.tag === 'latest') {
|
|
1185
|
+
await storage.updateVersion(v.id, { tag: undefined });
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
// Update skill
|
|
1189
|
+
await storage.updateSkill(existingSkill.id, {
|
|
1190
|
+
latestVersion: version,
|
|
1191
|
+
latestVersionId: versionId,
|
|
1192
|
+
securityScore: scanResult.summary.score,
|
|
1193
|
+
securityLevel: scanResult.summary.level,
|
|
1194
|
+
stats: {
|
|
1195
|
+
...existingSkill.stats,
|
|
1196
|
+
versionCount: existingSkill.stats.versionCount + 1,
|
|
1197
|
+
},
|
|
1198
|
+
});
|
|
1199
|
+
logger.info(`Skill ${fullName} updated to version ${version}`);
|
|
1200
|
+
}
|
|
1201
|
+
else {
|
|
1202
|
+
// Create new skill
|
|
1203
|
+
const skillId = generateUUID();
|
|
1204
|
+
const skillRecord = {
|
|
1205
|
+
id: skillId,
|
|
1206
|
+
name: skillName,
|
|
1207
|
+
scope,
|
|
1208
|
+
fullName,
|
|
1209
|
+
displayName: parsed.frontmatter['displayName'],
|
|
1210
|
+
description: parsed.frontmatter['description'] ?? '',
|
|
1211
|
+
keywords: [],
|
|
1212
|
+
visibility: 'public',
|
|
1213
|
+
status: 'active',
|
|
1214
|
+
latestVersion: version,
|
|
1215
|
+
latestVersionId: versionId,
|
|
1216
|
+
ownerType: 'user',
|
|
1217
|
+
securityScore: scanResult.summary.score,
|
|
1218
|
+
securityLevel: scanResult.summary.level,
|
|
1219
|
+
stats: { totalUses: 0, weeklyUses: 0, monthlyUses: 0, versionCount: 1, derivationCount: 0 },
|
|
1220
|
+
rating: { average: 0, count: 0 },
|
|
1221
|
+
createdAt: timestamp,
|
|
1222
|
+
updatedAt: timestamp,
|
|
1223
|
+
publishedAt: timestamp,
|
|
1224
|
+
};
|
|
1225
|
+
await storage.createSkill(skillRecord);
|
|
1226
|
+
// Create version
|
|
1227
|
+
const versionRecord = {
|
|
1228
|
+
id: versionId,
|
|
1229
|
+
skillId,
|
|
1230
|
+
version,
|
|
1231
|
+
tag: 'latest',
|
|
1232
|
+
content: skillContent,
|
|
1233
|
+
packageUrl: `local://${fullName}/${version}`,
|
|
1234
|
+
packageSize: contentString.length,
|
|
1235
|
+
packageHash: contentHash,
|
|
1236
|
+
status: 'published',
|
|
1237
|
+
uses: 0,
|
|
1238
|
+
createdAt: timestamp,
|
|
1239
|
+
publishedAt: timestamp,
|
|
1240
|
+
};
|
|
1241
|
+
await storage.createVersion(versionRecord);
|
|
1242
|
+
}
|
|
1243
|
+
// Create audit log
|
|
1244
|
+
const auditLog = {
|
|
1245
|
+
id: generateUUID(),
|
|
1246
|
+
timestamp,
|
|
1247
|
+
eventType: isUpdate ? 'version.published' : 'skill.published',
|
|
1248
|
+
actor: { type: 'user', id: 'mcp-user' },
|
|
1249
|
+
resource: { type: 'skill', name: fullName, version },
|
|
1250
|
+
action: 'publish',
|
|
1251
|
+
result: 'success',
|
|
1252
|
+
details: { filesCount: collected.files.length + 1, totalSize: collected.totalSize },
|
|
1253
|
+
};
|
|
1254
|
+
await storage.createAuditLog(auditLog);
|
|
1255
|
+
logger.info('Audit log created, syncing database...');
|
|
1256
|
+
// Force sync to disk (SQLite is in-memory, need to persist)
|
|
1257
|
+
await storage.sync();
|
|
1258
|
+
logger.info('Database synced to disk');
|
|
1259
|
+
// Reload API to pick up the changes
|
|
1260
|
+
const apiUrl = config.get().apiUrl;
|
|
1261
|
+
if (apiUrl && apiUrl !== 'https://api.open-skills-hub.io') {
|
|
1262
|
+
await reloadApi(apiUrl);
|
|
1263
|
+
}
|
|
1264
|
+
else {
|
|
1265
|
+
// Try local API at default port
|
|
1266
|
+
await reloadApi('http://localhost:3001');
|
|
1267
|
+
}
|
|
1268
|
+
const filesList = collected.files && collected.files.length > 0
|
|
1269
|
+
? collected.files.map(f => `- ${f.path}`).join('\n')
|
|
1270
|
+
: 'No additional files';
|
|
1271
|
+
const resultText = `## ✅ Published Successfully!
|
|
1272
|
+
|
|
1273
|
+
**Skill:** ${fullName}
|
|
1274
|
+
**Version:** ${version}
|
|
1275
|
+
**Action:** ${isUpdate ? 'New version added' : 'New skill created'}
|
|
1276
|
+
|
|
1277
|
+
### Files
|
|
1278
|
+
- SKILL.md
|
|
1279
|
+
${filesList}
|
|
1280
|
+
|
|
1281
|
+
### Security Scan
|
|
1282
|
+
- Score: ${scanResult.summary.score}/100
|
|
1283
|
+
- Level: ${scanResult.summary.level.toUpperCase()}
|
|
1284
|
+
${scanResult.issues.length > 0 ? `- Issues: ${scanResult.issues.length}` : ''}
|
|
1285
|
+
|
|
1286
|
+
---
|
|
1287
|
+
Use \`skills_get\` to retrieve this skill, or \`skills_install\` to download it to a project.`;
|
|
1288
|
+
return {
|
|
1289
|
+
content: [{ type: 'text', text: resultText }],
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Handle skills_install tool call
|
|
1294
|
+
*
|
|
1295
|
+
* Downloads a skill from the hub and installs it to a local directory.
|
|
1296
|
+
* Creates the skill folder with SKILL.md and all attachments (scripts, configs, etc.).
|
|
1297
|
+
* The skill name must match exactly as returned from skills_search.
|
|
1298
|
+
*
|
|
1299
|
+
* Supports automatic platform detection and default directory resolution:
|
|
1300
|
+
* - If targetDir is not provided, uses platform-specific default
|
|
1301
|
+
* - If platform is specified, uses that platform's default directory
|
|
1302
|
+
* - Automatically creates directories if they don't exist
|
|
1303
|
+
*
|
|
1304
|
+
* @param storage - Storage interface for database operations
|
|
1305
|
+
* @param args - Parameters (name: required; targetDir, platform, version: optional)
|
|
1306
|
+
* @returns MCP response with installation confirmation
|
|
1307
|
+
* @throws McpError if required parameters are missing, skill not found, or install fails
|
|
1308
|
+
*/
|
|
1309
|
+
async function handleInstall(storage, args) {
|
|
1310
|
+
const name = args['name'];
|
|
1311
|
+
let targetDir = args['targetDir'];
|
|
1312
|
+
const platform = args['platform'];
|
|
1313
|
+
const requestedVersion = args['version'];
|
|
1314
|
+
if (!name) {
|
|
1315
|
+
throw new McpError(ErrorCode.InvalidParams, 'Skill name is required');
|
|
1316
|
+
}
|
|
1317
|
+
// Auto-detect target directory if not provided
|
|
1318
|
+
let autoDetected = false;
|
|
1319
|
+
let detectedPlatformName = '';
|
|
1320
|
+
if (!targetDir) {
|
|
1321
|
+
detectedPlatformName = platform || detectPlatform();
|
|
1322
|
+
targetDir = getDefaultSkillsDir(platform);
|
|
1323
|
+
autoDetected = true;
|
|
1324
|
+
logger.info(`Auto-detected target directory: ${targetDir}`, { platform: detectedPlatformName });
|
|
1325
|
+
}
|
|
1326
|
+
// Parse name
|
|
1327
|
+
const { scope, name: skillName } = parseSkillFullName(name);
|
|
1328
|
+
const fullName = buildSkillFullName(skillName, scope);
|
|
1329
|
+
// Get skill
|
|
1330
|
+
const skill = await storage.getSkillByName(fullName);
|
|
1331
|
+
if (!skill) {
|
|
1332
|
+
throw new McpError(ErrorCode.InvalidParams, `Skill '${fullName}' not found`);
|
|
1333
|
+
}
|
|
1334
|
+
// Get version
|
|
1335
|
+
let version;
|
|
1336
|
+
if (requestedVersion) {
|
|
1337
|
+
version = await storage.getVersion(skill.id, requestedVersion);
|
|
1338
|
+
if (!version) {
|
|
1339
|
+
throw new McpError(ErrorCode.InvalidParams, `Version '${requestedVersion}' not found for '${fullName}'`);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
else {
|
|
1343
|
+
version = await storage.getLatestVersion(skill.id);
|
|
1344
|
+
if (!version) {
|
|
1345
|
+
throw new McpError(ErrorCode.InvalidParams, `No versions found for '${fullName}'`);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
// Resolve target directory
|
|
1349
|
+
const resolvedTargetDir = path.resolve(targetDir);
|
|
1350
|
+
const skillDir = path.join(resolvedTargetDir, skillName);
|
|
1351
|
+
// Create directories
|
|
1352
|
+
if (!fs.existsSync(resolvedTargetDir)) {
|
|
1353
|
+
fs.mkdirSync(resolvedTargetDir, { recursive: true });
|
|
1354
|
+
}
|
|
1355
|
+
if (!fs.existsSync(skillDir)) {
|
|
1356
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
1357
|
+
}
|
|
1358
|
+
const content = version.content;
|
|
1359
|
+
const installedFiles = [];
|
|
1360
|
+
// Write SKILL.md
|
|
1361
|
+
const frontmatterLines = Object.entries(content.frontmatter)
|
|
1362
|
+
.filter(([_, v]) => v !== undefined && v !== null)
|
|
1363
|
+
.map(([k, v]) => {
|
|
1364
|
+
if (Array.isArray(v)) {
|
|
1365
|
+
return `${k}:\n${v.map(item => ` - ${item}`).join('\n')}`;
|
|
1366
|
+
}
|
|
1367
|
+
if (typeof v === 'object') {
|
|
1368
|
+
return `${k}:\n${Object.entries(v).map(([k2, v2]) => ` ${k2}: ${v2}`).join('\n')}`;
|
|
1369
|
+
}
|
|
1370
|
+
return `${k}: ${v}`;
|
|
1371
|
+
});
|
|
1372
|
+
const skillMdContent = `---
|
|
1373
|
+
${frontmatterLines.join('\n')}
|
|
1374
|
+
---
|
|
1375
|
+
|
|
1376
|
+
${content.markdown}`;
|
|
1377
|
+
const skillMdPath = path.join(skillDir, 'SKILL.md');
|
|
1378
|
+
fs.writeFileSync(skillMdPath, skillMdContent, 'utf-8');
|
|
1379
|
+
installedFiles.push('SKILL.md');
|
|
1380
|
+
// Write additional files
|
|
1381
|
+
if (content.files && content.files.length > 0) {
|
|
1382
|
+
for (const file of content.files) {
|
|
1383
|
+
const filePath = path.join(skillDir, file.path);
|
|
1384
|
+
const fileDir = path.dirname(filePath);
|
|
1385
|
+
// Create subdirectories if needed
|
|
1386
|
+
if (!fs.existsSync(fileDir)) {
|
|
1387
|
+
fs.mkdirSync(fileDir, { recursive: true });
|
|
1388
|
+
}
|
|
1389
|
+
// Check if it's a base64 encoded binary file
|
|
1390
|
+
if (file.content.startsWith('data:') && file.content.includes(';base64,')) {
|
|
1391
|
+
const base64Data = file.content.split(';base64,')[1] ?? '';
|
|
1392
|
+
const buffer = Buffer.from(base64Data, 'base64');
|
|
1393
|
+
fs.writeFileSync(filePath, buffer);
|
|
1394
|
+
}
|
|
1395
|
+
else {
|
|
1396
|
+
fs.writeFileSync(filePath, file.content, 'utf-8');
|
|
1397
|
+
}
|
|
1398
|
+
installedFiles.push(file.path);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
// Record use
|
|
1402
|
+
await storage.incrementSkillUses(skill.id);
|
|
1403
|
+
await storage.incrementVersionUses(version.id);
|
|
1404
|
+
const useRecord = {
|
|
1405
|
+
id: generateUUID(),
|
|
1406
|
+
skillId: skill.id,
|
|
1407
|
+
versionId: version.id,
|
|
1408
|
+
source: 'mcp',
|
|
1409
|
+
cacheHit: false,
|
|
1410
|
+
ip: '127.0.0.1',
|
|
1411
|
+
createdAt: now(),
|
|
1412
|
+
};
|
|
1413
|
+
await storage.createUseRecord(useRecord);
|
|
1414
|
+
const installedFilesList = installedFiles.length > 0
|
|
1415
|
+
? installedFiles.map(f => `• ${f}`).join('\n')
|
|
1416
|
+
: 'No files installed';
|
|
1417
|
+
const platformInfo = autoDetected
|
|
1418
|
+
? `\n**Platform:** ${detectedPlatformName} (auto-detected)\n**Target Directory:** ${resolvedTargetDir} (auto-created)`
|
|
1419
|
+
: `\n**Target Directory:** ${resolvedTargetDir}`;
|
|
1420
|
+
const resultText = `## ✅ Skill Installed Successfully!
|
|
1421
|
+
|
|
1422
|
+
**Name:** ${fullName}
|
|
1423
|
+
**Version:** ${version.version}${platformInfo}
|
|
1424
|
+
**Installation Path:** \`${skillDir}\`
|
|
1425
|
+
|
|
1426
|
+
### Files Installed (${installedFiles.length})
|
|
1427
|
+
${installedFilesList}
|
|
1428
|
+
|
|
1429
|
+
---
|
|
1430
|
+
${autoDetected ? '✨ Directory was automatically created for your platform.\n' : ''}The skill is ready to use at: \`${skillDir}\``;
|
|
1431
|
+
return {
|
|
1432
|
+
content: [{ type: 'text', text: resultText }],
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
// ============================================================================
|
|
1436
|
+
// Command Line Arguments Parsing
|
|
1437
|
+
// ============================================================================
|
|
1438
|
+
/**
|
|
1439
|
+
* Parse command line arguments
|
|
1440
|
+
* Supports: --platform=<platform_name>
|
|
1441
|
+
*/
|
|
1442
|
+
function parseCliArgs() {
|
|
1443
|
+
const args = process.argv.slice(2);
|
|
1444
|
+
const result = {};
|
|
1445
|
+
for (const arg of args) {
|
|
1446
|
+
if (arg.startsWith('--platform=')) {
|
|
1447
|
+
result.platform = arg.split('=')[1];
|
|
1448
|
+
}
|
|
1449
|
+
else if (arg === '--platform' && args[args.indexOf(arg) + 1]) {
|
|
1450
|
+
result.platform = args[args.indexOf(arg) + 1];
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
return result;
|
|
1454
|
+
}
|
|
1455
|
+
// Global variable to store CLI-specified platform
|
|
1456
|
+
let CLI_PLATFORM;
|
|
1457
|
+
// ============================================================================
|
|
1458
|
+
// Main Entry Point
|
|
1459
|
+
// ============================================================================
|
|
1460
|
+
async function main() {
|
|
1461
|
+
// Parse command line arguments
|
|
1462
|
+
const cliArgs = parseCliArgs();
|
|
1463
|
+
CLI_PLATFORM = cliArgs.platform;
|
|
1464
|
+
if (CLI_PLATFORM) {
|
|
1465
|
+
logger.info(`Starting Open Skills Hub MCP Server with platform override: ${CLI_PLATFORM}`);
|
|
1466
|
+
}
|
|
1467
|
+
else {
|
|
1468
|
+
logger.info('Starting Open Skills Hub MCP Server');
|
|
1469
|
+
}
|
|
1470
|
+
const server = await createMcpServer();
|
|
1471
|
+
const transport = new StdioServerTransport();
|
|
1472
|
+
await server.connect(transport);
|
|
1473
|
+
logger.info('Open Skills Hub MCP Server started');
|
|
1474
|
+
// Handle shutdown
|
|
1475
|
+
process.on('SIGINT', async () => {
|
|
1476
|
+
logger.info('Shutting down MCP server...');
|
|
1477
|
+
await server.close();
|
|
1478
|
+
process.exit(0);
|
|
1479
|
+
});
|
|
1480
|
+
process.on('SIGTERM', async () => {
|
|
1481
|
+
logger.info('Shutting down MCP server...');
|
|
1482
|
+
await server.close();
|
|
1483
|
+
process.exit(0);
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
main().catch((error) => {
|
|
1487
|
+
console.error('Fatal error:', error);
|
|
1488
|
+
process.exit(1);
|
|
1489
|
+
});
|
|
1490
|
+
//# sourceMappingURL=index.js.map
|