mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
fix(skills): bind bundles to exact files and origins
This commit is contained in:
parent
c36f6b7259
commit
51382ac244
9 changed files with 164 additions and 72 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1065,7 +1065,7 @@ hermes skills inspect official/security/1password
|
|||
hermes skills inspect skills-sh/vercel-labs/json-render/json-render-react
|
||||
hermes skills install official/migration/openclaw-migration
|
||||
hermes skills install skills-sh/anthropics/skills/pdf --force
|
||||
hermes skills install https://sharethis.chat/SKILL.md # Direct URL (single-file SKILL.md)
|
||||
hermes skills install https://sharethis.chat/SKILL.md # Direct URL (+ referenced support files)
|
||||
hermes skills install https://example.com/SKILL.md --name my-skill # Override name when frontmatter has none
|
||||
hermes skills check
|
||||
hermes skills update
|
||||
|
|
@ -1083,7 +1083,7 @@ Notes:
|
|||
- `--source skills-sh` searches the public `skills.sh` directory.
|
||||
- `--source well-known` lets you point Hermes at a site exposing `/.well-known/skills/index.json`.
|
||||
- `--source browse-sh` searches [browse.sh](https://browse.sh)'s catalog of 200+ site-specific browser-automation skills. Identifiers look like `browse-sh/airbnb.com/search-listings-ddgioa`.
|
||||
- Passing an `http(s)://…/*.md` URL installs a single-file SKILL.md directly. When frontmatter has no `name:` and the URL slug isn't a valid identifier, an interactive terminal prompts for a name; non-interactive surfaces (`/skills install` inside the TUI, gateway platforms) require `--name <x>` instead.
|
||||
- Passing an `http(s)://…/*.md` URL installs `SKILL.md` plus explicitly referenced files under `references/`, `templates/`, `scripts/`, `assets/`, and `examples/`. When frontmatter has no `name:` and the URL slug isn't a valid identifier, an interactive terminal prompts for a name; non-interactive surfaces (`/skills install` inside the TUI, gateway platforms) require `--name <x>` instead.
|
||||
|
||||
## `hermes bundles`
|
||||
|
||||
|
|
|
|||
|
|
@ -290,6 +290,7 @@ See [Skill Settings](/user-guide/configuration#skill-settings) and [Creating Ski
|
|||
│ │ ├── references/ # Additional docs
|
||||
│ │ ├── templates/ # Output formats
|
||||
│ │ ├── scripts/ # Helper scripts callable from the skill
|
||||
│ │ ├── examples/ # Referenced example outputs
|
||||
│ │ └── assets/ # Supplementary files
|
||||
│ └── vllm/
|
||||
│ └── SKILL.md
|
||||
|
|
@ -304,6 +305,13 @@ See [Skill Settings](/user-guide/configuration#skill-settings) and [Creating Ski
|
|||
└── .bundled_manifest # Tracks seeded bundled skills
|
||||
```
|
||||
|
||||
Third-party URL and GitHub installs include `SKILL.md` plus the exact local
|
||||
files it references under `references/`, `templates/`, `scripts/`, `assets/`,
|
||||
and `examples/`. Unreferenced repository files are not copied. Hermes scans the
|
||||
complete quarantined bundle and records the source URL, exact content hash,
|
||||
scanner version, findings, timestamp, and fresh-or-cached status in
|
||||
`skills/.hub/lock.json`.
|
||||
|
||||
## External Skill Directories
|
||||
|
||||
If you maintain skills outside of Hermes — for example, a shared `~/.agents/skills/` directory used by multiple AI tools — you can tell Hermes to scan those directories too.
|
||||
|
|
@ -517,7 +525,7 @@ hermes skills install openai/skills/k8s # Install with security scan
|
|||
hermes skills install official/security/1password
|
||||
hermes skills install skills-sh/vercel-labs/json-render/json-render-react --force
|
||||
hermes skills install well-known:https://mintlify.com/docs/.well-known/skills/mintlify
|
||||
hermes skills install https://sharethis.chat/SKILL.md # Direct URL (single-file SKILL.md)
|
||||
hermes skills install https://sharethis.chat/SKILL.md # Direct URL (+ referenced support files)
|
||||
hermes skills install https://example.com/SKILL.md --name my-skill # Override name when frontmatter has none
|
||||
hermes skills list --source hub # List hub-installed skills
|
||||
hermes skills check # Check installed hub skills for upstream updates
|
||||
|
|
@ -538,7 +546,7 @@ hermes skills tap add myorg/skills-repo # Add a custom GitHub source
|
|||
| `official` | `official/security/1password` | Optional skills shipped with Hermes. |
|
||||
| `skills-sh` | `skills-sh/vercel-labs/agent-skills/vercel-react-best-practices` | Searchable via `hermes skills search <query> --source skills-sh`. Hermes resolves alias-style skills when the skills.sh slug differs from the repo folder. |
|
||||
| `well-known` | `well-known:https://mintlify.com/docs/.well-known/skills/mintlify` | Skills served directly from `/.well-known/skills/index.json` on a website. Search using the site or docs URL. |
|
||||
| `url` | `https://sharethis.chat/SKILL.md` | Direct HTTP(S) URL to a single-file `SKILL.md`. Name resolution: frontmatter → URL slug → interactive prompt → `--name` flag. |
|
||||
| `url` | `https://sharethis.chat/SKILL.md` | Direct HTTP(S) URL to `SKILL.md` plus explicitly referenced support files. Name resolution: frontmatter → URL slug → interactive prompt → `--name` flag. |
|
||||
| `github` | `openai/skills/k8s` | Direct GitHub repo/path installs and custom taps. |
|
||||
| `clawhub`, `lobehub`, `browse-sh` | Source-specific identifiers | Community or marketplace integrations. |
|
||||
|
||||
|
|
@ -670,11 +678,11 @@ Identifiers use the form `browse-sh/<hostname>/<task-id>` and match the slug exp
|
|||
|
||||
#### 9. Direct URL (`url`)
|
||||
|
||||
Install a single-file `SKILL.md` directly from any HTTP(S) URL — useful when an author hosts a skill on their own site (no hub listing, no GitHub path to type). Hermes fetches the URL, parses the YAML frontmatter, security-scans it, and installs.
|
||||
Install `SKILL.md` directly from any HTTP(S) URL — useful when an author hosts a skill on their own site (no hub listing, no GitHub path to type). Hermes also fetches explicitly referenced files under `references/`, `templates/`, `scripts/`, `assets/`, and `examples/`, then scans and installs the complete bundle.
|
||||
|
||||
- Hermes source id: `url`
|
||||
- Identifier: the URL itself (no prefix needed)
|
||||
- Scope: **single-file `SKILL.md`** only. Multi-file skills with `references/` or `scripts/` need a manifest and should be published via one of the other sources above.
|
||||
- Scope: `SKILL.md` plus exact referenced support files in the allowlisted directories. Hermes does not enumerate or copy unrelated files from the host.
|
||||
|
||||
```bash
|
||||
hermes skills install https://sharethis.chat/SKILL.md
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -820,7 +820,7 @@ hermes skills inspect official/security/1password
|
|||
hermes skills inspect skills-sh/vercel-labs/json-render/json-render-react
|
||||
hermes skills install official/migration/openclaw-migration
|
||||
hermes skills install skills-sh/anthropics/skills/pdf --force
|
||||
hermes skills install https://sharethis.chat/SKILL.md # 直接 URL(单文件 SKILL.md)
|
||||
hermes skills install https://sharethis.chat/SKILL.md # 直接 URL(含引用的支持文件)
|
||||
hermes skills install https://example.com/SKILL.md --name my-skill # frontmatter 无名称时覆盖名称
|
||||
hermes skills check
|
||||
hermes skills update
|
||||
|
|
@ -835,7 +835,7 @@ hermes skills reset google-workspace --restore --yes
|
|||
- `--source skills-sh` 搜索公共 `skills.sh` 目录。
|
||||
- `--source well-known` 允许你将 Hermes 指向暴露 `/.well-known/skills/index.json` 的站点。
|
||||
- `--source browse-sh` 搜索 [browse.sh](https://browse.sh) 包含 200+ 站点特定浏览器自动化 skill 的目录。标识符形如 `browse-sh/airbnb.com/search-listings-ddgioa`。
|
||||
- 传入 `http(s)://…/*.md` URL 可直接安装单文件 SKILL.md。当 frontmatter 没有 `name:` 且 URL slug 不是有效标识符时,交互式终端会提示输入名称;非交互式界面(TUI 内的 `/skills install`、gateway 平台)需要改用 `--name <x>`。
|
||||
- 传入 `http(s)://…/*.md` URL 可安装 `SKILL.md`,以及其中明确引用且位于 `references/`、`templates/`、`scripts/`、`assets/` 和 `examples/` 下的文件。当 frontmatter 没有 `name:` 且 URL slug 不是有效标识符时,交互式终端会提示输入名称;非交互式界面(TUI 内的 `/skills install`、gateway 平台)需要改用 `--name <x>`。
|
||||
|
||||
## `hermes bundles`
|
||||
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@ metadata:
|
|||
│ │ ├── references/ # Additional docs
|
||||
│ │ ├── templates/ # Output formats
|
||||
│ │ ├── scripts/ # Helper scripts callable from the skill
|
||||
│ │ ├── examples/ # Referenced example outputs
|
||||
│ │ └── assets/ # Supplementary files
|
||||
│ └── vllm/
|
||||
│ └── SKILL.md
|
||||
|
|
@ -218,6 +219,8 @@ metadata:
|
|||
└── .bundled_manifest # Tracks seeded bundled skills
|
||||
```
|
||||
|
||||
通过第三方 URL 或 GitHub 安装时,Hermes 会安装 `SKILL.md`,以及其中明确引用且位于 `references/`、`templates/`、`scripts/`、`assets/` 和 `examples/` 下的文件。未引用的仓库文件不会被复制。Hermes 会扫描完整的隔离捆绑包,并在 `skills/.hub/lock.json` 中记录来源 URL、精确内容哈希、扫描器版本、发现项、时间戳,以及本次结果是新扫描还是缓存复用。
|
||||
|
||||
## 外部 Skill 目录
|
||||
|
||||
如果你在 Hermes 之外维护 skills——例如,供多个 AI 工具使用的共享 `~/.agents/skills/` 目录——你可以告诉 Hermes 也扫描这些目录。
|
||||
|
|
@ -388,7 +391,7 @@ hermes skills install openai/skills/k8s # Install with security scan
|
|||
hermes skills install official/security/1password
|
||||
hermes skills install skills-sh/vercel-labs/json-render/json-render-react --force
|
||||
hermes skills install well-known:https://mintlify.com/docs/.well-known/skills/mintlify
|
||||
hermes skills install https://sharethis.chat/SKILL.md # Direct URL (single-file SKILL.md)
|
||||
hermes skills install https://sharethis.chat/SKILL.md # 直接 URL(含引用的支持文件)
|
||||
hermes skills install https://example.com/SKILL.md --name my-skill # Override name when frontmatter has none
|
||||
hermes skills list --source hub # List hub-installed skills
|
||||
hermes skills check # Check installed hub skills for upstream updates
|
||||
|
|
@ -409,7 +412,7 @@ hermes skills tap add myorg/skills-repo # Add a custom GitHub source
|
|||
| `official` | `official/security/1password` | Hermes 随附的可选 skills。 |
|
||||
| `skills-sh` | `skills-sh/vercel-labs/agent-skills/vercel-react-best-practices` | 可通过 `hermes skills search <query> --source skills-sh` 搜索。当 skills.sh slug 与仓库文件夹不同时,Hermes 会解析别名式 skills。 |
|
||||
| `well-known` | `well-known:https://mintlify.com/docs/.well-known/skills/mintlify` | 直接从网站的 `/.well-known/skills/index.json` 提供的 skills。使用站点或文档 URL 搜索。 |
|
||||
| `url` | `https://sharethis.chat/SKILL.md` | 指向单文件 `SKILL.md` 的直接 HTTP(S) URL。名称解析顺序:frontmatter → URL slug → 交互式提示 → `--name` 标志。 |
|
||||
| `url` | `https://sharethis.chat/SKILL.md` | 指向 `SKILL.md` 及其明确引用的支持文件的直接 HTTP(S) URL。名称解析顺序:frontmatter → URL slug → 交互式提示 → `--name` 标志。 |
|
||||
| `github` | `openai/skills/k8s` | 直接从 GitHub 仓库/路径安装以及基于 GitHub 的自定义 tap。 |
|
||||
| `clawhub`、`lobehub`、`browse-sh`、`claude-marketplace` | 来源特定标识符 | 社区或市场集成。 |
|
||||
|
||||
|
|
@ -523,11 +526,11 @@ hermes skills install browse-sh/airbnb.com/search-listings-ddgioa
|
|||
|
||||
#### 9. 直接 URL(`url`)
|
||||
|
||||
直接从任何 HTTP(S) URL 安装单文件 `SKILL.md`——当作者在自己的站点上托管 skill 时非常有用(无 hub 列表,无需输入 GitHub 路径)。Hermes 获取 URL,解析 YAML frontmatter,进行安全扫描并安装。
|
||||
直接从任何 HTTP(S) URL 安装 `SKILL.md`——当作者在自己的站点上托管 skill 时非常有用(无 hub 列表,无需输入 GitHub 路径)。Hermes 还会获取其中明确引用且位于 `references/`、`templates/`、`scripts/`、`assets/` 和 `examples/` 下的文件,然后扫描并安装完整捆绑包。
|
||||
|
||||
- Hermes 来源 id:`url`
|
||||
- 标识符:URL 本身(无需前缀)
|
||||
- 范围:**仅限单文件 `SKILL.md`**。包含 `references/` 或 `scripts/` 的多文件 skills 需要清单,应通过上述其他来源之一发布。
|
||||
- 范围:`SKILL.md` 加上允许目录中明确引用的支持文件。Hermes 不会枚举或复制托管站点上的其他文件。
|
||||
|
||||
```bash
|
||||
hermes skills install https://sharethis.chat/SKILL.md
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue