fix(skills): bind bundles to exact files and origins

This commit is contained in:
teknium1 2026-07-12 00:47:27 -07:00 committed by Teknium
parent c36f6b7259
commit 51382ac244
9 changed files with 164 additions and 72 deletions

View file

@ -20,8 +20,9 @@ name: demo-bundle
description: A multi-file test skill.
---
# Demo
Read [the guide](references/guide.md), use `templates/report.md`, and run
`scripts/run.py`. The repository also contains assets/logo.txt.
Read [the guide](references/guide.md#usage), use `templates/report.md?raw=1`, and run
`scripts/run.py`. See `examples/endpoint-inventory.md`. The repository also
contains assets/logo.png.
"""
@ -35,17 +36,21 @@ def served_repo(tmp_path):
repo = tmp_path / "upstream"
repo.mkdir()
(repo / "SKILL.md").write_text(SKILL_MD)
for rel, text in {
for rel, content in {
"references/guide.md": "safe guide\n",
"templates/report.md": "report\n",
"scripts/run.py": "print('ok')\n",
"assets/logo.txt": "logo\n",
"assets/logo.png": b"\x89PNG\r\n\x1a\n\x00\xff",
"examples/endpoint-inventory.md": "example\n",
"examples/not-installed.md": "must not be copied\n",
"README.md": "must not be copied\n",
}.items():
path = repo / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
if isinstance(content, bytes):
path.write_bytes(content)
else:
path.write_text(content)
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
subprocess.run(["git", "add", "."], cwd=repo, check=True)
subprocess.run(
@ -79,8 +84,11 @@ def test_url_source_fetches_only_referenced_allowed_support_directories(served_r
"references/guide.md",
"templates/report.md",
"scripts/run.py",
"assets/logo.txt",
"assets/logo.png",
"examples/endpoint-inventory.md",
}
assert bundle.files["assets/logo.png"] == b"\x89PNG\r\n\x1a\n\x00\xff"
assert "examples/not-installed.md" not in bundle.files
assert bundle.metadata["source_url"] == url
@ -106,6 +114,40 @@ def test_github_source_rejects_symlink_in_referenced_directory(monkeypatch):
assert source.fetch("owner/repo/skill") is None
def test_github_source_fetches_only_exact_references_and_records_tree_revision(monkeypatch):
source = GitHubSource(GitHubAuth())
skill = "---\nname: demo\ndescription: demo\n---\n[guide](references/guide.md)\n"
fetched = []
monkeypatch.setattr(
source,
"_fetch_file_content",
lambda _repo, path: skill if path.endswith("SKILL.md") else None,
)
def _fetch_bytes(_repo, path):
fetched.append(path)
return b"guide"
monkeypatch.setattr(source, "_fetch_file_bytes", _fetch_bytes, raising=False)
source._tree_cache["owner/repo"] = (
"develop",
[
{"path": "skill/SKILL.md", "type": "blob", "mode": "100644"},
{"path": "skill/references/guide.md", "type": "blob", "mode": "100644"},
{"path": "skill/references/unreferenced.md", "type": "blob", "mode": "100644"},
],
)
source._tree_revisions = {"owner/repo": "deadbeef"}
bundle = source.fetch("owner/repo/skill")
assert bundle is not None
assert fetched == ["skill/references/guide.md"]
assert bundle.files["references/guide.md"] == b"guide"
assert bundle.metadata["source_url"] == "https://github.com/owner/repo/tree/deadbeef/skill"
assert bundle.metadata["source_revision"] == "deadbeef"
def test_scan_cache_records_full_provenance_and_hash_change_forces_rescan(tmp_path):
skill = tmp_path / "skill"
skill.mkdir()
@ -137,6 +179,24 @@ def test_scan_cache_records_full_provenance_and_hash_change_forces_rescan(tmp_pa
assert first_provenance["scanned_at"]
def test_scan_cache_never_reuses_provenance_across_sources(tmp_path):
skill = tmp_path / "skill"
skill.mkdir()
(skill / "SKILL.md").write_text("---\nname: skill\ndescription: test\n---\n")
cache = tmp_path / "scan-cache"
_first, first = scan_skill_cached(
skill, source="community", source_url="https://one.example/SKILL.md", cache_dir=cache
)
_second, second = scan_skill_cached(
skill, source="community", source_url="https://two.example/SKILL.md", cache_dir=cache
)
assert first["fresh"] is True
assert second["fresh"] is True
assert second["source_url"] == "https://two.example/SKILL.md"
def test_lock_file_persists_scan_provenance(tmp_path):
lock = HubLockFile(tmp_path / "lock.json")
provenance = {
@ -175,8 +235,9 @@ def test_real_temp_repo_and_home_install_e2e(served_repo, monkeypatch, tmp_path)
assert (installed / "references" / "guide.md").read_text() == "safe guide\n"
assert (installed / "templates" / "report.md").is_file()
assert (installed / "scripts" / "run.py").is_file()
assert (installed / "assets" / "logo.txt").is_file()
assert not (installed / "examples").exists()
assert (installed / "examples" / "endpoint-inventory.md").is_file()
assert not (installed / "examples" / "not-installed.md").exists()
assert (installed / "assets" / "logo.png").read_bytes() == b"\x89PNG\r\n\x1a\n\x00\xff"
entry = json.loads((home / "skills" / ".hub" / "lock.json").read_text())["installed"]["demo-bundle"]
assert entry["scan_provenance"]["source_url"] == url
assert entry["scan_provenance"]["fresh"] is True

View file

@ -688,20 +688,23 @@ def scan_skill(skill_path: Path, source: str = "community") -> ScanResult:
)
def _full_content_hash(skill_path: Path) -> str:
"""Complete SHA-256 over paths and bytes for scan cache identity."""
def _content_digest(skill_path: Path) -> str:
"""Canonical SHA-256 over relative paths and exact file bytes."""
h = hashlib.sha256()
if skill_path.is_dir():
for file_path in sorted(skill_path.rglob("*")):
rel = file_path.relative_to(skill_path).as_posix()
if file_path.is_symlink():
h.update(rel.encode() + b"\x00SYMLINK\x00")
elif file_path.is_file():
h.update(rel.encode() + b"\x00")
if file_path.is_file():
rel = file_path.relative_to(skill_path).as_posix()
h.update(rel.encode("utf-8") + b"\x00")
h.update(file_path.read_bytes())
else:
h.update(skill_path.read_bytes())
return f"sha256:{h.hexdigest()}"
return h.hexdigest()
def full_content_hash(skill_path: Path) -> str:
"""Full canonical digest used to bind scanner attestations."""
return f"sha256:{_content_digest(skill_path)}"
def _finding_dict(finding: Finding) -> dict:
@ -718,9 +721,10 @@ def scan_skill_cached(
cache_dir: Path | None = None,
) -> Tuple[ScanResult, dict]:
"""Return a scan plus attestation, caching only exact current content."""
bundle_hash = _full_content_hash(skill_path)
bundle_hash = full_content_hash(skill_path)
cache_root = cache_dir or skill_path.parent / ".scan-cache"
cache_file = cache_root / f"{bundle_hash.split(':', 1)[1]}.json"
source_identity = hashlib.sha256(f"{source}\0{source_url}".encode("utf-8")).hexdigest()[:16]
cache_file = cache_root / f"{bundle_hash.split(':', 1)[1]}-{source_identity}.json"
try:
cached = json.loads(cache_file.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
@ -728,7 +732,8 @@ def scan_skill_cached(
if (isinstance(cached, dict)
and cached.get("bundle_hash") == bundle_hash
and cached.get("scanner_version") == SCANNER_VERSION
and cached.get("source") == source):
and cached.get("source") == source
and cached.get("source_url") == source_url):
result = ScanResult(
skill_name=skill_path.name, source=source,
trust_level=cached["trust_level"], verdict=cached["verdict"],
@ -849,20 +854,7 @@ def content_hash(skill_path: Path) -> str:
one on an in-memory bundle), so any change to the hash shape MUST
land in both places at once.
"""
h = hashlib.sha256()
if skill_path.is_dir():
for f in sorted(skill_path.rglob("*")):
if f.is_file():
try:
rel = f.relative_to(skill_path).as_posix()
h.update(rel.encode("utf-8"))
h.update(b"\x00")
h.update(f.read_bytes())
except OSError:
continue
elif skill_path.is_file():
h.update(skill_path.read_bytes())
return f"sha256:{h.hexdigest()[:16]}"
return f"sha256:{_content_digest(skill_path)[:16]}"
# ---------------------------------------------------------------------------

View file

@ -29,7 +29,7 @@ from hermes_constants import get_hermes_home
from hermes_cli._subprocess_compat import windows_hide_flags
from agent.skill_utils import is_excluded_skill_path
from typing import Any, Dict, List, Optional, Tuple, Union
from urllib.parse import urljoin, urlparse, urlunparse
from urllib.parse import unquote, urljoin, urlparse, urlsplit, urlunparse
import httpx
import yaml
@ -152,13 +152,13 @@ class SkillBundle:
metadata: Dict[str, Any] = field(default_factory=dict)
_ALLOWED_SUPPORT_DIRS = frozenset({"references", "templates", "scripts", "assets"})
_ALLOWED_SUPPORT_DIRS = frozenset({"references", "templates", "scripts", "assets", "examples"})
_LOCAL_LINK_RE = re.compile(
r"(?:\]\(|`|(?:^|[\s\"']))((?:references|templates|scripts|assets)/[^\s)`\"'<>]+)",
r"(?:\]\(|`|(?:^|[\s\"']))((?:references|templates|scripts|assets|examples)/[^\s)`\"'<>]+)",
re.MULTILINE,
)
_SUSPICIOUS_LOCAL_REF_RE = re.compile(
r"(?:references|templates|scripts|assets)/(?:[^\s)`\"'<>]*/)?\.\.(?:/|$)"
r"(?:references|templates|scripts|assets|examples)/(?:[^\s)`\"'<>]*/)?\.\.(?:/|$)"
)
@ -169,7 +169,7 @@ def _referenced_support_paths(skill_md: str) -> Optional[set[str]]:
return None
paths: set[str] = set()
for match in _LOCAL_LINK_RE.finditer(normalized):
raw = match.group(1).rstrip(".,;:")
raw = unquote(urlsplit(match.group(1).rstrip(".,;:")).path)
try:
safe = _validate_bundle_rel_path(raw)
except ValueError:
@ -575,6 +575,7 @@ class GitHubSource(SkillSource):
# Per-instance cache: repo -> (default_branch, tree_entries)
# Survives within a single search/install flow, avoiding redundant API calls.
self._tree_cache: Dict[str, Tuple[str, List[dict]]] = {}
self._tree_revisions: Dict[str, str] = {}
# Per-repo cache of the optional skills.sh.json grouping sidecar,
# mapping skill_name -> human-readable grouping title. ``None`` means
# "fetched, no sidecar"; a missing key means "not fetched yet".
@ -651,29 +652,30 @@ class GitHubSource(SkillSource):
files: Dict[str, Union[str, bytes]] = {"SKILL.md": skill_md}
tree = self._get_repo_tree(repo)
if tree is not None:
_branch, entries = tree
branch, entries = tree
prefix = f"{skill_path.rstrip('/')}/"
wanted_roots = {path.split("/", 1)[0] for path in referenced}
for item in entries:
item_path = item.get("path", "")
if not item_path.startswith(prefix):
continue
rel_path = item_path[len(prefix):]
if rel_path.split("/", 1)[0] not in wanted_roots:
continue
entries_by_path = {item.get("path", ""): item for item in entries}
for rel_path in sorted(referenced):
item_path = f"{prefix}{rel_path}"
item = entries_by_path.get(item_path)
if item is None:
logger.warning("Referenced skill support file is missing: %s", item_path)
return None
if item.get("type") != "blob" or item.get("mode") == "120000":
logger.warning("Rejected non-regular file in skill bundle: %s", item_path)
return None
content = self._fetch_file_content(repo, item_path)
if content is None:
return None
files[_validate_bundle_rel_path(rel_path)] = content
else:
for rel_path in referenced:
content = self._fetch_file_content(repo, f"{skill_path.rstrip('/')}/{rel_path}")
content = self._fetch_file_bytes(repo, item_path)
if content is None:
return None
files[rel_path] = content
revision = self._tree_revisions.get(repo) or branch
else:
for rel_path in referenced:
content = self._fetch_file_bytes(repo, f"{skill_path.rstrip('/')}/{rel_path}")
if content is None:
return None
files[rel_path] = content
revision = ""
skill_name = skill_path.rstrip("/").split("/")[-1]
trust = self.trust_level_for(identifier)
@ -684,7 +686,13 @@ class GitHubSource(SkillSource):
source="github",
identifier=identifier,
trust_level=trust,
metadata={"source_url": f"https://github.com/{repo}/tree/main/{skill_path}"},
metadata={
"source_url": (
f"https://github.com/{repo}/tree/{revision}/{skill_path}"
if revision else f"https://github.com/{repo}/{skill_path}"
),
"source_revision": revision,
},
)
def inspect(self, identifier: str) -> Optional[SkillMeta]:
@ -823,6 +831,9 @@ class GitHubSource(SkillSource):
return None
entries = tree_data.get("tree", [])
revision = tree_data.get("sha")
if isinstance(revision, str) and revision:
self._tree_revisions[repo] = revision
self._tree_cache[repo] = (default_branch, entries)
return (default_branch, entries)
@ -1039,14 +1050,24 @@ class GitHubSource(SkillSource):
return None
def _fetch_file_content(self, repo: str, path: str) -> Optional[str]:
"""Fetch a single file's content from GitHub."""
"""Fetch a single text file from GitHub."""
content = self._fetch_file_bytes(repo, path)
if content is None:
return None
try:
return content.decode("utf-8")
except UnicodeDecodeError:
return None
def _fetch_file_bytes(self, repo: str, path: str) -> Optional[bytes]:
"""Fetch exact file bytes from GitHub without text decoding."""
url = f"https://api.github.com/repos/{repo}/contents/{path}"
resp = self._github_get(
url,
headers={**self.auth.get_headers(), "Accept": "application/vnd.github.v3.raw"},
)
if resp is not None and resp.status_code == 200:
return resp.text
return resp.content
return None
def _get_skillsh_groupings(self, repo: str) -> Optional[Dict[str, str]]:
@ -1482,7 +1503,7 @@ class UrlSource(SkillSource):
support_url = urljoin(base_url, rel_path)
if urlparse(support_url).netloc != urlparse(url).netloc:
return None
content = self._fetch_text(support_url)
content = self._fetch_bytes(support_url)
if content is None:
return None
files[rel_path] = content
@ -1516,6 +1537,13 @@ class UrlSource(SkillSource):
return resp.text
return None
@staticmethod
def _fetch_bytes(url: str) -> Optional[bytes]:
resp = _guarded_http_get(url, timeout=20)
if resp is not None and resp.status_code == 200:
return resp.content
return None
# Skill names must look like identifiers: lowercase letters/digits with
# optional hyphens/underscores. Blocks dangerous (``../evil``) AND useless
# (``SKILL``, ``README``, empty) candidates before they hit the disk.

