skillmds 1.1.1 → 1.2.0

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,96 @@
2
2
 
3
3
  All notable changes to `skillmds` are documented here. Versions follow [semver](https://semver.org).
4
4
 
5
+ ## 1.2.0
6
+
7
+ `skillmd add` was rewritten. It no longer copies a skill into every agent folder it can find: it writes **one canonical copy** and links that copy into the agents you chose, in the scope you chose, and records what it did in a lock file.
8
+
9
+ ### Never litters your folders
10
+
11
+ - In a terminal, `add` asks **Installation scope: Project / Global** before writing anything, then which of the detected agents to link into. The agent selection is remembered and pre-checked next time.
12
+ - With `-y`, `--json`, piped stdin, or inside a host agent (Claude Code, Cursor, Codex, Gemini CLI, CI) the scope is auto-detected: **project** when the current directory carries `.git`, `package.json`, `pyproject.toml`, `Cargo.toml`, `go.mod`, `AGENTS.md`, `CLAUDE.md`, `skills-lock.json` or an agent dot-dir, **global** otherwise. The run says which it picked.
13
+ - A piped run with neither `-y` nor a scope flag now **exits 1 with a hint** instead of guessing. Running `skillmd add owner/name` from your Desktop no longer scatters `.claude/`, `.cursor/`, `.codex/`, `.agents/` there.
14
+ - New `-p, --project` forces the current directory; `-g, --global` is unchanged. Cancelling a prompt installs nothing.
15
+ - Project installs only touch agents that are **already present in the project** (or named with `-a`) — `add -p` never creates a folder for an agent the project does not use.
16
+
17
+ ### One canonical copy, links everywhere
18
+
19
+ - The skill's files are written once to `.agents/skills/<name>` (project) or `~/.agents/skills/<name>` (global). Each chosen agent directory gets a **junction** (Windows) or **symlink** (everywhere else) pointing at it; if the filesystem refuses, the CLI falls back to a copy and says so.
20
+ - `--mode link|copy` picks between the two (`link` is the default). The old `--copy` spelling still works as a hidden alias.
21
+ - Writes are staged and moved into place, so an interrupted install never leaves a half-written skill behind.
22
+ - `add` refuses to replace a skill installed from a **different source**, or a directory it does not track, unless you pass `--force`.
23
+ - Local directory sources install **all** of the skill's files. GitHub and gist sources install `SKILL.md` only for now; registry installs deliver the full bundle.
24
+
25
+ ### Lock files
26
+
27
+ - Project installs write `skills-lock.json` next to your code — sorted, no timestamps, designed to be committed and to produce clean diffs. Global installs write `~/.skillmd/lock.json`.
28
+ - Each entry records the `source`, the `commit_sha` it came from, a `digest` of the installed files, the `agents` it is linked into, and the link `mode` per agent. `list`, `update`, `check` and `remove` all read it.
29
+
30
+ ### Real source grammar
31
+
32
+ One parser now decides what a `<source>` argument means, and every command uses it:
33
+
34
+ - `owner/name` — a registry slug (GitHub is the fallback).
35
+ - `owner/repo#ref` — a branch, tag or commit.
36
+ - `owner/repo/sub/path` — a subdirectory of a repo.
37
+ - `owner/repo@skill` — one named skill inside a repo or pack.
38
+ - `github:owner/repo` / `gh:owner/repo` — force the GitHub reading of a bare `owner/name`.
39
+ - GitHub `tree/` and `blob/` URLs, with the ref in the URL preserved (a `blob` URL pointing at a `SKILL.md` resolves to its directory).
40
+ - Gist URLs.
41
+ - `--ref <ref>` overrides the ref on any GitHub source; `-l, --list` prints the skills a source contains without installing anything.
42
+ - GitLab and `git@`/`ssh://` sources get a clear "clone it locally and run `skillmd add ./path/to/skill`" message instead of a confusing 404.
43
+
44
+ ### Production behaviour
45
+
46
+ - **`list` / `ls`** — both scopes by default (`-g`, `-p` to narrow, `-a` to filter by agent, `--json`). Columns: name · scope · agents · mode · source. Skills that are on disk but not in a lock file — hand-made, or installed by 1.1.x — are tagged `untracked`.
47
+ - **`remove` / `rm`** — takes `[names...]` or opens a picker; confirms unless `-y`. `--all` removes everything in scope (not combinable with names). `-a` unlinks only those agents and leaves the canonical copy alone; `-g`/`-p` narrow the scope, both are searched by default. `--json` supported; exits 1 when nothing was removed.
48
+ - **`update [names]`** — lock-driven: it refreshes the **full bundle** from the source recorded for each skill, re-lints before writing, and short-circuits when the recorded `commit_sha` has not moved. `--check` reports without writing, `-g`/`-p` narrow the scope, `--json` is supported, and any failure exits 1. Untracked skills are reported and skipped with "reinstall with `skillmd add`".
49
+ - **New `check` command** — `update --check` under its own name.
50
+ - **`login` / `logout` / `init`** never prompt off a TTY: they exit 1 naming the flags to pass instead of hanging an agent or a CI job. All three accept `--json`.
51
+ - A stored token is now **bound to the API host it was saved for**, so a token for one registry is never sent to another. `http://` bases are refused unless you pass `--insecure-http`.
52
+ - `--token` on a terminal prints a warning on stderr: it lands in shell history and in every `ps` listing — prefer `skillmd login` or `SKILLMD_TOKEN`.
53
+
54
+ ### Security
55
+
56
+ - Every file in a registry bundle must carry a sha256 and match it, including companion files delivered by URL. A missing or mismatched hash refuses the install rather than writing unverifiable content. The one exception is the bundle's inline `SKILL.md`: the registry generates that file from the skill's stored record rather than storing it, so there is no hash to compare it against. Every other path, hashed or not, is either verified or refused.
57
+ - Registry bundles are capped at **200 files / 20 MB**. Every registry, GitHub raw and gist request carries a **30 s** timeout; the repository download itself runs through `giget`, which takes no deadline — it is bounded instead by the pre-download size check against the GitHub tree API and by the same 200 files / 20 MB caps once the files are on disk. Temp directories are cleaned up on both the success and the failure path.
58
+ - Terminal escape sequences are stripped from registry text before it is printed, so a description can never repaint your terminal.
59
+ - Windows reserved device names (`con`, `nul`, `lpt1`, …) are refused as skill or file names.
60
+ - The install-count `POST /api/skills/<owner>/<name>/install` (body `{"via":"cli"}`, nothing else) is documented in the README and skipped entirely when `SKILLMD_NO_TELEMETRY=1` or `DO_NOT_TRACK=1` is set.
61
+
62
+ ### MCP server rewritten in TypeScript
63
+
64
+ - The server is now TypeScript on the same code path as the CLI — one installer, one source parser, one sanitiser, one set of schemas.
65
+ - `skillmd_install` gained `scope`, `agents`, `mode`, `force` and `deny`, and installs through the canonical-copy-plus-links model like the CLI.
66
+ - `dest` is **deprecated**: it is accepted only when it points at a known agent skills directory. Use `scope` + `agents`.
67
+ - `skillmd_lint` diagnostics declare the correct severity enum (`warn`, not `warning`).
68
+ - `skillmd_list_saved` results go through the same sanitiser as every other tool's output.
69
+
70
+ ### Agent table refreshed
71
+
72
+ - codex, goose, amp and zed use the cross-agent `.agents/skills` convention; gemini-cli uses `.gemini/skills`; antigravity uses `.agent/skills`; droid uses `.factory/skills`.
73
+ - `CLAUDE_CONFIG_DIR` and `CODEX_HOME` relocate those agents' global directories.
74
+ - Project-only agents (eve, promptscript) no longer claim a global directory.
75
+ - Legacy directories stay in the detection list, so an existing install is still found.
76
+
77
+ ### Removed
78
+
79
+ - `--copy` is now a hidden alias of `--mode copy` (still works, no longer advertised).
80
+ - The `yaml` dependency — nothing imported it since the validation engine moved to `@skillmds/core`.
81
+ - `lint`'s undocumented `check` alias, which collided with the new `check` command. Use `skillmd lint`.
82
+
83
+ ### Upgrade notes
84
+
85
+ - A token saved by an earlier `skillmd login --api <other-host>` is now treated as bound to `api.skillmd.com`. If you use a different registry, re-run `skillmd login --api <url>`.
86
+ - Skills installed by 1.1.x show as `untracked` in `list` and are skipped by `update`. Re-run `skillmd add` on them once to bring them under the lock file.
87
+ - If a script used `skillmd check .` to lint, change it to `skillmd lint .` — `check` now reports available updates.
88
+
89
+ ## 1.1.2
90
+
91
+ - **MCP tool metadata.** Every tool now declares a `title`, `annotations` (all read-only except `skillmd_install`, none destructive), an `outputSchema`, and a description on every parameter; list-returning tools respond with `structuredContent: { items }`, `skillmd_lint` and `skillmd_install` return structured results, and refusals are flagged `isError`.
92
+ - **Server metadata.** `initialize` reports a title, description, website URL, icons and usage instructions.
93
+ - No behavior changes to what the tools do.
94
+
5
95
  ## 1.1.1
6
96
 
7
97
  - Releases are now published through npm Trusted Publishing (OIDC from GitHub Actions) with provenance; no long-lived npm tokens exist for this package.
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="https://cdn.jsdelivr.net/npm/skillmds/assets/skillmd-banner.jpg" alt="SkillMD" width="560">
2
+ <img src="https://skillmd.com/brand/banner.png" alt="SkillMD" width="560">
3
3
  </p>
4
4
 
5
5
  # skillmds
@@ -27,25 +27,27 @@ This installs both the `skillmd` and `skillmds` commands. To run the CLI without
27
27
  npx skillmds lint .
28
28
  ```
29
29
 
30
- `npx skillmds <command>` runs the CLI; `npx skillmds` with no command starts the MCP server.
30
+ **Which binary runs what.** `skillmds` with any argument runs the CLI. `skillmds` with no arguments starts the **MCP server** when stdin is piped (how an MCP client launches it), and opens the interactive CLI menu when there's a terminal attached. `skillmd` is always the CLI.
31
31
 
32
32
  ## CLI: `skillmd`
33
33
 
34
34
  | Command | Description |
35
35
  |---|---|
36
- | `skillmd lint [path]` (`check`) | Validate `SKILL.md` files; print diagnostics and a quality score. |
36
+ | `skillmd lint [path]` | Validate `SKILL.md` files; print diagnostics and a quality score. |
37
37
  | `skillmd scan [path]` | Report scripts, network calls, and secret access. |
38
38
  | `skillmd rules [id]` | List lint rules, or show one. |
39
39
  | `skillmd init [name]` | Create a `SKILL.md` from a template. |
40
40
  | `skillmd search <query>` | Search the registry. |
41
- | `skillmd add <source>` | Install a skill (registry slug, GitHub repo, or local path). Lints before writing. |
42
- | `skillmd list` (`ls`) | List installed skills. |
43
- | `skillmd remove <names...>` (`rm`) | Remove installed skills. |
44
- | `skillmd update [names...]` | Update installed skills from the registry. |
41
+ | `skillmd info <slug>` | Show a skill's registry record: verification, security flags, provenance, rating. |
42
+ | `skillmd add <source>` | Install a skill (registry slug, GitHub source, gist, or local path). Lints before writing. |
43
+ | `skillmd list` (`ls`) | List installed skills with their scope, agents, link mode and source. |
44
+ | `skillmd remove [names...]` (`rm`) | Remove installed skills, or unlink them from specific agents. |
45
+ | `skillmd update [names...]` | Update installed skills from the source recorded in the lock file. |
46
+ | `skillmd check [names...]` | Report which installed skills have updates (`update --check`). |
45
47
  | `skillmd publish [path]` (`submit`) | Publish to the registry. Exits non-zero on lint errors. |
46
48
  | `skillmd login` / `logout` | Store or remove a token. |
47
49
 
48
- All commands accept `--json`.
50
+ > `check` is a command of its own since 1.2.0. It used to be an alias of `lint` — scripts that ran `skillmd check .` to validate a skill should now run `skillmd lint .`.
49
51
 
50
52
  ### lint
51
53
 
@@ -57,19 +59,90 @@ skillmd lint . --fix
57
59
  ```
58
60
 
59
61
  Formats: `text` (default), `json`, `sarif` (GitHub Code Scanning), `github` (Actions annotations).
60
- Exit codes: `0` clean, `1` errors (or warnings with `--strict` / `--fail-on-warning`), `2` usage error.
62
+ Exit codes: `0` clean, `1` errors (or warnings with `--strict` / `--fail-on-warning`).
61
63
 
62
64
  ### add
63
65
 
64
66
  ```bash
65
- skillmd add anthropic/pdf # registry slug
66
- skillmd add owner/repo # GitHub repo
67
- skillmd add ./path # local
68
- skillmd add anthropic/pdf -a claude-code -g
67
+ skillmd add anthropic/pdf # registry slug
68
+ skillmd add owner/repo # GitHub repo
69
+ skillmd add owner/repo/skills/pdf#main # a subfolder at a ref
70
+ skillmd add owner/repo@pdf # one skill inside a repo or pack
71
+ skillmd add https://github.com/owner/repo/tree/v2/skills/pdf
72
+ skillmd add https://gist.github.com/user/0123456789abcdef
73
+ skillmd add ./path # local directory
74
+ skillmd add owner/repo --list # what's in there? install nothing
75
+ skillmd add anthropic/pdf -a claude-code cursor -g
76
+ skillmd add anthropic/pdf --mode copy # no links; real files per agent
69
77
  skillmd add some/skill --deny executes_scripts
70
78
  skillmd add some/skill --skip-lint
71
79
  ```
72
80
 
81
+ Sources: a registry slug `owner/name`; a GitHub repo, subdirectory, ref (`#ref`) or named skill (`@skill`), including `tree/` and `blob/` URLs; `github:`/`gh:` to force the GitHub reading of a bare `owner/name`; a gist URL; or a local path. `--ref <ref>` overrides the ref on any GitHub source. GitLab and `git@` sources aren't supported — clone them and install from the path.
82
+
83
+ **Where skills land.** One canonical copy, linked into each agent you chose:
84
+
85
+ | Scope | Flag | Canonical copy | Agent links | Lock file |
86
+ | --- | --- | --- | --- | --- |
87
+ | **Project** | `-p`, `--project` | `.agents/skills/<name>` | `./<agent-dir>/skills/<name>` | `skills-lock.json` (commit it) |
88
+ | **Global** | `-g`, `--global` | `~/.agents/skills/<name>` | `~/<agent-dir>/skills/<name>` | `~/.skillmd/lock.json` |
89
+
90
+ Each agent link is a junction on Windows and a symlink elsewhere, so updating the canonical copy updates every agent at once; `--mode copy` writes real files instead. A project install only touches agents the project already uses (or the ones you name with `-a`), so `add -p` never invents a folder for an agent you don't run.
91
+
92
+ In a terminal, `add` asks for the scope (**Project** / **Global**) and then which detected agents to link into, remembering your agent choice for next time. Off a terminal the scope is auto-detected — see [Non-interactive use](#non-interactive-use).
93
+
94
+ Re-installing the same skill from the same source is fine. Replacing a skill that came from a *different* source, or a directory the lock file doesn't know about, needs `--force`.
95
+
96
+ ### list / remove / update / check
97
+
98
+ ```bash
99
+ skillmd list # everything installed, project and global
100
+ skillmd list -p -a claude-code # just this project, just one agent
101
+ skillmd remove pdf # unlink from every agent, delete the copy
102
+ skillmd remove pdf -a cursor # unlink from Cursor only; copy stays
103
+ skillmd remove --all -g -y # clear your user-level skills
104
+ skillmd check # which skills have updates?
105
+ skillmd update # refresh them from their recorded sources
106
+ skillmd update pdf -g
107
+ ```
108
+
109
+ `list` shows name · scope · agents · mode · source. Skills installed by hand or by an older CLI have no lock entry and are tagged `untracked`: `update` skips them and tells you to re-run `skillmd add` once to start tracking them.
110
+
111
+ `update` re-fetches the full bundle from the source recorded in the lock file, re-lints it, and skips skills whose recorded commit hasn't moved. `check` is `update --check`: it reports and writes nothing.
112
+
113
+ `remove` with no names opens a picker, and confirms before deleting unless you pass `-y`.
114
+
115
+ ### Non-interactive use
116
+
117
+ Every command works without a terminal, and none of them will ever block on a prompt when there isn't one.
118
+
119
+ ```bash
120
+ skillmd add anthropic/pdf -y --json
121
+ skillmd list --json
122
+ skillmd check --json
123
+ ```
124
+
125
+ - **`--json` requires `-y`** on `add`, and prints exactly one document on stdout; human text and warnings go to stderr, so `skillmd add … --json | jq` is always valid JSON. The `add` document is `{ ok, scope, installed: [{ name, canonical, targets, skipped, digest }], blocked, warnings }`.
126
+ - **Scope auto-detection.** With `-y`, `--json`, piped stdin, or inside a host agent (Claude Code, Cursor, Codex, Gemini CLI, or any CI), `add` picks **project** when the working directory carries `.git`, `package.json`, `pyproject.toml`, `Cargo.toml`, `go.mod`, `AGENTS.md`, `CLAUDE.md`, `skills-lock.json` or an agent dot-dir, and **global** otherwise. It says which it picked. Pass `-p` or `-g` to decide yourself.
127
+ - **Piped with neither `-y` nor a scope flag exits 1** with a hint rather than guessing where to write.
128
+ - **Exit codes.** `0` success; `1` anything that failed — lint errors, a blocked or denied skill, nothing removed, an update that couldn't be applied, or a prompt that was required but impossible. `login`, `logout` and `init` also exit 1 off a TTY, naming the flags to pass instead.
129
+ - **Tokens.** Prefer `skillmd login` or `SKILLMD_TOKEN` over `--token`: a token on the command line is visible in your shell history and in every process listing on the machine, and the CLI warns about it. A stored token is bound to the API base it was saved for and is never sent anywhere else. An `http://` API base is refused unless you add `--insecure-http`.
130
+
131
+ ### What the CLI sends
132
+
133
+ Installing a skill **from the registry** sends one fire-and-forget request so the skill's install count on skillmd.com stays accurate:
134
+
135
+ ```
136
+ POST https://api.skillmd.com/api/skills/<owner>/<name>/install
137
+ {"via":"cli"}
138
+ ```
139
+
140
+ That is the whole payload — no identifiers, no paths, no machine information — and the install succeeds whether or not it goes through. Installs from GitHub, gists and local paths send nothing. To turn it off:
141
+
142
+ ```bash
143
+ export SKILLMD_NO_TELEMETRY=1 # or the cross-tool DO_NOT_TRACK=1
144
+ ```
145
+
73
146
  ### publish
74
147
 
75
148
  ```bash
@@ -97,7 +170,9 @@ Other MCP clients:
97
170
  }
98
171
  ```
99
172
 
100
- Tools: `skillmd_search`, `skillmd_get`, `skillmd_install`, `skillmd_trending`, `skillmd_recommend`, `skillmd_list_saved`, `skillmd_lint`. `skillmd_install` writes a skill's `SKILL.md` and never executes scripts.
173
+ Tools: `skillmd_search`, `skillmd_get`, `skillmd_install`, `skillmd_trending`, `skillmd_recommend`, `skillmd_list_saved`, `skillmd_lint`.
174
+
175
+ `skillmd_install` runs the same installer as the CLI — it validates the skill, writes one canonical copy and links it into the chosen agents — and takes `scope` (`project` | `global`), `agents`, `mode` (`link` | `copy`), `force` and `deny`. It never executes scripts. The old `dest` parameter is deprecated and is accepted only when it points at a known agent skills directory; use `scope` + `agents` instead.
101
176
 
102
177
  ### Remote server (no install)
103
178
 
@@ -126,7 +201,7 @@ Resolution order, highest first:
126
201
  | Token | `--token` → `SKILLMD_TOKEN` → `~/.skillmd/config.json` |
127
202
  | API base | `--api` → `SKILLMD_API` → default registry |
128
203
 
129
- `SKILLMD_TOKEN` enables `skillmd_list_saved`, personalized recommendations, and publishing.
204
+ `SKILLMD_TOKEN` enables `skillmd_list_saved`, personalized recommendations, and publishing. A stored token records the API base it belongs to and is only ever sent there. `SKILLMD_NO_TELEMETRY=1` (or `DO_NOT_TRACK=1`) disables the install-count ping described above.
130
205
 
131
206
  ## License
132
207
 
Binary file
@@ -1,309 +1,3 @@
1
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 { createHash } from "node:crypto";
17
- import { homedir } from "node:os";
18
- import { join, resolve, dirname, sep } from "node:path";
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 + registry helpers from @skillmds/core (a runtime
28
- // dependency of this package). Falls back gracefully when running from an
29
- // unbuilt source checkout.
30
- let lint = null;
31
- let skillMdFor = null;
32
- let isAllowedSourceUrl = () => false;
33
- try {
34
- ({ lint, skillMdFor, isAllowedSourceUrl } = await import("@skillmds/core"));
35
- } catch {
36
- /* skillmd_lint / skillmd_install will report that the engine is unavailable */
37
- }
38
-
39
- const API = process.env.SKILLMD_API || "https://api.skillmd.com";
40
- const TOKEN = process.env.SKILLMD_TOKEN || "";
41
-
42
- async function api(path, init = {}) {
43
- const headers = { accept: "application/json", ...(init.headers || {}) };
44
- if (TOKEN) headers.authorization = `Bearer ${TOKEN}`;
45
- const res = await fetch(API + path, { ...init, headers });
46
- if (!res.ok) throw new Error(`SkillMD API ${res.status} on ${path}: ${await res.text().catch(() => "")}`);
47
- return res.json();
48
- }
49
- const text = (obj) => ({ content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }] });
50
-
51
- // The public API returns site-oriented fields (ratings, counters, avatars,
52
- // ranker internals) that are noise for an agent picking a skill. Strip them
53
- // from every list-returning tool so results stay compact and rankable only
54
- // on what matters to the caller.
55
- const OMIT_FIELDS = new Set([
56
- "type",
57
- "verified",
58
- "avg_rating",
59
- "rating_count",
60
- "install_count",
61
- "save_count",
62
- "owner_avatar",
63
- "confident",
64
- "category_slug",
65
- ]);
66
- const trimItems = (items) => (items ?? []).map((s) => Object.fromEntries(Object.entries(s).filter(([k]) => !OMIT_FIELDS.has(k))));
67
-
68
- const TOOLS = [
69
- {
70
- name: "skillmd_search",
71
- description: "Search SkillMD for Agent Skills by name, description, or task. Returns matching skills with their owner/name slug and install snippets.",
72
- inputSchema: {
73
- type: "object",
74
- properties: {
75
- query: { type: "string", description: "Natural-language query or keywords" },
76
- category: { type: "string", description: "Filter by category slug (e.g. devops, data, security)" },
77
- verified_only: { type: "boolean", description: "Only verified skills" },
78
- type: { type: "string", enum: ["single", "pack"], description: "single file or pack" },
79
- min_rating: { type: "number", description: "Minimum average rating 0-5" },
80
- limit: { type: "number", description: "Max results (default 20)" },
81
- },
82
- required: ["query"],
83
- },
84
- },
85
- {
86
- name: "skillmd_get",
87
- description: "Fetch a single skill by slug (owner/name), including its SKILL.md body, provenance, license and security flags.",
88
- inputSchema: { type: "object", properties: { slug: { type: "string", description: "owner/name, e.g. anthropic/pdf" } }, required: ["slug"] },
89
- },
90
- {
91
- name: "skillmd_install",
92
- description:
93
- "Install a skill by slug: writes its SKILL.md into the agent's skills directory. Never executes any scripts. Any skill installs; the response reports its security flags (docs_only / network_calls / executes_scripts / reads_secrets) as information.",
94
- inputSchema: {
95
- type: "object",
96
- properties: {
97
- slug: { type: "string", description: "owner/name, e.g. anthropic/pdf" },
98
- dest: { type: "string", description: "Target skills dir. Default ~/.claude/skills" },
99
- },
100
- required: ["slug"],
101
- },
102
- },
103
- {
104
- name: "skillmd_trending",
105
- description: "Top skills on the SkillMD leaderboard. range: '30d' or 'all'.",
106
- inputSchema: {
107
- type: "object",
108
- properties: {
109
- range: { type: "string", enum: ["30d", "all"], description: "Time window (default all)" },
110
- category: { type: "string", description: "Optional category slug" },
111
- limit: { type: "number", description: "Max results (default 20)" },
112
- },
113
- },
114
- },
115
- {
116
- name: "skillmd_recommend",
117
- description: "Recommend skills. If based_on is given, suggests skills similar to it (same category); otherwise returns trending.",
118
- inputSchema: {
119
- type: "object",
120
- properties: { based_on: { type: "string", description: "A slug to base recommendations on" }, limit: { type: "number" } },
121
- },
122
- },
123
- {
124
- name: "skillmd_list_saved",
125
- description: "List the signed-in user's saved skills. Requires SKILLMD_TOKEN to be set.",
126
- inputSchema: { type: "object", properties: {} },
127
- },
128
- {
129
- name: "skillmd_lint",
130
- description:
131
- "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.",
132
- inputSchema: {
133
- type: "object",
134
- properties: {
135
- content: { type: "string", description: "Raw SKILL.md content to validate" },
136
- slug: { type: "string", description: "Registry slug owner/name to fetch and validate" },
137
- },
138
- },
139
- },
140
- ];
141
-
142
- async function handleCall(name, args) {
143
- switch (name) {
144
- case "skillmd_search": {
145
- const p = new URLSearchParams({ q: args.query, limit: String(args.limit ?? 20) });
146
- if (args.category) p.set("category", args.category);
147
- if (args.verified_only) p.set("verified", "true");
148
- if (args.type) p.set("type", args.type);
149
- if (args.min_rating != null) p.set("min_rating", String(args.min_rating));
150
- const r = await api(`/api/search?${p}`);
151
- return text(trimItems(r.items));
152
- }
153
- case "skillmd_get": {
154
- const [owner, slugName] = String(args.slug).split("/");
155
- const r = await api(`/api/skills/${owner}/${slugName}`);
156
- return text(r);
157
- }
158
- case "skillmd_trending": {
159
- const p = new URLSearchParams({ range: args.range ?? "all", limit: String(args.limit ?? 20) });
160
- if (args.category) p.set("category", args.category);
161
- const r = await api(`/api/leaderboard?${p}`);
162
- return text(trimItems(r.items));
163
- }
164
- case "skillmd_recommend": {
165
- if (args.based_on) {
166
- const [owner, slugName] = String(args.based_on).split("/");
167
- const base = await api(`/api/skills/${owner}/${slugName}`).catch(() => null);
168
- if (base?.category_slug) {
169
- const r = await api(`/api/leaderboard?range=all&limit=${args.limit ?? 10}&category=${base.category_slug}`);
170
- return text(trimItems(r.items).filter((s) => s.slug !== args.based_on));
171
- }
172
- }
173
- const r = await api(`/api/leaderboard?range=30d&limit=${args.limit ?? 10}`);
174
- return text(trimItems(r.items));
175
- }
176
- case "skillmd_list_saved": {
177
- if (!TOKEN) return text("Set SKILLMD_TOKEN (a SkillMD personal access token) to list your saved skills.");
178
- const r = await api(`/api/favorites`);
179
- return text(r.items ?? []);
180
- }
181
- case "skillmd_lint": {
182
- if (!lint) return text("Validation engine unavailable — reinstall the skillmds package (npm i -g skillmds), or run `npm ci && npm run build` in a source checkout.");
183
- let raw = args.content;
184
- let slug;
185
- let verified = false;
186
- if (!raw && args.slug) {
187
- const [owner, slugName] = String(args.slug).split("/");
188
- const skill = await api(`/api/skills/${owner}/${slugName}`);
189
- raw = skillMdFor(skill);
190
- slug = slugName;
191
- verified = !!skill.verified;
192
- }
193
- if (!raw) return text("Provide `content` (raw SKILL.md) or `slug` (owner/name).");
194
- const result = lint(raw, { ...(slug ? { slug } : {}), verified });
195
- return text({ ok: result.ok, score: result.score, diagnostics: result.diagnostics, security: result.security });
196
- }
197
- case "skillmd_install": {
198
- if (!skillMdFor) return text("skillmds is not fully installed — reinstall it (npm i -g skillmds) and try again.");
199
- const [owner, slugName] = String(args.slug).split("/");
200
- // Defense in depth: slugName becomes a directory name below — refuse
201
- // anything outside the registry's slugify alphabet (path separators,
202
- // "..", etc.) before touching the network or filesystem.
203
- if (!/^[a-z0-9][a-z0-9-]*$/.test(slugName ?? "")) {
204
- return text(`Refused: invalid skill name "${slugName}" — expected a lowercase slug.`);
205
- }
206
- const skill = await api(`/api/skills/${owner}/${slugName}`);
207
- const flags = skill.security_flags ?? [];
208
- // Verification never gates installs — any skill installs; flags are
209
- // reported in the response as information, not a wall.
210
- const raw = skillMdFor(skill); // returns raw_md verbatim when the registry has it
211
- if (lint) {
212
- const result = lint(raw, { slug: slugName });
213
- if (!result.ok) {
214
- const errors = result.diagnostics.filter((d) => d.severity === "error");
215
- return text(`Refused: ${args.slug} failed validation (${errors.map((d) => `${d.id}: ${d.message}`).join("; ")}). Not installed.`);
216
- }
217
- }
218
- const baseDir = args.dest ? resolve(args.dest) : join(homedir(), ".claude", "skills");
219
- const dir = join(baseDir, slugName);
220
- await mkdir(dir, { recursive: true });
221
- const root = resolve(dir);
222
-
223
- // Zip-slip guard: refuse any path that resolves outside the skill's dir.
224
- const writeSafe = async (relPath, contents) => {
225
- const fp = resolve(dir, relPath);
226
- if (fp !== root && !fp.startsWith(root + sep)) throw new Error(`unsafe path: ${relPath}`);
227
- await mkdir(dirname(fp), { recursive: true });
228
- await writeFile(fp, contents);
229
- };
230
-
231
- let written = 1;
232
- let note = "";
233
- if (skill.type === "pack") {
234
- // Pull the full pack (SKILL.md + companion files) in one call. The JSON
235
- // bundle avoids adding an unzip dependency to this standalone binary.
236
- try {
237
- const bundle = await api(`/api/skills/${owner}/${slugName}/bundle?format=json`);
238
- // Resolve + integrity-check every file BEFORE writing any, so a
239
- // tampered pack refuses cleanly instead of leaving a partial install.
240
- const resolved = [];
241
- for (const f of bundle.files ?? []) {
242
- if (f.path.includes("..") || f.path.startsWith("/")) continue;
243
- let buf;
244
- if (f.content_base64 != null) {
245
- buf = Buffer.from(f.content_base64, "base64");
246
- // Registry-stored files are content-addressed; verify the bytes
247
- // match the registry's sha256. Files without one (the reconstructed
248
- // inline SKILL.md) carry no hash and are skipped.
249
- if (f.sha256) {
250
- const got = createHash("sha256").update(buf).digest("hex");
251
- if (got !== f.sha256) {
252
- const err = new Error(`integrity check failed for "${f.path}"`);
253
- err.integrity = true;
254
- throw err;
255
- }
256
- }
257
- } else if (f.source_url && isAllowedSourceUrl(f.source_url)) {
258
- const r = await fetch(f.source_url).catch(() => null);
259
- if (!r || !r.ok) continue;
260
- buf = Buffer.from(await r.arrayBuffer());
261
- } else continue;
262
- resolved.push({ path: f.path, contents: f.path === "SKILL.md" ? raw : buf });
263
- }
264
- for (const f of resolved) await writeSafe(f.path, f.contents);
265
- written = resolved.length || 1;
266
- if (!resolved.some((f) => f.path === "SKILL.md")) await writeSafe("SKILL.md", raw);
267
- } catch (e) {
268
- if (e && e.integrity) return text(`Refused: ${args.slug} failed integrity verification — ${e.message}. Not installed.`);
269
- await writeSafe("SKILL.md", raw);
270
- note = ` NOTE: pack assets could not be fetched — only SKILL.md was written. For the full pack run: npx skillmds add ${args.slug}`;
271
- }
272
- } else {
273
- await writeSafe("SKILL.md", raw);
274
- }
275
- api(`/api/skills/${owner}/${slugName}/install`, { method: "POST", body: JSON.stringify({ via: "mcp" }) }).catch(() => {});
276
- return text(`Installed ${args.slug} → ${dir} (${written} file${written === 1 ? "" : "s"}; security: ${flags.join(", ") || "unscanned"})${note}`);
277
- }
278
- default:
279
- throw new Error(`Unknown tool: ${name}`);
280
- }
281
- }
282
-
283
- async function startMcpServer() {
284
- const server = new Server({ name: "skillmd", version: VERSION }, { capabilities: { tools: {} } });
285
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
286
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
287
- try {
288
- return await handleCall(req.params.name, req.params.arguments ?? {});
289
- } catch (e) {
290
- return { isError: true, content: [{ type: "text", text: String(e?.message || e) }] };
291
- }
292
- });
293
- const transport = new StdioServerTransport();
294
- await server.connect(transport);
295
- console.error("skillmd MCP server running (stdio)");
296
- }
297
-
298
- // This binary is invoked three ways:
299
- // - `skillmds <anything>` → run the CLI. commander owns argv, so known
300
- // commands run and unknown commands get a proper "unknown command" error
301
- // instead of silently starting an MCP server.
302
- // - `skillmds` in a terminal → run the CLI (opens the interactive menu)
303
- // - `skillmds` over piped stdio → start the MCP server (how MCP clients launch it;
304
- // they always connect stdin as a pipe, never a TTY, and pass no args)
305
- if (process.argv.length > 2 || process.stdin.isTTY) {
306
- await import("../dist/cli.js"); // the CLI parses process.argv itself
307
- } else {
308
- await startMcpServer();
309
- }
2
+ // SkillMD MCP server (stdio) + CLI dispatch. Source: src/mcp/index.ts.
3
+ import("../dist/mcp.js");