diff --git a/tests/tools/test_skill_bundle_provenance.py b/tests/tools/test_skill_bundle_provenance.py index 25f696c775bf..33878b3f9caf 100644 --- a/tests/tools/test_skill_bundle_provenance.py +++ b/tests/tools/test_skill_bundle_provenance.py @@ -20,8 +20,9 @@ name: demo-bundle description: A multi-file test skill. --- # Demo -Read [the guide](references/guide.md), use `templates/report.md`, and run -`scripts/run.py`. The repository also contains assets/logo.txt. +Read [the guide](references/guide.md#usage), use `templates/report.md?raw=1`, and run +`scripts/run.py`. See `examples/endpoint-inventory.md`. The repository also +contains assets/logo.png. """ @@ -35,17 +36,21 @@ def served_repo(tmp_path): repo = tmp_path / "upstream" repo.mkdir() (repo / "SKILL.md").write_text(SKILL_MD) - for rel, text in { + for rel, content in { "references/guide.md": "safe guide\n", "templates/report.md": "report\n", "scripts/run.py": "print('ok')\n", - "assets/logo.txt": "logo\n", + "assets/logo.png": b"\x89PNG\r\n\x1a\n\x00\xff", + "examples/endpoint-inventory.md": "example\n", "examples/not-installed.md": "must not be copied\n", "README.md": "must not be copied\n", }.items(): path = repo / rel path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text) + if isinstance(content, bytes): + path.write_bytes(content) + else: + path.write_text(content) subprocess.run(["git", "init", "-q"], cwd=repo, check=True) subprocess.run(["git", "add", "."], cwd=repo, check=True) subprocess.run( @@ -79,8 +84,11 @@ def test_url_source_fetches_only_referenced_allowed_support_directories(served_r "references/guide.md", "templates/report.md", "scripts/run.py", - "assets/logo.txt", + "assets/logo.png", + "examples/endpoint-inventory.md", } + assert bundle.files["assets/logo.png"] == b"\x89PNG\r\n\x1a\n\x00\xff" + assert "examples/not-installed.md" not in bundle.files assert bundle.metadata["source_url"] == url @@ -106,6 +114,40 @@ def test_github_source_rejects_symlink_in_referenced_directory(monkeypatch): assert source.fetch("owner/repo/skill") is None +def test_github_source_fetches_only_exact_references_and_records_tree_revision(monkeypatch): + source = GitHubSource(GitHubAuth()) + skill = "---\nname: demo\ndescription: demo\n---\n[guide](references/guide.md)\n" + fetched = [] + monkeypatch.setattr( + source, + "_fetch_file_content", + lambda _repo, path: skill if path.endswith("SKILL.md") else None, + ) + + def _fetch_bytes(_repo, path): + fetched.append(path) + return b"guide" + + monkeypatch.setattr(source, "_fetch_file_bytes", _fetch_bytes, raising=False) + source._tree_cache["owner/repo"] = ( + "develop", + [ + {"path": "skill/SKILL.md", "type": "blob", "mode": "100644"}, + {"path": "skill/references/guide.md", "type": "blob", "mode": "100644"}, + {"path": "skill/references/unreferenced.md", "type": "blob", "mode": "100644"}, + ], + ) + source._tree_revisions = {"owner/repo": "deadbeef"} + + bundle = source.fetch("owner/repo/skill") + + assert bundle is not None + assert fetched == ["skill/references/guide.md"] + assert bundle.files["references/guide.md"] == b"guide" + assert bundle.metadata["source_url"] == "https://github.com/owner/repo/tree/deadbeef/skill" + assert bundle.metadata["source_revision"] == "deadbeef" + + def test_scan_cache_records_full_provenance_and_hash_change_forces_rescan(tmp_path): skill = tmp_path / "skill" skill.mkdir() @@ -137,6 +179,24 @@ def test_scan_cache_records_full_provenance_and_hash_change_forces_rescan(tmp_pa assert first_provenance["scanned_at"] +def test_scan_cache_never_reuses_provenance_across_sources(tmp_path): + skill = tmp_path / "skill" + skill.mkdir() + (skill / "SKILL.md").write_text("---\nname: skill\ndescription: test\n---\n") + cache = tmp_path / "scan-cache" + + _first, first = scan_skill_cached( + skill, source="community", source_url="https://one.example/SKILL.md", cache_dir=cache + ) + _second, second = scan_skill_cached( + skill, source="community", source_url="https://two.example/SKILL.md", cache_dir=cache + ) + + assert first["fresh"] is True + assert second["fresh"] is True + assert second["source_url"] == "https://two.example/SKILL.md" + + def test_lock_file_persists_scan_provenance(tmp_path): lock = HubLockFile(tmp_path / "lock.json") provenance = { @@ -175,8 +235,9 @@ def test_real_temp_repo_and_home_install_e2e(served_repo, monkeypatch, tmp_path) assert (installed / "references" / "guide.md").read_text() == "safe guide\n" assert (installed / "templates" / "report.md").is_file() assert (installed / "scripts" / "run.py").is_file() - assert (installed / "assets" / "logo.txt").is_file() - assert not (installed / "examples").exists() + assert (installed / "examples" / "endpoint-inventory.md").is_file() + assert not (installed / "examples" / "not-installed.md").exists() + assert (installed / "assets" / "logo.png").read_bytes() == b"\x89PNG\r\n\x1a\n\x00\xff" entry = json.loads((home / "skills" / ".hub" / "lock.json").read_text())["installed"]["demo-bundle"] assert entry["scan_provenance"]["source_url"] == url assert entry["scan_provenance"]["fresh"] is True diff --git a/tools/skills_guard.py b/tools/skills_guard.py index 98acf6a9ff45..47f200ab880b 100644 --- a/tools/skills_guard.py +++ b/tools/skills_guard.py @@ -688,20 +688,23 @@ def scan_skill(skill_path: Path, source: str = "community") -> ScanResult: ) -def _full_content_hash(skill_path: Path) -> str: - """Complete SHA-256 over paths and bytes for scan cache identity.""" +def _content_digest(skill_path: Path) -> str: + """Canonical SHA-256 over relative paths and exact file bytes.""" h = hashlib.sha256() if skill_path.is_dir(): for file_path in sorted(skill_path.rglob("*")): - rel = file_path.relative_to(skill_path).as_posix() - if file_path.is_symlink(): - h.update(rel.encode() + b"\x00SYMLINK\x00") - elif file_path.is_file(): - h.update(rel.encode() + b"\x00") + if file_path.is_file(): + rel = file_path.relative_to(skill_path).as_posix() + h.update(rel.encode("utf-8") + b"\x00") h.update(file_path.read_bytes()) else: h.update(skill_path.read_bytes()) - return f"sha256:{h.hexdigest()}" + return h.hexdigest() + + +def full_content_hash(skill_path: Path) -> str: + """Full canonical digest used to bind scanner attestations.""" + return f"sha256:{_content_digest(skill_path)}" def _finding_dict(finding: Finding) -> dict: @@ -718,9 +721,10 @@ def scan_skill_cached( cache_dir: Path | None = None, ) -> Tuple[ScanResult, dict]: """Return a scan plus attestation, caching only exact current content.""" - bundle_hash = _full_content_hash(skill_path) + bundle_hash = full_content_hash(skill_path) cache_root = cache_dir or skill_path.parent / ".scan-cache" - cache_file = cache_root / f"{bundle_hash.split(':', 1)[1]}.json" + source_identity = hashlib.sha256(f"{source}\0{source_url}".encode("utf-8")).hexdigest()[:16] + cache_file = cache_root / f"{bundle_hash.split(':', 1)[1]}-{source_identity}.json" try: cached = json.loads(cache_file.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): @@ -728,7 +732,8 @@ def scan_skill_cached( if (isinstance(cached, dict) and cached.get("bundle_hash") == bundle_hash and cached.get("scanner_version") == SCANNER_VERSION - and cached.get("source") == source): + and cached.get("source") == source + and cached.get("source_url") == source_url): result = ScanResult( skill_name=skill_path.name, source=source, trust_level=cached["trust_level"], verdict=cached["verdict"], @@ -849,20 +854,7 @@ def content_hash(skill_path: Path) -> str: one on an in-memory bundle), so any change to the hash shape MUST land in both places at once. """ - h = hashlib.sha256() - if skill_path.is_dir(): - for f in sorted(skill_path.rglob("*")): - if f.is_file(): - try: - rel = f.relative_to(skill_path).as_posix() - h.update(rel.encode("utf-8")) - h.update(b"\x00") - h.update(f.read_bytes()) - except OSError: - continue - elif skill_path.is_file(): - h.update(skill_path.read_bytes()) - return f"sha256:{h.hexdigest()[:16]}" + return f"sha256:{_content_digest(skill_path)[:16]}" # --------------------------------------------------------------------------- diff --git a/tools/skills_hub.py b/tools/skills_hub.py index c3c2ec2faf56..755b32fcbbe2 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -29,7 +29,7 @@ from hermes_constants import get_hermes_home from hermes_cli._subprocess_compat import windows_hide_flags from agent.skill_utils import is_excluded_skill_path from typing import Any, Dict, List, Optional, Tuple, Union -from urllib.parse import urljoin, urlparse, urlunparse +from urllib.parse import unquote, urljoin, urlparse, urlsplit, urlunparse import httpx import yaml @@ -152,13 +152,13 @@ class SkillBundle: metadata: Dict[str, Any] = field(default_factory=dict) -_ALLOWED_SUPPORT_DIRS = frozenset({"references", "templates", "scripts", "assets"}) +_ALLOWED_SUPPORT_DIRS = frozenset({"references", "templates", "scripts", "assets", "examples"}) _LOCAL_LINK_RE = re.compile( - r"(?:\]\(|`|(?:^|[\s\"']))((?:references|templates|scripts|assets)/[^\s)`\"'<>]+)", + r"(?:\]\(|`|(?:^|[\s\"']))((?:references|templates|scripts|assets|examples)/[^\s)`\"'<>]+)", re.MULTILINE, ) _SUSPICIOUS_LOCAL_REF_RE = re.compile( - r"(?:references|templates|scripts|assets)/(?:[^\s)`\"'<>]*/)?\.\.(?:/|$)" + r"(?:references|templates|scripts|assets|examples)/(?:[^\s)`\"'<>]*/)?\.\.(?:/|$)" ) @@ -169,7 +169,7 @@ def _referenced_support_paths(skill_md: str) -> Optional[set[str]]: return None paths: set[str] = set() for match in _LOCAL_LINK_RE.finditer(normalized): - raw = match.group(1).rstrip(".,;:") + raw = unquote(urlsplit(match.group(1).rstrip(".,;:")).path) try: safe = _validate_bundle_rel_path(raw) except ValueError: @@ -575,6 +575,7 @@ class GitHubSource(SkillSource): # Per-instance cache: repo -> (default_branch, tree_entries) # Survives within a single search/install flow, avoiding redundant API calls. self._tree_cache: Dict[str, Tuple[str, List[dict]]] = {} + self._tree_revisions: Dict[str, str] = {} # Per-repo cache of the optional skills.sh.json grouping sidecar, # mapping skill_name -> human-readable grouping title. ``None`` means # "fetched, no sidecar"; a missing key means "not fetched yet". @@ -651,29 +652,30 @@ class GitHubSource(SkillSource): files: Dict[str, Union[str, bytes]] = {"SKILL.md": skill_md} tree = self._get_repo_tree(repo) if tree is not None: - _branch, entries = tree + branch, entries = tree prefix = f"{skill_path.rstrip('/')}/" - wanted_roots = {path.split("/", 1)[0] for path in referenced} - for item in entries: - item_path = item.get("path", "") - if not item_path.startswith(prefix): - continue - rel_path = item_path[len(prefix):] - if rel_path.split("/", 1)[0] not in wanted_roots: - continue + entries_by_path = {item.get("path", ""): item for item in entries} + for rel_path in sorted(referenced): + item_path = f"{prefix}{rel_path}" + item = entries_by_path.get(item_path) + if item is None: + logger.warning("Referenced skill support file is missing: %s", item_path) + return None if item.get("type") != "blob" or item.get("mode") == "120000": logger.warning("Rejected non-regular file in skill bundle: %s", item_path) return None - content = self._fetch_file_content(repo, item_path) - if content is None: - return None - files[_validate_bundle_rel_path(rel_path)] = content - else: - for rel_path in referenced: - content = self._fetch_file_content(repo, f"{skill_path.rstrip('/')}/{rel_path}") + content = self._fetch_file_bytes(repo, item_path) if content is None: return None files[rel_path] = content + revision = self._tree_revisions.get(repo) or branch + else: + for rel_path in referenced: + content = self._fetch_file_bytes(repo, f"{skill_path.rstrip('/')}/{rel_path}") + if content is None: + return None + files[rel_path] = content + revision = "" skill_name = skill_path.rstrip("/").split("/")[-1] trust = self.trust_level_for(identifier) @@ -684,7 +686,13 @@ class GitHubSource(SkillSource): source="github", identifier=identifier, trust_level=trust, - metadata={"source_url": f"https://github.com/{repo}/tree/main/{skill_path}"}, + metadata={ + "source_url": ( + f"https://github.com/{repo}/tree/{revision}/{skill_path}" + if revision else f"https://github.com/{repo}/{skill_path}" + ), + "source_revision": revision, + }, ) def inspect(self, identifier: str) -> Optional[SkillMeta]: @@ -823,6 +831,9 @@ class GitHubSource(SkillSource): return None entries = tree_data.get("tree", []) + revision = tree_data.get("sha") + if isinstance(revision, str) and revision: + self._tree_revisions[repo] = revision self._tree_cache[repo] = (default_branch, entries) return (default_branch, entries) @@ -1039,14 +1050,24 @@ class GitHubSource(SkillSource): return None def _fetch_file_content(self, repo: str, path: str) -> Optional[str]: - """Fetch a single file's content from GitHub.""" + """Fetch a single text file from GitHub.""" + content = self._fetch_file_bytes(repo, path) + if content is None: + return None + try: + return content.decode("utf-8") + except UnicodeDecodeError: + return None + + def _fetch_file_bytes(self, repo: str, path: str) -> Optional[bytes]: + """Fetch exact file bytes from GitHub without text decoding.""" url = f"https://api.github.com/repos/{repo}/contents/{path}" resp = self._github_get( url, headers={**self.auth.get_headers(), "Accept": "application/vnd.github.v3.raw"}, ) if resp is not None and resp.status_code == 200: - return resp.text + return resp.content return None def _get_skillsh_groupings(self, repo: str) -> Optional[Dict[str, str]]: @@ -1482,7 +1503,7 @@ class UrlSource(SkillSource): support_url = urljoin(base_url, rel_path) if urlparse(support_url).netloc != urlparse(url).netloc: return None - content = self._fetch_text(support_url) + content = self._fetch_bytes(support_url) if content is None: return None files[rel_path] = content @@ -1516,6 +1537,13 @@ class UrlSource(SkillSource): return resp.text return None + @staticmethod + def _fetch_bytes(url: str) -> Optional[bytes]: + resp = _guarded_http_get(url, timeout=20) + if resp is not None and resp.status_code == 200: + return resp.content + return None + # Skill names must look like identifiers: lowercase letters/digits with # optional hyphens/underscores. Blocks dangerous (``../evil``) AND useless # (``SKILL``, ``README``, empty) candidates before they hit the disk. diff --git a/website/docs/guides/work-with-skills.md b/website/docs/guides/work-with-skills.md index f191ff146d11..768a7c9cf83d 100644 --- a/website/docs/guides/work-with-skills.md +++ b/website/docs/guides/work-with-skills.md @@ -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 ``` diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 6fa916786f3c..804a25aab411 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -1065,7 +1065,7 @@ hermes skills inspect official/security/1password hermes skills inspect skills-sh/vercel-labs/json-render/json-render-react hermes skills install official/migration/openclaw-migration hermes skills install skills-sh/anthropics/skills/pdf --force -hermes skills install https://sharethis.chat/SKILL.md # Direct URL (single-file SKILL.md) +hermes skills install https://sharethis.chat/SKILL.md # Direct URL (+ referenced support files) hermes skills install https://example.com/SKILL.md --name my-skill # Override name when frontmatter has none hermes skills check hermes skills update @@ -1083,7 +1083,7 @@ Notes: - `--source skills-sh` searches the public `skills.sh` directory. - `--source well-known` lets you point Hermes at a site exposing `/.well-known/skills/index.json`. - `--source browse-sh` searches [browse.sh](https://browse.sh)'s catalog of 200+ site-specific browser-automation skills. Identifiers look like `browse-sh/airbnb.com/search-listings-ddgioa`. -- Passing an `http(s)://…/*.md` URL installs a single-file SKILL.md directly. When frontmatter has no `name:` and the URL slug isn't a valid identifier, an interactive terminal prompts for a name; non-interactive surfaces (`/skills install` inside the TUI, gateway platforms) require `--name ` 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 ` instead. ## `hermes bundles` diff --git a/website/docs/user-guide/features/skills.md b/website/docs/user-guide/features/skills.md index 19fffb1f1b23..fccff01e1a21 100644 --- a/website/docs/user-guide/features/skills.md +++ b/website/docs/user-guide/features/skills.md @@ -290,6 +290,7 @@ See [Skill Settings](/user-guide/configuration#skill-settings) and [Creating Ski │ │ ├── references/ # Additional docs │ │ ├── templates/ # Output formats │ │ ├── scripts/ # Helper scripts callable from the skill +│ │ ├── examples/ # Referenced example outputs │ │ └── assets/ # Supplementary files │ └── vllm/ │ └── SKILL.md @@ -304,6 +305,13 @@ See [Skill Settings](/user-guide/configuration#skill-settings) and [Creating Ski └── .bundled_manifest # Tracks seeded bundled skills ``` +Third-party URL and GitHub installs include `SKILL.md` plus the exact local +files it references under `references/`, `templates/`, `scripts/`, `assets/`, +and `examples/`. Unreferenced repository files are not copied. Hermes scans the +complete quarantined bundle and records the source URL, exact content hash, +scanner version, findings, timestamp, and fresh-or-cached status in +`skills/.hub/lock.json`. + ## External Skill Directories If you maintain skills outside of Hermes — for example, a shared `~/.agents/skills/` directory used by multiple AI tools — you can tell Hermes to scan those directories too. @@ -517,7 +525,7 @@ hermes skills install openai/skills/k8s # Install with security scan hermes skills install official/security/1password hermes skills install skills-sh/vercel-labs/json-render/json-render-react --force hermes skills install well-known:https://mintlify.com/docs/.well-known/skills/mintlify -hermes skills install https://sharethis.chat/SKILL.md # Direct URL (single-file SKILL.md) +hermes skills install https://sharethis.chat/SKILL.md # Direct URL (+ referenced support files) hermes skills install https://example.com/SKILL.md --name my-skill # Override name when frontmatter has none hermes skills list --source hub # List hub-installed skills hermes skills check # Check installed hub skills for upstream updates @@ -538,7 +546,7 @@ hermes skills tap add myorg/skills-repo # Add a custom GitHub source | `official` | `official/security/1password` | Optional skills shipped with Hermes. | | `skills-sh` | `skills-sh/vercel-labs/agent-skills/vercel-react-best-practices` | Searchable via `hermes skills search --source skills-sh`. Hermes resolves alias-style skills when the skills.sh slug differs from the repo folder. | | `well-known` | `well-known:https://mintlify.com/docs/.well-known/skills/mintlify` | Skills served directly from `/.well-known/skills/index.json` on a website. Search using the site or docs URL. | -| `url` | `https://sharethis.chat/SKILL.md` | Direct HTTP(S) URL to a single-file `SKILL.md`. Name resolution: frontmatter → URL slug → interactive prompt → `--name` flag. | +| `url` | `https://sharethis.chat/SKILL.md` | Direct HTTP(S) URL to `SKILL.md` plus explicitly referenced support files. Name resolution: frontmatter → URL slug → interactive prompt → `--name` flag. | | `github` | `openai/skills/k8s` | Direct GitHub repo/path installs and custom taps. | | `clawhub`, `lobehub`, `browse-sh` | Source-specific identifiers | Community or marketplace integrations. | @@ -670,11 +678,11 @@ Identifiers use the form `browse-sh//` 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 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/work-with-skills.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/work-with-skills.md index a443fab9915a..d834538b2548 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/work-with-skills.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/work-with-skills.md @@ -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 ``` diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md index a1191cfbd5f5..d4ef1885a6eb 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md @@ -820,7 +820,7 @@ hermes skills inspect official/security/1password hermes skills inspect skills-sh/vercel-labs/json-render/json-render-react hermes skills install official/migration/openclaw-migration hermes skills install skills-sh/anthropics/skills/pdf --force -hermes skills install https://sharethis.chat/SKILL.md # 直接 URL(单文件 SKILL.md) +hermes skills install https://sharethis.chat/SKILL.md # 直接 URL(含引用的支持文件) hermes skills install https://example.com/SKILL.md --name my-skill # frontmatter 无名称时覆盖名称 hermes skills check hermes skills update @@ -835,7 +835,7 @@ hermes skills reset google-workspace --restore --yes - `--source skills-sh` 搜索公共 `skills.sh` 目录。 - `--source well-known` 允许你将 Hermes 指向暴露 `/.well-known/skills/index.json` 的站点。 - `--source browse-sh` 搜索 [browse.sh](https://browse.sh) 包含 200+ 站点特定浏览器自动化 skill 的目录。标识符形如 `browse-sh/airbnb.com/search-listings-ddgioa`。 -- 传入 `http(s)://…/*.md` URL 可直接安装单文件 SKILL.md。当 frontmatter 没有 `name:` 且 URL slug 不是有效标识符时,交互式终端会提示输入名称;非交互式界面(TUI 内的 `/skills install`、gateway 平台)需要改用 `--name `。 +- 传入 `http(s)://…/*.md` URL 可安装 `SKILL.md`,以及其中明确引用且位于 `references/`、`templates/`、`scripts/`、`assets/` 和 `examples/` 下的文件。当 frontmatter 没有 `name:` 且 URL slug 不是有效标识符时,交互式终端会提示输入名称;非交互式界面(TUI 内的 `/skills install`、gateway 平台)需要改用 `--name `。 ## `hermes bundles` diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/skills.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/skills.md index 5e71afd86fba..ea2da9625577 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/skills.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/skills.md @@ -204,6 +204,7 @@ metadata: │ │ ├── references/ # Additional docs │ │ ├── templates/ # Output formats │ │ ├── scripts/ # Helper scripts callable from the skill +│ │ ├── examples/ # Referenced example outputs │ │ └── assets/ # Supplementary files │ └── vllm/ │ └── SKILL.md @@ -218,6 +219,8 @@ metadata: └── .bundled_manifest # Tracks seeded bundled skills ``` +通过第三方 URL 或 GitHub 安装时,Hermes 会安装 `SKILL.md`,以及其中明确引用且位于 `references/`、`templates/`、`scripts/`、`assets/` 和 `examples/` 下的文件。未引用的仓库文件不会被复制。Hermes 会扫描完整的隔离捆绑包,并在 `skills/.hub/lock.json` 中记录来源 URL、精确内容哈希、扫描器版本、发现项、时间戳,以及本次结果是新扫描还是缓存复用。 + ## 外部 Skill 目录 如果你在 Hermes 之外维护 skills——例如,供多个 AI 工具使用的共享 `~/.agents/skills/` 目录——你可以告诉 Hermes 也扫描这些目录。 @@ -388,7 +391,7 @@ hermes skills install openai/skills/k8s # Install with security scan hermes skills install official/security/1password hermes skills install skills-sh/vercel-labs/json-render/json-render-react --force hermes skills install well-known:https://mintlify.com/docs/.well-known/skills/mintlify -hermes skills install https://sharethis.chat/SKILL.md # Direct URL (single-file SKILL.md) +hermes skills install https://sharethis.chat/SKILL.md # 直接 URL(含引用的支持文件) hermes skills install https://example.com/SKILL.md --name my-skill # Override name when frontmatter has none hermes skills list --source hub # List hub-installed skills hermes skills check # Check installed hub skills for upstream updates @@ -409,7 +412,7 @@ hermes skills tap add myorg/skills-repo # Add a custom GitHub source | `official` | `official/security/1password` | Hermes 随附的可选 skills。 | | `skills-sh` | `skills-sh/vercel-labs/agent-skills/vercel-react-best-practices` | 可通过 `hermes skills search --source skills-sh` 搜索。当 skills.sh slug 与仓库文件夹不同时,Hermes 会解析别名式 skills。 | | `well-known` | `well-known:https://mintlify.com/docs/.well-known/skills/mintlify` | 直接从网站的 `/.well-known/skills/index.json` 提供的 skills。使用站点或文档 URL 搜索。 | -| `url` | `https://sharethis.chat/SKILL.md` | 指向单文件 `SKILL.md` 的直接 HTTP(S) URL。名称解析顺序:frontmatter → URL slug → 交互式提示 → `--name` 标志。 | +| `url` | `https://sharethis.chat/SKILL.md` | 指向 `SKILL.md` 及其明确引用的支持文件的直接 HTTP(S) URL。名称解析顺序:frontmatter → URL slug → 交互式提示 → `--name` 标志。 | | `github` | `openai/skills/k8s` | 直接从 GitHub 仓库/路径安装以及基于 GitHub 的自定义 tap。 | | `clawhub`、`lobehub`、`browse-sh`、`claude-marketplace` | 来源特定标识符 | 社区或市场集成。 | @@ -523,11 +526,11 @@ hermes skills install browse-sh/airbnb.com/search-listings-ddgioa #### 9. 直接 URL(`url`) -直接从任何 HTTP(S) URL 安装单文件 `SKILL.md`——当作者在自己的站点上托管 skill 时非常有用(无 hub 列表,无需输入 GitHub 路径)。Hermes 获取 URL,解析 YAML frontmatter,进行安全扫描并安装。 +直接从任何 HTTP(S) URL 安装 `SKILL.md`——当作者在自己的站点上托管 skill 时非常有用(无 hub 列表,无需输入 GitHub 路径)。Hermes 还会获取其中明确引用且位于 `references/`、`templates/`、`scripts/`、`assets/` 和 `examples/` 下的文件,然后扫描并安装完整捆绑包。 - Hermes 来源 id:`url` - 标识符:URL 本身(无需前缀) -- 范围:**仅限单文件 `SKILL.md`**。包含 `references/` 或 `scripts/` 的多文件 skills 需要清单,应通过上述其他来源之一发布。 +- 范围:`SKILL.md` 加上允许目录中明确引用的支持文件。Hermes 不会枚举或复制托管站点上的其他文件。 ```bash hermes skills install https://sharethis.chat/SKILL.md