skillmds 1.0.16 → 1.0.18
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 +5 -0
- package/bin/skillmd-mcp.mjs +64 -13
- package/dist/cli.js +47 -14
- package/package.json +1 -1
- package/vendor/index.js +10 -7
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `skillmds` are documented here. Versions follow [semver](https://semver.org).
|
|
4
4
|
|
|
5
|
+
## 1.0.18
|
|
6
|
+
|
|
7
|
+
- **Cleaner tool output for agents.** `skillmd_search`, `skillmd_trending`, and `skillmd_recommend` no longer echo site-oriented fields (`avg_rating`, `rating_count`, `install_count`, `save_count`, `verified`, `type`, `owner_avatar`, `category_slug`, and the internal `confident` ranker flag). Results now carry just what an agent needs to pick a skill: slug, title, description, category, agents, repo stars, and the install snippet.
|
|
8
|
+
- **Quality score no longer punishes ordinary skills.** The security flags (`reads_secrets`, `network_calls`, `executes_scripts`) are pattern matches against prose and code samples, not proof of anything unsafe — they now cost 2/1/5 points respectively (was 25/8/15), so a skill that merely documents an env var or a URL keeps its high score. Verified skills are floored at 85 (unless the content fails to parse). `skillmd_lint` passes a skill's verified status through to the score.
|
|
9
|
+
|
|
5
10
|
## 1.0.16
|
|
6
11
|
|
|
7
12
|
- **Installs are no longer gated on verification.** Any skill installs directly — verified or not — with no flag, no prompt, and no second step, from both the CLI (`skillmd add`) and the MCP `skillmd_install` tool. Security flags (network_calls, executes_scripts, reads_secrets, docs_only) are still shown on the result as information, but never block. `--deny <flag>` remains available as an opt-in for anyone who wants to exclude a flag; `--allow-unverified` is now a no-op kept for backward compatibility.
|
package/bin/skillmd-mcp.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
|
|
|
14
14
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
15
15
|
import { readFileSync } from "node:fs";
|
|
16
16
|
import { homedir } from "node:os";
|
|
17
|
-
import { join, resolve } from "node:path";
|
|
17
|
+
import { join, resolve, dirname, sep } from "node:path";
|
|
18
18
|
import { stringify as stringifyYaml } from "yaml";
|
|
19
19
|
|
|
20
20
|
const VERSION = (() => {
|
|
@@ -45,10 +45,27 @@ async function api(path, init = {}) {
|
|
|
45
45
|
}
|
|
46
46
|
const text = (obj) => ({ content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }] });
|
|
47
47
|
|
|
48
|
+
// The public API returns site-oriented fields (ratings, counters, avatars,
|
|
49
|
+
// ranker internals) that are noise for an agent picking a skill. Strip them
|
|
50
|
+
// from every list-returning tool so results stay compact and rankable only
|
|
51
|
+
// on what matters to the caller.
|
|
52
|
+
const OMIT_FIELDS = new Set([
|
|
53
|
+
"type",
|
|
54
|
+
"verified",
|
|
55
|
+
"avg_rating",
|
|
56
|
+
"rating_count",
|
|
57
|
+
"install_count",
|
|
58
|
+
"save_count",
|
|
59
|
+
"owner_avatar",
|
|
60
|
+
"confident",
|
|
61
|
+
"category_slug",
|
|
62
|
+
]);
|
|
63
|
+
const trimItems = (items) => (items ?? []).map((s) => Object.fromEntries(Object.entries(s).filter(([k]) => !OMIT_FIELDS.has(k))));
|
|
64
|
+
|
|
48
65
|
const TOOLS = [
|
|
49
66
|
{
|
|
50
67
|
name: "skillmd_search",
|
|
51
|
-
description: "Search SkillMD for Agent Skills by name, description, or task. Returns matching skills with
|
|
68
|
+
description: "Search SkillMD for Agent Skills by name, description, or task. Returns matching skills with their owner/name slug and install snippets.",
|
|
52
69
|
inputSchema: {
|
|
53
70
|
type: "object",
|
|
54
71
|
properties: {
|
|
@@ -138,7 +155,7 @@ async function handleCall(name, args) {
|
|
|
138
155
|
if (args.type) p.set("type", args.type);
|
|
139
156
|
if (args.min_rating != null) p.set("min_rating", String(args.min_rating));
|
|
140
157
|
const r = await api(`/api/search?${p}`);
|
|
141
|
-
return text(r.items
|
|
158
|
+
return text(trimItems(r.items));
|
|
142
159
|
}
|
|
143
160
|
case "skillmd_get": {
|
|
144
161
|
const [owner, slugName] = String(args.slug).split("/");
|
|
@@ -149,7 +166,7 @@ async function handleCall(name, args) {
|
|
|
149
166
|
const p = new URLSearchParams({ range: args.range ?? "all", limit: String(args.limit ?? 20) });
|
|
150
167
|
if (args.category) p.set("category", args.category);
|
|
151
168
|
const r = await api(`/api/leaderboard?${p}`);
|
|
152
|
-
return text(r.items
|
|
169
|
+
return text(trimItems(r.items));
|
|
153
170
|
}
|
|
154
171
|
case "skillmd_recommend": {
|
|
155
172
|
if (args.based_on) {
|
|
@@ -157,11 +174,11 @@ async function handleCall(name, args) {
|
|
|
157
174
|
const base = await api(`/api/skills/${owner}/${slugName}`).catch(() => null);
|
|
158
175
|
if (base?.category_slug) {
|
|
159
176
|
const r = await api(`/api/leaderboard?range=all&limit=${args.limit ?? 10}&category=${base.category_slug}`);
|
|
160
|
-
return text((r.items
|
|
177
|
+
return text(trimItems(r.items).filter((s) => s.slug !== args.based_on));
|
|
161
178
|
}
|
|
162
179
|
}
|
|
163
180
|
const r = await api(`/api/leaderboard?range=30d&limit=${args.limit ?? 10}`);
|
|
164
|
-
return text(r.items
|
|
181
|
+
return text(trimItems(r.items));
|
|
165
182
|
}
|
|
166
183
|
case "skillmd_list_saved": {
|
|
167
184
|
if (!TOKEN) return text("Set SKILLMD_TOKEN (a SkillMD personal access token) to list your saved skills.");
|
|
@@ -172,14 +189,16 @@ async function handleCall(name, args) {
|
|
|
172
189
|
if (!lint) return text("Validation engine unavailable — run `npm run build` in the skillmds package.");
|
|
173
190
|
let raw = args.content;
|
|
174
191
|
let slug;
|
|
192
|
+
let verified = false;
|
|
175
193
|
if (!raw && args.slug) {
|
|
176
194
|
const [owner, slugName] = String(args.slug).split("/");
|
|
177
195
|
const skill = await api(`/api/skills/${owner}/${slugName}`);
|
|
178
196
|
raw = reconstructSkillMd(skill);
|
|
179
197
|
slug = slugName;
|
|
198
|
+
verified = !!skill.verified;
|
|
180
199
|
}
|
|
181
200
|
if (!raw) return text("Provide `content` (raw SKILL.md) or `slug` (owner/name).");
|
|
182
|
-
const result = lint(raw, slug ? { slug } : {});
|
|
201
|
+
const result = lint(raw, { ...(slug ? { slug } : {}), verified });
|
|
183
202
|
return text({ ok: result.ok, score: result.score, diagnostics: result.diagnostics, security: result.security });
|
|
184
203
|
}
|
|
185
204
|
case "skillmd_install": {
|
|
@@ -205,13 +224,45 @@ async function handleCall(name, args) {
|
|
|
205
224
|
const baseDir = args.dest ? resolve(args.dest) : join(homedir(), ".claude", "skills");
|
|
206
225
|
const dir = join(baseDir, slugName);
|
|
207
226
|
await mkdir(dir, { recursive: true });
|
|
208
|
-
const
|
|
209
|
-
|
|
227
|
+
const root = resolve(dir);
|
|
228
|
+
|
|
229
|
+
// Zip-slip guard: refuse any path that resolves outside the skill's dir.
|
|
230
|
+
const writeSafe = async (relPath, contents) => {
|
|
231
|
+
const fp = resolve(dir, relPath);
|
|
232
|
+
if (fp !== root && !fp.startsWith(root + sep)) throw new Error(`unsafe path: ${relPath}`);
|
|
233
|
+
await mkdir(dirname(fp), { recursive: true });
|
|
234
|
+
await writeFile(fp, contents);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
let written = 1;
|
|
238
|
+
let note = "";
|
|
239
|
+
if (skill.type === "pack") {
|
|
240
|
+
// Pull the full pack (SKILL.md + companion files) in one call. The JSON
|
|
241
|
+
// bundle avoids adding an unzip dependency to this standalone binary.
|
|
242
|
+
try {
|
|
243
|
+
const bundle = await api(`/api/skills/${owner}/${slugName}/bundle?format=json`);
|
|
244
|
+
for (const f of bundle.files ?? []) {
|
|
245
|
+
if (f.path.includes("..") || f.path.startsWith("/")) continue;
|
|
246
|
+
let buf;
|
|
247
|
+
if (f.content_base64 != null) buf = Buffer.from(f.content_base64, "base64");
|
|
248
|
+
else if (f.source_url) {
|
|
249
|
+
const r = await fetch(f.source_url).catch(() => null);
|
|
250
|
+
if (!r || !r.ok) continue;
|
|
251
|
+
buf = Buffer.from(await r.arrayBuffer());
|
|
252
|
+
} else continue;
|
|
253
|
+
await writeSafe(f.path, f.path === "SKILL.md" ? raw : buf);
|
|
254
|
+
}
|
|
255
|
+
written = (bundle.files ?? []).length || 1;
|
|
256
|
+
if (!(bundle.files ?? []).some((f) => f.path === "SKILL.md")) await writeSafe("SKILL.md", raw);
|
|
257
|
+
} catch {
|
|
258
|
+
await writeSafe("SKILL.md", raw);
|
|
259
|
+
note = ` NOTE: pack assets could not be fetched — only SKILL.md was written. For the full pack run: npx skillmds add ${args.slug}`;
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
await writeSafe("SKILL.md", raw);
|
|
263
|
+
}
|
|
210
264
|
api(`/api/skills/${owner}/${slugName}/install`, { method: "POST", body: JSON.stringify({ via: "mcp" }) }).catch(() => {});
|
|
211
|
-
|
|
212
|
-
? ` NOTE: this is a pack and only SKILL.md was written — its assets/references are missing. For the full pack run: npx skillmds add ${args.slug}`
|
|
213
|
-
: "";
|
|
214
|
-
return text(`Installed ${args.slug} → ${file} (security: ${flags.join(", ") || "unscanned"})${note}`);
|
|
265
|
+
return text(`Installed ${args.slug} → ${dir} (${written} file${written === 1 ? "" : "s"}; security: ${flags.join(", ") || "unscanned"})${note}`);
|
|
215
266
|
}
|
|
216
267
|
default:
|
|
217
268
|
throw new Error(`Unknown tool: ${name}`);
|
package/dist/cli.js
CHANGED
|
@@ -163,16 +163,19 @@ var WEIGHT = {
|
|
|
163
163
|
};
|
|
164
164
|
var RISK = {
|
|
165
165
|
docs_only: 0,
|
|
166
|
-
reads_secrets:
|
|
167
|
-
|
|
168
|
-
|
|
166
|
+
reads_secrets: 2,
|
|
167
|
+
network_calls: 1,
|
|
168
|
+
executes_scripts: 5
|
|
169
169
|
};
|
|
170
|
-
|
|
170
|
+
var VERIFIED_FLOOR = 85;
|
|
171
|
+
function qualityScore(diagnostics, security, opts = {}) {
|
|
171
172
|
let penalty = diagnostics.reduce((sum, d) => sum + WEIGHT[d.severity], 0);
|
|
172
173
|
if (security) {
|
|
173
174
|
for (const flag of security.flags) penalty += RISK[flag] ?? 0;
|
|
174
175
|
}
|
|
175
|
-
|
|
176
|
+
const score = Math.max(0, Math.min(100, 100 - penalty));
|
|
177
|
+
const hasError = diagnostics.some((d) => d.severity === "error");
|
|
178
|
+
return opts.verified && !hasError ? Math.max(score, VERIFIED_FLOOR) : score;
|
|
176
179
|
}
|
|
177
180
|
|
|
178
181
|
// ../../packages/core/src/lint.ts
|
|
@@ -185,14 +188,14 @@ function lint(raw, opts = {}) {
|
|
|
185
188
|
ok: false,
|
|
186
189
|
diagnostics: diagnostics2,
|
|
187
190
|
security: security2,
|
|
188
|
-
score: qualityScore(diagnostics2, security2)
|
|
191
|
+
score: qualityScore(diagnostics2, security2, { verified: opts.verified })
|
|
189
192
|
};
|
|
190
193
|
}
|
|
191
194
|
const ctx = opts.slug !== void 0 ? { slug: opts.slug } : {};
|
|
192
195
|
const diagnostics = runRules(parsed, ctx);
|
|
193
196
|
const security = scanSecurity(parsed.body);
|
|
194
197
|
const ok = !diagnostics.some((d) => d.severity === "error");
|
|
195
|
-
return { ok, skill: parsed, diagnostics, security, score: qualityScore(diagnostics, security) };
|
|
198
|
+
return { ok, skill: parsed, diagnostics, security, score: qualityScore(diagnostics, security, { verified: opts.verified }) };
|
|
196
199
|
}
|
|
197
200
|
|
|
198
201
|
// ../../packages/core/src/format/text.ts
|
|
@@ -779,7 +782,25 @@ function createClient(flags = {}) {
|
|
|
779
782
|
}
|
|
780
783
|
return await res.json();
|
|
781
784
|
}
|
|
782
|
-
return { api, base, hasToken: Boolean(token) };
|
|
785
|
+
return { api, base, token, hasToken: Boolean(token) };
|
|
786
|
+
}
|
|
787
|
+
async function fetchBundle(base, slug, token) {
|
|
788
|
+
const headers = { accept: "application/json" };
|
|
789
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
790
|
+
const res = await fetch(`${base}/api/skills/${slug}/bundle?format=json`, { headers }).catch(() => null);
|
|
791
|
+
if (!res || !res.ok) return null;
|
|
792
|
+
const data = await res.json().catch(() => null);
|
|
793
|
+
if (!data?.files) return null;
|
|
794
|
+
const out = [];
|
|
795
|
+
for (const f of data.files) {
|
|
796
|
+
if (f.content_base64 != null) {
|
|
797
|
+
out.push({ path: f.path, contents: Buffer.from(f.content_base64, "base64") });
|
|
798
|
+
} else if (f.source_url) {
|
|
799
|
+
const r = await fetch(f.source_url).catch(() => null);
|
|
800
|
+
if (r && r.ok) out.push({ path: f.path, contents: Buffer.from(await r.arrayBuffer()) });
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return out;
|
|
783
804
|
}
|
|
784
805
|
function skillMdFor(skill) {
|
|
785
806
|
if (skill.raw_md) return skill.raw_md;
|
|
@@ -901,7 +922,7 @@ import pc7 from "picocolors";
|
|
|
901
922
|
// ../cli/src/agents.ts
|
|
902
923
|
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync4, rmSync as rmSync2, symlinkSync, existsSync as existsSync3, readdirSync as readdirSync2, statSync as statSync2, readFileSync as readFileSync3 } from "fs";
|
|
903
924
|
import { homedir as homedir3 } from "os";
|
|
904
|
-
import { join as join4, resolve as resolve2, dirname as dirname2 } from "path";
|
|
925
|
+
import { join as join4, resolve as resolve2, dirname as dirname2, sep as sep2 } from "path";
|
|
905
926
|
var AGENTS = [
|
|
906
927
|
{ id: "claude-code", segments: [".claude", "skills"] },
|
|
907
928
|
{ id: "cursor", segments: [".cursor", "skills"] },
|
|
@@ -919,6 +940,9 @@ function detectAgents(opts = {}) {
|
|
|
919
940
|
return AGENTS.filter((a) => existsSync3(agentDir(a.id, opts))).map((a) => a.id);
|
|
920
941
|
}
|
|
921
942
|
function writeSkill(skillName, files, targetDir, opts = {}) {
|
|
943
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(skillName) || skillName.includes("..")) {
|
|
944
|
+
throw new Error(`refused unsafe skill name: "${skillName}"`);
|
|
945
|
+
}
|
|
922
946
|
const dest = join4(targetDir, skillName);
|
|
923
947
|
if (opts.mode === "symlink" && opts.canonicalDir) {
|
|
924
948
|
mkdirSync3(dirname2(dest), { recursive: true });
|
|
@@ -927,8 +951,12 @@ function writeSkill(skillName, files, targetDir, opts = {}) {
|
|
|
927
951
|
return dest;
|
|
928
952
|
}
|
|
929
953
|
mkdirSync3(dest, { recursive: true });
|
|
954
|
+
const root = resolve2(dest);
|
|
930
955
|
for (const f of files) {
|
|
931
|
-
const fp =
|
|
956
|
+
const fp = resolve2(dest, f.path);
|
|
957
|
+
if (fp !== root && !fp.startsWith(root + sep2)) {
|
|
958
|
+
throw new Error(`refused unsafe path in skill "${skillName}": ${f.path}`);
|
|
959
|
+
}
|
|
932
960
|
mkdirSync3(dirname2(fp), { recursive: true });
|
|
933
961
|
writeFileSync4(fp, f.contents);
|
|
934
962
|
}
|
|
@@ -974,12 +1002,12 @@ var defaultDeps = {
|
|
|
974
1002
|
return rs2.map((r) => ({ name: r.slug, raw: r.raw, slug: r.slug }));
|
|
975
1003
|
}
|
|
976
1004
|
if (/^[^/\s:]+\/[^/\s:]+$/.test(arg)) {
|
|
977
|
-
const { api } = createClient(flags);
|
|
1005
|
+
const { api, base: apiBase, token } = createClient(flags);
|
|
978
1006
|
const [owner, name] = arg.split("/");
|
|
979
1007
|
try {
|
|
980
1008
|
const skill = await api(`/api/skills/${owner}/${name}`);
|
|
981
1009
|
const skillName = skill.slug.split("/")[1] || name;
|
|
982
|
-
const
|
|
1010
|
+
const meta = {
|
|
983
1011
|
name: skillName,
|
|
984
1012
|
slug: skillName,
|
|
985
1013
|
registrySlug: arg,
|
|
@@ -988,10 +1016,15 @@ var defaultDeps = {
|
|
|
988
1016
|
securityFlags: skill.security_flags
|
|
989
1017
|
};
|
|
990
1018
|
if (skill.type === "pack") {
|
|
1019
|
+
const bundle = await fetchBundle(apiBase, arg, token);
|
|
1020
|
+
if (bundle && bundle.length) {
|
|
1021
|
+
const sk = bundle.find((f) => f.path === "SKILL.md");
|
|
1022
|
+
return [{ ...meta, raw: sk ? sk.contents.toString("utf8") : skillMdFor(skill), files: bundle }];
|
|
1023
|
+
}
|
|
991
1024
|
const pack = await resolvePackFiles(skill);
|
|
992
|
-
if (pack) return [{ ...
|
|
1025
|
+
if (pack) return [{ ...meta, ...pack }];
|
|
993
1026
|
}
|
|
994
|
-
return [{ ...
|
|
1027
|
+
return [{ ...meta, raw: skillMdFor(skill) }];
|
|
995
1028
|
} catch {
|
|
996
1029
|
}
|
|
997
1030
|
}
|
package/package.json
CHANGED
package/vendor/index.js
CHANGED
|
@@ -169,16 +169,19 @@ var WEIGHT = {
|
|
|
169
169
|
};
|
|
170
170
|
var RISK = {
|
|
171
171
|
docs_only: 0,
|
|
172
|
-
reads_secrets:
|
|
173
|
-
|
|
174
|
-
|
|
172
|
+
reads_secrets: 2,
|
|
173
|
+
network_calls: 1,
|
|
174
|
+
executes_scripts: 5
|
|
175
175
|
};
|
|
176
|
-
|
|
176
|
+
var VERIFIED_FLOOR = 85;
|
|
177
|
+
function qualityScore(diagnostics, security, opts = {}) {
|
|
177
178
|
let penalty = diagnostics.reduce((sum, d) => sum + WEIGHT[d.severity], 0);
|
|
178
179
|
if (security) {
|
|
179
180
|
for (const flag of security.flags) penalty += RISK[flag] ?? 0;
|
|
180
181
|
}
|
|
181
|
-
|
|
182
|
+
const score = Math.max(0, Math.min(100, 100 - penalty));
|
|
183
|
+
const hasError = diagnostics.some((d) => d.severity === "error");
|
|
184
|
+
return opts.verified && !hasError ? Math.max(score, VERIFIED_FLOOR) : score;
|
|
182
185
|
}
|
|
183
186
|
|
|
184
187
|
// ../../packages/core/src/lint.ts
|
|
@@ -191,14 +194,14 @@ function lint(raw, opts = {}) {
|
|
|
191
194
|
ok: false,
|
|
192
195
|
diagnostics: diagnostics2,
|
|
193
196
|
security: security2,
|
|
194
|
-
score: qualityScore(diagnostics2, security2)
|
|
197
|
+
score: qualityScore(diagnostics2, security2, { verified: opts.verified })
|
|
195
198
|
};
|
|
196
199
|
}
|
|
197
200
|
const ctx = opts.slug !== void 0 ? { slug: opts.slug } : {};
|
|
198
201
|
const diagnostics = runRules(parsed, ctx);
|
|
199
202
|
const security = scanSecurity(parsed.body);
|
|
200
203
|
const ok = !diagnostics.some((d) => d.severity === "error");
|
|
201
|
-
return { ok, skill: parsed, diagnostics, security, score: qualityScore(diagnostics, security) };
|
|
204
|
+
return { ok, skill: parsed, diagnostics, security, score: qualityScore(diagnostics, security, { verified: opts.verified }) };
|
|
202
205
|
}
|
|
203
206
|
|
|
204
207
|
// ../../packages/core/src/format/text.ts
|