skillmds 1.0.12 → 1.0.14

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,27 @@
2
2
 
3
3
  All notable changes to `skillmds` are documented here. Versions follow [semver](https://semver.org).
4
4
 
5
+ ## 1.0.14
6
+
7
+ - Interactive menu (`skillmds` in a terminal) no longer exits after one action — it returns to the menu until you pick **Quit**; cancelling a sub-prompt goes back to the menu instead of quitting.
8
+ - "List installed skills" now shows project **and** global skills together, tagged `[agent/scope]`, replacing the misleading "Include global skills?" prompt (which actually toggled global-only vs project-only). Invalid skills show the parse reason.
9
+ - The in-menu command reference now includes `info`.
10
+
11
+ ## 1.0.13
12
+
13
+ Safe & easy installs release.
14
+
15
+ - **Packs install their full file tree.** `skillmd add owner/pack` now downloads the pack's source directory (pinned to the registry's commit; falls back to the default branch with a warning if the pin is gone upstream), so `assets/` and `references/` files referenced by SKILL.md actually exist. Hardened: symlinks are skipped, 200-file/20MB caps, temp dirs cleaned up.
16
+ - **Unverified skills prompt instead of hard-blocking.** Interactive installs show the lint score and security flags and ask before installing; non-interactive installs (and `-y`) still require the explicit `--allow-unverified`. Blocked output links the skill's page for review.
17
+ - **New `skillmd info <slug>`** shows verification status, security flags, rating, provenance (source repo @ commit), and the install snippet. Unknown subcommands now print a proper error instead of silently starting the MCP server.
18
+ - **MCP `skillmd_install` gained the CLI's safety gates**: refuses unverified skills unless `allow_unverified: true` (only after explicit human approval), validates before writing, reports security flags, and guards against malformed skill names.
19
+ - **Installs are exact.** The registry now stores the published SKILL.md verbatim (`raw_md`) and installs deliver it byte-for-byte; reconstructed frontmatter (legacy rows) is YAML-quoted so descriptions with `:`/`#`/quotes can't corrupt the file.
20
+ - Description limit aligned with Claude Code's real spec (1024 chars, was 500) — valid skills are no longer flagged "(invalid SKILL.md)" by `skillmd list`, and `list` now says *why* a file is invalid.
21
+
22
+ ## 1.0.12
23
+
24
+ - Registry-native `skillmd` CLI shipped inside `skillmds`: `search`, `add`, `list`, `remove`, `update`, `publish`, `login`/`logout`, plus lint/scan/rules/init. (Release notes added retroactively.)
25
+
5
26
  ## 1.0.11
6
27
 
7
28
  - Security flags render with full readable labels, each with its own marker: `⚑ Executes Scripts ⚑ Calls Network ⚑ Reads Secrets`.
