skillmds 1.0.21 → 1.1.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.md +125 -117
- package/LICENSE +21 -0
- package/README.md +25 -6
- package/bin/skillmd-mcp.mjs +308 -328
- package/bin/skillmd.mjs +0 -0
- package/dist/cli.js +42 -351
- package/package.json +33 -20
- package/vendor/index.js +0 -333
package/bin/skillmd-mcp.mjs
CHANGED
|
@@ -1,329 +1,309 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// SkillMD MCP server (stdio). Wraps the public SkillMD API so any MCP-capable agent
|
|
3
|
-
// can search, fetch, install, and discover Agent Skills by name or task.
|
|
4
|
-
//
|
|
5
|
-
// claude mcp add skillmd -- npx -y skillmds
|
|
6
|
-
//
|
|
7
|
-
// Env:
|
|
8
|
-
// SKILLMD_API override API base (default https://api.skillmd.com)
|
|
9
|
-
// SKILLMD_TOKEN personal access token (enables skillmd_list_saved / personalized recommend)
|
|
10
|
-
|
|
11
|
-
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
12
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
|
-
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
14
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
15
|
-
import { readFileSync } from "node:fs";
|
|
16
|
-
import { createHash } from "node:crypto";
|
|
17
|
-
import { homedir } from "node:os";
|
|
18
|
-
import { join, resolve, dirname, sep } from "node:path";
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
let lint = null;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
return text(
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
if (args.
|
|
187
|
-
const [owner, slugName] = String(args.
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
return text(
|
|
196
|
-
}
|
|
197
|
-
case "
|
|
198
|
-
if (!
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
return { isError: true, content: [{ type: "text", text: String(e?.message || e) }] };
|
|
311
|
-
}
|
|
312
|
-
});
|
|
313
|
-
const transport = new StdioServerTransport();
|
|
314
|
-
await server.connect(transport);
|
|
315
|
-
console.error("skillmd MCP server running (stdio)");
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// This binary is invoked three ways:
|
|
319
|
-
// - `skillmds <anything>` → run the CLI. commander owns argv, so known
|
|
320
|
-
// commands run and unknown commands get a proper "unknown command" error
|
|
321
|
-
// instead of silently starting an MCP server.
|
|
322
|
-
// - `skillmds` in a terminal → run the CLI (opens the interactive menu)
|
|
323
|
-
// - `skillmds` over piped stdio → start the MCP server (how MCP clients launch it;
|
|
324
|
-
// they always connect stdin as a pipe, never a TTY, and pass no args)
|
|
325
|
-
if (process.argv.length > 2 || process.stdin.isTTY) {
|
|
326
|
-
await import("../dist/cli.js"); // the CLI parses process.argv itself
|
|
327
|
-
} else {
|
|
328
|
-
await startMcpServer();
|
|
329
|
-
}
|
|
2
|
+
// SkillMD MCP server (stdio). Wraps the public SkillMD API so any MCP-capable agent
|
|
3
|
+
// can search, fetch, install, and discover Agent Skills by name or task.
|
|
4
|
+
//
|
|
5
|
+
// claude mcp add skillmd -- npx -y skillmds
|
|
6
|
+
//
|
|
7
|
+
// Env:
|
|
8
|
+
// SKILLMD_API override API base (default https://api.skillmd.com)
|
|
9
|
+
// SKILLMD_TOKEN personal access token (enables skillmd_list_saved / personalized recommend)
|
|
10
|
+
|
|
11
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
12
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
14
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { join, resolve, dirname, sep } from "node:path";
|
|
19
|
+
|
|
20
|
+
const VERSION = (() => {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
23
|
+
} catch {
|
|
24
|
+
return "0.0.0";
|
|
25
|
+
}
|
|
26
|
+
})();
|
|
27
|
+
// Shared validation engine + registry helpers from @skillmds/core (a runtime
|
|
28
|
+
// dependency of this package). Falls back gracefully when running from an
|
|
29
|
+
// unbuilt source checkout.
|
|
30
|
+
let lint = null;
|
|
31
|
+
let skillMdFor = null;
|
|
32
|
+
let isAllowedSourceUrl = () => false;
|
|
33
|
+
try {
|
|
34
|
+
({ lint, skillMdFor, isAllowedSourceUrl } = await import("@skillmds/core"));
|
|
35
|
+
} catch {
|
|
36
|
+
/* skillmd_lint / skillmd_install will report that the engine is unavailable */
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const API = process.env.SKILLMD_API || "https://api.skillmd.com";
|
|
40
|
+
const TOKEN = process.env.SKILLMD_TOKEN || "";
|
|
41
|
+
|
|
42
|
+
async function api(path, init = {}) {
|
|
43
|
+
const headers = { accept: "application/json", ...(init.headers || {}) };
|
|
44
|
+
if (TOKEN) headers.authorization = `Bearer ${TOKEN}`;
|
|
45
|
+
const res = await fetch(API + path, { ...init, headers });
|
|
46
|
+
if (!res.ok) throw new Error(`SkillMD API ${res.status} on ${path}: ${await res.text().catch(() => "")}`);
|
|
47
|
+
return res.json();
|
|
48
|
+
}
|
|
49
|
+
const text = (obj) => ({ content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }] });
|
|
50
|
+
|
|
51
|
+
// The public API returns site-oriented fields (ratings, counters, avatars,
|
|
52
|
+
// ranker internals) that are noise for an agent picking a skill. Strip them
|
|
53
|
+
// from every list-returning tool so results stay compact and rankable only
|
|
54
|
+
// on what matters to the caller.
|
|
55
|
+
const OMIT_FIELDS = new Set([
|
|
56
|
+
"type",
|
|
57
|
+
"verified",
|
|
58
|
+
"avg_rating",
|
|
59
|
+
"rating_count",
|
|
60
|
+
"install_count",
|
|
61
|
+
"save_count",
|
|
62
|
+
"owner_avatar",
|
|
63
|
+
"confident",
|
|
64
|
+
"category_slug",
|
|
65
|
+
]);
|
|
66
|
+
const trimItems = (items) => (items ?? []).map((s) => Object.fromEntries(Object.entries(s).filter(([k]) => !OMIT_FIELDS.has(k))));
|
|
67
|
+
|
|
68
|
+
const TOOLS = [
|
|
69
|
+
{
|
|
70
|
+
name: "skillmd_search",
|
|
71
|
+
description: "Search SkillMD for Agent Skills by name, description, or task. Returns matching skills with their owner/name slug and install snippets.",
|
|
72
|
+
inputSchema: {
|
|
73
|
+
type: "object",
|
|
74
|
+
properties: {
|
|
75
|
+
query: { type: "string", description: "Natural-language query or keywords" },
|
|
76
|
+
category: { type: "string", description: "Filter by category slug (e.g. devops, data, security)" },
|
|
77
|
+
verified_only: { type: "boolean", description: "Only verified skills" },
|
|
78
|
+
type: { type: "string", enum: ["single", "pack"], description: "single file or pack" },
|
|
79
|
+
min_rating: { type: "number", description: "Minimum average rating 0-5" },
|
|
80
|
+
limit: { type: "number", description: "Max results (default 20)" },
|
|
81
|
+
},
|
|
82
|
+
required: ["query"],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: "skillmd_get",
|
|
87
|
+
description: "Fetch a single skill by slug (owner/name), including its SKILL.md body, provenance, license and security flags.",
|
|
88
|
+
inputSchema: { type: "object", properties: { slug: { type: "string", description: "owner/name, e.g. anthropic/pdf" } }, required: ["slug"] },
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: "skillmd_install",
|
|
92
|
+
description:
|
|
93
|
+
"Install a skill by slug: writes its SKILL.md into the agent's skills directory. Never executes any scripts. Any skill installs; the response reports its security flags (docs_only / network_calls / executes_scripts / reads_secrets) as information.",
|
|
94
|
+
inputSchema: {
|
|
95
|
+
type: "object",
|
|
96
|
+
properties: {
|
|
97
|
+
slug: { type: "string", description: "owner/name, e.g. anthropic/pdf" },
|
|
98
|
+
dest: { type: "string", description: "Target skills dir. Default ~/.claude/skills" },
|
|
99
|
+
},
|
|
100
|
+
required: ["slug"],
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: "skillmd_trending",
|
|
105
|
+
description: "Top skills on the SkillMD leaderboard. range: '30d' or 'all'.",
|
|
106
|
+
inputSchema: {
|
|
107
|
+
type: "object",
|
|
108
|
+
properties: {
|
|
109
|
+
range: { type: "string", enum: ["30d", "all"], description: "Time window (default all)" },
|
|
110
|
+
category: { type: "string", description: "Optional category slug" },
|
|
111
|
+
limit: { type: "number", description: "Max results (default 20)" },
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "skillmd_recommend",
|
|
117
|
+
description: "Recommend skills. If based_on is given, suggests skills similar to it (same category); otherwise returns trending.",
|
|
118
|
+
inputSchema: {
|
|
119
|
+
type: "object",
|
|
120
|
+
properties: { based_on: { type: "string", description: "A slug to base recommendations on" }, limit: { type: "number" } },
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: "skillmd_list_saved",
|
|
125
|
+
description: "List the signed-in user's saved skills. Requires SKILLMD_TOKEN to be set.",
|
|
126
|
+
inputSchema: { type: "object", properties: {} },
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: "skillmd_lint",
|
|
130
|
+
description:
|
|
131
|
+
"Validate a SKILL.md against the SkillMD spec and return diagnostics, security flags, and a quality score (0-100). Pass raw SKILL.md content, or a registry slug (owner/name) to fetch and lint.",
|
|
132
|
+
inputSchema: {
|
|
133
|
+
type: "object",
|
|
134
|
+
properties: {
|
|
135
|
+
content: { type: "string", description: "Raw SKILL.md content to validate" },
|
|
136
|
+
slug: { type: "string", description: "Registry slug owner/name to fetch and validate" },
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
async function handleCall(name, args) {
|
|
143
|
+
switch (name) {
|
|
144
|
+
case "skillmd_search": {
|
|
145
|
+
const p = new URLSearchParams({ q: args.query, limit: String(args.limit ?? 20) });
|
|
146
|
+
if (args.category) p.set("category", args.category);
|
|
147
|
+
if (args.verified_only) p.set("verified", "true");
|
|
148
|
+
if (args.type) p.set("type", args.type);
|
|
149
|
+
if (args.min_rating != null) p.set("min_rating", String(args.min_rating));
|
|
150
|
+
const r = await api(`/api/search?${p}`);
|
|
151
|
+
return text(trimItems(r.items));
|
|
152
|
+
}
|
|
153
|
+
case "skillmd_get": {
|
|
154
|
+
const [owner, slugName] = String(args.slug).split("/");
|
|
155
|
+
const r = await api(`/api/skills/${owner}/${slugName}`);
|
|
156
|
+
return text(r);
|
|
157
|
+
}
|
|
158
|
+
case "skillmd_trending": {
|
|
159
|
+
const p = new URLSearchParams({ range: args.range ?? "all", limit: String(args.limit ?? 20) });
|
|
160
|
+
if (args.category) p.set("category", args.category);
|
|
161
|
+
const r = await api(`/api/leaderboard?${p}`);
|
|
162
|
+
return text(trimItems(r.items));
|
|
163
|
+
}
|
|
164
|
+
case "skillmd_recommend": {
|
|
165
|
+
if (args.based_on) {
|
|
166
|
+
const [owner, slugName] = String(args.based_on).split("/");
|
|
167
|
+
const base = await api(`/api/skills/${owner}/${slugName}`).catch(() => null);
|
|
168
|
+
if (base?.category_slug) {
|
|
169
|
+
const r = await api(`/api/leaderboard?range=all&limit=${args.limit ?? 10}&category=${base.category_slug}`);
|
|
170
|
+
return text(trimItems(r.items).filter((s) => s.slug !== args.based_on));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const r = await api(`/api/leaderboard?range=30d&limit=${args.limit ?? 10}`);
|
|
174
|
+
return text(trimItems(r.items));
|
|
175
|
+
}
|
|
176
|
+
case "skillmd_list_saved": {
|
|
177
|
+
if (!TOKEN) return text("Set SKILLMD_TOKEN (a SkillMD personal access token) to list your saved skills.");
|
|
178
|
+
const r = await api(`/api/favorites`);
|
|
179
|
+
return text(r.items ?? []);
|
|
180
|
+
}
|
|
181
|
+
case "skillmd_lint": {
|
|
182
|
+
if (!lint) return text("Validation engine unavailable — reinstall the skillmds package (npm i -g skillmds), or run `npm ci && npm run build` in a source checkout.");
|
|
183
|
+
let raw = args.content;
|
|
184
|
+
let slug;
|
|
185
|
+
let verified = false;
|
|
186
|
+
if (!raw && args.slug) {
|
|
187
|
+
const [owner, slugName] = String(args.slug).split("/");
|
|
188
|
+
const skill = await api(`/api/skills/${owner}/${slugName}`);
|
|
189
|
+
raw = skillMdFor(skill);
|
|
190
|
+
slug = slugName;
|
|
191
|
+
verified = !!skill.verified;
|
|
192
|
+
}
|
|
193
|
+
if (!raw) return text("Provide `content` (raw SKILL.md) or `slug` (owner/name).");
|
|
194
|
+
const result = lint(raw, { ...(slug ? { slug } : {}), verified });
|
|
195
|
+
return text({ ok: result.ok, score: result.score, diagnostics: result.diagnostics, security: result.security });
|
|
196
|
+
}
|
|
197
|
+
case "skillmd_install": {
|
|
198
|
+
if (!skillMdFor) return text("skillmds is not fully installed — reinstall it (npm i -g skillmds) and try again.");
|
|
199
|
+
const [owner, slugName] = String(args.slug).split("/");
|
|
200
|
+
// Defense in depth: slugName becomes a directory name below — refuse
|
|
201
|
+
// anything outside the registry's slugify alphabet (path separators,
|
|
202
|
+
// "..", etc.) before touching the network or filesystem.
|
|
203
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(slugName ?? "")) {
|
|
204
|
+
return text(`Refused: invalid skill name "${slugName}" — expected a lowercase slug.`);
|
|
205
|
+
}
|
|
206
|
+
const skill = await api(`/api/skills/${owner}/${slugName}`);
|
|
207
|
+
const flags = skill.security_flags ?? [];
|
|
208
|
+
// Verification never gates installs — any skill installs; flags are
|
|
209
|
+
// reported in the response as information, not a wall.
|
|
210
|
+
const raw = skillMdFor(skill); // returns raw_md verbatim when the registry has it
|
|
211
|
+
if (lint) {
|
|
212
|
+
const result = lint(raw, { slug: slugName });
|
|
213
|
+
if (!result.ok) {
|
|
214
|
+
const errors = result.diagnostics.filter((d) => d.severity === "error");
|
|
215
|
+
return text(`Refused: ${args.slug} failed validation (${errors.map((d) => `${d.id}: ${d.message}`).join("; ")}). Not installed.`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const baseDir = args.dest ? resolve(args.dest) : join(homedir(), ".claude", "skills");
|
|
219
|
+
const dir = join(baseDir, slugName);
|
|
220
|
+
await mkdir(dir, { recursive: true });
|
|
221
|
+
const root = resolve(dir);
|
|
222
|
+
|
|
223
|
+
// Zip-slip guard: refuse any path that resolves outside the skill's dir.
|
|
224
|
+
const writeSafe = async (relPath, contents) => {
|
|
225
|
+
const fp = resolve(dir, relPath);
|
|
226
|
+
if (fp !== root && !fp.startsWith(root + sep)) throw new Error(`unsafe path: ${relPath}`);
|
|
227
|
+
await mkdir(dirname(fp), { recursive: true });
|
|
228
|
+
await writeFile(fp, contents);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
let written = 1;
|
|
232
|
+
let note = "";
|
|
233
|
+
if (skill.type === "pack") {
|
|
234
|
+
// Pull the full pack (SKILL.md + companion files) in one call. The JSON
|
|
235
|
+
// bundle avoids adding an unzip dependency to this standalone binary.
|
|
236
|
+
try {
|
|
237
|
+
const bundle = await api(`/api/skills/${owner}/${slugName}/bundle?format=json`);
|
|
238
|
+
// Resolve + integrity-check every file BEFORE writing any, so a
|
|
239
|
+
// tampered pack refuses cleanly instead of leaving a partial install.
|
|
240
|
+
const resolved = [];
|
|
241
|
+
for (const f of bundle.files ?? []) {
|
|
242
|
+
if (f.path.includes("..") || f.path.startsWith("/")) continue;
|
|
243
|
+
let buf;
|
|
244
|
+
if (f.content_base64 != null) {
|
|
245
|
+
buf = Buffer.from(f.content_base64, "base64");
|
|
246
|
+
// Registry-stored files are content-addressed; verify the bytes
|
|
247
|
+
// match the registry's sha256. Files without one (the reconstructed
|
|
248
|
+
// inline SKILL.md) carry no hash and are skipped.
|
|
249
|
+
if (f.sha256) {
|
|
250
|
+
const got = createHash("sha256").update(buf).digest("hex");
|
|
251
|
+
if (got !== f.sha256) {
|
|
252
|
+
const err = new Error(`integrity check failed for "${f.path}"`);
|
|
253
|
+
err.integrity = true;
|
|
254
|
+
throw err;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
} else if (f.source_url && isAllowedSourceUrl(f.source_url)) {
|
|
258
|
+
const r = await fetch(f.source_url).catch(() => null);
|
|
259
|
+
if (!r || !r.ok) continue;
|
|
260
|
+
buf = Buffer.from(await r.arrayBuffer());
|
|
261
|
+
} else continue;
|
|
262
|
+
resolved.push({ path: f.path, contents: f.path === "SKILL.md" ? raw : buf });
|
|
263
|
+
}
|
|
264
|
+
for (const f of resolved) await writeSafe(f.path, f.contents);
|
|
265
|
+
written = resolved.length || 1;
|
|
266
|
+
if (!resolved.some((f) => f.path === "SKILL.md")) await writeSafe("SKILL.md", raw);
|
|
267
|
+
} catch (e) {
|
|
268
|
+
if (e && e.integrity) return text(`Refused: ${args.slug} failed integrity verification — ${e.message}. Not installed.`);
|
|
269
|
+
await writeSafe("SKILL.md", raw);
|
|
270
|
+
note = ` NOTE: pack assets could not be fetched — only SKILL.md was written. For the full pack run: npx skillmds add ${args.slug}`;
|
|
271
|
+
}
|
|
272
|
+
} else {
|
|
273
|
+
await writeSafe("SKILL.md", raw);
|
|
274
|
+
}
|
|
275
|
+
api(`/api/skills/${owner}/${slugName}/install`, { method: "POST", body: JSON.stringify({ via: "mcp" }) }).catch(() => {});
|
|
276
|
+
return text(`Installed ${args.slug} → ${dir} (${written} file${written === 1 ? "" : "s"}; security: ${flags.join(", ") || "unscanned"})${note}`);
|
|
277
|
+
}
|
|
278
|
+
default:
|
|
279
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function startMcpServer() {
|
|
284
|
+
const server = new Server({ name: "skillmd", version: VERSION }, { capabilities: { tools: {} } });
|
|
285
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
286
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
287
|
+
try {
|
|
288
|
+
return await handleCall(req.params.name, req.params.arguments ?? {});
|
|
289
|
+
} catch (e) {
|
|
290
|
+
return { isError: true, content: [{ type: "text", text: String(e?.message || e) }] };
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
const transport = new StdioServerTransport();
|
|
294
|
+
await server.connect(transport);
|
|
295
|
+
console.error("skillmd MCP server running (stdio)");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// This binary is invoked three ways:
|
|
299
|
+
// - `skillmds <anything>` → run the CLI. commander owns argv, so known
|
|
300
|
+
// commands run and unknown commands get a proper "unknown command" error
|
|
301
|
+
// instead of silently starting an MCP server.
|
|
302
|
+
// - `skillmds` in a terminal → run the CLI (opens the interactive menu)
|
|
303
|
+
// - `skillmds` over piped stdio → start the MCP server (how MCP clients launch it;
|
|
304
|
+
// they always connect stdin as a pipe, never a TTY, and pass no args)
|
|
305
|
+
if (process.argv.length > 2 || process.stdin.isTTY) {
|
|
306
|
+
await import("../dist/cli.js"); // the CLI parses process.argv itself
|
|
307
|
+
} else {
|
|
308
|
+
await startMcpServer();
|
|
309
|
+
}
|