View file

@ -95,7 +95,7 @@ hermes skills install official/research/arxiv
# Install from the hub in a chat session
/skills install official/creative/songwriting-and-ai-music
# Install a single-file SKILL.md directly from any HTTP(S) URL
# Install SKILL.md and its referenced support files from an HTTP(S) URL
hermes skills install https://sharethis.chat/SKILL.md
/skills install https://example.com/SKILL.md --name my-skill
```

View file

@ -1065,7 +1065,7 @@ hermes skills inspect official/security/1password
hermes skills inspect skills-sh/vercel-labs/json-render/json-render-react
hermes skills install official/migration/openclaw-migration
hermes skills install skills-sh/anthropics/skills/pdf --force
hermes skills install https://sharethis.chat/SKILL.md # Direct URL (single-file SKILL.md)
hermes skills install https://sharethis.chat/SKILL.md # Direct URL (+ referenced support files)
hermes skills install https://example.com/SKILL.md --name my-skill # Override name when frontmatter has none
hermes skills check
hermes skills update
@ -1083,7 +1083,7 @@ Notes:
- `--source skills-sh` searches the public `skills.sh` directory.
- `--source well-known` lets you point Hermes at a site exposing `/.well-known/skills/index.json`.
- `--source browse-sh` searches [browse.sh](https://browse.sh)'s catalog of 200+ site-specific browser-automation skills. Identifiers look like `browse-sh/airbnb.com/search-listings-ddgioa`.
- Passing an `http(s)://…/*.md` URL installs a single-file SKILL.md directly. When frontmatter has no `name:` and the URL slug isn't a valid identifier, an interactive terminal prompts for a name; non-interactive surfaces (`/skills install` inside the TUI, gateway platforms) require `--name <x>` instead.
- Passing an `http(s)://…/*.md` URL installs `SKILL.md` plus explicitly referenced files under `references/`, `templates/`, `scripts/`, `assets/`, and `examples/`. When frontmatter has no `name:` and the URL slug isn't a valid identifier, an interactive terminal prompts for a name; non-interactive surfaces (`/skills install` inside the TUI, gateway platforms) require `--name <x>` instead.
## `hermes bundles`

