skillmds 1.0.17 → 1.0.19

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 CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to `skillmds` are documented here. Versions follow [semver](https://semver.org).
4
4
 
5
+ ## 1.0.19
6
+
7
+ - **Clear diagnostics when the registry is blocked.** In sandboxed or proxied environments that deny network egress to `api.skillmd.com`, `skillmd add owner/name` used to fall back to GitHub and surface a misleading "repo not found" error. The CLI now tells 404 ("not in the registry" — GitHub fallback proceeds as before) apart from unreachable (DNS failure, proxy 403, 5xx): if the GitHub fallback also fails, the error names the blocked registry and says what fixes it (allowlist `api.skillmd.com`, or install via the SkillMD MCP server); if the fallback succeeds, a warning notes the copy came from GitHub, not the registry.
8
+
9
+ ## 1.0.18
10
+
11
+ - **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.
12
+ - **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.
13
+
5
14
  ## 1.0.16
6
15
 
7
16
  - **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.
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  // SkillMD MCP server (stdio). Wraps the public SkillMD API so any MCP-capable agent
3
3
  // can search, fetch, install, and discover Agent Skills by name or task.
4
4
  //
@@ -13,6 +13,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
13
13
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
14
14
  import { mkdir, writeFile } from "node:fs/promises";
15
15
  import { readFileSync } from "node:fs";
16
+ import { createHash } from "node:crypto";
16
17
  import { homedir } from "node:os";
17
18
  import { join, resolve, dirname, sep } from "node:path";
18
19
  import { stringify as stringifyYaml } from "yaml";
@@ -36,6 +37,19 @@ try {
36
37
  const API = process.env.SKILLMD_API || "https://api.skillmd.com";
37
38
  const TOKEN = process.env.SKILLMD_TOKEN || "";
38
39
 
40
+ // Companion files not in R2 carry a registry-supplied source_url. Ingest only
41
+ // pins these to GitHub raw hosts, so anything else is a hostile/compromised
42
+ // registry response — refuse to fetch it (client-side SSRF guard).
43
+ const ALLOWED_SOURCE_HOSTS = new Set(["raw.githubusercontent.com", "gist.githubusercontent.com"]);
44
+ function isAllowedSourceUrl(u) {
45
+ try {
46
+ const parsed = new URL(u);
47
+ return parsed.protocol === "https:" && ALLOWED_SOURCE_HOSTS.has(parsed.hostname.toLowerCase());
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
39
53
  async function api(path, init = {}) {
40
54
  const headers = { accept: "application/json", ...(init.headers || {}) };
41
55
  if (TOKEN) headers.authorization = `Bearer ${TOKEN}`;
@@ -45,10 +59,27 @@ async function api(path, init = {}) {
45
59
  }
46
60
  const text = (obj) => ({ content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }] });
47
61
 
62
+ // The public API returns site-oriented fields (ratings, counters, avatars,
63
+ // ranker internals) that are noise for an agent picking a skill. Strip them
64
+ // from every list-returning tool so results stay compact and rankable only
65
+ // on what matters to the caller.
66
+ const OMIT_FIELDS = new Set([
67
+ "type",
68
+ "verified",
69
+ "avg_rating",
70
+ "rating_count",
71
+ "install_count",
72
+ "save_count",
73
+ "owner_avatar",
74
+ "confident",
75
+ "category_slug",
76
+ ]);
77
+ const trimItems = (items) => (items ?? []).map((s) => Object.fromEntries(Object.entries(s).filter(([k]) => !OMIT_FIELDS.has(k))));
78
+
48
79
  const TOOLS = [
49
80
  {
50
81
  name: "skillmd_search",
51
- description: "Search SkillMD for Agent Skills by name, description, or task. Returns matching skills with ratings and install snippets.",
82
+ description: "Search SkillMD for Agent Skills by name, description, or task. Returns matching skills with their owner/name slug and install snippets.",
52
83
  inputSchema: {
53
84
  type: "object",
54
85
  properties: {
@@ -138,7 +169,7 @@ async function handleCall(name, args) {
138
169
  if (args.type) p.set("type", args.type);
139
170
  if (args.min_rating != null) p.set("min_rating", String(args.min_rating));
140
171
  const r = await api(`/api/search?${p}`);
141
- return text(r.items ?? []);
172
+ return text(trimItems(r.items));
142
173
  }
143
174
  case "skillmd_get": {
144
175
  const [owner, slugName] = String(args.slug).split("/");
@@ -149,7 +180,7 @@ async function handleCall(name, args) {
149
180
  const p = new URLSearchParams({ range: args.range ?? "all", limit: String(args.limit ?? 20) });
150
181
  if (args.category) p.set("category", args.category);
151
182
  const r = await api(`/api/leaderboard?${p}`);
152
- return text(r.items ?? []);
183
+ return text(trimItems(r.items));
153
184
  }
154
185
  case "skillmd_recommend": {
155
186
  if (args.based_on) {
@@ -157,11 +188,11 @@ async function handleCall(name, args) {
157
188
  const base = await api(`/api/skills/${owner}/${slugName}`).catch(() => null);
158
189
  if (base?.category_slug) {
159
190
  const r = await api(`/api/leaderboard?range=all&limit=${args.limit ?? 10}&category=${base.category_slug}`);
160
- return text((r.items ?? []).filter((s) => s.slug !== args.based_on));
191
+ return text(trimItems(r.items).filter((s) => s.slug !== args.based_on));
161
192
  }
162
193
  }
163
194
  const r = await api(`/api/leaderboard?range=30d&limit=${args.limit ?? 10}`);
164
- return text(r.items ?? []);
195
+ return text(trimItems(r.items));
165
196
  }
166
197
  case "skillmd_list_saved": {
167
198
  if (!TOKEN) return text("Set SKILLMD_TOKEN (a SkillMD personal access token) to list your saved skills.");
@@ -172,14 +203,16 @@ async function handleCall(name, args) {
172
203
  if (!lint) return text("Validation engine unavailable — run `npm run build` in the skillmds package.");
173
204
  let raw = args.content;
174
205
  let slug;
206
+ let verified = false;
175
207
  if (!raw && args.slug) {
176
208
  const [owner, slugName] = String(args.slug).split("/");
177
209
  const skill = await api(`/api/skills/${owner}/${slugName}`);
178
210
  raw = reconstructSkillMd(skill);
179
211
  slug = slugName;
212
+ verified = !!skill.verified;
180
213
  }
181
214
  if (!raw) return text("Provide `content` (raw SKILL.md) or `slug` (owner/name).");
182
- const result = lint(raw, slug ? { slug } : {});
215
+ const result = lint(raw, { ...(slug ? { slug } : {}), verified });
183
216
  return text({ ok: result.ok, score: result.score, diagnostics: result.diagnostics, security: result.security });
184
217
  }
185
218
  case "skillmd_install": {
@@ -222,20 +255,37 @@ async function handleCall(name, args) {
222
255
  // bundle avoids adding an unzip dependency to this standalone binary.
223
256
  try {
224
257
  const bundle = await api(`/api/skills/${owner}/${slugName}/bundle?format=json`);
258
+ // Resolve + integrity-check every file BEFORE writing any, so a
259
+ // tampered pack refuses cleanly instead of leaving a partial install.
260
+ const resolved = [];
225
261
  for (const f of bundle.files ?? []) {
226
262
  if (f.path.includes("..") || f.path.startsWith("/")) continue;
227
263
  let buf;
228
- if (f.content_base64 != null) buf = Buffer.from(f.content_base64, "base64");
229
- else if (f.source_url) {
264
+ if (f.content_base64 != null) {
265
+ buf = Buffer.from(f.content_base64, "base64");
266
+ // R2 files are content-addressed; verify the bytes match the
267
+ // registry's sha256. Files without one (the reconstructed inline
268
+ // SKILL.md) carry no hash and are skipped.
269
+ if (f.sha256) {
270
+ const got = createHash("sha256").update(buf).digest("hex");
271
+ if (got !== f.sha256) {
272
+ const err = new Error(`integrity check failed for "${f.path}"`);
273
+ err.integrity = true;
274
+ throw err;
275
+ }
276
+ }
277
+ } else if (f.source_url && isAllowedSourceUrl(f.source_url)) {
230
278
  const r = await fetch(f.source_url).catch(() => null);
231
279
  if (!r || !r.ok) continue;
232
280
  buf = Buffer.from(await r.arrayBuffer());
233
281
  } else continue;
234
- await writeSafe(f.path, f.path === "SKILL.md" ? raw : buf);
282
+ resolved.push({ path: f.path, contents: f.path === "SKILL.md" ? raw : buf });
235
283
  }
236
- written = (bundle.files ?? []).length || 1;
237
- if (!(bundle.files ?? []).some((f) => f.path === "SKILL.md")) await writeSafe("SKILL.md", raw);
238
- } catch {
284
+ for (const f of resolved) await writeSafe(f.path, f.contents);
285
+ written = resolved.length || 1;
286
+ if (!resolved.some((f) => f.path === "SKILL.md")) await writeSafe("SKILL.md", raw);
287
+ } catch (e) {
288
+ if (e && e.integrity) return text(`Refused: ${args.slug} failed integrity verification — ${e.message}. Not installed.`);
239
289
  await writeSafe("SKILL.md", raw);
240
290
  note = ` NOTE: pack assets could not be fetched — only SKILL.md was written. For the full pack run: npx skillmds add ${args.slug}`;
241
291
  }
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: 25,
167
- executes_scripts: 15,
168
- network_calls: 8
166
+ reads_secrets: 2,
167
+ network_calls: 1,
168
+ executes_scripts: 5
169
169
  };
170
- function qualityScore(diagnostics, security) {
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
- return Math.max(0, Math.min(100, 100 - penalty));
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
@@ -736,6 +739,7 @@ import { Command as Command5 } from "commander";
736
739
  import pc5 from "picocolors";
737
740
 
738
741
  // ../cli/src/api.ts
742
+ import { createHash } from "crypto";
739
743
  import { stringify as stringifyYaml } from "yaml";
740
744
 
741
745
  // ../cli/src/config.ts
@@ -766,16 +770,46 @@ function resolveApi(flags = {}) {
766
770
  }
767
771
 
768
772
  // ../cli/src/api.ts
773
+ var IntegrityError = class extends Error {
774
+ integrity = true;
775
+ constructor(message) {
776
+ super(message);
777
+ this.name = "IntegrityError";
778
+ }
779
+ };
780
+ var RegistryError = class extends Error {
781
+ constructor(message, status) {
782
+ super(message);
783
+ this.status = status;
784
+ this.name = "RegistryError";
785
+ }
786
+ status;
787
+ };
788
+ var ALLOWED_SOURCE_HOSTS = /* @__PURE__ */ new Set(["raw.githubusercontent.com", "gist.githubusercontent.com"]);
789
+ function isAllowedSourceUrl(u) {
790
+ try {
791
+ const parsed = new URL(u);
792
+ return parsed.protocol === "https:" && ALLOWED_SOURCE_HOSTS.has(parsed.hostname.toLowerCase());
793
+ } catch {
794
+ return false;
795
+ }
796
+ }
769
797
  function createClient(flags = {}) {
770
798
  const base = resolveApi(flags);
771
799
  const token = resolveToken(flags);
772
800
  async function api(path, init = {}) {
773
801
  const headers = { accept: "application/json", ...init.headers };
774
802
  if (token) headers.authorization = `Bearer ${token}`;
775
- const res = await fetch(base + path, { ...init, headers });
803
+ let res;
804
+ try {
805
+ res = await fetch(base + path, { ...init, headers });
806
+ } catch (e) {
807
+ const cause = e instanceof Error ? e.cause?.message ?? e.message : String(e);
808
+ throw new RegistryError(`could not reach ${base}: ${cause}`);
809
+ }
776
810
  if (!res.ok) {
777
811
  const detail = await res.text().catch(() => "");
778
- throw new Error(`SkillMD API ${res.status} on ${path}: ${detail}`);
812
+ throw new RegistryError(`SkillMD API ${res.status} on ${path}: ${detail}`, res.status);
779
813
  }
780
814
  return await res.json();
781
815
  }
@@ -791,8 +825,15 @@ async function fetchBundle(base, slug, token) {
791
825
  const out = [];
792
826
  for (const f of data.files) {
793
827
  if (f.content_base64 != null) {
794
- out.push({ path: f.path, contents: Buffer.from(f.content_base64, "base64") });
795
- } else if (f.source_url) {
828
+ const contents = Buffer.from(f.content_base64, "base64");
829
+ if (f.sha256) {
830
+ const got = createHash("sha256").update(contents).digest("hex");
831
+ if (got !== f.sha256) {
832
+ throw new IntegrityError(`integrity check failed for "${f.path}" (expected ${f.sha256.slice(0, 12)}\u2026, got ${got.slice(0, 12)}\u2026)`);
833
+ }
834
+ }
835
+ out.push({ path: f.path, contents });
836
+ } else if (f.source_url && isAllowedSourceUrl(f.source_url)) {
796
837
  const r = await fetch(f.source_url).catch(() => null);
797
838
  if (r && r.ok) out.push({ path: f.path, contents: Buffer.from(await r.arrayBuffer()) });
798
839
  }
@@ -937,6 +978,9 @@ function detectAgents(opts = {}) {
937
978
  return AGENTS.filter((a) => existsSync3(agentDir(a.id, opts))).map((a) => a.id);
938
979
  }
939
980
  function writeSkill(skillName, files, targetDir, opts = {}) {
981
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(skillName) || skillName.includes("..")) {
982
+ throw new Error(`refused unsafe skill name: "${skillName}"`);
983
+ }
940
984
  const dest = join4(targetDir, skillName);
941
985
  if (opts.mode === "symlink" && opts.canonicalDir) {
942
986
  mkdirSync3(dirname2(dest), { recursive: true });
@@ -989,12 +1033,31 @@ async function resolvePackFiles(skill, fetchTree = resolveTree) {
989
1033
  }
990
1034
  return null;
991
1035
  }
1036
+ function registryFallbackNote(host) {
1037
+ return `installed from GitHub because the SkillMD registry (${host}) was unreachable \u2014 this copy is not the registry-reviewed version`;
1038
+ }
1039
+ function registryUnreachableError(arg, host, e) {
1040
+ const why = e.status ? `HTTP ${e.status}` : "network error";
1041
+ return new Error(
1042
+ `could not reach the SkillMD registry at ${host} (${why}), and "${arg}" was not found on GitHub either.
1043
+ If this environment restricts network access (sandbox, proxy, CI), ask an administrator to allow ${host} \u2014 or install through the SkillMD MCP server, which many sandboxed agents can already reach.
1044
+ Registry error: ${e.message}`
1045
+ );
1046
+ }
992
1047
  var defaultDeps = {
993
1048
  async resolve(arg, flags) {
994
1049
  if (isLocalish(arg)) {
995
- const rs2 = await resolveSource(arg);
996
- return rs2.map((r) => ({ name: r.slug, raw: r.raw, slug: r.slug }));
1050
+ const rs = await resolveSource(arg);
1051
+ return rs.map((r) => ({ name: r.slug, raw: r.raw, slug: r.slug }));
997
1052
  }
1053
+ let registryDown = null;
1054
+ const registryHost = (() => {
1055
+ try {
1056
+ return new URL(createClient(flags).base).host;
1057
+ } catch {
1058
+ return "api.skillmd.com";
1059
+ }
1060
+ })();
998
1061
  if (/^[^/\s:]+\/[^/\s:]+$/.test(arg)) {
999
1062
  const { api, base: apiBase, token } = createClient(flags);
1000
1063
  const [owner, name] = arg.split("/");
@@ -1019,11 +1082,19 @@ var defaultDeps = {
1019
1082
  if (pack) return [{ ...meta, ...pack }];
1020
1083
  }
1021
1084
  return [{ ...meta, raw: skillMdFor(skill) }];
1022
- } catch {
1085
+ } catch (e) {
1086
+ if (e instanceof IntegrityError) throw e;
1087
+ if (e instanceof RegistryError && e.status !== 404) registryDown = e;
1023
1088
  }
1024
1089
  }
1025
- const rs = await resolveSource(arg);
1026
- return rs.map((r) => ({ name: r.slug, raw: r.raw, slug: r.slug }));
1090
+ try {
1091
+ const rs = await resolveSource(arg);
1092
+ const note2 = registryDown ? registryFallbackNote(registryHost) : void 0;
1093
+ return rs.map((r) => ({ name: r.slug, raw: r.raw, slug: r.slug, note: note2 }));
1094
+ } catch (e) {
1095
+ if (registryDown) throw registryUnreachableError(arg, registryHost, registryDown);
1096
+ throw e;
1097
+ }
1027
1098
  },
1028
1099
  fireInstall(registrySlug, flags) {
1029
1100
  const { api } = createClient(flags);
@@ -1037,7 +1108,20 @@ var defaultDeps = {
1037
1108
  }
1038
1109
  };
1039
1110
  async function runAdd(arg, flags, deps = defaultDeps) {
1040
- let candidates = await deps.resolve(arg, flags);
1111
+ let candidates;
1112
+ try {
1113
+ candidates = await deps.resolve(arg, flags);
1114
+ } catch (e) {
1115
+ if (e instanceof IntegrityError) {
1116
+ return {
1117
+ written: [],
1118
+ blocked: [{ name: arg, reason: e.message }],
1119
+ exitCode: 1,
1120
+ output: pc7.red(`\u2717 ${arg} blocked \u2014 ${e.message}`)
1121
+ };
1122
+ }
1123
+ throw e;
1124
+ }
1041
1125
  if (flags.skill?.length) candidates = candidates.filter((c) => flags.skill.includes(c.name) || flags.skill.includes("*"));
1042
1126
  const deny = new Set(flags.deny ?? []);
1043
1127
  const written = [];
@@ -1069,6 +1153,9 @@ async function runAdd(arg, flags, deps = defaultDeps) {
1069
1153
  if (c.pinMiss) {
1070
1154
  lines.push(pc7.yellow(`\u26A0 ${c.name}: pinned commit unavailable upstream \u2014 fetched the source repo's default branch instead`));
1071
1155
  }
1156
+ if (c.note) {
1157
+ lines.push(pc7.yellow(`\u26A0 ${c.name}: ${c.note}`));
1158
+ }
1072
1159
  if (c.type === "pack" && !c.files) {
1073
1160
  lines.push(pc7.yellow(`\u26A0 ${c.name} is a pack but its asset files could not be fetched \u2014 only SKILL.md was installed. It may reference missing files.`));
1074
1161
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillmds",
3
- "version": "1.0.17",
3
+ "version": "1.0.19",
4
4
  "description": "SkillMD for agents: the `skillmds` MCP server + the `skillmd` CLI (lint, scan, search, install, publish Agent Skills) from skillmd.com.",
5
5
  "type": "module",
6
6
  "bin": {
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: 25,
173
- executes_scripts: 15,
174
- network_calls: 8
172
+ reads_secrets: 2,
173
+ network_calls: 1,
174
+ executes_scripts: 5
175
175
  };
176
- function qualityScore(diagnostics, security) {
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
- return Math.max(0, Math.min(100, 100 - penalty));
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