{"repo":"NousResearch/hermes-agent","public_id":"35125","display_label":"GitHub skill install missing bundle files fix","summary":"A bug where installing skills from GitHub URLs only downloaded SKILL.md while omitting reference files, templates, and scripts. Multiple fixes address complete bundle downloading and scan provenance for GitHub-sourced skills.","subsystem":"skills","counts":{"members":7,"open_issues":2,"open_issues_distinct":2,"open_prs":0,"closed_issues":0,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":63051},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-12T09:59:28+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.2552380952380952},"fix_edges":[{"issue_number":35125,"pr_number":63051,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":35125,"title":"hermes skills install URL source only downloads SKILL.md, misses reference files (references/, templates/, scripts/)","url":null,"state":"open"},{"kind":"issue","number":60598,"title":"Bug: community skill scanner verdict may be stale and third-party installs only copy SKILL.md","url":null,"state":"open"},{"kind":"pr","number":35173,"title":"fix: install full bundles from raw GitHub skill URLs","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"51382ac244702b09482e339ecba5a88649f5ea62","number":0},{"kind":"pr","value":"#63051","number":63051}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":35175,"title":"fix(skills): preserve support files for GitHub URL installs","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"51382ac244702b09482e339ecba5a88649f5ea62","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":35177,"title":"fix(skills): route raw.githubusercontent.com URLs to GitHubSource for directory download","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":61771,"title":"fix(skills): install complete bundles with scan provenance","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#63051","number":63051}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":63051,"title":"fix(skills): install complete bundles with bound scan provenance","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/skills_hub.py","tests/tools/test_skill_bundle_provenance.py","tools/skills_guard.py","tools/skills_hub.py","website/docs/guides/work-with-skills.md","website/docs/reference/cli-commands.md","website/docs/user-guide/features/skills.md","website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/work-with-skills.md","website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md","website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/skills.md"],"fix_hunks":[{"file":"hermes_cli/skills_hub.py","hunks":["@@ -516,7 +516,7 @@ def do_install(identifier: str, category: str = \"\", force: bool = False,\n         GitHubAuth, create_source_router, ensure_hub_dirs,\n         quarantine_bundle, install_from_quarantine, HubLockFile,\n     )\n-    from tools.skills_guard import scan_skill, should_allow_install, format_scan_report\n+    from tools.skills_guard import scan_skill_cached, should_allow_install, format_scan_report\n \n     c = console or _console\n     ensure_hub_dirs()","@@ -648,8 +648,24 @@ def do_install(identifier: str, category: str = \"\", force: bool = False,\n             or getattr(meta, \"identifier\", \"\")\n             or identifier\n         )\n-    result = scan_skill(q_path, source=scan_source)\n+    from tools.skills_hub import HUB_DIR, source_url_for_bundle\n+    result, scan_provenance = scan_skill_cached(\n+        q_path,\n+        source=scan_source,\n+        source_url=source_url_for_bundle(bundle),\n+        cache_dir=HUB_DIR / \"scan-cache\",\n+    )\n     c.print(format_scan_report(result))\n+    freshness = \"fresh\" if scan_provenance[\"fresh\"] else \"cached\"\n+    c.print(\n+        f\"[dim]Scan provenance: {freshness}; scanner \"\n+        f\"{scan_provenance['scanner_version']}; hash {scan_provenance['bundle_hash']}[/]\"\n+    )\n+    rules = \", \".join(scan_provenance[\"rules\"]) or \"none\"\n+    c.print(\n+        f\"[dim]Source: {scan_provenance['source_url']}; scanned \"\n+        f\"{scan_provenance['scanned_at']}; rules: {rules}[/]\"\n+    )\n \n     # Check install policy\n     allowed, reason = should_allow_install(result, force=force)"]},{"file":"tests/tools/test_skill_bundle_provenance.py","hunks":["@@ -0,0 +1,260 @@\n+\"\"\"Multi-file third-party skill bundles and scanner provenance (#60598).\"\"\"\n+\n+import json\n+import subprocess\n+import threading\n+from functools import partial\n+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer\n+from io import StringIO\n+from pathlib import Path\n+\n+import pytest\n+from rich.console import Console\n+\n+from tools.skills_guard import SCANNER_VERSION, scan_skill_cached\n+from tools.skills_hub import GitHubAuth, GitHubSource, HubLockFile, SkillBundle, UrlSource\n+\n+\n+SKILL_MD = \"\"\"---\n+name: demo-bundle\n+description: A multi-file test skill.\n+---\n+# Demo\n+Read [the guide](references/guide.md#usage), use `templates/report.md?raw=1`, and run\n+`scripts/run.py`. See `examples/endpoint-inventory.md`. The repository also\n+contains assets/logo.png.\n+\"\"\"\n+\n+\n+class _QuietHandler(SimpleHTTPRequestHandler):\n+    def log_message(self, *_args):\n+        pass\n+\n+\n[redacted:email]\n+def served_repo(tmp_path):\n+    repo = tmp_path / \"upstream\"\n+    repo.mkdir()\n+    (repo / \"SKILL.md\").write_text(SKILL_MD)\n+    for rel, content in {\n+        \"references/guide.md\": \"safe guide\\n\",\n+        \"templates/report.md\": \"report\\n\",\n+        \"scripts/run.py\": \"print('ok')\\n\",\n+        \"assets/logo.png\": b\"\\x89PNG\\r\\n\\x1a\\n\\x00\\xff\",\n+        \"examples/endpoint-inventory.md\": \"example\\n\",\n+        \"examples/not-installed.md\": \"must not be copied\\n\",\n+        \"README.md\": \"must not be copied\\n\",\n+    }.items():\n+        path = repo / rel\n+        path.parent.mkdir(parents=True, exist_ok=True)\n+        if isinstance(content, bytes):\n+            path.write_bytes(content)\n+        else:\n+            path.write_text(content)\n+    subprocess.run([\"git\", \"init\", \"-q\"], cwd=repo, check=True)\n+    subprocess.run([\"git\", \"add\", \".\"], cwd=repo, check=True)\n+    subprocess.run(\n+        [\"git\", \"-c\", \"user.name=Test\", \"-c\", \"user.email=[redacted:email]\", \"commit\", \"-qm\", \"fixture\"],\n+        cwd=repo,\n+        check=True,\n+    )\n+\n+    server = ThreadingHTTPServer(\n+        (\"127.0.0.1\", 0), partial(_QuietHandler, directory=str(repo))\n+    )\n+    thread = threading.Thread(target=server.serve_forever, daemon=True)\n+    thread.start()\n+    try:\n+        yield repo, f\"http://127.0.0.1:{server.server_port}/SKILL.md\"\n+    finally:\n+        server.shutdown()\n+        thread.join()\n+\n+\n+def test_url_source_fetches_only_referenced_allowed_support_directories(served_repo, monkeypatch):\n+    _repo, url = served_repo\n+    monkeypatch.setattr(\"tools.skills_hub.is_safe_url\", lambda _url: True)\n+    monkeypatch.setattr(\"tools.skills_hub.check_website_access\", lambda _url: None)\n+\n+    bundle = UrlSource().fetch(url)\n+\n+    assert bundle is not None\n+    assert set(bundle.files) == {\n+        \"SKILL.md\",\n+        \"references/guide.md\",\n+        \"templates/report.md\",\n+        \"scripts/run.py\",\n+        \"assets/logo.png\",\n+        \"examples/endpoint-inventory.md\",\n+    }\n+    assert bundle.files[\"assets/logo.png\"] == b\"\\x89PNG\\r\\n\\x1a\\n\\x00\\xff\"\n+    assert \"examples/not-installed.md\" not in bundle.files\n+    assert bundle.metadata[\"source_url\"] == url\n+\n+\n+def test_url_source_rejects_traversal_reference(monkeypatch):\n+    source = UrlSource()\n+    skill = \"---\\nname: bad\\ndescription: bad\\n---\\n[bad](references/../../secret.txt)\\n\"\n+    monkeypatch.setattr(source, \"_fetch_text\", lambda _url: skill)\n+\n+    assert source.fetch(\"https://example.com/bad/SKILL.md\") is None\n+\n+\n+def test_github_source_rejects_symlink_in_referenced_directory(monkeypatch):\n+    source = GitHubSource(GitHubAuth())\n+    monkeypatch.setattr(source, \"_fetch_file_content\", lambda _repo, path: SKILL_MD if path.endswith(\"SKILL.md\") else \"x\")\n+    source._tree_cache[\"owner/repo\"] = (\n+        \"main\",\n+        [\n+            {\"path\": \"skill/SKILL.md\", \"type\": \"blob\", \"mode\": \"100644\"},\n+            {\"path\": \"skill/references/guide.md\", \"type\": \"blob\", \"mode\": \"120000\"},\n+        ],\n+    )\n+\n+    assert source.fetch(\"owner/repo/skill\") is None\n+\n+\n+def test_github_source_fetches_only_exact_references_and_records_tree_revision(monkeypatch):\n+    source = GitHubSource(GitHubAuth())\n+    skill = \"---\\nname: demo\\ndescription: demo\\n---\\n[guide](references/guide.md)\\n\"\n+    fetched = []\n+    monkeypatch.setattr(\n+        source,\n+        \"_fetch_file_content\",\n+        lambda _repo, path: skill if path.endswith(\"SKILL.md\") else None,\n+    )\n+\n+    def _fetch_bytes(_repo, path):\n+        fetched.append(path)\n+        return b\"guide\"\n+\n+    monkeypatch.setattr(source, \"_fetch_file_bytes\", _fetch_bytes, raising=False)\n+    source._tree_cache[\"owner/repo\"] = (\n+        \"develop\",\n+        [\n+            {\"path\": \"skill/SKILL.md\", \"type\": \"blob\", \"mode\": \"100644\"},\n+            {\"path\": \"skill/references/guide.md\", \"type\": \"blob\", \"mode\": \"100644\"},\n+            {\"path\": \"skill/references/unreferenced.md\", \"type\": \"blob\", \"mode\": \"100644\"},\n+        ],\n+    )\n+    source._tree_revisions = {\"owner/repo\": \"deadbeef\"}\n+\n+    bundle = source.fetch(\"owner/repo/skill\")\n+\n+    assert bundle is not None\n+    assert fetched == [\"skill/references/guide.md\"]\n+    assert bundle.files[\"references/guide.md\"] == b\"guide\"\n+    assert bundle.metadata[\"source_url\"] == \"https://github.com/owner/repo/tree/deadbeef/skill\"\n+    assert bundle.metadata[\"source_revision\"] == \"deadbeef\"\n+\n+\n+def test_scan_cache_records_full_provenance_and_hash_change_forces_rescan(tmp_path):\n+    skill = tmp_path / \"skill\"\n+    skill.mkdir()\n+    (skill / \"SKILL.md\").write_text(\"---\\nname: skill\\ndescription: test\\n---\\n# safe\\n\")\n+    cache = tmp_path / \"scan-cache\"\n+\n+    first, first_provenance = scan_skill_cached(\n+        skill, source=\"owner/repo/skill\", source_url=\"https://github.com/owner/repo\", cache_dir=cache\n+    )\n+    second, second_provenance = scan_skill_cached(\n+        skill, source=\"owner/repo/skill\", source_url=\"https://github.com/owner/repo\", cache_dir=cache\n+    )\n+    (skill / \"SKILL.md\").write_text(\"---\\nname: skill\\ndescription: changed\\n---\\n# safe\\n\")\n+    third, third_provenance = scan_skill_cached(\n+        skill, source=\"owner/repo/skill\", source_url=\"https://github.com/owner/repo\", cache_dir=cache\n+    )\n+\n+    assert first.verdict == second.verdict == third.verdict == \"safe\"\n+    assert first_provenance[\"fresh\"] is True\n+    assert second_provenance[\"fresh\"] is False\n+    assert third_provenance[\"fresh\"] is True\n+    assert first_provenance[\"bundle_hash\"].startswith(\"sha256:\")\n+    assert len(first_provenance[\"bundle_hash\"].split(\":\", 1)[1]) == 64\n+    assert third_provenance[\"bundle_hash\"] != first_provenance[\"bundle_hash\"]\n+    assert first_provenance[\"scanner_version\"] == SCANNER_VERSION\n+    assert first_provenance[\"source_url\"] == \"https://github.com/owner/repo\"\n+    assert isinstance(first_provenance[\"findings\"], list)\n+    assert isinstance(first_provenance[\"rules\"], list)\n+    assert first_provenance[\"scanned_at\"]\n+\n+\n+def test_scan_cache_never_reuses_provenance_across_sources(tmp_path):\n+    skill = tmp_path / \"skill\"\n+    skill.mkdir()\n+    (skill / \"SKILL.md\").write_text(\"---\\nname: skill\\ndescription: test\\n---\\n\")\n+    cache = tmp_path / \"scan-cache\"\n+\n+    _first, first = scan_skill_cached(\n+        skill, source=\"community\", source_url=\"https://one.example/SKILL.md\", cache_dir=cache\n+    )\n+    _second, second = scan_skill_cached(\n+        skill, source=\"community\", source_url=\"https://two.example/SKILL.md\", cache_dir=cache\n+    )\n+\n+    assert first[\"fresh\"] is True\n+    assert second[\"fresh\"] is True\n+    assert second[\"source_url\"] == \"https://two.example/SKILL.md\"\n+\n+\n+def test_lock_file_persists_scan_provenance(tmp_path):\n+    lock = HubLockFile(tmp_path / \"lock.json\")\n+    provenance = {\n+        \"source_url\": \"https://example.com/SKILL.md\",\n+        \"bundle_hash\": \"sha256:\" + \"a\" * 64,\n+        \"scanner_version\": SCANNER_VERSION,\n+        \"findings\": [],\n+        \"rules\": [],\n+        \"scanned_at\": \"2026-07-09T00:00:00+00:00\",\n+        \"fresh\": True,\n+    }\n+    lock.record_install(\n+        name=\"demo\", source=\"url\", identifier=\"https://example.com/SKILL.md\",\n+        trust_level=\"community\", scan_verdict=\"safe\", skill_hash=\"sha256:legacy\",\n+        install_path=\"demo\", files=[\"SKILL.md\"], scan_provenance=provenance,\n+    )\n+\n+    assert lock.get_installed(\"demo\")[\"scan_provenance\"] == provenance\n+\n+\n+def test_real_temp_repo_and_home_install_e2e(served_repo, monkeypatch, tmp_path):\n+    from hermes_cli.skills_hub import do_install\n+    import tools.skills_hub as hub\n+\n+    _repo, url = served_repo\n+    home = tmp_path / \"home\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(home))\n+    monkeypatch.setattr(\"tools.skills_hub.is_safe_url\", lambda _url: True)\n+    monkeypatch.setattr(\"tools.skills_hub.check_website_access\", lambda _url: None)\n+    monkeypatch.setattr(hub, \"create_source_router\", lambda auth=None: [UrlSource()])\n+\n+    sink = StringIO()\n+    do_install(url, console=Console(file=sink, force_terminal=False), skip_confirm=True)\n+\n+    installed = home / \"skills\" / \"demo-bundle\"\n+    assert (installed / \"references\" / \"guide.md\").read_text() == \"safe guide\\n\"\n+    assert (installed / \"templates\" / \"report.md\").is_file()\n+    assert (installed / \"scripts\" / \"run.py\").is_file()\n+    assert (installed / \"examples\" / \"endpoint-inventory.md\").is_file()\n+    assert not (installed / \"examples\" / \"not-installed.md\").exists()\n+    assert (installed / \"assets\" / \"logo.png\").read_bytes() == b\"\\x89PNG\\r\\n\\x1a\\n\\x00\\xff\"\n+    entry = json.loads((home / \"skills\" / \".hub\" / \"lock.json\").read_text())[\"installed\"][\"demo-bundle\"]\n+    assert entry[\"scan_provenance\"][\"source_url\"] == url\n+    assert entry[\"scan_provenance\"][\"fresh\"] is True\n+    assert \"Scan provenance: fresh\" in sink.getvalue()\n+\n+\n+def test_bundled_optional_source_still_includes_support_files(tmp_path, monkeypatch):\n+    from tools.skills_hub import OptionalSkillSource\n+\n+    root = tmp_path / \"optional-skills\"\n+    skill = root / \"category\" / \"official-demo\"\n+    (skill / \"references\").mkdir(parents=True)\n+    (skill / \"SKILL.md\").write_text(\"---\\nname: official-demo\\ndescription: demo\\n---\\n\")\n+    (skill / \"references\" / \"all.md\").write_text(\"all\")\n+    source = OptionalSkillSource()\n+    source._optional_dir = root\n+\n+    bundle = source.fetch(\"official/category/official-demo\")\n+    assert bundle is not None\n+    assert set(bundle.files) == {\"SKILL.md\", \"references/all.md\"}"]},{"file":"tools/skills_guard.py","hunks":["@@ -25,12 +25,16 @@\n import re\n import fnmatch\n import hashlib\n+import json\n from dataclasses import dataclass, field\n from datetime import datetime, timezone\n from pathlib import Path\n from typing import List, Tuple\n \n \n+SCANNER_VERSION = \"skills-guard-v1\"\n+\n+\n \n \n # ---------------------------------------------------------------------------","@@ -87,6 +91,7 @@ class ScanResult:\n     findings: List[Finding] = field(default_factory=list)\n     scanned_at: str = \"\"\n     summary: str = \"\"\n+    scan_provenance: dict = field(default_factory=dict)\n \n \n # ---------------------------------------------------------------------------","@@ -683,6 +688,81 @@ def scan_skill(skill_path: Path, source: str = \"community\") -> ScanResult:\n     )\n \n \n+def _content_digest(skill_path: Path) -> str:\n+    \"\"\"Canonical SHA-256 over relative paths and exact file bytes.\"\"\"\n+    h = hashlib.sha256()\n+    if skill_path.is_dir():\n+        for file_path in sorted(skill_path.rglob(\"*\")):\n+            if file_path.is_file():\n+                rel = file_path.relative_to(skill_path).as_posix()\n+                h.update(rel.encode(\"utf-8\") + b\"\\x00\")\n+                h.update(file_path.read_bytes())\n+    else:\n+        h.update(skill_path.read_bytes())\n+    return h.hexdigest()\n+\n+\n+def full_content_hash(skill_path: Path) -> str:\n+    \"\"\"Full canonical digest used to bind scanner attestations.\"\"\"\n+    return f\"sha256:{_content_digest(skill_path)}\"\n+\n+\n+def _finding_dict(finding: Finding) -> dict:\n+    return {key: getattr(finding, key) for key in (\n+        \"pattern_id\", \"severity\", \"category\", \"file\", \"line\", \"match\", \"description\"\n+    )}\n+\n+\n+def scan_skill_cached(\n+    skill_path: Path,\n+    source: str = \"community\",\n+    *,\n+    source_url: str = \"\",\n+    cache_dir: Path | None = None,\n+) -> Tuple[ScanResult, dict]:\n+    \"\"\"Return a scan plus attestation, caching only exact current content.\"\"\"\n+    bundle_hash = full_content_hash(skill_path)\n+    cache_root = cache_dir or skill_path.parent / \".scan-cache\"\n+    source_identity = hashlib.sha256(f\"{source}\\0{source_url}\".encode(\"utf-8\")).hexdigest()[:16]\n+    cache_file = cache_root / f\"{bundle_hash.split(':', 1)[1]}-{source_identity}.json\"\n+    try:\n+        cached = json.loads(cache_file.read_text(encoding=\"utf-8\"))\n+    except (OSError, json.JSONDecodeError):\n+        cached = None\n+    if (isinstance(cached, dict)\n+            and cached.get(\"bundle_hash\") == bundle_hash\n+            and cached.get(\"scanner_version\") == SCANNER_VERSION\n+            and cached.get(\"source\") == source\n+            and cached.get(\"source_url\") == source_url):\n+        result = ScanResult(\n+            skill_name=skill_path.name, source=source,\n+            trust_level=cached[\"trust_level\"], verdict=cached[\"verdict\"],\n+            findings=[Finding(**item) for item in cached.get(\"findings\", [])],\n+            scanned_at=cached[\"scanned_at\"], summary=cached.get(\"summary\", \"\"),\n+        )\n+        provenance = dict(cached)\n+        provenance[\"fresh\"] = False\n+        result.scan_provenance = provenance\n+        return result, provenance\n+\n+    result = scan_skill(skill_path, source=source)\n+    findings = [_finding_dict(item) for item in result.findings]\n+    provenance = {\n+        \"source\": source, \"source_url\": source_url, \"bundle_hash\": bundle_hash,\n+        \"scanner_version\": SCANNER_VERSION, \"verdict\": result.verdict,\n+        \"trust_level\": result.trust_level, \"findings\": findings,\n+        \"rules\": sorted({item[\"pattern_id\"] for item in findings}),\n+        \"scanned_at\": result.scanned_at, \"summary\": result.summary, \"fresh\": True,\n+    }\n+    try:\n+        cache_root.mkdir(parents=True, exist_ok=True)\n+        cache_file.write_text(json.dumps(provenance, indent=2) + \"\\n\", encoding=\"utf-8\")\n+    except OSError:\n+        pass\n+    result.scan_provenance = provenance\n+    return result, provenance\n+\n+\n def should_allow_install(result: ScanResult, force: bool = False) -> Tuple[bool, str]:\n     \"\"\"\n     Determine whether a skill should be installed based on scan result and trust.","@@ -774,20 +854,7 @@ def content_hash(skill_path: Path) -> str:\n     one on an in-memory bundle), so any change to the hash shape MUST\n     land in both places at once.\n     \"\"\"\n-    h = hashlib.sha256()\n-    if skill_path.is_dir():\n-        for f in sorted(skill_path.rglob(\"*\")):\n-            if f.is_file():\n-                try:\n-                    rel = f.relative_to(skill_path).as_posix()\n-                    h.update(rel.encode(\"utf-8\"))\n-                    h.update(b\"\\x00\")\n-                    h.update(f.read_bytes())\n-                except OSError:\n-                    continue\n-    elif skill_path.is_file():\n-        h.update(skill_path.read_bytes())\n-    return f\"sha256:{h.hexdigest()[:16]}\"\n+    return f\"sha256:{_content_digest(skill_path)[:16]}\"\n \n \n # ---------------------------------------------------------------------------"]},{"file":"tools/skills_hub.py","hunks":["@@ -29,7 +29,7 @@\n from hermes_cli._subprocess_compat import windows_hide_flags\n from agent.skill_utils import is_excluded_skill_path\n from typing import Any, Dict, List, Optional, Tuple, Union\n-from urllib.parse import urljoin, urlparse, urlunparse\n+from urllib.parse import unquote, urljoin, urlparse, urlsplit, urlunparse\n \n import httpx\n import yaml","@@ -152,6 +152,46 @@ class SkillBundle:\n     metadata: Dict[str, Any] = field(default_factory=dict)\n \n \n+_ALLOWED_SUPPORT_DIRS = frozenset({\"references\", \"templates\", \"scripts\", \"assets\", \"examples\"})\n+_LOCAL_LINK_RE = re.compile(\n+    r\"(?:\\]\\(|`|(?:^|[\\s\\\"']))((?:references|templates|scripts|assets|examples)/[^\\s)`\\\"'<>]+)\",\n+    re.MULTILINE,\n+)\n+_SUSPICIOUS_LOCAL_REF_RE = re.compile(\n+    r\"(?:references|templates|scripts|assets|examples)/(?:[^\\s)`\\\"'<>]*/)?\\.\\.(?:/|$)\"\n+)\n+\n+\n+def _referenced_support_paths(skill_md: str) -> Optional[set[str]]:\n+    \"\"\"Extract safe referenced paths; return None on a traversal attempt.\"\"\"\n+    normalized = skill_md.replace(\"\\\\\", \"/\")\n+    if _SUSPICIOUS_LOCAL_REF_RE.search(normalized):\n+        return None\n+    paths: set[str] = set()\n+    for match in _LOCAL_LINK_RE.finditer(normalized):\n+        raw = unquote(urlsplit(match.group(1).rstrip(\".,;:\")).path)\n+        try:\n+            safe = _validate_bundle_rel_path(raw)\n+        except ValueError:\n+            return None\n+        if safe.split(\"/\", 1)[0] in _ALLOWED_SUPPORT_DIRS:\n+            paths.add(safe)\n+    return paths\n+\n+\n+def source_url_for_bundle(bundle: SkillBundle) -> str:\n+    \"\"\"Best available human-facing immutable-source provenance URL.\"\"\"\n+    explicit = bundle.metadata.get(\"source_url\") or bundle.metadata.get(\"url\")\n+    if explicit:\n+        return str(explicit)\n+    if bundle.source == \"github\":\n+        parts = bundle.identifier.split(\"/\", 2)\n+        if len(parts) >= 2:\n+            suffix = f\"/tree/main/{parts[2]}\" if len(parts) == 3 else \"\"\n+            return f\"https://github.com/{parts[0]}/{parts[1]}{suffix}\"\n+    return bundle.identifier\n+\n+\n def _normalize_bundle_path(path_value: str, *, field_name: str, allow_nested: bool) -> str:\n     \"\"\"Normalize and validate bundle-controlled paths before touching disk.\"\"\"\n     if not isinstance(path_value, str):","@@ -535,6 +575,7 @@ def __init__(self, auth: GitHubAuth, extra_taps: Optional[List[Dict]] = None):\n         # Per-instance cache: repo -> (default_branch, tree_entries)\n         # Survives within a single search/install flow, avoiding redundant API calls.\n         self._tree_cache: Dict[str, Tuple[str, List[dict]]] = {}\n+        self._tree_revisions: Dict[str, str] = {}\n         # Per-repo cache of the optional skills.sh.json grouping sidecar,\n         # mapping skill_name -> human-readable grouping title. ``None`` means\n         # \"fetched, no sidecar\"; a missing key means \"not fetched yet\".","@@ -601,10 +642,41 @@ def fetch(self, identifier: str) -> Optional[SkillBundle]:\n         repo = f\"{parts[0]}/{parts[1]}\"\n         skill_path = parts[2]\n \n-        files = self._download_directory(repo, skill_path)\n-        if not files or \"SKILL.md\" not in files:\n+        skill_md = self._fetch_file_content(repo, f\"{skill_path.rstrip('/')}/SKILL.md\")\n+        if skill_md is None:\n+            return None\n+        referenced = _referenced_support_paths(skill_md)\n+        if referenced is None:\n             return None\n \n+        files: Dict[str, Union[str, bytes]] = {\"SKILL.md\": skill_md}\n+        tree = self._get_repo_tree(repo)\n+        if tree is not None:\n+            branch, entries = tree\n+            prefix = f\"{skill_path.rstrip('/')}/\"\n+            entries_by_path = {item.get(\"path\", \"\"): item for item in entries}\n+            for rel_path in sorted(referenced):\n+                item_path = f\"{prefix}{rel_path}\"\n+                item = entries_by_path.get(item_path)\n+                if item is None:\n+                    logger.warning(\"Referenced skill support file is missing: %s\", item_path)\n+                    return None\n+                if item.get(\"type\") != \"blob\" or item.get(\"mode\") == \"120000\":\n+                    logger.warning(\"Rejected non-regular file in skill bundle: %s\", item_path)\n+                    return None\n+                content = self._fetch_file_bytes(repo, item_path)\n+                if content is None:\n+                    return None\n+                files[rel_path] = content\n+            revision = self._tree_revisions.get(repo) or branch\n+        else:\n+            for rel_path in referenced:\n+                content = self._fetch_file_bytes(repo, f\"{skill_path.rstrip('/')}/{rel_path}\")\n+                if content is None:\n+                    return None\n+                files[rel_path] = content\n+            revision = \"\"\n+\n         skill_name = skill_path.rstrip(\"/\").split(\"/\")[-1]\n         trust = self.trust_level_for(identifier)\n ","@@ -614,6 +686,13 @@ def fetch(self, identifier: str) -> Optional[SkillBundle]:\n             source=\"github\",\n             identifier=identifier,\n             trust_level=trust,\n+            metadata={\n+                \"source_url\": (\n+                    f\"https://github.com/{repo}/tree/{revision}/{skill_path}\"\n+                    if revision else f\"https://github.com/{repo}/{skill_path}\"\n+                ),\n+                \"source_revision\": revision,\n+            },\n         )\n \n     def inspect(self, identifier: str) -> Optional[SkillMeta]:","@@ -752,6 +831,9 @@ def _get_repo_tree(self, repo: str) -> Optional[Tuple[str, List[dict]]]:\n             return None\n \n         entries = tree_data.get(\"tree\", [])\n+        revision = tree_data.get(\"sha\")\n+        if isinstance(revision, str) and revision:\n+            self._tree_revisions[repo] = revision\n         self._tree_cache[repo] = (default_branch, entries)\n         return (default_branch, entries)\n ","@@ -968,14 +1050,24 @@ def _find_skill_in_repo_tree(self, repo: str, skill_name: str) -> Optional[str]:\n         return None\n \n     def _fetch_file_content(self, repo: str, path: str) -> Optional[str]:\n-        \"\"\"Fetch a single file's content from GitHub.\"\"\"\n+        \"\"\"Fetch a single text file from GitHub.\"\"\"\n+        content = self._fetch_file_bytes(repo, path)\n+        if content is None:\n+            return None\n+        try:\n+            return content.decode(\"utf-8\")\n+        except UnicodeDecodeError:\n+            return None\n+\n+    def _fetch_file_bytes(self, repo: str, path: str) -> Optional[bytes]:\n+        \"\"\"Fetch exact file bytes from GitHub without text decoding.\"\"\"\n         url = f\"https://api.github.com/repos/{repo}/contents/{path}\"\n         resp = self._github_get(\n             url,\n             headers={**self.auth.get_headers(), \"Accept\": \"application/vnd.github.v3.raw\"},\n         )\n         if resp is not None and resp.status_code == 200:\n-            return resp.text\n+            return resp.content\n         return None\n \n     def _get_skillsh_groupings(self, repo: str) -> Optional[Dict[str, str]]:","@@ -1318,12 +1410,12 @@ def _wrap_identifier(base_url: str, skill_name: str) -> str:\n # ---------------------------------------------------------------------------\n \n class UrlSource(SkillSource):\n-    \"\"\"Fetch a single-file SKILL.md skill directly from an HTTP(S) URL.\n+    \"\"\"Fetch SKILL.md plus explicitly referenced, allowlisted support files.\n \n     The identifier IS the URL (e.g. ``https://example.com/path/SKILL.md``).\n-    Only single-file skills are supported — multi-file skills with\n-    ``references/`` or ``scripts/`` subfolders need a manifest we can't\n-    discover from a bare URL.\n+    Bare URLs cannot safely enumerate a repository, so only exact references\n+    below references/templates/scripts/assets are fetched. Other repository\n+    files are never copied.\n \n     The skill name is read from the ``name:`` field in the SKILL.md YAML\n     frontmatter (with a URL-slug fallback). Trust level is always","@@ -1402,6 +1494,19 @@ def fetch(self, identifier: str) -> Optional[SkillBundle]:\n \n         fm = GitHubSource._parse_frontmatter_quick(text)\n         name = self._resolve_skill_name(fm, url)\n+        referenced = _referenced_support_paths(text)\n+        if referenced is None:\n+            return None\n+        files: Dict[str, Union[str, bytes]] = {\"SKILL.md\": text}\n+        base_url = url.rsplit(\"/\", 1)[0] + \"/\"\n+        for rel_path in sorted(referenced):\n+            support_url = urljoin(base_url, rel_path)\n+            if urlparse(support_url).netloc != urlparse(url).netloc:\n+                return None\n+            content = self._fetch_bytes(support_url)\n+            if content is None:\n+                return None\n+            files[rel_path] = content\n \n         # When auto-resolution fails, return a bundle with an empty name and\n         # ``awaiting_name=True`` in metadata. The install flow (``do_install``)","@@ -1418,11 +1523,11 @@ def fetch(self, identifier: str) -> Optional[SkillBundle]:\n \n         return SkillBundle(\n             name=skill_name,\n-            files={\"SKILL.md\": text},\n+            files=files,\n             source=\"url\",\n             identifier=url,\n             trust_level=\"community\",\n-            metadata={\"url\": url, \"awaiting_name\": not skill_name},\n+            metadata={\"url\": url, \"source_url\": url, \"awaiting_name\": not skill_name},\n         )\n \n     @staticmethod","@@ -1432,6 +1537,13 @@ def _fetch_text(url: str) -> Optional[str]:\n             return resp.text\n         return None\n \n+    @staticmethod\n+    def _fetch_bytes(url: str) -> Optional[bytes]:\n+        resp = _guarded_http_get(url, timeout=20)\n+        if resp is not None and resp.status_code == 200:\n+            return resp.content\n+        return None\n+\n     # Skill names must look like identifiers: lowercase letters/digits with\n     # optional hyphens/underscores. Blocks dangerous (``../evil``) AND useless\n     # (``SKILL``, ``README``, empty) candidates before they hit the disk.","@@ -3304,6 +3416,7 @@ def record_install(\n         install_path: str,\n         files: List[str],\n         metadata: Optional[Dict[str, Any]] = None,\n+        scan_provenance: Optional[Dict[str, Any]] = None,\n     ) -> None:\n         # Validate both the skill name and the install path SHAPE before\n         # writing into lock.json. A poisoned lock entry is the precondition","@@ -3321,6 +3434,7 @@ def record_install(\n             \"install_path\": safe_install_path,\n             \"files\": files,\n             \"metadata\": metadata or {},\n+            \"scan_provenance\": scan_provenance or {},\n             \"installed_at\": datetime.now(timezone.utc).isoformat(),\n             \"updated_at\": datetime.now(timezone.utc).isoformat(),\n         }","@@ -3461,6 +3575,7 @@ def install_from_quarantine(\n     category: str,\n     bundle: SkillBundle,\n     scan_result: ScanResult,\n+    scan_provenance: Optional[Dict[str, Any]] = None,\n ) -> Path:\n     \"\"\"Move a scanned skill from quarantine into the skills directory.\"\"\"\n     safe_skill_name = _validate_skill_name(skill_name)","@@ -3529,6 +3644,7 @@ def install_from_quarantine(\n         install_path=str(install_dir.relative_to(_skills_dir())),\n         files=list(bundle.files.keys()),\n         metadata=bundle.metadata,\n+        scan_provenance=scan_provenance or getattr(scan_result, \"scan_provenance\", None),\n     )\n \n     append_audit_log("]},{"file":"website/docs/guides/work-with-skills.md","hunks":["@@ -95,7 +95,7 @@ hermes skills install official/research/arxiv\n # Install from the hub in a chat session\n /skills install official/creative/songwriting-and-ai-music\n \n-# Install a single-file SKILL.md directly from any HTTP(S) URL\n+# Install SKILL.md and its referenced support files from an HTTP(S) URL\n hermes skills install https://sharethis.chat/SKILL.md\n /skills install https://example.com/SKILL.md --name my-skill\n ```"]},{"file":"website/docs/reference/cli-commands.md","hunks":["@@ -1065,7 +1065,7 @@ hermes skills inspect official/security/1password\n hermes skills inspect skills-sh/vercel-labs/json-render/json-render-react\n hermes skills install official/migration/openclaw-migration\n hermes skills install skills-sh/anthropics/skills/pdf --force\n-hermes skills install https://sharethis.chat/SKILL.md                     # Direct URL (single-file SKILL.md)\n+hermes skills install https://sharethis.chat/SKILL.md                     # Direct URL (+ referenced support files)\n hermes skills install https://example.com/SKILL.md --name my-skill        # Override name when frontmatter has none\n hermes skills check\n hermes skills update","@@ -1083,7 +1083,7 @@ Notes:\n - `--source skills-sh` searches the public `skills.sh` directory.\n - `--source well-known` lets you point Hermes at a site exposing `/.well-known/skills/index.json`.\n - `--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`.\n-- 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.\n+- 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.\n \n ## `hermes bundles`\n "]},{"file":"website/docs/user-guide/features/skills.md","hunks":["@@ -290,6 +290,7 @@ See [Skill Settings](/user-guide/configuration#skill-settings) and [Creating Ski\n │   │   ├── references/            # Additional docs\n │   │   ├── templates/             # Output formats\n │   │   ├── scripts/               # Helper scripts callable from the skill\n+│   │   ├── examples/              # Referenced example outputs\n │   │   └── assets/                # Supplementary files\n │   └── vllm/\n │       └── SKILL.md","@@ -304,6 +305,13 @@ See [Skill Settings](/user-guide/configuration#skill-settings) and [Creating Ski\n └── .bundled_manifest              # Tracks seeded bundled skills\n ```\n \n+Third-party URL and GitHub installs include `SKILL.md` plus the exact local\n+files it references under `references/`, `templates/`, `scripts/`, `assets/`,\n+and `examples/`. Unreferenced repository files are not copied. Hermes scans the\n+complete quarantined bundle and records the source URL, exact content hash,\n+scanner version, findings, timestamp, and fresh-or-cached status in\n+`skills/.hub/lock.json`.\n+\n ## External Skill Directories\n \n 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\n hermes skills install official/security/1password\n hermes skills install skills-sh/vercel-labs/json-render/json-render-react --force\n hermes skills install well-known:https://mintlify.com/docs/.well-known/skills/mintlify\n-hermes skills install https://sharethis.chat/SKILL.md              # Direct URL (single-file SKILL.md)\n+hermes skills install https://sharethis.chat/SKILL.md              # Direct URL (+ referenced support files)\n hermes skills install https://example.com/SKILL.md --name my-skill # Override name when frontmatter has none\n hermes skills list --source hub                   # List hub-installed skills\n 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\n | `official` | `official/security/1password` | Optional skills shipped with Hermes. |\n | `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. |\n | `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. |\n-| `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. |\n+| `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. |\n | `github` | `openai/skills/k8s` | Direct GitHub repo/path installs and custom taps. |\n | `clawhub`, `lobehub`, `browse-sh` | Source-specific identifiers | Community or marketplace integrations. |\n ","@@ -670,11 +678,11 @@ Identifiers use the form `browse-sh/<hostname>/<task-id>` and match the slug exp\n \n #### 9. Direct URL (`url`)\n \n-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.\n+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.\n \n - Hermes source id: `url`\n - Identifier: the URL itself (no prefix needed)\n-- 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.\n+- Scope: `SKILL.md` plus exact referenced support files in the allowlisted directories. Hermes does not enumerate or copy unrelated files from the host.\n \n ```bash\n hermes skills install https://sharethis.chat/SKILL.md"]},{"file":"website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/work-with-skills.md","hunks":["@@ -95,7 +95,7 @@ hermes skills install official/research/arxiv\n # 在聊天会话中从 Hub 安装\n /skills install official/creative/songwriting-and-ai-music\n \n-# 直接从任意 HTTP(S) URL 安装单文件 SKILL.md\n+# 从 HTTP(S) URL 安装 SKILL.md 及其引用的支持文件\n hermes skills install https://sharethis.chat/SKILL.md\n /skills install https://example.com/SKILL.md --name my-skill\n ```"]},{"file":"website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md","hunks":["@@ -820,7 +820,7 @@ hermes skills inspect official/security/1password\n hermes skills inspect skills-sh/vercel-labs/json-render/json-render-react\n hermes skills install official/migration/openclaw-migration\n hermes skills install skills-sh/anthropics/skills/pdf --force\n-hermes skills install https://sharethis.chat/SKILL.md                     # 直接 URL（单文件 SKILL.md）\n+hermes skills install https://sharethis.chat/SKILL.md                     # 直接 URL（含引用的支持文件）\n hermes skills install https://example.com/SKILL.md --name my-skill        # frontmatter 无名称时覆盖名称\n hermes skills check\n hermes skills update","@@ -835,7 +835,7 @@ hermes skills reset google-workspace --restore --yes\n - `--source skills-sh` 搜索公共 `skills.sh` 目录。\n - `--source well-known` 允许你将 Hermes 指向暴露 `/.well-known/skills/index.json` 的站点。\n - `--source browse-sh` 搜索 [browse.sh](https://browse.sh) 包含 200+ 站点特定浏览器自动化 skill 的目录。标识符形如 `browse-sh/airbnb.com/search-listings-ddgioa`。\n-- 传入 `http(s)://…/*.md` URL 可直接安装单文件 SKILL.md。当 frontmatter 没有 `name:` 且 URL slug 不是有效标识符时，交互式终端会提示输入名称；非交互式界面（TUI 内的 `/skills install`、gateway 平台）需要改用 `--name <x>`。\n+- 传入 `http(s)://…/*.md` URL 可安装 `SKILL.md`，以及其中明确引用且位于 `references/`、`templates/`、`scripts/`、`assets/` 和 `examples/` 下的文件。当 frontmatter 没有 `name:` 且 URL slug 不是有效标识符时，交互式终端会提示输入名称；非交互式界面（TUI 内的 `/skills install`、gateway 平台）需要改用 `--name <x>`。\n \n ## `hermes bundles`\n "]},{"file":"website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/skills.md","hunks":["@@ -204,6 +204,7 @@ metadata:\n │   │   ├── references/            # Additional docs\n │   │   ├── templates/             # Output formats\n │   │   ├── scripts/               # Helper scripts callable from the skill\n+│   │   ├── examples/              # Referenced example outputs\n │   │   └── assets/                # Supplementary files\n │   └── vllm/\n │       └── SKILL.md","@@ -218,6 +219,8 @@ metadata:\n └── .bundled_manifest              # Tracks seeded bundled skills\n ```\n \n+通过第三方 URL 或 GitHub 安装时，Hermes 会安装 `SKILL.md`，以及其中明确引用且位于 `references/`、`templates/`、`scripts/`、`assets/` 和 `examples/` 下的文件。未引用的仓库文件不会被复制。Hermes 会扫描完整的隔离捆绑包，并在 `skills/.hub/lock.json` 中记录来源 URL、精确内容哈希、扫描器版本、发现项、时间戳，以及本次结果是新扫描还是缓存复用。\n+\n ## 外部 Skill 目录\n \n 如果你在 Hermes 之外维护 skills——例如，供多个 AI 工具使用的共享 `~/.agents/skills/` 目录——你可以告诉 Hermes 也扫描这些目录。","@@ -388,7 +391,7 @@ hermes skills install openai/skills/k8s           # Install with security scan\n hermes skills install official/security/1password\n hermes skills install skills-sh/vercel-labs/json-render/json-render-react --force\n hermes skills install well-known:https://mintlify.com/docs/.well-known/skills/mintlify\n-hermes skills install https://sharethis.chat/SKILL.md              # Direct URL (single-file SKILL.md)\n+hermes skills install https://sharethis.chat/SKILL.md              # 直接 URL（含引用的支持文件）\n hermes skills install https://example.com/SKILL.md --name my-skill # Override name when frontmatter has none\n hermes skills list --source hub                   # List hub-installed skills\n 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\n | `official` | `official/security/1password` | Hermes 随附的可选 skills。 |\n | `skills-sh` | `skills-sh/vercel-labs/agent-skills/vercel-react-best-practices` | 可通过 `hermes skills search <query> --source skills-sh` 搜索。当 skills.sh slug 与仓库文件夹不同时，Hermes 会解析别名式 skills。 |\n | `well-known` | `well-known:https://mintlify.com/docs/.well-known/skills/mintlify` | 直接从网站的 `/.well-known/skills/index.json` 提供的 skills。使用站点或文档 URL 搜索。 |\n-| `url` | `https://sharethis.chat/SKILL.md` | 指向单文件 `SKILL.md` 的直接 HTTP(S) URL。名称解析顺序：frontmatter → URL slug → 交互式提示 → `--name` 标志。 |\n+| `url` | `https://sharethis.chat/SKILL.md` | 指向 `SKILL.md` 及其明确引用的支持文件的直接 HTTP(S) URL。名称解析顺序：frontmatter → URL slug → 交互式提示 → `--name` 标志。 |\n | `github` | `openai/skills/k8s` | 直接从 GitHub 仓库/路径安装以及基于 GitHub 的自定义 tap。 |\n | `clawhub`、`lobehub`、`browse-sh`、`claude-marketplace` | 来源特定标识符 | 社区或市场集成。 |\n ","@@ -523,11 +526,11 @@ hermes skills install browse-sh/airbnb.com/search-listings-ddgioa\n \n #### 9. 直接 URL（`url`）\n \n-直接从任何 HTTP(S) URL 安装单文件 `SKILL.md`——当作者在自己的站点上托管 skill 时非常有用（无 hub 列表，无需输入 GitHub 路径）。Hermes 获取 URL，解析 YAML frontmatter，进行安全扫描并安装。\n+直接从任何 HTTP(S) URL 安装 `SKILL.md`——当作者在自己的站点上托管 skill 时非常有用（无 hub 列表，无需输入 GitHub 路径）。Hermes 还会获取其中明确引用且位于 `references/`、`templates/`、`scripts/`、`assets/` 和 `examples/` 下的文件，然后扫描并安装完整捆绑包。\n \n - Hermes 来源 id：`url`\n - 标识符：URL 本身（无需前缀）\n-- 范围：**仅限单文件 `SKILL.md`**。包含 `references/` 或 `scripts/` 的多文件 skills 需要清单，应通过上述其他来源之一发布。\n+- 范围：`SKILL.md` 加上允许目录中明确引用的支持文件。Hermes 不会枚举或复制托管站点上的其他文件。\n \n ```bash\n hermes skills install https://sharethis.chat/SKILL.md\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}