# NousResearch/hermes-agent · GitHub skill install missing bundle files fix

## Open now

- 2 issues, 0 PRs open on GitHub.
- #35125: hermes skills install URL source only downloads SKILL.md, misses reference files (references/, templates/, scripts/)
- #60598: Bug: community skill scanner verdict may be stale and third-party installs only copy SKILL.md

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.

## TL;DR

- 7 member(s): 2 open issue(s), 0 closed issue(s), 0 open PR(s), 1 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #63051. Verify it addresses your case.
- **Recommended:** read PR #63051 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.26`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #35125: hermes skills install URL source only downloads SKILL.md, misses reference files (references/, templates/, scripts/)
- #60598: Bug: community skill scanner verdict may be stale and third-party installs only copy SKILL.md

## Prior & competing PRs

- **#63051: fix(skills): install complete bundles with bound scan provenance**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `hermes_cli/skills_hub.py`

    ```diff
    @@ -516,7 +516,7 @@ def do_install(identifier: str, category: str = "", force: bool = False,
             GitHubAuth, create_source_router, ensure_hub_dirs,
             quarantine_bundle, install_from_quarantine, HubLockFile,
         )
    -    from tools.skills_guard import scan_skill, should_allow_install, format_scan_report
    +    from tools.skills_guard import scan_skill_cached, should_allow_install, format_scan_report
     
         c = console or _console
         ensure_hub_dirs()
    ```

    ```diff
    @@ -648,8 +648,24 @@ def do_install(identifier: str, category: str = "", force: bool = False,
                 or getattr(meta, "identifier", "")
                 or identifier
             )
    -    result = scan_skill(q_path, source=scan_source)
    +    from tools.skills_hub import HUB_DIR, source_url_for_bundle
    +    result, scan_provenance = scan_skill_cached(
    +        q_path,
    +        source=scan_source,
    +        source_url=source_url_for_bundle(bundle),
    +        cache_dir=HUB_DIR / "scan-cache",
    +    )
         c.print(format_scan_report(result))
    +    freshness = "fresh" if scan_provenance["fresh"] else "cached"
    +    c.print(
    +        f"[dim]Scan provenance: {freshness}; scanner "
    +        f"{scan_provenance['scanner_version']}; hash {scan_provenance['bundle_hash']}[/]"
    +    )
    +    rules = ", ".join(scan_provenance["rules"]) or "none"
    +    c.print(
    +        f"[dim]Source: {scan_provenance['source_url']}; scanned "
    +        f"{scan_provenance['scanned_at']}; rules: {rules}[/]"
    +    )
     
         # Check install policy
         allowed, reason = should_allow_install(result, force=force)
    ```
  - `tests/tools/test_skill_bundle_provenance.py`

    ```diff
    @@ -0,0 +1,260 @@
    +"""Multi-file third-party skill bundles and scanner provenance (#60598)."""
    +
    +import json
    +import subprocess
    +import threading
    +from functools import partial
    +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
    +from io import StringIO
    +from pathlib import Path
    +
    +import pytest
    +from rich.console import Console
    +
    +from tools.skills_guard import SCANNER_VERSION, scan_skill_cached
    +from tools.skills_hub import GitHubAuth, GitHubSource, HubLockFile, SkillBundle, UrlSource
    +
    +
    +SKILL_MD = """---
    +name: demo-bundle
    +description: A multi-file test skill.
    +---
    +# Demo
    +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.
    +"""
    +
    +
    +class _QuietHandler(SimpleHTTPRequestHandler):
    +    def log_message(self, *_args):
    +        pass
    +
    +
    [redacted:email]
    +def served_repo(tmp_path):
    +    repo = tmp_path / "upstream"
    +    repo.mkdir()
    +    (repo / "SKILL.md").write_text(SKILL_MD)
    +    for rel, content in {
    +        "references/guide.md": "safe guide\n",
    +        "templates/report.md": "report\n",
    +        "scripts/run.py": "print('ok')\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)
    +        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(
    +        ["git", "-c", "user.name=Test", "-c", "user.email=[redacted:email]", "commit", "-qm", "fixture"],
    +        cwd=repo,
    +        check=True,
    +    )
    +
    +    server = ThreadingHTTPServer(
    +        ("127.0.0.1", 0), partial(_QuietHandler, directory=str(repo))
    +    )
    +    thread = threading.Thread(target=server.serve_forever, daemon=True)
    +    thread.start()
    +    try:
    +        yield repo, f"http://127.0.0.1:{server.server_port}/SKILL.md"
    +    finally:
    +        server.shutdown()
    +        thread.join()
    +
    +
    +def test_url_source_fetches_only_referenced_allowed_support_directories(served_repo, monkeypatch):
    +    _repo, url = served_repo
    +    monkeypatch.setattr("tools.skills_hub.is_safe_url", lambda _url: True)
    +    monkeypatch.setattr("tools.skills_hub.check_website_access", lambda _url: None)
    +
    +    bundle = UrlSource().fetch(url)
    +
    +    assert bundle is not None
    +    assert set(bundle.files) == {
    +        "SKILL.md",
    +        "references/guide.md",
    +        "templates/report.md",
    +        "scripts/run.py",
    +        "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
    +
    +
    +def test_url_source_rejects_traversal_reference(monkeypatch):
    +    source = UrlSource()
    +    skill = "---\nname: bad\ndescription: bad\n---\n[bad](references/../../secret.txt)\n"
    +    monkeypatch.setattr(source, "_fetch_text", lambda _url: skill)
    +
    +    assert source.fetch("https://example.com/bad/SKILL.md") is None
    +
    +
    +def test_github_source_rejects_symlink_in_referenced_directory(monkeypatch):
    +    source = GitHubSource(GitHubAuth())
    +    monkeypatch.setattr(source, "_fetch_file_content", lambda _repo, path: SKILL_MD if path.endswith("SKILL.md") else "x")
    +    source._tree_cache["owner/repo"] = (
    +        "main",
    +        [
    +            {"path": "skill/SKILL.md", "type": "blob", "mode": "100644"},
    +            {"path": "skill/references/guide.md", "type": "blob", "mode": "120000"},
    +        ],
    +    )
    +
    +    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()
    +    (skill / "SKILL.md").write_text("---\nname: skill\ndescription: test\n---\n# safe\n")
    +    cache = tmp_path / "scan-cache"
    +
    +    first, first_provenance = scan_skill_cached(
    +        skill, source="owner/repo/skill", source_url="https://github.com/owner/repo", cache_dir=cache
    +    )
    +    second, second_provenance = scan_skill_cached(
    +        skill, source="owner/repo/skill", source_url="https://github.com/owner/repo", cache_dir=cache
    +    )
    +    (skill / "SKILL.md").write_text("---\nname: skill\ndescription: changed\n---\n# safe\n")
    +    third, third_provenance = scan_skill_cached(
    +        skill, source="owner/repo/skill", source_url="https://github.com/owner/repo", cache_dir=cache
    +    )
    +
    +    assert first.verdict == second.verdict == third.verdict == "safe"
    +    assert first_provenance["fresh"] is True
    +    assert second_provenance["fresh"] is False
    +    assert third_provenance["fresh"] is True
    +    assert first_provenance["bundle_hash"].startswith("sha256:")
    +    assert len(first_provenance["bundle_hash"].split(":", 1)[1]) == 64
    +    assert third_provenance["bundle_hash"] != first_provenance["bundle_hash"]
    +    assert first_provenance["scanner_version"] == SCANNER_VERSION
    +    assert first_provenance["source_url"] == "https://github.com/owner/repo"
    +    assert isinstance(first_provenance["findings"], list)
    +    assert isinstance(first_provenance["rules"], list)
    +    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 = {
    +        "source_url": "https://example.com/SKILL.md",
    +        "bundle_hash": "sha256:" + "a" * 64,
    +        "scanner_version": SCANNER_VERSION,
    +        "findings": [],
    +        "rules": [],
    +        "scanned_at": "2026-07-09T00:00:00+00:00",
    +        "fresh": True,
    +    }
    +    lock.record_install(
    +        name="demo", source="url", identifier="https://example.com/SKILL.md",
    +        trust_level="community", scan_verdict="safe", skill_hash="sha256:legacy",
    +        install_path="demo", files=["SKILL.md"], scan_provenance=provenance,
    +    )
    +
    +    assert lock.get_installed("demo")["scan_provenance"] == provenance
    +
    +
    +def test_real_temp_repo_and_home_install_e2e(served_repo, monkeypatch, tmp_path):
    +    from hermes_cli.skills_hub import do_install
    +    import tools.skills_hub as hub
    +
    +    _repo, url = served_repo
    +    home = tmp_path / "home"
    +    monkeypatch.setenv("HERMES_HOME", str(home))
    +    monkeypatch.setattr("tools.skills_hub.is_safe_url", lambda _url: True)
    +    monkeypatch.setattr("tools.skills_hub.check_website_access", lambda _url: None)
    +    monkeypatch.setattr(hub, "create_source_router", lambda auth=None: [UrlSource()])
    +
    +    sink = StringIO()
    +    do_install(url, console=Console(file=sink, force_terminal=False), skip_confirm=True)
    +
    +    installed = home / "skills" / "demo-bundle"
    +    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 / "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
    +    assert "Scan provenance: fresh" in sink.getvalue()
    +
    +
    +def test_bundled_optional_source_still_includes_support_files(tmp_path, monkeypatch):
    +    from tools.skills_hub import OptionalSkillSource
    +
    +    root = tmp_path / "optional-skills"
    +    skill = root / "category" / "official-demo"
    +    (skill / "references").mkdir(parents=True)
    +    (skill / "SKILL.md").write_text("---\nname: official-demo\ndescription: demo\n---\n")
    +    (skill / "references" / "all.md").write_text("all")
    +    source = OptionalSkillSource()
    +    source._optional_dir = root
    +
    +    bundle = source.fetch("official/category/official-demo")
    +    assert bundle is not None
    +    assert set(bundle.files) == {"SKILL.md", "references/all.md"}
    ```
  - `tools/skills_guard.py`

    ```diff
    @@ -25,12 +25,16 @@
     import re
     import fnmatch
     import hashlib
    +import json
     from dataclasses import dataclass, field
     from datetime import datetime, timezone
     from pathlib import Path
     from typing import List, Tuple
     
     
    +SCANNER_VERSION = "skills-guard-v1"
    +
    +
     
     
     # ---------------------------------------------------------------------------
    ```

    ```diff
    @@ -87,6 +91,7 @@ class ScanResult:
         findings: List[Finding] = field(default_factory=list)
         scanned_at: str = ""
         summary: str = ""
    +    scan_provenance: dict = field(default_factory=dict)
     
     
     # ---------------------------------------------------------------------------
    ```

    ```diff
    @@ -683,6 +688,81 @@ def scan_skill(skill_path: Path, source: str = "community") -> ScanResult:
         )
     
     
    +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("*")):
    +            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 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:
    +    return {key: getattr(finding, key) for key in (
    +        "pattern_id", "severity", "category", "file", "line", "match", "description"
    +    )}
    +
    +
    +def scan_skill_cached(
    +    skill_path: Path,
    +    source: str = "community",
    +    *,
    +    source_url: str = "",
    +    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)
    +    cache_root = cache_dir or skill_path.parent / ".scan-cache"
    +    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):
    +        cached = None
    +    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_url") == source_url):
    +        result = ScanResult(
    +            skill_name=skill_path.name, source=source,
    +            trust_level=cached["trust_level"], verdict=cached["verdict"],
    +            findings=[Finding(**item) for item in cached.get("findings", [])],
    +            scanned_at=cached["scanned_at"], summary=cached.get("summary", ""),
    +        )
    +        provenance = dict(cached)
    +        provenance["fresh"] = False
    +        result.scan_provenance = provenance
    +        return result, provenance
    +
    +    result = scan_skill(skill_path, source=source)
    +    findings = [_finding_dict(item) for item in result.findings]
    +    provenance = {
    +        "source": source, "source_url": source_url, "bundle_hash": bundle_hash,
    +        "scanner_version": SCANNER_VERSION, "verdict": result.verdict,
    +        "trust_level": result.trust_level, "findings": findings,
    +        "rules": sorted({item["pattern_id"] for item in findings}),
    +        "scanned_at": result.scanned_at, "summary": result.summary, "fresh": True,
    +    }
    +    try:
    +        cache_root.mkdir(parents=True, exist_ok=True)
    +        cache_file.write_text(json.dumps(provenance, indent=2) + "\n", encoding="utf-8")
    +    except OSError:
    +        pass
    +    result.scan_provenance = provenance
    +    return result, provenance
    +
    +
     def should_allow_install(result: ScanResult, force: bool = False) -> Tuple[bool, str]:
         """
         Determine whether a skill should be installed based on scan result and trust.
    ```

    ```diff
    @@ -774,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]}"
     
     
     # ---------------------------------------------------------------------------
    ```
  - `tools/skills_hub.py`

    ```diff
    @@ -29,7 +29,7 @@
     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
    ```

    ```diff
    @@ -152,6 +152,46 @@ class SkillBundle:
         metadata: Dict[str, Any] = field(default_factory=dict)
     
     
    +_ALLOWED_SUPPORT_DIRS = frozenset({"references", "templates", "scripts", "assets", "examples"})
    +_LOCAL_LINK_RE = re.compile(
    +    r"(?:\]\(|`|(?:^|[\s\"']))((?:references|templates|scripts|assets|examples)/[^\s)`\"'<>]+)",
    +    re.MULTILINE,
    +)
    +_SUSPICIOUS_LOCAL_REF_RE = re.compile(
    +    r"(?:references|templates|scripts|assets|examples)/(?:[^\s)`\"'<>]*/)?\.\.(?:/|$)"
    +)
    +
    +
    +def _referenced_support_paths(skill_md: str) -> Optional[set[str]]:
    +    """Extract safe referenced paths; return None on a traversal attempt."""
    +    normalized = skill_md.replace("\\", "/")
    +    if _SUSPICIOUS_LOCAL_REF_RE.search(normalized):
    +        return None
    +    paths: set[str] = set()
    +    for match in _LOCAL_LINK_RE.finditer(normalized):
    +        raw = unquote(urlsplit(match.group(1).rstrip(".,;:")).path)
    +        try:
    +            safe = _validate_bundle_rel_path(raw)
    +        except ValueError:
    +            return None
    +        if safe.split("/", 1)[0] in _ALLOWED_SUPPORT_DIRS:
    +            paths.add(safe)
    +    return paths
    +
    +
    +def source_url_for_bundle(bundle: SkillBundle) -> str:
    +    """Best available human-facing immutable-source provenance URL."""
    +    explicit = bundle.metadata.get("source_url") or bundle.metadata.get("url")
    +    if explicit:
    +        return str(explicit)
    +    if bundle.source == "github":
    +        parts = bundle.identifier.split("/", 2)
    +        if len(parts) >= 2:
    +            suffix = f"/tree/main/{parts[2]}" if len(parts) == 3 else ""
    +            return f"https://github.com/{parts[0]}/{parts[1]}{suffix}"
    +    return bundle.identifier
    +
    +
     def _normalize_bundle_path(path_value: str, *, field_name: str, allow_nested: bool) -> str:
         """Normalize and validate bundle-controlled paths before touching disk."""
         if not isinstance(path_value, str):
    ```

    ```diff
    @@ -535,6 +575,7 @@ def __init__(self, auth: GitHubAuth, extra_taps: Optional[List[Dict]] = None):
             # 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".
    ```

    ```diff
    @@ -601,10 +642,41 @@ def fetch(self, identifier: str) -> Optional[SkillBundle]:
             repo = f"{parts[0]}/{parts[1]}"
             skill_path = parts[2]
     
    -        files = self._download_directory(repo, skill_path)
    -        if not files or "SKILL.md" not in files:
    +        skill_md = self._fetch_file_content(repo, f"{skill_path.rstrip('/')}/SKILL.md")
    +        if skill_md is None:
    +            return None
    +        referenced = _referenced_support_paths(skill_md)
    +        if referenced is None:
                 return None
     
    +        files: Dict[str, Union[str, bytes]] = {"SKILL.md": skill_md}
    +        tree = self._get_repo_tree(repo)
    +        if tree is not None:
    +            branch, entries = tree
    +            prefix = f"{skill_path.rstrip('/')}/"
    +            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_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)
     
    ```

    ```diff
    @@ -614,6 +686,13 @@ def fetch(self, identifier: str) -> Optional[SkillBundle]:
                 source="github",
                 identifier=identifier,
                 trust_level=trust,
    +            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]:
    ```

    ```diff
    @@ -752,6 +831,9 @@ def _get_repo_tree(self, repo: str) -> Optional[Tuple[str, List[dict]]]:
                 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)
     
    ```

    ```diff
    @@ -968,14 +1050,24 @@ def _find_skill_in_repo_tree(self, repo: str, skill_name: str) -> Optional[str]:
             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]]:
    ```

    ```diff
    @@ -1318,12 +1410,12 @@ def _wrap_identifier(base_url: str, skill_name: str) -> str:
     # ---------------------------------------------------------------------------
     
     class UrlSource(SkillSource):
    -    """Fetch a single-file SKILL.md skill directly from an HTTP(S) URL.
    +    """Fetch SKILL.md plus explicitly referenced, allowlisted support files.
     
         The identifier IS the URL (e.g. ``https://example.com/path/SKILL.md``).
    -    Only single-file skills are supported — multi-file skills with
    -    ``references/`` or ``scripts/`` subfolders need a manifest we can't
    -    discover from a bare URL.
    +    Bare URLs cannot safely enumerate a repository, so only exact references
    +    below references/templates/scripts/assets are fetched. Other repository
    +    files are never copied.
     
         The skill name is read from the ``name:`` field in the SKILL.md YAML
         frontmatter (with a URL-slug fallback). Trust level is always
    ```

    ```diff
    @@ -1402,6 +1494,19 @@ def fetch(self, identifier: str) -> Optional[SkillBundle]:
     
             fm = GitHubSource._parse_frontmatter_quick(text)
             name = self._resolve_skill_name(fm, url)
    +        referenced = _referenced_support_paths(text)
    +        if referenced is None:
    +            return None
    +        files: Dict[str, Union[str, bytes]] = {"SKILL.md": text}
    +        base_url = url.rsplit("/", 1)[0] + "/"
    +        for rel_path in sorted(referenced):
    +            support_url = urljoin(base_url, rel_path)
    +            if urlparse(support_url).netloc != urlparse(url).netloc:
    +                return None
    +            content = self._fetch_bytes(support_url)
    +            if content is None:
    +                return None
    +            files[rel_path] = content
     
             # When auto-resolution fails, return a bundle with an empty name and
             # ``awaiting_name=True`` in metadata. The install flow (``do_install``)
    ```

    ```diff
    @@ -1418,11 +1523,11 @@ def fetch(self, identifier: str) -> Optional[SkillBundle]:
     
             return SkillBundle(
                 name=skill_name,
    -            files={"SKILL.md": text},
    +            files=files,
                 source="url",
                 identifier=url,
                 trust_level="community",
    -            metadata={"url": url, "awaiting_name": not skill_name},
    +            metadata={"url": url, "source_url": url, "awaiting_name": not skill_name},
             )
     
         @staticmethod
    ```

    ```diff
    @@ -1432,6 +1537,13 @@ def _fetch_text(url: str) -> Optional[str]:
                 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.
    ```

    ```diff
    @@ -3304,6 +3416,7 @@ def record_install(
             install_path: str,
             files: List[str],
             metadata: Optional[Dict[str, Any]] = None,
    +        scan_provenance: Optional[Dict[str, Any]] = None,
         ) -> None:
             # Validate both the skill name and the install path SHAPE before
             # writing into lock.json. A poisoned lock entry is the precondition
    ```

    ```diff
    @@ -3321,6 +3434,7 @@ def record_install(
                 "install_path": safe_install_path,
                 "files": files,
                 "metadata": metadata or {},
    +            "scan_provenance": scan_provenance or {},
                 "installed_at": datetime.now(timezone.utc).isoformat(),
                 "updated_at": datetime.now(timezone.utc).isoformat(),
             }
    ```

    ```diff
    @@ -3461,6 +3575,7 @@ def install_from_quarantine(
         category: str,
         bundle: SkillBundle,
         scan_result: ScanResult,
    +    scan_provenance: Optional[Dict[str, Any]] = None,
     ) -> Path:
         """Move a scanned skill from quarantine into the skills directory."""
         safe_skill_name = _validate_skill_name(skill_name)
    ```

    ```diff
    @@ -3529,6 +3644,7 @@ def install_from_quarantine(
             install_path=str(install_dir.relative_to(_skills_dir())),
             files=list(bundle.files.keys()),
             metadata=bundle.metadata,
    +        scan_provenance=scan_provenance or getattr(scan_result, "scan_provenance", None),
         )
     
         append_audit_log(
    ```
  - `website/docs/guides/work-with-skills.md`

    ```diff
    @@ -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
     ```
    ```
  - `website/docs/reference/cli-commands.md`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`
     
    ```
  - `website/docs/user-guide/features/skills.md`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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. |
     
    ```

    ```diff
    @@ -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
    ```
  - `website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/work-with-skills.md`

    ```diff
    @@ -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
     ```
    ```
  - `website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`
     
    ```
  - `website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/skills.md`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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 也扫描这些目录。
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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` | 来源特定标识符 | 社区或市场集成。 |
     
    ```

    ```diff
    @@ -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
    ```
- **#35173: fix: install full bundles from raw GitHub skill URLs**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `51382ac244702b09482e339ecba5a88649f5ea62`, `#63051`
- **#35175: fix(skills): preserve support files for GitHub URL installs**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `51382ac244702b09482e339ecba5a88649f5ea62`
- **#35177: fix(skills): route raw.githubusercontent.com URLs to GitHubSource for directory download**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`
- **#61771: fix(skills): install complete bundles with scan provenance**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#63051`

## Fix status

- Merged to main in PR #63051 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-12`.
- Subsystem: `skills`
