skillmds 1.0.18 → 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,10 @@
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
+
5
9
  ## 1.0.18
6
10
 
7
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.
@@ -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}`;
@@ -241,20 +255,37 @@ async function handleCall(name, args) {
241
255
  // bundle avoids adding an unzip dependency to this standalone binary.
242
256
  try {
243
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 = [];
244
261
  for (const f of bundle.files ?? []) {
245
262
  if (f.path.includes("..") || f.path.startsWith("/")) continue;
246
263
  let buf;
247
- if (f.content_base64 != null) buf = Buffer.from(f.content_base64, "base64");
248
- 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)) {
249
278
  const r = await fetch(f.source_url).catch(() => null);
250
279
  if (!r || !r.ok) continue;
251
280
  buf = Buffer.from(await r.arrayBuffer());
252
281
  } else continue;
253
- await writeSafe(f.path, f.path === "SKILL.md" ? raw : buf);
282
+ resolved.push({ path: f.path, contents: f.path === "SKILL.md" ? raw : buf });
254
283
  }
255
- written = (bundle.files ?? []).length || 1;
256
- if (!(bundle.files ?? []).some((f) => f.path === "SKILL.md")) await writeSafe("SKILL.md", raw);
257
- } 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.`);
258
289
  await writeSafe("SKILL.md", raw);
259
290
  note = ` NOTE: pack assets could not be fetched — only SKILL.md was written. For the full pack run: npx skillmds add ${args.slug}`;
260
291
  }
package/dist/cli.js CHANGED
@@ -739,6 +739,7 @@ import { Command as Command5 } from "commander";
739
739
  import pc5 from "picocolors";
740
740
 
741
741
  // ../cli/src/api.ts
742
+ import { createHash } from "crypto";
742
743
  import { stringify as stringifyYaml } from "yaml";
743
744
 
744
745
  // ../cli/src/config.ts
@@ -769,16 +770,46 @@ function resolveApi(flags = {}) {
769
770
  }
770
771
 
771
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
+ }
772
797
  function createClient(flags = {}) {
773
798
  const base = resolveApi(flags);
774
799
  const token = resolveToken(flags);
775
800
  async function api(path, init = {}) {
776
801
  const headers = { accept: "application/json", ...init.headers };
777
802
  if (token) headers.authorization = `Bearer ${token}`;
778
- 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
+ }
779
810
  if (!res.ok) {
780
811
  const detail = await res.text().catch(() => "");
781
- throw new Error(`SkillMD API ${res.status} on ${path}: ${detail}`);
812
+ throw new RegistryError(`SkillMD API ${res.status} on ${path}: ${detail}`, res.status);
782
813
  }
783
814
  return await res.json();
784
815
  }
@@ -794,8 +825,15 @@ async function fetchBundle(base, slug, token) {
794
825
  const out = [];
795
826
  for (const f of data.files) {
796
827
  if (f.content_base64 != null) {
797
- out.push({ path: f.path, contents: Buffer.from(f.content_base64, "base64") });
798
- } 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)) {
799
837
  const r = await fetch(f.source_url).catch(() => null);
800
838
  if (r && r.ok) out.push({ path: f.path, contents: Buffer.from(await r.arrayBuffer()) });
801
839
  }
@@ -995,12 +1033,31 @@ async function resolvePackFiles(skill, fetchTree = resolveTree) {
995
1033
  }
996
1034
  return null;
997
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
+ }
998
1047
  var defaultDeps = {
999
1048
  async resolve(arg, flags) {
1000
1049
  if (isLocalish(arg)) {
1001
- const rs2 = await resolveSource(arg);
1002
- 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 }));
1003
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
+ })();
1004
1061
  if (/^[^/\s:]+\/[^/\s:]+$/.test(arg)) {
1005
1062
  const { api, base: apiBase, token } = createClient(flags);
1006
1063
  const [owner, name] = arg.split("/");
@@ -1025,11 +1082,19 @@ var defaultDeps = {
1025
1082
  if (pack) return [{ ...meta, ...pack }];
1026
1083
  }
1027
1084
  return [{ ...meta, raw: skillMdFor(skill) }];
1028
- } catch {
1085
+ } catch (e) {
1086
+ if (e instanceof IntegrityError) throw e;
1087
+ if (e instanceof RegistryError && e.status !== 404) registryDown = e;
1029
1088
  }
1030
1089
  }
1031
- const rs = await resolveSource(arg);
1032
- 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
+ }
1033
1098
  },
1034
1099
  fireInstall(registrySlug, flags) {
1035
1100
  const { api } = createClient(flags);
@@ -1043,7 +1108,20 @@ var defaultDeps = {
1043
1108
  }
1044
1109
  };
1045
1110
  async function runAdd(arg, flags, deps = defaultDeps) {
1046
- 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
+ }
1047
1125
  if (flags.skill?.length) candidates = candidates.filter((c) => flags.skill.includes(c.name) || flags.skill.includes("*"));
1048
1126
  const deny = new Set(flags.deny ?? []);
1049
1127
  const written = [];
@@ -1075,6 +1153,9 @@ async function runAdd(arg, flags, deps = defaultDeps) {
1075
1153
  if (c.pinMiss) {
1076
1154
  lines.push(pc7.yellow(`\u26A0 ${c.name}: pinned commit unavailable upstream \u2014 fetched the source repo's default branch instead`));
1077
1155
  }
1156
+ if (c.note) {
1157
+ lines.push(pc7.yellow(`\u26A0 ${c.name}: ${c.note}`));
1158
+ }
1078
1159
  if (c.type === "pack" && !c.files) {
1079
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.`));
1080
1161
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillmds",
3
- "version": "1.0.18",
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": {