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 +4 -0
- package/bin/skillmd-mcp.mjs +37 -6
- package/dist/cli.js +91 -10
- package/package.json +1 -1
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.
|
package/bin/skillmd-mcp.mjs
CHANGED
|
@@ -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)
|
|
248
|
-
|
|
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
|
-
|
|
282
|
+
resolved.push({ path: f.path, contents: f.path === "SKILL.md" ? raw : buf });
|
|
254
283
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
798
|
-
|
|
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
|
|
1002
|
-
return
|
|
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
|
-
|
|
1032
|
-
|
|
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
|
|
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