@@ -1,232 +1,253 @@
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 { homedir } from "node:os";
17
- import { join, resolve } from "node:path";
18
-
19
- const VERSION = (() => {
20
- try {
21
- return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
22
- } catch {
23
- return "0.0.0";
24
- }
25
- })();
26
- // Shared validation engine, bundled into ./vendor at publish time (npm run build).
27
- // Falls back gracefully if the vendor build is absent (e.g. running from source).
28
- let lint = null;
29
- try {
30
- ({ lint } = await import("../vendor/index.js"));
31
- } catch {
32
- /* skillmd_lint will report that validation is unavailable */
33
- }
34
-
35
- const API = process.env.SKILLMD_API || "https://api.skillmd.com";
36
- const TOKEN = process.env.SKILLMD_TOKEN || "";
37
-
38
- async function api(path, init = {}) {
39
- const headers = { accept: "application/json", ...(init.headers || {}) };
40
- if (TOKEN) headers.authorization = `Bearer ${TOKEN}`;
41
- const res = await fetch(API + path, { ...init, headers });
42
- if (!res.ok) throw new Error(`SkillMD API ${res.status} on ${path}: ${await res.text().catch(() => "")}`);
43
- return res.json();
44
- }
45
- const text = (obj) => ({ content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }] });
46
-
47
- const TOOLS = [
48
- {
49
- name: "skillmd_search",
50
- description: "Search SkillMD for Agent Skills by name, description, or task. Returns matching skills with ratings and install snippets.",
51
- inputSchema: {
52
- type: "object",
53
- properties: {
54
- query: { type: "string", description: "Natural-language query or keywords" },
55
- category: { type: "string", description: "Filter by category slug (e.g. devops, data, security)" },
56
- verified_only: { type: "boolean", description: "Only verified skills" },
57
- type: { type: "string", enum: ["single", "pack"], description: "single file or pack" },
58
- min_rating: { type: "number", description: "Minimum average rating 0-5" },
59
- limit: { type: "number", description: "Max results (default 20)" },
60
- },
61
- required: ["query"],
62
- },
63
- },
64
- {
65
- name: "skillmd_get",
66
- description: "Fetch a single skill by slug (owner/name), including its SKILL.md body, provenance, license and security flags.",
67
- inputSchema: { type: "object", properties: { slug: { type: "string", description: "owner/name, e.g. anthropic/pdf" } }, required: ["slug"] },
68
- },
69
- {
70
- name: "skillmd_install",
71
- description: "Install a skill by slug: writes its SKILL.md into the agent's skills directory. Never executes any scripts.",
72
- inputSchema: {
73
- type: "object",
74
- properties: {
75
- slug: { type: "string", description: "owner/name, e.g. anthropic/pdf" },
76
- dest: { type: "string", description: "Target skills dir. Default ~/.claude/skills" },
77
- },
78
- required: ["slug"],
79
- },
80
- },
81
- {
82
- name: "skillmd_trending",
83
- description: "Top skills on the SkillMD leaderboard. range: '30d' or 'all'.",
84
- inputSchema: {
85
- type: "object",
86
- properties: {
87
- range: { type: "string", enum: ["30d", "all"], description: "Time window (default all)" },
88
- category: { type: "string", description: "Optional category slug" },
89
- limit: { type: "number", description: "Max results (default 20)" },
90
- },
91
- },
92
- },
93
- {
94
- name: "skillmd_recommend",
95
- description: "Recommend skills. If based_on is given, suggests skills similar to it (same category); otherwise returns trending.",
96
- inputSchema: {
97
- type: "object",
98
- properties: { based_on: { type: "string", description: "A slug to base recommendations on" }, limit: { type: "number" } },
99
- },
100
- },
101
- {
102
- name: "skillmd_list_saved",
103
- description: "List the signed-in user's saved skills. Requires SKILLMD_TOKEN to be set.",
104
- inputSchema: { type: "object", properties: {} },
105
- },
106
- {
107
- name: "skillmd_lint",
108
- description:
109
- "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.",
110
- inputSchema: {
111
- type: "object",
112
- properties: {
113
- content: { type: "string", description: "Raw SKILL.md content to validate" },
114
- slug: { type: "string", description: "Registry slug owner/name to fetch and validate" },
115
- },
116
- },
117
- },
118
- ];
119
-
120
- function reconstructSkillMd(skill) {
121
- const fm = ["---", `name: ${skill.slug.split("/")[1] || skill.title}`, `description: ${String(skill.description).replace(/\n/g, " ")}`];
122
- if (skill.license) fm.push(`license: ${skill.license}`);
123
- fm.push("---", "");
124
- return fm.join("\n") + (skill.body_md || "");
125
- }
126
-
127
- async function handleCall(name, args) {
128
- switch (name) {
129
- case "skillmd_search": {
130
- const p = new URLSearchParams({ q: args.query, limit: String(args.limit ?? 20) });
131
- if (args.category) p.set("category", args.category);
132
- if (args.verified_only) p.set("verified", "true");
133
- if (args.type) p.set("type", args.type);
134
- if (args.min_rating != null) p.set("min_rating", String(args.min_rating));
135
- const r = await api(`/api/search?${p}`);
136
- return text(r.items ?? []);
137
- }
138
- case "skillmd_get": {
139
- const [owner, slugName] = String(args.slug).split("/");
140
- const r = await api(`/api/skills/${owner}/${slugName}`);
141
- return text(r);
142
- }
143
- case "skillmd_trending": {
144
- const p = new URLSearchParams({ range: args.range ?? "all", limit: String(args.limit ?? 20) });
145
- if (args.category) p.set("category", args.category);
146
- const r = await api(`/api/leaderboard?${p}`);
147
- return text(r.items ?? []);
148
- }
149
- case "skillmd_recommend": {
150
- if (args.based_on) {
151
- const [owner, slugName] = String(args.based_on).split("/");
152
- const base = await api(`/api/skills/${owner}/${slugName}`).catch(() => null);
153
- if (base?.category_slug) {
154
- const r = await api(`/api/leaderboard?range=all&limit=${args.limit ?? 10}&category=${base.category_slug}`);
155
- return text((r.items ?? []).filter((s) => s.slug !== args.based_on));
156
- }
157
- }
158
- const r = await api(`/api/leaderboard?range=30d&limit=${args.limit ?? 10}`);
159
- return text(r.items ?? []);
160
- }
161
- case "skillmd_list_saved": {
162
- if (!TOKEN) return text("Set SKILLMD_TOKEN (a SkillMD personal access token) to list your saved skills.");
163
- const r = await api(`/api/favorites`);
164
- return text(r.items ?? []);
165
- }
166
- case "skillmd_lint": {
167
- if (!lint) return text("Validation engine unavailable — run `npm run build` in the skillmds package.");
168
- let raw = args.content;
169
- let slug;
170
- if (!raw && args.slug) {
171
- const [owner, slugName] = String(args.slug).split("/");
172
- const skill = await api(`/api/skills/${owner}/${slugName}`);
173
- raw = reconstructSkillMd(skill);
174
- slug = slugName;
175
- }
176
- if (!raw) return text("Provide `content` (raw SKILL.md) or `slug` (owner/name).");
177
- const result = lint(raw, slug ? { slug } : {});
178
- return text({ ok: result.ok, score: result.score, diagnostics: result.diagnostics, security: result.security });
179
- }
180
- case "skillmd_install": {
181
- const [owner, slugName] = String(args.slug).split("/");
182
- const skill = await api(`/api/skills/${owner}/${slugName}`);
183
- const baseDir = args.dest ? resolve(args.dest) : join(homedir(), ".claude", "skills");
184
- const dir = join(baseDir, slugName);
185
- await mkdir(dir, { recursive: true });
186
- const file = join(dir, "SKILL.md");
187
- await writeFile(file, reconstructSkillMd(skill), "utf8");
188
- api(`/api/skills/${owner}/${slugName}/install`, { method: "POST", body: JSON.stringify({ via: "mcp" }) }).catch(() => {});
189
- const note = skill.type === "pack"
190
- ? " (pack assets are not bundled yet — only SKILL.md was written)"
191
- : "";
192
- return text(`Installed ${args.slug} → ${file}${note}`);
193
- }
194
- default:
195
- throw new Error(`Unknown tool: ${name}`);
196
- }
197
- }
198
-
199
- async function startMcpServer() {
200
- const server = new Server({ name: "skillmd", version: VERSION }, { capabilities: { tools: {} } });
201
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
202
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
203
- try {
204
- return await handleCall(req.params.name, req.params.arguments ?? {});
205
- } catch (e) {
206
- return { isError: true, content: [{ type: "text", text: String(e?.message || e) }] };
207
- }
208
- });
209
- const transport = new StdioServerTransport();
210
- await server.connect(transport);
211
- console.error("skillmd MCP server running (stdio)");
212
- }
213
-
214
- // This binary is invoked three ways:
215
- // - `skillmds lint .` etc. → run the CLI (so `npx skillmds <cmd>` works without -p)
216
- // - `skillmds` in a terminal → run the CLI (opens the interactive menu)
217
- // - `skillmds` over piped stdio → start the MCP server (how MCP clients launch it)
218
- // MCP clients always connect stdin as a pipe (never a TTY), so an interactive
219
- // stdin is a reliable signal that a human is present.
220
- const CLI_COMMANDS = new Set([
221
- "lint", "check", "scan", "rules", "init", "search", "add", "list", "ls",
222
- "remove", "rm", "update", "publish", "submit", "login", "logout", "help",
223
- "--help", "-h", "--version", "-V",
224
- ]);
225
- const first = process.argv[2];
226
- const isCliCommand = first && CLI_COMMANDS.has(first);
227
- const humanInTerminal = !first && process.stdin.isTTY;
228
- if (isCliCommand || humanInTerminal) {
229
- await import("../dist/cli.js"); // the CLI parses process.argv itself
230
- } else {
231
- await startMcpServer();
232
- }
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 { homedir } from "node:os";
17
+ import { join, resolve } from "node:path";
18
+ import { stringify as stringifyYaml } from "yaml";
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, bundled into ./vendor at publish time (npm run build).
28
+ // Falls back gracefully if the vendor build is absent (e.g. running from source).
29
+ let lint = null;
30
+ try {
31
+ ({ lint } = await import("../vendor/index.js"));
32
+ } catch {
33
+ /* skillmd_lint will report that validation is unavailable */
34
+ }
35
+
36
+ const API = process.env.SKILLMD_API || "https://api.skillmd.com";
37
+ const TOKEN = process.env.SKILLMD_TOKEN || "";
38
+
39
+ async function api(path, init = {}) {
40
+ const headers = { accept: "application/json", ...(init.headers || {}) };
41
+ if (TOKEN) headers.authorization = `Bearer ${TOKEN}`;
42
+ const res = await fetch(API + path, { ...init, headers });
43
+ if (!res.ok) throw new Error(`SkillMD API ${res.status} on ${path}: ${await res.text().catch(() => "")}`);
44
+ return res.json();
45
+ }
46
+ const text = (obj) => ({ content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }] });
47
+
48
+ const TOOLS = [
49
+ {
50
+ name: "skillmd_search",
51
+ description: "Search SkillMD for Agent Skills by name, description, or task. Returns matching skills with ratings and install snippets.",
52
+ inputSchema: {
53
+ type: "object",
54
+ properties: {
55
+ query: { type: "string", description: "Natural-language query or keywords" },
56
+ category: { type: "string", description: "Filter by category slug (e.g. devops, data, security)" },
57
+ verified_only: { type: "boolean", description: "Only verified skills" },
58
+ type: { type: "string", enum: ["single", "pack"], description: "single file or pack" },
59
+ min_rating: { type: "number", description: "Minimum average rating 0-5" },
60
+ limit: { type: "number", description: "Max results (default 20)" },
61
+ },
62
+ required: ["query"],
63
+ },
64
+ },
65
+ {
66
+ name: "skillmd_get",
67
+ description: "Fetch a single skill by slug (owner/name), including its SKILL.md body, provenance, license and security flags.",
68
+ inputSchema: { type: "object", properties: { slug: { type: "string", description: "owner/name, e.g. anthropic/pdf" } }, required: ["slug"] },
69
+ },
70
+ {
71
+ name: "skillmd_install",
72
+ description:
73
+ "Install a skill by slug: validates it, then writes its SKILL.md into the agent's skills directory. Never executes any scripts. Unverified skills are refused unless allow_unverified is true. Do not set allow_unverified on your own initiative — only after the human has explicitly approved installing this specific unverified skill.",
74
+ inputSchema: {
75
+ type: "object",
76
+ properties: {
77
+ slug: { type: "string", description: "owner/name, e.g. anthropic/pdf" },
78
+ dest: { type: "string", description: "Target skills dir. Default ~/.claude/skills" },
79
+ allow_unverified: { type: "boolean", description: "Install even if the skill is not verified by SkillMD. Never set this on your own initiative — only after the human has explicitly approved this specific skill." },
80
+ },
81
+ required: ["slug"],
82
+ },
83
+ },
84
+ {
85
+ name: "skillmd_trending",
86
+ description: "Top skills on the SkillMD leaderboard. range: '30d' or 'all'.",
87
+ inputSchema: {
88
+ type: "object",
89
+ properties: {
90
+ range: { type: "string", enum: ["30d", "all"], description: "Time window (default all)" },
91
+ category: { type: "string", description: "Optional category slug" },
92
+ limit: { type: "number", description: "Max results (default 20)" },
93
+ },
94
+ },
95
+ },
96
+ {
97
+ name: "skillmd_recommend",
98
+ description: "Recommend skills. If based_on is given, suggests skills similar to it (same category); otherwise returns trending.",
99
+ inputSchema: {
100
+ type: "object",
101
+ properties: { based_on: { type: "string", description: "A slug to base recommendations on" }, limit: { type: "number" } },
102
+ },
103
+ },
104
+ {
105
+ name: "skillmd_list_saved",
106
+ description: "List the signed-in user's saved skills. Requires SKILLMD_TOKEN to be set.",
107
+ inputSchema: { type: "object", properties: {} },
108
+ },
109
+ {
110
+ name: "skillmd_lint",
111
+ description:
112
+ "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.",
113
+ inputSchema: {
114
+ type: "object",
115
+ properties: {
116
+ content: { type: "string", description: "Raw SKILL.md content to validate" },
117
+ slug: { type: "string", description: "Registry slug owner/name to fetch and validate" },
118
+ },
119
+ },
120
+ },
121
+ ];
122
+
123
+ // KEEP IN SYNC with apps/cli/src/api.ts (reconstructSkillMd / skillMdFor) —
124
+ // duplicated because the MCP bin ships as a standalone script; no automated
125
+ // check enforces this.
126
+ function reconstructSkillMd(skill) {
127
+ if (skill.raw_md) return skill.raw_md;
128
+ const fm = { name: skill.slug.split("/")[1] || skill.title, description: String(skill.description).replace(/\n/g, " ") };
129
+ if (skill.license) fm.license = skill.license;
130
+ return `---\n${stringifyYaml(fm)}---\n${skill.body_md || ""}`;
131
+ }
132
+
133
+ async function handleCall(name, args) {
134
+ switch (name) {
135
+ case "skillmd_search": {
136
+ const p = new URLSearchParams({ q: args.query, limit: String(args.limit ?? 20) });
137
+ if (args.category) p.set("category", args.category);
138
+ if (args.verified_only) p.set("verified", "true");
139
+ if (args.type) p.set("type", args.type);
140
+ if (args.min_rating != null) p.set("min_rating", String(args.min_rating));
141
+ const r = await api(`/api/search?${p}`);
142
+ return text(r.items ?? []);
143
+ }
144
+ case "skillmd_get": {
145
+ const [owner, slugName] = String(args.slug).split("/");
146
+ const r = await api(`/api/skills/${owner}/${slugName}`);
147
+ return text(r);
148
+ }
149
+ case "skillmd_trending": {
150
+ const p = new URLSearchParams({ range: args.range ?? "all", limit: String(args.limit ?? 20) });
151
+ if (args.category) p.set("category", args.category);
152
+ const r = await api(`/api/leaderboard?${p}`);
153
+ return text(r.items ?? []);
154
+ }
155
+ case "skillmd_recommend": {
156
+ if (args.based_on) {
157
+ const [owner, slugName] = String(args.based_on).split("/");
158
+ const base = await api(`/api/skills/${owner}/${slugName}`).catch(() => null);
159
+ if (base?.category_slug) {
160
+ const r = await api(`/api/leaderboard?range=all&limit=${args.limit ?? 10}&category=${base.category_slug}`);
161
+ return text((r.items ?? []).filter((s) => s.slug !== args.based_on));
162
+ }
163
+ }
164
+ const r = await api(`/api/leaderboard?range=30d&limit=${args.limit ?? 10}`);
165
+ return text(r.items ?? []);
166
+ }
167
+ case "skillmd_list_saved": {
168
+ if (!TOKEN) return text("Set SKILLMD_TOKEN (a SkillMD personal access token) to list your saved skills.");
169
+ const r = await api(`/api/favorites`);
170
+ return text(r.items ?? []);
171
+ }
172
+ case "skillmd_lint": {
173
+ if (!lint) return text("Validation engine unavailable — run `npm run build` in the skillmds package.");
174
+ let raw = args.content;
175
+ let slug;
176
+ if (!raw && args.slug) {
177
+ const [owner, slugName] = String(args.slug).split("/");
178
+ const skill = await api(`/api/skills/${owner}/${slugName}`);
179
+ raw = reconstructSkillMd(skill);
180
+ slug = slugName;
181
+ }
182
+ if (!raw) return text("Provide `content` (raw SKILL.md) or `slug` (owner/name).");
183
+ const result = lint(raw, slug ? { slug } : {});
184
+ return text({ ok: result.ok, score: result.score, diagnostics: result.diagnostics, security: result.security });
185
+ }
186
+ case "skillmd_install": {
187
+ const [owner, slugName] = String(args.slug).split("/");
188
+ // Defense in depth: slugName becomes a directory name below — refuse
189
+ // anything outside the registry's slugify alphabet (path separators,
190
+ // "..", etc.) before touching the network or filesystem.
191
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(slugName ?? "")) {
192
+ return text(`Refused: invalid skill name "${slugName}" — expected a lowercase slug.`);
193
+ }
194
+ const skill = await api(`/api/skills/${owner}/${slugName}`);
195
+ const flags = skill.security_flags ?? [];
196
+ if (skill.verified === false && !args.allow_unverified) {
197
+ return text(
198
+ `Refused: ${args.slug} is not yet verified by SkillMD (security flags: ${flags.join(", ") || "unscanned"}). ` +
199
+ `Review it at https://skillmd.com/skills/${args.slug}. Only call skillmd_install again with allow_unverified: true ` +
200
+ `if the human explicitly approves installing this specific unverified skill — do not decide this yourself.`,
201
+ );
202
+ }
203
+ const raw = reconstructSkillMd(skill); // returns raw_md verbatim when the registry has it
204
+ if (lint) {
205
+ const result = lint(raw, { slug: slugName });
206
+ if (!result.ok) {
207
+ const errors = result.diagnostics.filter((d) => d.severity === "error");
208
+ return text(`Refused: ${args.slug} failed validation (${errors.map((d) => `${d.id}: ${d.message}`).join("; ")}). Not installed.`);
209
+ }
210
+ }
211
+ const baseDir = args.dest ? resolve(args.dest) : join(homedir(), ".claude", "skills");
212
+ const dir = join(baseDir, slugName);
213
+ await mkdir(dir, { recursive: true });
214
+ const file = join(dir, "SKILL.md");
215
+ await writeFile(file, raw, "utf8");
216
+ api(`/api/skills/${owner}/${slugName}/install`, { method: "POST", body: JSON.stringify({ via: "mcp" }) }).catch(() => {});
217
+ const note = skill.type === "pack"
218
+ ? ` 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}`
219
+ : "";
220
+ return text(`Installed ${args.slug} → ${file} (security: ${flags.join(", ") || "unscanned"})${note}`);
221
+ }
222
+ default:
223
+ throw new Error(`Unknown tool: ${name}`);
224
+ }
225
+ }
226
+
227
+ async function startMcpServer() {
228
+ const server = new Server({ name: "skillmd", version: VERSION }, { capabilities: { tools: {} } });
229
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
230
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
231
+ try {
232
+ return await handleCall(req.params.name, req.params.arguments ?? {});
233
+ } catch (e) {
234
+ return { isError: true, content: [{ type: "text", text: String(e?.message || e) }] };
235
+ }
236
+ });
237
+ const transport = new StdioServerTransport();
238
+ await server.connect(transport);
239
+ console.error("skillmd MCP server running (stdio)");
240
+ }
241
+
242
+ // This binary is invoked three ways:
243
+ // - `skillmds <anything>` → run the CLI. commander owns argv, so known
244
+ // commands run and unknown commands get a proper "unknown command" error
245
+ // instead of silently starting an MCP server.
246
+ // - `skillmds` in a terminal → run the CLI (opens the interactive menu)
247
+ // - `skillmds` over piped stdio → start the MCP server (how MCP clients launch it;
248
+ // they always connect stdin as a pipe, never a TTY, and pass no args)
249
+ if (process.argv.length > 2 || process.stdin.isTTY) {
250
+ await import("../dist/cli.js"); // the CLI parses process.argv itself
251
+ } else {
252
+ await startMcpServer();
253
+ }