View file

@ -290,6 +290,7 @@ See [Skill Settings](/user-guide/configuration#skill-settings) and [Creating Ski
│ │ ├── references/ # Additional docs
│ │ ├── templates/ # Output formats
│ │ ├── scripts/ # Helper scripts callable from the skill
│ │ ├── examples/ # Referenced example outputs
│ │ └── assets/ # Supplementary files
│ └── vllm/
│ └── SKILL.md
@ -304,6 +305,13 @@ See [Skill Settings](/user-guide/configuration#skill-settings) and [Creating Ski
└── .bundled_manifest # Tracks seeded bundled skills
```
Third-party URL and GitHub installs include `SKILL.md` plus the exact local
files it references under `references/`, `templates/`, `scripts/`, `assets/`,
and `examples/`. Unreferenced repository files are not copied. Hermes scans the
complete quarantined bundle and records the source URL, exact content hash,
scanner version, findings, timestamp, and fresh-or-cached status in
`skills/.hub/lock.json`.
## External Skill Directories
If you maintain skills outside of Hermes — for example, a shared `~/.agents/skills/` directory used by multiple AI tools — you can tell Hermes to scan those directories too.
@ -517,7 +525,7 @@ hermes skills install openai/skills/k8s # Install with security scan
hermes skills install official/security/1password
hermes skills install skills-sh/vercel-labs/json-render/json-render-react --force
hermes skills install well-known:https://mintlify.com/docs/.well-known/skills/mintlify
hermes skills install https://sharethis.chat/SKILL.md # Direct URL (single-file SKILL.md)
hermes skills install https://sharethis.chat/SKILL.md # Direct URL (+ referenced support files)
hermes skills install https://example.com/SKILL.md --name my-skill # Override name when frontmatter has none
hermes skills list --source hub # List hub-installed skills
hermes skills check # Check installed hub skills for upstream updates
@ -538,7 +546,7 @@ hermes skills tap add myorg/skills-repo # Add a custom GitHub source
| `official` | `official/security/1password` | Optional skills shipped with Hermes. |
| `skills-sh` | `skills-sh/vercel-labs/agent-skills/vercel-react-best-practices` | Searchable via `hermes skills search <query> --source skills-sh`. Hermes resolves alias-style skills when the skills.sh slug differs from the repo folder. |
| `well-known` | `well-known:https://mintlify.com/docs/.well-known/skills/mintlify` | Skills served directly from `/.well-known/skills/index.json` on a website. Search using the site or docs URL. |
| `url` | `https://sharethis.chat/SKILL.md` | Direct HTTP(S) URL to a single-file `SKILL.md`. Name resolution: frontmatter → URL slug → interactive prompt → `--name` flag. |
| `url` | `https://sharethis.chat/SKILL.md` | Direct HTTP(S) URL to `SKILL.md` plus explicitly referenced support files. Name resolution: frontmatter → URL slug → interactive prompt → `--name` flag. |
| `github` | `openai/skills/k8s` | Direct GitHub repo/path installs and custom taps. |
| `clawhub`, `lobehub`, `browse-sh` | Source-specific identifiers | Community or marketplace integrations. |
@ -670,11 +678,11 @@ Identifiers use the form `browse-sh/<hostname>/<task-id>` and match the slug exp
#### 9. Direct URL (`url`)
Install a single-file `SKILL.md` directly from any HTTP(S) URL — useful when an author hosts a skill on their own site (no hub listing, no GitHub path to type). Hermes fetches the URL, parses the YAML frontmatter, security-scans it, and installs.
Install `SKILL.md` directly from any HTTP(S) URL — useful when an author hosts a skill on their own site (no hub listing, no GitHub path to type). Hermes also fetches explicitly referenced files under `references/`, `templates/`, `scripts/`, `assets/`, and `examples/`, then scans and installs the complete bundle.
- Hermes source id: `url`
- Identifier: the URL itself (no prefix needed)
- Scope: **single-file `SKILL.md`** only. Multi-file skills with `references/` or `scripts/` need a manifest and should be published via one of the other sources above.
- Scope: `SKILL.md` plus exact referenced support files in the allowlisted directories. Hermes does not enumerate or copy unrelated files from the host.
```bash
hermes skills install https://sharethis.chat/SKILL.md

View file

@ -95,7 +95,7 @@ hermes skills install official/research/arxiv
# 在聊天会话中从 Hub 安装
/skills install official/creative/songwriting-and-ai-music
# 直接从任意 HTTP(S) URL 安装单文件 SKILL.md
# 从 HTTP(S) URL 安装 SKILL.md 及其引用的支持文件
hermes skills install https://sharethis.chat/SKILL.md
/skills install https://example.com/SKILL.md --name my-skill
```

View file

@ -820,7 +820,7 @@ hermes skills inspect official/security/1password
hermes skills inspect skills-sh/vercel-labs/json-render/json-render-react
hermes skills install official/migration/openclaw-migration
hermes skills install skills-sh/anthropics/skills/pdf --force
hermes skills install https://sharethis.chat/SKILL.md # 直接 URL单文件 SKILL.md
hermes skills install https://sharethis.chat/SKILL.md # 直接 URL含引用的支持文件
hermes skills install https://example.com/SKILL.md --name my-skill # frontmatter 无名称时覆盖名称
hermes skills check
hermes skills update
@ -835,7 +835,7 @@ hermes skills reset google-workspace --restore --yes
- `--source skills-sh` 搜索公共 `skills.sh` 目录。
- `--source well-known` 允许你将 Hermes 指向暴露 `/.well-known/skills/index.json` 的站点。
- `--source browse-sh` 搜索 [browse.sh](https://browse.sh) 包含 200+ 站点特定浏览器自动化 skill 的目录。标识符形如 `browse-sh/airbnb.com/search-listings-ddgioa`
- 传入 `http(s)://…/*.md` URL 可直接安装单文件 SKILL.md。当 frontmatter 没有 `name:` 且 URL slug 不是有效标识符时交互式终端会提示输入名称非交互式界面TUI 内的 `/skills install`、gateway 平台)需要改用 `--name <x>`
- 传入 `http(s)://…/*.md` URL 可安装 `SKILL.md`,以及其中明确引用且位于 `references/``templates/``scripts/``assets/``examples/` 下的文件。当 frontmatter 没有 `name:` 且 URL slug 不是有效标识符时交互式终端会提示输入名称非交互式界面TUI 内的 `/skills install`、gateway 平台)需要改用 `--name <x>`
## `hermes bundles`

View file

@ -204,6 +204,7 @@ metadata:
│ │ ├── references/ # Additional docs
│ │ ├── templates/ # Output formats
│ │ ├── scripts/ # Helper scripts callable from the skill
│ │ ├── examples/ # Referenced example outputs
│ │ └── assets/ # Supplementary files
│ └── vllm/
│ └── SKILL.md
@ -218,6 +219,8 @@ metadata:
└── .bundled_manifest # Tracks seeded bundled skills
```
通过第三方 URL 或 GitHub 安装时Hermes 会安装 `SKILL.md`,以及其中明确引用且位于 `references/``templates/``scripts/``assets/``examples/` 下的文件。未引用的仓库文件不会被复制。Hermes 会扫描完整的隔离捆绑包,并在 `skills/.hub/lock.json` 中记录来源 URL、精确内容哈希、扫描器版本、发现项、时间戳以及本次结果是新扫描还是缓存复用。
## 外部 Skill 目录
如果你在 Hermes 之外维护 skills——例如供多个 AI 工具使用的共享 `~/.agents/skills/` 目录——你可以告诉 Hermes 也扫描这些目录。
@ -388,7 +391,7 @@ hermes skills install openai/skills/k8s # Install with security scan
hermes skills install official/security/1password
hermes skills install skills-sh/vercel-labs/json-render/json-render-react --force
hermes skills install well-known:https://mintlify.com/docs/.well-known/skills/mintlify
hermes skills install https://sharethis.chat/SKILL.md # Direct URL (single-file SKILL.md)
hermes skills install https://sharethis.chat/SKILL.md # 直接 URL含引用的支持文件
hermes skills install https://example.com/SKILL.md --name my-skill # Override name when frontmatter has none
hermes skills list --source hub # List hub-installed skills
hermes skills check # Check installed hub skills for upstream updates
@ -409,7 +412,7 @@ hermes skills tap add myorg/skills-repo # Add a custom GitHub source
| `official` | `official/security/1password` | Hermes 随附的可选 skills。 |
| `skills-sh` | `skills-sh/vercel-labs/agent-skills/vercel-react-best-practices` | 可通过 `hermes skills search <query> --source skills-sh` 搜索。当 skills.sh slug 与仓库文件夹不同时Hermes 会解析别名式 skills。 |
| `well-known` | `well-known:https://mintlify.com/docs/.well-known/skills/mintlify` | 直接从网站的 `/.well-known/skills/index.json` 提供的 skills。使用站点或文档 URL 搜索。 |
| `url` | `https://sharethis.chat/SKILL.md` | 指向单文件 `SKILL.md` 的直接 HTTP(S) URL。名称解析顺序frontmatter → URL slug → 交互式提示 → `--name` 标志。 |
| `url` | `https://sharethis.chat/SKILL.md` | 指向 `SKILL.md` 及其明确引用的支持文件的直接 HTTP(S) URL。名称解析顺序frontmatter → URL slug → 交互式提示 → `--name` 标志。 |
| `github` | `openai/skills/k8s` | 直接从 GitHub 仓库/路径安装以及基于 GitHub 的自定义 tap。 |
| `clawhub``lobehub``browse-sh``claude-marketplace` | 来源特定标识符 | 社区或市场集成。 |
@ -523,11 +526,11 @@ hermes skills install browse-sh/airbnb.com/search-listings-ddgioa
#### 9. 直接 URL`url`
直接从任何 HTTP(S) URL 安装单文件 `SKILL.md`——当作者在自己的站点上托管 skill 时非常有用(无 hub 列表,无需输入 GitHub 路径。Hermes 获取 URL解析 YAML frontmatter进行安全扫描并安装
直接从任何 HTTP(S) URL 安装 `SKILL.md`——当作者在自己的站点上托管 skill 时非常有用(无 hub 列表,无需输入 GitHub 路径。Hermes 还会获取其中明确引用且位于 `references/``templates/``scripts/``assets/``examples/` 下的文件,然后扫描并安装完整捆绑包
- Hermes 来源 id`url`
- 标识符URL 本身(无需前缀)
- 范围:**仅限单文件 `SKILL.md`**。包含 `references/``scripts/` 的多文件 skills 需要清单,应通过上述其他来源之一发布
- 范围:`SKILL.md` 加上允许目录中明确引用的支持文件。Hermes 不会枚举或复制托管站点上的其他文件
```bash
hermes skills install https://sharethis.chat/SKILL.md