diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 8b211280b0..6adf4ff709 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2682,7 +2682,7 @@ For more help on a command: skills_parser = subparsers.add_parser( "skills", help="Search, install, configure, and manage skills", - description="Search, install, inspect, audit, configure, and manage skills from GitHub, ClawHub, and other registries." + description="Search, install, inspect, audit, configure, and manage skills from skills.sh, well-known agent skill endpoints, GitHub, ClawHub, and other registries." ) skills_subparsers = skills_parser.add_subparsers(dest="skills_action") @@ -2690,12 +2690,12 @@ For more help on a command: skills_browse.add_argument("--page", type=int, default=1, help="Page number (default: 1)") skills_browse.add_argument("--size", type=int, default=20, help="Results per page (default: 20)") skills_browse.add_argument("--source", default="all", - choices=["all", "official", "github", "clawhub", "lobehub"], + choices=["all", "official", "skills-sh", "well-known", "github", "clawhub", "lobehub"], help="Filter by source (default: all)") skills_search = skills_subparsers.add_parser("search", help="Search skill registries") skills_search.add_argument("query", help="Search query") - skills_search.add_argument("--source", default="all", choices=["all", "official", "github", "clawhub", "lobehub"]) + skills_search.add_argument("--source", default="all", choices=["all", "official", "skills-sh", "well-known", "github", "clawhub", "lobehub"]) skills_search.add_argument("--limit", type=int, default=10, help="Max results") skills_install = skills_subparsers.add_parser("install", help="Install a skill") @@ -2709,6 +2709,12 @@ For more help on a command: skills_list = skills_subparsers.add_parser("list", help="List installed skills") skills_list.add_argument("--source", default="all", choices=["all", "hub", "builtin", "local"]) + skills_check = skills_subparsers.add_parser("check", help="Check installed hub skills for updates") + skills_check.add_argument("name", nargs="?", help="Specific skill to check (default: all)") + + skills_update = skills_subparsers.add_parser("update", help="Update installed hub skills") + skills_update.add_argument("name", nargs="?", help="Specific skill to update (default: all outdated skills)") + skills_audit = skills_subparsers.add_parser("audit", help="Re-scan installed hub skills") skills_audit.add_argument("name", nargs="?", help="Specific skill to audit (default: all)") diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index e39b098a2e..60cfaf6be0 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -13,7 +13,7 @@ handler are thin wrappers that parse args and delegate. import json import shutil from pathlib import Path -from typing import Optional +from typing import Any, Dict, Optional from rich.console import Console from rich.panel import Panel @@ -76,6 +76,70 @@ def _resolve_short_name(name: str, sources, console: Console) -> str: return "" +def _format_extra_metadata_lines(extra: Dict[str, Any]) -> list[str]: + lines: list[str] = [] + if not extra: + return lines + + if extra.get("repo_url"): + lines.append(f"[bold]Repo:[/] {extra['repo_url']}") + if extra.get("detail_url"): + lines.append(f"[bold]Detail Page:[/] {extra['detail_url']}") + if extra.get("index_url"): + lines.append(f"[bold]Index:[/] {extra['index_url']}") + if extra.get("endpoint"): + lines.append(f"[bold]Endpoint:[/] {extra['endpoint']}") + if extra.get("install_command"): + lines.append(f"[bold]Install Command:[/] {extra['install_command']}") + if extra.get("installs") is not None: + lines.append(f"[bold]Installs:[/] {extra['installs']}") + if extra.get("weekly_installs"): + lines.append(f"[bold]Weekly Installs:[/] {extra['weekly_installs']}") + + security = extra.get("security_audits") + if isinstance(security, dict) and security: + ordered = ", ".join(f"{name}={status}" for name, status in sorted(security.items())) + lines.append(f"[bold]Security:[/] {ordered}") + + return lines + + +def _resolve_source_meta_and_bundle(identifier: str, sources): + """Resolve metadata and bundle for a specific identifier.""" + meta = None + bundle = None + matched_source = None + + for src in sources: + if meta is None: + try: + meta = src.inspect(identifier) + if meta: + matched_source = src + except Exception: + meta = None + try: + bundle = src.fetch(identifier) + except Exception: + bundle = None + if bundle: + matched_source = src + if meta is None: + try: + meta = src.inspect(identifier) + except Exception: + meta = None + break + + return meta, bundle, matched_source + + +def _derive_category_from_install_path(install_path: str) -> str: + path = Path(install_path) + parent = str(path.parent) + return "" if parent == "." else parent + + def do_search(query: str, source: str = "all", limit: int = 10, console: Optional[Console] = None) -> None: """Search registries and display results as a Rich table.""" @@ -136,7 +200,7 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all", # Collect results from all (or filtered) sources # Use empty query to get everything; per-source limits prevent overload _TRUST_RANK = {"builtin": 3, "trusted": 2, "community": 1} - _PER_SOURCE_LIMIT = {"official": 100, "github": 100, "clawhub": 50, + _PER_SOURCE_LIMIT = {"official": 100, "skills-sh": 100, "well-known": 25, "github": 100, "clawhub": 50, "claude-marketplace": 50, "lobehub": 50} all_results: list = [] @@ -263,11 +327,7 @@ def do_install(identifier: str, category: str = "", force: bool = False, c.print(f"\n[bold]Fetching:[/] {identifier}") - bundle = None - for src in sources: - bundle = src.fetch(identifier) - if bundle: - break + meta, bundle, _matched_source = _resolve_source_meta_and_bundle(identifier, sources) if not bundle: c.print(f"[bold red]Error:[/] Could not fetch '{identifier}' from any source.\n") @@ -288,6 +348,9 @@ def do_install(identifier: str, category: str = "", force: bool = False, c.print("Use --force to reinstall.\n") return + extra_metadata = dict(getattr(meta, "extra", {}) or {}) + extra_metadata.update(getattr(bundle, "metadata", {}) or {}) + # Quarantine the bundle q_path = quarantine_bundle(bundle) c.print(f"[dim]Quarantined to {q_path.relative_to(q_path.parent.parent.parent)}[/]") @@ -309,6 +372,11 @@ def do_install(identifier: str, category: str = "", force: bool = False, f"{len(result.findings)}_findings") return + if extra_metadata: + metadata_lines = _format_extra_metadata_lines(extra_metadata) + if metadata_lines: + c.print(Panel("\n".join(metadata_lines), title="Upstream Metadata", border_style="blue")) + # Confirm with user — show appropriate warning based on source if not force: c.print() @@ -361,23 +429,12 @@ def do_inspect(identifier: str, console: Optional[Console] = None) -> None: if not identifier: return - meta = None - for src in sources: - meta = src.inspect(identifier) - if meta: - break + meta, bundle, _matched_source = _resolve_source_meta_and_bundle(identifier, sources) if not meta: c.print(f"[bold red]Error:[/] Could not find '{identifier}' in any source.\n") return - # Also fetch full content for preview - bundle = None - for src in sources: - bundle = src.fetch(identifier) - if bundle: - break - c.print() trust_style = {"builtin": "bright_cyan", "trusted": "green", "community": "yellow"}.get(meta.trust_level, "dim") trust_label = "official" if meta.source == "official" else meta.trust_level @@ -391,6 +448,7 @@ def do_inspect(identifier: str, console: Optional[Console] = None) -> None: ] if meta.tags: info_lines.append(f"[bold]Tags:[/] {', '.join(meta.tags)}") + info_lines.extend(_format_extra_metadata_lines(meta.extra)) c.print(Panel("\n".join(info_lines), title=f"Skill: {meta.name}")) @@ -464,6 +522,49 @@ def do_list(source_filter: str = "all", console: Optional[Console] = None) -> No ) +def do_check(name: Optional[str] = None, console: Optional[Console] = None) -> None: + """Check hub-installed skills for upstream updates.""" + from tools.skills_hub import check_for_skill_updates + + c = console or _console + results = check_for_skill_updates(name=name) + if not results: + c.print("[dim]No hub-installed skills to check.[/]\n") + return + + table = Table(title="Skill Updates") + table.add_column("Name", style="bold cyan") + table.add_column("Source", style="dim") + table.add_column("Status", style="dim") + + for entry in results: + table.add_row(entry.get("name", ""), entry.get("source", ""), entry.get("status", "")) + + c.print(table) + update_count = sum(1 for entry in results if entry.get("status") == "update_available") + c.print(f"[dim]{update_count} update(s) available across {len(results)} checked skill(s)[/]\n") + + +def do_update(name: Optional[str] = None, console: Optional[Console] = None) -> None: + """Update hub-installed skills with upstream changes.""" + from tools.skills_hub import HubLockFile, check_for_skill_updates + + c = console or _console + lock = HubLockFile() + updates = [entry for entry in check_for_skill_updates(name=name) if entry.get("status") == "update_available"] + if not updates: + c.print("[dim]No updates available.[/]\n") + return + + for entry in updates: + installed = lock.get_installed(entry["name"]) + category = _derive_category_from_install_path(installed.get("install_path", "")) if installed else "" + c.print(f"[bold]Updating:[/] {entry['name']}") + do_install(entry["identifier"], category=category, force=True, console=c) + + c.print(f"[bold green]Updated {len(updates)} skill(s).[/]\n") + + def do_audit(name: Optional[str] = None, console: Optional[Console] = None) -> None: """Re-run security scan on installed hub skills.""" from tools.skills_hub import HubLockFile, SKILLS_DIR @@ -827,6 +928,10 @@ def skills_command(args) -> None: do_inspect(args.identifier) elif action == "list": do_list(source_filter=args.source) + elif action == "check": + do_check(name=getattr(args, "name", None)) + elif action == "update": + do_update(name=getattr(args, "name", None)) elif action == "audit": do_audit(name=getattr(args, "name", None)) elif action == "uninstall": @@ -853,7 +958,7 @@ def skills_command(args) -> None: return do_tap(tap_action, repo=repo) else: - _console.print("Usage: hermes skills [browse|search|install|inspect|list|audit|uninstall|publish|snapshot|tap]\n") + _console.print("Usage: hermes skills [browse|search|install|inspect|list|check|update|audit|uninstall|publish|snapshot|tap]\n") _console.print("Run 'hermes skills --help' for details.\n") @@ -872,6 +977,8 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None: /skills inspect openai/skills/skill-creator /skills list /skills list --source hub + /skills check + /skills update /skills audit /skills audit my-skill /skills uninstall my-skill @@ -920,7 +1027,7 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None: elif action == "search": if not args: - c.print("[bold red]Usage:[/] /skills search [--source github] [--limit N]\n") + c.print("[bold red]Usage:[/] /skills search [--source skills-sh|well-known|github|official] [--limit N]\n") return source = "all" limit = 10 @@ -967,6 +1074,14 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None: source_filter = args[idx + 1] do_list(source_filter=source_filter, console=c) + elif action == "check": + name = args[0] if args else None + do_check(name=name, console=c) + + elif action == "update": + name = args[0] if args else None + do_update(name=name, console=c) + elif action == "audit": name = args[0] if args else None do_audit(name=name, console=c) @@ -1029,6 +1144,8 @@ def _print_skills_help(console: Console) -> None: " [cyan]install[/] Install a skill (with security scan)\n" " [cyan]inspect[/] Preview a skill without installing\n" " [cyan]list[/] [--source hub|builtin|local] List installed skills\n" + " [cyan]check[/] [name] Check hub skills for upstream updates\n" + " [cyan]update[/] [name] Update hub skills with upstream changes\n" " [cyan]audit[/] [name] Re-scan hub skills for security\n" " [cyan]uninstall[/] Remove a hub-installed skill\n" " [cyan]publish[/] --repo Publish a skill to GitHub via PR\n" diff --git a/tests/hermes_cli/test_skills_hub.py b/tests/hermes_cli/test_skills_hub.py index b877211b95..4e3af6c7d5 100644 --- a/tests/hermes_cli/test_skills_hub.py +++ b/tests/hermes_cli/test_skills_hub.py @@ -3,7 +3,7 @@ from io import StringIO import pytest from rich.console import Console -from hermes_cli.skills_hub import do_list +from hermes_cli.skills_hub import do_check, do_list, do_update class _DummyLockFile: @@ -68,6 +68,34 @@ def _capture(source_filter: str = "all") -> str: return sink.getvalue() +def _capture_check(monkeypatch, results, name=None) -> str: + import tools.skills_hub as hub + + sink = StringIO() + console = Console(file=sink, force_terminal=False, color_system=None) + monkeypatch.setattr(hub, "check_for_skill_updates", lambda **_kwargs: results) + do_check(name=name, console=console) + return sink.getvalue() + + +def _capture_update(monkeypatch, results) -> tuple[str, list[tuple[str, str, bool]]]: + import tools.skills_hub as hub + import hermes_cli.skills_hub as cli_hub + + sink = StringIO() + console = Console(file=sink, force_terminal=False, color_system=None) + installs = [] + + monkeypatch.setattr(hub, "check_for_skill_updates", lambda **_kwargs: results) + monkeypatch.setattr(hub, "HubLockFile", lambda: type("L", (), { + "get_installed": lambda self, name: {"install_path": "category/" + name} + })()) + monkeypatch.setattr(cli_hub, "do_install", lambda identifier, category="", force=False, console=None: installs.append((identifier, category, force))) + + do_update(console=console) + return sink.getvalue(), installs + + # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- @@ -122,3 +150,30 @@ def test_do_list_filter_builtin(three_source_env): assert "builtin-skill" in output assert "hub-skill" not in output assert "local-skill" not in output + + +def test_do_check_reports_available_updates(monkeypatch): + output = _capture_check(monkeypatch, [ + {"name": "hub-skill", "source": "skills.sh", "status": "update_available"}, + {"name": "other-skill", "source": "github", "status": "up_to_date"}, + ]) + + assert "hub-skill" in output + assert "update_available" in output + assert "up_to_date" in output + + +def test_do_check_handles_no_installed_updates(monkeypatch): + output = _capture_check(monkeypatch, []) + + assert "No hub-installed skills to check" in output + + +def test_do_update_reinstalls_outdated_skills(monkeypatch): + output, installs = _capture_update(monkeypatch, [ + {"name": "hub-skill", "identifier": "skills-sh/example/repo/hub-skill", "status": "update_available"}, + {"name": "other-skill", "identifier": "github/example/other-skill", "status": "up_to_date"}, + ]) + + assert installs == [("skills-sh/example/repo/hub-skill", "category", True)] + assert "Updated 1 skill" in output diff --git a/tests/tools/test_skills_hub.py b/tests/tools/test_skills_hub.py index c907e9db16..89ed5f5e96 100644 --- a/tests/tools/test_skills_hub.py +++ b/tests/tools/test_skills_hub.py @@ -8,10 +8,15 @@ from tools.skills_hub import ( GitHubAuth, GitHubSource, LobeHubSource, + SkillsShSource, + WellKnownSkillSource, SkillMeta, SkillBundle, HubLockFile, TapsManager, + bundle_content_hash, + check_for_skill_updates, + create_source_router, unified_search, append_audit_log, _skill_meta_to_dict, @@ -93,6 +98,387 @@ class TestTrustLevelFor: assert result in ("trusted", "community") +# --------------------------------------------------------------------------- +# SkillsShSource +# --------------------------------------------------------------------------- + + +class TestSkillsShSource: + def _source(self): + auth = MagicMock(spec=GitHubAuth) + return SkillsShSource(auth=auth) + + @patch("tools.skills_hub._write_index_cache") + @patch("tools.skills_hub._read_index_cache", return_value=None) + @patch("tools.skills_hub.httpx.get") + def test_search_maps_skills_sh_results_to_prefixed_identifiers(self, mock_get, _mock_read_cache, _mock_write_cache): + mock_get.return_value = MagicMock( + status_code=200, + json=lambda: { + "skills": [ + { + "id": "vercel-labs/agent-skills/vercel-react-best-practices", + "skillId": "vercel-react-best-practices", + "name": "vercel-react-best-practices", + "installs": 207679, + "source": "vercel-labs/agent-skills", + } + ] + }, + ) + + results = self._source().search("react", limit=5) + + assert len(results) == 1 + assert results[0].source == "skills.sh" + assert results[0].identifier == "skills-sh/vercel-labs/agent-skills/vercel-react-best-practices" + assert "skills.sh" in results[0].description + assert results[0].repo == "vercel-labs/agent-skills" + assert results[0].path == "vercel-react-best-practices" + assert results[0].extra["installs"] == 207679 + + @patch("tools.skills_hub._write_index_cache") + @patch("tools.skills_hub._read_index_cache", return_value=None) + @patch("tools.skills_hub.httpx.get") + def test_empty_search_uses_featured_homepage_links(self, mock_get, _mock_read_cache, _mock_write_cache): + mock_get.return_value = MagicMock( + status_code=200, + text=''' + React + PDF + React again + ''', + ) + + results = self._source().search("", limit=10) + + assert [r.identifier for r in results] == [ + "skills-sh/vercel-labs/agent-skills/vercel-react-best-practices", + "skills-sh/anthropics/skills/pdf", + ] + assert all(r.source == "skills.sh" for r in results) + + @patch.object(GitHubSource, "fetch") + def test_fetch_delegates_to_github_source_and_relabels_bundle(self, mock_fetch): + mock_fetch.return_value = SkillBundle( + name="vercel-react-best-practices", + files={"SKILL.md": "# Test"}, + source="github", + identifier="vercel-labs/agent-skills/vercel-react-best-practices", + trust_level="community", + ) + + bundle = self._source().fetch("skills-sh/vercel-labs/agent-skills/vercel-react-best-practices") + + assert bundle is not None + assert bundle.source == "skills.sh" + assert bundle.identifier == "skills-sh/vercel-labs/agent-skills/vercel-react-best-practices" + mock_fetch.assert_called_once_with("vercel-labs/agent-skills/vercel-react-best-practices") + + @patch("tools.skills_hub._write_index_cache") + @patch("tools.skills_hub._read_index_cache", return_value=None) + @patch("tools.skills_hub.httpx.get") + @patch.object(GitHubSource, "inspect") + def test_inspect_delegates_to_github_source_and_relabels_meta(self, mock_inspect, mock_get, _mock_read_cache, _mock_write_cache): + mock_inspect.return_value = SkillMeta( + name="vercel-react-best-practices", + description="React rules", + source="github", + identifier="vercel-labs/agent-skills/vercel-react-best-practices", + trust_level="community", + repo="vercel-labs/agent-skills", + path="vercel-react-best-practices", + ) + mock_get.return_value = MagicMock( + status_code=200, + text=''' +

vercel-react-best-practices

+ $ npx skills add https://github.com/vercel-labs/agent-skills --skill vercel-react-best-practices +

Vercel React Best Practices

React rules.

+ Socket Pass + Snyk Pass + ''', + ) + + meta = self._source().inspect("skills-sh/vercel-labs/agent-skills/vercel-react-best-practices") + + assert meta is not None + assert meta.source == "skills.sh" + assert meta.identifier == "skills-sh/vercel-labs/agent-skills/vercel-react-best-practices" + assert meta.extra["install_command"].endswith("--skill vercel-react-best-practices") + assert meta.extra["security_audits"]["socket"] == "Pass" + mock_inspect.assert_called_once_with("vercel-labs/agent-skills/vercel-react-best-practices") + + @patch.object(GitHubSource, "_list_skills_in_repo") + @patch.object(GitHubSource, "inspect") + def test_inspect_falls_back_to_repo_skill_catalog_when_slug_differs(self, mock_inspect, mock_list_skills): + resolved = SkillMeta( + name="vercel-react-best-practices", + description="React rules", + source="github", + identifier="vercel-labs/agent-skills/skills/react-best-practices", + trust_level="community", + repo="vercel-labs/agent-skills", + path="skills/react-best-practices", + ) + mock_inspect.side_effect = lambda identifier: resolved if identifier == resolved.identifier else None + mock_list_skills.return_value = [resolved] + + meta = self._source().inspect("skills-sh/vercel-labs/agent-skills/vercel-react-best-practices") + + assert meta is not None + assert meta.identifier == "skills-sh/vercel-labs/agent-skills/vercel-react-best-practices" + assert mock_list_skills.called + + @patch("tools.skills_hub._write_index_cache") + @patch("tools.skills_hub._read_index_cache", return_value=None) + @patch("tools.skills_hub.httpx.get") + @patch.object(GitHubSource, "_list_skills_in_repo") + @patch.object(GitHubSource, "inspect") + def test_inspect_uses_detail_page_to_resolve_alias_skill(self, mock_inspect, mock_list_skills, mock_get, _mock_read_cache, _mock_write_cache): + resolved = SkillMeta( + name="react", + description="React renderer", + source="github", + identifier="vercel-labs/json-render/skills/react", + trust_level="community", + repo="vercel-labs/json-render", + path="skills/react", + ) + mock_inspect.side_effect = lambda identifier: resolved if identifier == resolved.identifier else None + mock_list_skills.return_value = [resolved] + mock_get.return_value = MagicMock( + status_code=200, + text=''' +

json-render-react

+ $ npx skills add https://github.com/vercel-labs/json-render --skill json-render-react +

@json-render/react

React renderer.

+ ''', + ) + + meta = self._source().inspect("skills-sh/vercel-labs/json-render/json-render-react") + + assert meta is not None + assert meta.identifier == "skills-sh/vercel-labs/json-render/json-render-react" + assert meta.path == "skills/react" + assert mock_get.called + + @patch("tools.skills_hub._write_index_cache") + @patch("tools.skills_hub._read_index_cache", return_value=None) + @patch("tools.skills_hub.httpx.get") + @patch.object(GitHubSource, "_list_skills_in_repo") + @patch.object(GitHubSource, "fetch") + def test_fetch_uses_detail_page_to_resolve_alias_skill(self, mock_fetch, mock_list_skills, mock_get, _mock_read_cache, _mock_write_cache): + resolved_meta = SkillMeta( + name="react", + description="React renderer", + source="github", + identifier="vercel-labs/json-render/skills/react", + trust_level="community", + repo="vercel-labs/json-render", + path="skills/react", + ) + resolved_bundle = SkillBundle( + name="react", + files={"SKILL.md": "# react"}, + source="github", + identifier="vercel-labs/json-render/skills/react", + trust_level="community", + ) + mock_fetch.side_effect = lambda identifier: resolved_bundle if identifier == resolved_bundle.identifier else None + mock_list_skills.return_value = [resolved_meta] + mock_get.return_value = MagicMock( + status_code=200, + text=''' +

json-render-react

+ $ npx skills add https://github.com/vercel-labs/json-render --skill json-render-react +

@json-render/react

React renderer.

+ ''', + ) + + bundle = self._source().fetch("skills-sh/vercel-labs/json-render/json-render-react") + + assert bundle is not None + assert bundle.identifier == "skills-sh/vercel-labs/json-render/json-render-react" + assert bundle.files["SKILL.md"] == "# react" + assert mock_get.called + + +class TestWellKnownSkillSource: + def _source(self): + return WellKnownSkillSource() + + @patch("tools.skills_hub._write_index_cache") + @patch("tools.skills_hub._read_index_cache", return_value=None) + @patch("tools.skills_hub.httpx.get") + def test_search_reads_index_from_well_known_url(self, mock_get, _mock_read_cache, _mock_write_cache): + mock_get.return_value = MagicMock( + status_code=200, + json=lambda: { + "skills": [ + {"name": "git-workflow", "description": "Git rules", "files": ["SKILL.md"]}, + {"name": "code-review", "description": "Review code", "files": ["SKILL.md", "references/checklist.md"]}, + ] + }, + ) + + results = self._source().search("https://example.com/.well-known/skills/index.json", limit=10) + + assert [r.identifier for r in results] == [ + "well-known:https://example.com/.well-known/skills/git-workflow", + "well-known:https://example.com/.well-known/skills/code-review", + ] + assert all(r.source == "well-known" for r in results) + + @patch("tools.skills_hub._write_index_cache") + @patch("tools.skills_hub._read_index_cache", return_value=None) + @patch("tools.skills_hub.httpx.get") + def test_search_accepts_domain_root_and_resolves_index(self, mock_get, _mock_read_cache, _mock_write_cache): + mock_get.return_value = MagicMock( + status_code=200, + json=lambda: {"skills": [{"name": "git-workflow", "description": "Git rules", "files": ["SKILL.md"]}]}, + ) + + results = self._source().search("https://example.com", limit=10) + + assert len(results) == 1 + called_url = mock_get.call_args.args[0] + assert called_url == "https://example.com/.well-known/skills/index.json" + + @patch("tools.skills_hub._write_index_cache") + @patch("tools.skills_hub._read_index_cache", return_value=None) + @patch("tools.skills_hub.httpx.get") + def test_inspect_fetches_skill_md_from_well_known_endpoint(self, mock_get, _mock_read_cache, _mock_write_cache): + def fake_get(url, *args, **kwargs): + if url.endswith("/index.json"): + return MagicMock(status_code=200, json=lambda: { + "skills": [{"name": "git-workflow", "description": "Git rules", "files": ["SKILL.md"]}] + }) + if url.endswith("/git-workflow/SKILL.md"): + return MagicMock(status_code=200, text="---\nname: git-workflow\ndescription: Git rules\n---\n\n# Git Workflow\n") + raise AssertionError(url) + + mock_get.side_effect = fake_get + + meta = self._source().inspect("well-known:https://example.com/.well-known/skills/git-workflow") + + assert meta is not None + assert meta.name == "git-workflow" + assert meta.source == "well-known" + assert meta.extra["base_url"] == "https://example.com/.well-known/skills" + + @patch("tools.skills_hub._write_index_cache") + @patch("tools.skills_hub._read_index_cache", return_value=None) + @patch("tools.skills_hub.httpx.get") + def test_fetch_downloads_skill_files_from_well_known_endpoint(self, mock_get, _mock_read_cache, _mock_write_cache): + def fake_get(url, *args, **kwargs): + if url.endswith("/index.json"): + return MagicMock(status_code=200, json=lambda: { + "skills": [{ + "name": "code-review", + "description": "Review code", + "files": ["SKILL.md", "references/checklist.md"], + }] + }) + if url.endswith("/code-review/SKILL.md"): + return MagicMock(status_code=200, text="# Code Review\n") + if url.endswith("/code-review/references/checklist.md"): + return MagicMock(status_code=200, text="- [ ] security\n") + raise AssertionError(url) + + mock_get.side_effect = fake_get + + bundle = self._source().fetch("well-known:https://example.com/.well-known/skills/code-review") + + assert bundle is not None + assert bundle.source == "well-known" + assert bundle.files["SKILL.md"] == "# Code Review\n" + assert bundle.files["references/checklist.md"] == "- [ ] security\n" + + +class TestCheckForSkillUpdates: + def test_bundle_content_hash_matches_installed_content_hash(self, tmp_path): + from tools.skills_guard import content_hash + + bundle = SkillBundle( + name="demo-skill", + files={ + "SKILL.md": "same content", + "references/checklist.md": "- [ ] security\n", + }, + source="github", + identifier="owner/repo/demo-skill", + trust_level="community", + ) + skill_dir = tmp_path / "demo-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("same content") + (skill_dir / "references").mkdir() + (skill_dir / "references" / "checklist.md").write_text("- [ ] security\n") + + assert bundle_content_hash(bundle) == content_hash(skill_dir) + + def test_reports_update_when_remote_hash_differs(self): + lock = MagicMock() + lock.list_installed.return_value = [{ + "name": "demo-skill", + "source": "github", + "identifier": "owner/repo/demo-skill", + "content_hash": "oldhash", + "install_path": "demo-skill", + }] + + source = MagicMock() + source.source_id.return_value = "github" + source.fetch.return_value = SkillBundle( + name="demo-skill", + files={"SKILL.md": "new content"}, + source="github", + identifier="owner/repo/demo-skill", + trust_level="community", + ) + + results = check_for_skill_updates(lock=lock, sources=[source]) + + assert len(results) == 1 + assert results[0]["name"] == "demo-skill" + assert results[0]["status"] == "update_available" + + def test_reports_up_to_date_when_hash_matches(self): + bundle = SkillBundle( + name="demo-skill", + files={"SKILL.md": "same content"}, + source="github", + identifier="owner/repo/demo-skill", + trust_level="community", + ) + lock = MagicMock() + lock.list_installed.return_value = [{ + "name": "demo-skill", + "source": "github", + "identifier": "owner/repo/demo-skill", + "content_hash": bundle_content_hash(bundle), + "install_path": "demo-skill", + }] + source = MagicMock() + source.source_id.return_value = "github" + source.fetch.return_value = bundle + + results = check_for_skill_updates(lock=lock, sources=[source]) + + assert results[0]["status"] == "up_to_date" + + +class TestCreateSourceRouter: + def test_includes_skills_sh_source(self): + sources = create_source_router(auth=MagicMock(spec=GitHubAuth)) + assert any(isinstance(src, SkillsShSource) for src in sources) + + def test_includes_well_known_source(self): + sources = create_source_router(auth=MagicMock(spec=GitHubAuth)) + assert any(isinstance(src, WellKnownSkillSource) for src in sources) + + # --------------------------------------------------------------------------- # HubLockFile # --------------------------------------------------------------------------- diff --git a/tools/skills_hub.py b/tools/skills_hub.py index eab8800238..94845fe92d 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -26,6 +26,7 @@ from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlparse, urlunparse import httpx import yaml @@ -69,6 +70,7 @@ class SkillMeta: repo: Optional[str] = None path: Optional[str] = None tags: List[str] = field(default_factory=list) + extra: Dict[str, Any] = field(default_factory=dict) @dataclass @@ -79,6 +81,7 @@ class SkillBundle: source: str identifier: str trust_level: str + metadata: Dict[str, Any] = field(default_factory=dict) # --------------------------------------------------------------------------- @@ -497,6 +500,643 @@ class GitHubSource(SkillSource): return {} +# --------------------------------------------------------------------------- +# Well-known Agent Skills endpoint source adapter +# --------------------------------------------------------------------------- + +class WellKnownSkillSource(SkillSource): + """Read skills from a domain exposing /.well-known/skills/index.json.""" + + BASE_PATH = "/.well-known/skills" + + def source_id(self) -> str: + return "well-known" + + def trust_level_for(self, identifier: str) -> str: + return "community" + + def search(self, query: str, limit: int = 10) -> List[SkillMeta]: + index_url = self._query_to_index_url(query) + if not index_url: + return [] + + parsed = self._parse_index(index_url) + if not parsed: + return [] + + results: List[SkillMeta] = [] + for entry in parsed["skills"][:limit]: + name = entry.get("name") + if not isinstance(name, str) or not name: + continue + description = entry.get("description", "") + files = entry.get("files", ["SKILL.md"]) + results.append(SkillMeta( + name=name, + description=str(description), + source="well-known", + identifier=self._wrap_identifier(parsed["base_url"], name), + trust_level="community", + path=name, + extra={ + "index_url": parsed["index_url"], + "base_url": parsed["base_url"], + "files": files if isinstance(files, list) else ["SKILL.md"], + }, + )) + return results + + def inspect(self, identifier: str) -> Optional[SkillMeta]: + parsed = self._parse_identifier(identifier) + if not parsed: + return None + + entry = self._index_entry(parsed["index_url"], parsed["skill_name"]) + if not entry: + return None + + skill_md = self._fetch_text(f"{parsed['skill_url']}/SKILL.md") + if skill_md is None: + return None + + fm = GitHubSource._parse_frontmatter_quick(skill_md) + description = str(fm.get("description") or entry.get("description") or "") + name = str(fm.get("name") or parsed["skill_name"]) + return SkillMeta( + name=name, + description=description, + source="well-known", + identifier=self._wrap_identifier(parsed["base_url"], parsed["skill_name"]), + trust_level="community", + path=parsed["skill_name"], + extra={ + "index_url": parsed["index_url"], + "base_url": parsed["base_url"], + "files": entry.get("files", ["SKILL.md"]), + "endpoint": parsed["skill_url"], + }, + ) + + def fetch(self, identifier: str) -> Optional[SkillBundle]: + parsed = self._parse_identifier(identifier) + if not parsed: + return None + + entry = self._index_entry(parsed["index_url"], parsed["skill_name"]) + if not entry: + return None + + files = entry.get("files", ["SKILL.md"]) + if not isinstance(files, list) or not files: + files = ["SKILL.md"] + + downloaded: Dict[str, str] = {} + for rel_path in files: + if not isinstance(rel_path, str) or not rel_path: + continue + text = self._fetch_text(f"{parsed['skill_url']}/{rel_path}") + if text is None: + return None + downloaded[rel_path] = text + + if "SKILL.md" not in downloaded: + return None + + return SkillBundle( + name=parsed["skill_name"], + files=downloaded, + source="well-known", + identifier=self._wrap_identifier(parsed["base_url"], parsed["skill_name"]), + trust_level="community", + metadata={ + "index_url": parsed["index_url"], + "base_url": parsed["base_url"], + "endpoint": parsed["skill_url"], + "files": files, + }, + ) + + def _query_to_index_url(self, query: str) -> Optional[str]: + query = query.strip() + if not query.startswith(("http://", "https://")): + return None + if query.endswith("/index.json"): + return query + if f"{self.BASE_PATH}/" in query: + base_url = query.split(f"{self.BASE_PATH}/", 1)[0] + self.BASE_PATH + return f"{base_url}/index.json" + return query.rstrip("/") + f"{self.BASE_PATH}/index.json" + + def _parse_identifier(self, identifier: str) -> Optional[dict]: + raw = identifier[len("well-known:"):] if identifier.startswith("well-known:") else identifier + if not raw.startswith(("http://", "https://")): + return None + + parsed_url = urlparse(raw) + clean_url = urlunparse(parsed_url._replace(fragment="")) + fragment = parsed_url.fragment + + if clean_url.endswith("/index.json"): + if not fragment: + return None + base_url = clean_url[:-len("/index.json")] + skill_name = fragment + skill_url = f"{base_url}/{skill_name}" + return { + "index_url": clean_url, + "base_url": base_url, + "skill_name": skill_name, + "skill_url": skill_url, + } + + if clean_url.endswith("/SKILL.md"): + skill_url = clean_url[:-len("/SKILL.md")] + else: + skill_url = clean_url.rstrip("/") + + if f"{self.BASE_PATH}/" not in skill_url: + return None + + base_url, skill_name = skill_url.rsplit("/", 1) + return { + "index_url": f"{base_url}/index.json", + "base_url": base_url, + "skill_name": skill_name, + "skill_url": skill_url, + } + + def _parse_index(self, index_url: str) -> Optional[dict]: + cache_key = f"well_known_index_{hashlib.md5(index_url.encode()).hexdigest()}" + cached = _read_index_cache(cache_key) + if isinstance(cached, dict) and isinstance(cached.get("skills"), list): + return cached + + try: + resp = httpx.get(index_url, timeout=20, follow_redirects=True) + if resp.status_code != 200: + return None + data = resp.json() + except (httpx.HTTPError, json.JSONDecodeError): + return None + + skills = data.get("skills", []) if isinstance(data, dict) else [] + if not isinstance(skills, list): + return None + + parsed = { + "index_url": index_url, + "base_url": index_url[:-len("/index.json")], + "skills": skills, + } + _write_index_cache(cache_key, parsed) + return parsed + + def _index_entry(self, index_url: str, skill_name: str) -> Optional[dict]: + parsed = self._parse_index(index_url) + if not parsed: + return None + for entry in parsed["skills"]: + if isinstance(entry, dict) and entry.get("name") == skill_name: + return entry + return None + + @staticmethod + def _fetch_text(url: str) -> Optional[str]: + try: + resp = httpx.get(url, timeout=20, follow_redirects=True) + if resp.status_code == 200: + return resp.text + except httpx.HTTPError: + return None + return None + + @staticmethod + def _wrap_identifier(base_url: str, skill_name: str) -> str: + return f"well-known:{base_url.rstrip('/')}/{skill_name}" + + +# --------------------------------------------------------------------------- +# skills.sh source adapter +# --------------------------------------------------------------------------- + +class SkillsShSource(SkillSource): + """Discover skills via skills.sh and fetch content from the underlying GitHub repo.""" + + BASE_URL = "https://skills.sh" + SEARCH_URL = f"{BASE_URL}/api/search" + _SKILL_LINK_RE = re.compile(r'href=["\']/(?P(?!agents/|_next/|api/)[^"\'/]+/[^"\'/]+/[^"\'/]+)["\']') + _INSTALL_CMD_RE = re.compile( + r'npx\s+skills\s+add\s+(?Phttps?://github\.com/[^\s<]+|[^\s<]+)' + r'(?:\s+--skill\s+(?P[^\s<]+))?', + re.IGNORECASE, + ) + _PAGE_H1_RE = re.compile(r']*>(?P.*?)</h1>', re.IGNORECASE | re.DOTALL) + _PROSE_H1_RE = re.compile( + r'<div[^>]*class=["\'][^"\']*prose[^"\']*["\'][^>]*>.*?<h1[^>]*>(?P<title>.*?)</h1>', + re.IGNORECASE | re.DOTALL, + ) + _PROSE_P_RE = re.compile( + r'<div[^>]*class=["\'][^"\']*prose[^"\']*["\'][^>]*>.*?<p[^>]*>(?P<body>.*?)</p>', + re.IGNORECASE | re.DOTALL, + ) + _WEEKLY_INSTALLS_RE = re.compile(r'Weekly Installs.*?children\\":\\"(?P<count>[0-9.,Kk]+)\\"', re.DOTALL) + + def __init__(self, auth: GitHubAuth): + self.auth = auth + self.github = GitHubSource(auth=auth) + + def source_id(self) -> str: + return "skills-sh" + + def trust_level_for(self, identifier: str) -> str: + return self.github.trust_level_for(self._normalize_identifier(identifier)) + + def search(self, query: str, limit: int = 10) -> List[SkillMeta]: + if not query.strip(): + return self._featured_skills(limit) + + cache_key = f"skills_sh_search_{hashlib.md5(f'{query}|{limit}'.encode()).hexdigest()}" + cached = _read_index_cache(cache_key) + if cached is not None: + return [SkillMeta(**item) for item in cached][:limit] + + try: + resp = httpx.get( + self.SEARCH_URL, + params={"q": query, "limit": limit}, + timeout=20, + ) + if resp.status_code != 200: + return [] + data = resp.json() + except (httpx.HTTPError, json.JSONDecodeError): + return [] + + items = data.get("skills", []) if isinstance(data, dict) else [] + if not isinstance(items, list): + return [] + + results: List[SkillMeta] = [] + for item in items[:limit]: + meta = self._meta_from_search_item(item) + if meta: + results.append(meta) + + _write_index_cache(cache_key, [_skill_meta_to_dict(item) for item in results]) + return results + + def fetch(self, identifier: str) -> Optional[SkillBundle]: + canonical = self._normalize_identifier(identifier) + detail = self._fetch_detail_page(canonical) + for candidate in self._candidate_identifiers(canonical): + bundle = self.github.fetch(candidate) + if bundle: + bundle.source = "skills.sh" + bundle.identifier = self._wrap_identifier(canonical) + bundle.metadata.update(self._detail_to_metadata(canonical, detail)) + return bundle + + resolved = self._discover_identifier(canonical, detail=detail) + if resolved: + bundle = self.github.fetch(resolved) + if bundle: + bundle.source = "skills.sh" + bundle.identifier = self._wrap_identifier(canonical) + bundle.metadata.update(self._detail_to_metadata(canonical, detail)) + return bundle + return None + + def inspect(self, identifier: str) -> Optional[SkillMeta]: + canonical = self._normalize_identifier(identifier) + detail: Optional[dict] = None + for candidate in self._candidate_identifiers(canonical): + meta = self.github.inspect(candidate) + if meta: + detail = self._fetch_detail_page(canonical) + return self._finalize_inspect_meta(meta, canonical, detail) + + detail = self._fetch_detail_page(canonical) + resolved = self._discover_identifier(canonical, detail=detail) + if resolved: + meta = self.github.inspect(resolved) + if meta: + return self._finalize_inspect_meta(meta, canonical, detail) + return None + + def _featured_skills(self, limit: int) -> List[SkillMeta]: + cache_key = "skills_sh_featured" + cached = _read_index_cache(cache_key) + if cached is not None: + return [SkillMeta(**item) for item in cached][:limit] + + try: + resp = httpx.get(self.BASE_URL, timeout=20) + if resp.status_code != 200: + return [] + except httpx.HTTPError: + return [] + + seen: set[str] = set() + results: List[SkillMeta] = [] + for match in self._SKILL_LINK_RE.finditer(resp.text): + canonical = match.group("id") + if canonical in seen: + continue + seen.add(canonical) + parts = canonical.split("/", 2) + if len(parts) < 3: + continue + repo = f"{parts[0]}/{parts[1]}" + skill_path = parts[2] + results.append(SkillMeta( + name=skill_path.split("/")[-1], + description=f"Featured on skills.sh from {repo}", + source="skills.sh", + identifier=self._wrap_identifier(canonical), + trust_level=self.github.trust_level_for(canonical), + repo=repo, + path=skill_path, + )) + if len(results) >= limit: + break + + _write_index_cache(cache_key, [_skill_meta_to_dict(item) for item in results]) + return results + + def _meta_from_search_item(self, item: dict) -> Optional[SkillMeta]: + if not isinstance(item, dict): + return None + + canonical = item.get("id") + repo = item.get("source") + skill_path = item.get("skillId") + if not isinstance(canonical, str) or canonical.count("/") < 2: + if not (isinstance(repo, str) and isinstance(skill_path, str)): + return None + canonical = f"{repo}/{skill_path}" + + parts = canonical.split("/", 2) + if len(parts) < 3: + return None + + repo = f"{parts[0]}/{parts[1]}" + skill_path = parts[2] + installs = item.get("installs") + installs_label = f" · {int(installs):,} installs" if isinstance(installs, int) else "" + + return SkillMeta( + name=str(item.get("name") or skill_path.split("/")[-1]), + description=f"Indexed by skills.sh from {repo}{installs_label}", + source="skills.sh", + identifier=self._wrap_identifier(canonical), + trust_level=self.github.trust_level_for(canonical), + repo=repo, + path=skill_path, + extra={ + "installs": installs, + "detail_url": f"{self.BASE_URL}/{canonical}", + "repo_url": f"https://github.com/{repo}", + }, + ) + + def _fetch_detail_page(self, identifier: str) -> Optional[dict]: + cache_key = f"skills_sh_detail_{hashlib.md5(identifier.encode()).hexdigest()}" + cached = _read_index_cache(cache_key) + if isinstance(cached, dict): + return cached + + try: + resp = httpx.get(f"{self.BASE_URL}/{identifier}", timeout=20) + if resp.status_code != 200: + return None + except httpx.HTTPError: + return None + + detail = self._parse_detail_page(identifier, resp.text) + if detail: + _write_index_cache(cache_key, detail) + return detail + + def _parse_detail_page(self, identifier: str, html: str) -> Optional[dict]: + parts = identifier.split("/", 2) + if len(parts) < 3: + return None + + default_repo = f"{parts[0]}/{parts[1]}" + skill_token = parts[2] + repo = default_repo + install_skill = skill_token + + install_command = None + install_match = self._INSTALL_CMD_RE.search(html) + if install_match: + install_command = install_match.group(0).strip() + repo_value = (install_match.group("repo") or "").strip() + install_skill = (install_match.group("skill") or install_skill).strip() + repo = self._extract_repo_slug(repo_value) or repo + + page_title = self._extract_first_match(self._PAGE_H1_RE, html) + body_title = self._extract_first_match(self._PROSE_H1_RE, html) + body_summary = self._extract_first_match(self._PROSE_P_RE, html) + weekly_installs = self._extract_weekly_installs(html) + security_audits = self._extract_security_audits(html, identifier) + + return { + "repo": repo, + "install_skill": install_skill, + "page_title": page_title, + "body_title": body_title, + "body_summary": body_summary, + "weekly_installs": weekly_installs, + "install_command": install_command, + "repo_url": f"https://github.com/{repo}", + "detail_url": f"{self.BASE_URL}/{identifier}", + "security_audits": security_audits, + } + + def _discover_identifier(self, identifier: str, detail: Optional[dict] = None) -> Optional[str]: + parts = identifier.split("/", 2) + if len(parts) < 3: + return None + + default_repo = f"{parts[0]}/{parts[1]}" + repo = detail.get("repo", default_repo) if isinstance(detail, dict) else default_repo + skill_token = parts[2] + tokens = [skill_token] + if isinstance(detail, dict): + tokens.extend([ + detail.get("install_skill", ""), + detail.get("page_title", ""), + detail.get("body_title", ""), + ]) + + for base_path in ("skills/", ".agents/skills/", ".claude/skills/"): + try: + skills = self.github._list_skills_in_repo(repo, base_path) + except Exception: + continue + for meta in skills: + if self._matches_skill_tokens(meta, tokens): + return meta.identifier + return None + + def _finalize_inspect_meta(self, meta: SkillMeta, canonical: str, detail: Optional[dict]) -> SkillMeta: + meta.source = "skills.sh" + meta.identifier = self._wrap_identifier(canonical) + meta.trust_level = self.trust_level_for(canonical) + merged_extra = dict(meta.extra) + merged_extra.update(self._detail_to_metadata(canonical, detail)) + meta.extra = merged_extra + + if isinstance(detail, dict): + body_summary = detail.get("body_summary") + weekly_installs = detail.get("weekly_installs") + if body_summary: + meta.description = body_summary + elif meta.description and weekly_installs: + meta.description = f"{meta.description} · {weekly_installs} weekly installs on skills.sh" + return meta + + @classmethod + def _matches_skill_tokens(cls, meta: SkillMeta, skill_tokens: List[str]) -> bool: + candidates = set() + candidates.update(cls._token_variants(meta.name)) + candidates.update(cls._token_variants(meta.path)) + candidates.update(cls._token_variants(meta.identifier.split("/", 2)[-1] if meta.identifier else None)) + + for token in skill_tokens: + variants = cls._token_variants(token) + if variants & candidates: + return True + return False + + @staticmethod + def _token_variants(value: Optional[str]) -> set[str]: + if not value: + return set() + + plain = SkillsShSource._strip_html(str(value)).strip().strip("/").lower() + if not plain: + return set() + + base = plain.split("/")[-1] + sanitized = re.sub(r'[^a-z0-9/_-]+', '-', plain).strip('-') + sanitized_base = sanitized.split("/")[-1] if sanitized else "" + slash_tail = plain.split("/")[-1] + slash_tail_clean = slash_tail.lstrip('@') + slash_tail_clean = slash_tail_clean.split('/')[-1] + + variants = { + plain, + plain.replace("_", "-"), + plain.replace("/", "-"), + base, + base.replace("_", "-"), + base.replace("/", "-"), + sanitized, + sanitized.replace("/", "-") if sanitized else "", + sanitized_base, + slash_tail_clean, + slash_tail_clean.replace("_", "-"), + } + return {v for v in variants if v} + + @staticmethod + def _extract_repo_slug(repo_value: str) -> Optional[str]: + repo_value = repo_value.strip() + if repo_value.startswith("https://github.com/"): + repo_value = repo_value[len("https://github.com/"):] + repo_value = repo_value.strip("/") + parts = repo_value.split("/") + if len(parts) >= 2: + return f"{parts[0]}/{parts[1]}" + return None + + @staticmethod + def _extract_first_match(pattern: re.Pattern, text: str) -> Optional[str]: + match = pattern.search(text) + if not match: + return None + value = next((group for group in match.groups() if group), None) + if value is None: + return None + return SkillsShSource._strip_html(value).strip() or None + + def _detail_to_metadata(self, canonical: str, detail: Optional[dict]) -> Dict[str, Any]: + parts = canonical.split("/", 2) + repo = f"{parts[0]}/{parts[1]}" if len(parts) >= 2 else "" + metadata = { + "detail_url": f"{self.BASE_URL}/{canonical}", + } + if repo: + metadata["repo_url"] = f"https://github.com/{repo}" + if isinstance(detail, dict): + for key in ("weekly_installs", "install_command", "repo_url", "detail_url", "security_audits"): + value = detail.get(key) + if value: + metadata[key] = value + return metadata + + @staticmethod + def _extract_weekly_installs(html: str) -> Optional[str]: + match = SkillsShSource._WEEKLY_INSTALLS_RE.search(html) + if not match: + return None + return match.group("count") + + @staticmethod + def _extract_security_audits(html: str, identifier: str) -> Dict[str, str]: + audits: Dict[str, str] = {} + for audit in ("agent-trust-hub", "socket", "snyk"): + idx = html.find(f"/security/{audit}") + if idx == -1: + continue + window = html[idx:idx + 500] + match = re.search(r'(Pass|Warn|Fail)', window, re.IGNORECASE) + if match: + audits[audit] = match.group(1).title() + return audits + + @staticmethod + def _strip_html(value: str) -> str: + return re.sub(r'<[^>]+>', '', value) + + @staticmethod + def _normalize_identifier(identifier: str) -> str: + if identifier.startswith("skills-sh/"): + return identifier[len("skills-sh/"):] + if identifier.startswith("skills.sh/"): + return identifier[len("skills.sh/"):] + return identifier + + @staticmethod + def _candidate_identifiers(identifier: str) -> List[str]: + parts = identifier.split("/", 2) + if len(parts) < 3: + return [identifier] + + repo = f"{parts[0]}/{parts[1]}" + skill_path = parts[2].lstrip("/") + candidates = [ + f"{repo}/{skill_path}", + f"{repo}/skills/{skill_path}", + f"{repo}/.agents/skills/{skill_path}", + f"{repo}/.claude/skills/{skill_path}", + ] + + seen = set() + deduped: List[str] = [] + for candidate in candidates: + if candidate not in seen: + seen.add(candidate) + deduped.append(candidate) + return deduped + + @staticmethod + def _wrap_identifier(identifier: str) -> str: + return f"skills-sh/{identifier}" + + # --------------------------------------------------------------------------- # ClawHub source adapter # --------------------------------------------------------------------------- @@ -1213,6 +1853,7 @@ def _skill_meta_to_dict(meta: SkillMeta) -> dict: "repo": meta.repo, "path": meta.path, "tags": meta.tags, + "extra": meta.extra, } @@ -1248,6 +1889,7 @@ class HubLockFile: skill_hash: str, install_path: str, files: List[str], + metadata: Optional[Dict[str, Any]] = None, ) -> None: data = self.load() data["installed"][name] = { @@ -1258,6 +1900,7 @@ class HubLockFile: "content_hash": skill_hash, "install_path": install_path, "files": files, + "metadata": metadata or {}, "installed_at": datetime.now(timezone.utc).isoformat(), "updated_at": datetime.now(timezone.utc).isoformat(), } @@ -1412,6 +2055,7 @@ def install_from_quarantine( skill_hash=content_hash(install_dir), install_path=str(install_dir.relative_to(SKILLS_DIR)), files=list(bundle.files.keys()), + metadata=bundle.metadata, ) append_audit_log( @@ -1440,6 +2084,78 @@ def uninstall_skill(skill_name: str) -> Tuple[bool, str]: return True, f"Uninstalled '{skill_name}' from {entry['install_path']}" +def bundle_content_hash(bundle: SkillBundle) -> str: + """Compute a deterministic hash for an in-memory skill bundle.""" + h = hashlib.sha256() + for rel_path in sorted(bundle.files): + h.update(bundle.files[rel_path].encode("utf-8")) + return f"sha256:{h.hexdigest()[:16]}" + + +def _source_matches(source: SkillSource, source_name: str) -> bool: + aliases = { + "skills.sh": "skills-sh", + } + normalized = aliases.get(source_name, source_name) + return source.source_id() == normalized + + +def check_for_skill_updates( + name: Optional[str] = None, + *, + lock: Optional[HubLockFile] = None, + sources: Optional[List[SkillSource]] = None, + auth: Optional[GitHubAuth] = None, +) -> List[dict]: + """Check installed hub skills for upstream changes.""" + lock = lock or HubLockFile() + installed = lock.list_installed() + if name: + installed = [entry for entry in installed if entry.get("name") == name] + + if sources is None: + sources = create_source_router(auth=auth) + + results: List[dict] = [] + for entry in installed: + identifier = entry.get("identifier", "") + source_name = entry.get("source", "") + candidate_sources = [src for src in sources if _source_matches(src, source_name)] or sources + + bundle = None + for src in candidate_sources: + try: + bundle = src.fetch(identifier) + except Exception: + bundle = None + if bundle: + break + + if not bundle: + results.append({ + "name": entry.get("name", ""), + "identifier": identifier, + "source": source_name, + "status": "unavailable", + }) + continue + + current_hash = entry.get("content_hash", "") + latest_hash = bundle_content_hash(bundle) + status = "up_to_date" if current_hash == latest_hash else "update_available" + results.append({ + "name": entry.get("name", ""), + "identifier": identifier, + "source": source_name, + "status": status, + "current_hash": current_hash, + "latest_hash": latest_hash, + "bundle": bundle, + }) + + return results + + def create_source_router(auth: Optional[GitHubAuth] = None) -> List[SkillSource]: """ Create all configured source adapters. @@ -1453,6 +2169,8 @@ def create_source_router(auth: Optional[GitHubAuth] = None) -> List[SkillSource] sources: List[SkillSource] = [ OptionalSkillSource(), # Official optional skills (highest priority) + SkillsShSource(auth=auth), + WellKnownSkillSource(), GitHubSource(auth=auth, extra_taps=extra_taps), ClawHubSource(), ClaudeMarketplaceSource(auth=auth), diff --git a/website/docs/developer-guide/creating-skills.md b/website/docs/developer-guide/creating-skills.md index ccec47c266..d3f0aeb57a 100644 --- a/website/docs/developer-guide/creating-skills.md +++ b/website/docs/developer-guide/creating-skills.md @@ -173,4 +173,11 @@ Trust levels: - `builtin` — ships with Hermes (always trusted) - `official` — from `optional-skills/` in the repo (builtin trust, no third-party warning) - `trusted` — from openai/skills, anthropics/skills -- `community` — any findings = blocked unless `--force` +- `community` — non-dangerous findings can be overridden with `--force`; `dangerous` verdicts remain blocked + +Hermes can now consume third-party skills from multiple external discovery models: +- direct GitHub identifiers (for example `openai/skills/k8s`) +- `skills.sh` identifiers (for example `skills-sh/vercel-labs/json-render/json-render-react`) +- well-known endpoints served from `/.well-known/skills/index.json` + +If you want your skills to be discoverable without a GitHub-specific installer, consider serving them from a well-known endpoint in addition to publishing them in a repo or marketplace. diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index 68d41ab349..e743baf6ad 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -141,10 +141,18 @@ The agent will set up a cron job that runs automatically via the gateway. ```bash hermes skills search kubernetes +hermes skills search react --source skills-sh +hermes skills search https://mintlify.com/docs --source well-known hermes skills install openai/skills/k8s hermes skills install official/security/1password +hermes skills install skills-sh/vercel-labs/json-render/json-render-react --force ``` +Tips: +- Use `--source skills-sh` to search the public `skills.sh` directory. +- Use `--source well-known` with a docs/site URL to discover skills from `/.well-known/skills/index.json`. +- Use `--force` only after reviewing a third-party skill. It can override non-dangerous policy blocks, but not a `dangerous` scan verdict. + Or use the `/skills` slash command inside chat. ### Use Hermes inside an editor via ACP diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 71a76b0715..1d68697432 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -246,6 +246,8 @@ Subcommands: | `install` | Install a skill. | | `inspect` | Preview a skill without installing it. | | `list` | List installed skills. | +| `check` | Check installed hub skills for upstream updates. | +| `update` | Reinstall hub skills with upstream changes when available. | | `audit` | Re-scan installed hub skills. | | `uninstall` | Remove a hub-installed skill. | | `publish` | Publish a skill to a registry. | @@ -258,12 +260,23 @@ Common examples: ```bash hermes skills browse hermes skills browse --source official -hermes skills search kubernetes +hermes skills search react --source skills-sh +hermes skills search https://mintlify.com/docs --source well-known 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 check +hermes skills update hermes skills config ``` +Notes: +- `--force` can override non-dangerous policy blocks for third-party/community skills. +- `--force` does not override a `dangerous` scan verdict. +- `--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`. + ## `hermes honcho` ```bash diff --git a/website/docs/user-guide/features/skills.md b/website/docs/user-guide/features/skills.md index 349791582b..3280866492 100644 --- a/website/docs/user-guide/features/skills.md +++ b/website/docs/user-guide/features/skills.md @@ -187,42 +187,98 @@ The `patch` action is preferred for updates — it's more token-efficient than ` ## Skills Hub -Browse, search, install, and manage skills from online registries and official optional skills: +Browse, search, install, and manage skills from online registries, `skills.sh`, direct well-known skill endpoints, and official optional skills. + +### Common commands ```bash -hermes skills browse # Browse all hub skills (official first) -hermes skills browse --source official # Browse only official optional skills -hermes skills search kubernetes # Search all sources -hermes skills install openai/skills/k8s # Install with security scan -hermes skills inspect openai/skills/k8s # Preview before installing -hermes skills list --source hub # List hub-installed skills -hermes skills audit # Re-scan all hub skills -hermes skills uninstall k8s # Remove a hub skill +hermes skills browse # Browse all hub skills (official first) +hermes skills browse --source official # Browse only official optional skills +hermes skills search kubernetes # Search all sources +hermes skills search react --source skills-sh # Search the skills.sh directory +hermes skills search https://mintlify.com/docs --source well-known +hermes skills inspect openai/skills/k8s # Preview before installing +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 list --source hub # List hub-installed skills +hermes skills check # Check installed hub skills for upstream updates +hermes skills update # Reinstall hub skills with upstream changes when needed +hermes skills audit # Re-scan all hub skills for security +hermes skills uninstall k8s # Remove a hub skill hermes skills publish skills/my-skill --to github --repo owner/repo -hermes skills snapshot export setup.json # Export skill config -hermes skills tap add myorg/skills-repo # Add a custom source +hermes skills snapshot export setup.json # Export skill config +hermes skills tap add myorg/skills-repo # Add a custom GitHub source ``` -All hub-installed skills go through a **security scanner** that checks for data exfiltration, prompt injection, destructive commands, and other threats. +### Supported hub sources -Official optional skills use identifiers like `official/security/1password` and `official/migration/openclaw-migration`. +| Source | Example | Notes | +|--------|---------|-------| +| `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. | +| `github` | `openai/skills/k8s` | Direct GitHub repo/path installs and custom taps. | +| `clawhub`, `lobehub`, `claude-marketplace` | Source-specific identifiers | Community or marketplace integrations. | -### Trust Levels +### Security scanning and `--force` + +All hub-installed skills go through a **security scanner** that checks for data exfiltration, prompt injection, destructive commands, supply-chain signals, and other threats. + +`hermes skills inspect ...` now also surfaces upstream metadata when available: +- repo URL +- skills.sh detail page URL +- install command +- weekly installs +- upstream security audit statuses +- well-known index/endpoint URLs + +Use `--force` when you have reviewed a third-party skill and want to override a non-dangerous policy block: + +```bash +hermes skills install skills-sh/anthropics/skills/pdf --force +``` + +Important behavior: +- `--force` can override policy blocks for caution/warn-style findings. +- `--force` does **not** override a `dangerous` scan verdict. +- Official optional skills (`official/...`) are treated as builtin trust and do not show the third-party warning panel. + +### Trust levels | Level | Source | Policy | |-------|--------|--------| | `builtin` | Ships with Hermes | Always trusted | | `official` | `optional-skills/` in the repo | Builtin trust, no third-party warning | -| `trusted` | openai/skills, anthropics/skills | Trusted sources | -| `community` | Everything else | Any findings = blocked unless `--force` | +| `trusted` | Trusted registries/repos such as `openai/skills`, `anthropics/skills` | More permissive policy than community sources | +| `community` | Everything else (`skills.sh`, well-known endpoints, custom GitHub repos, most marketplaces) | Non-dangerous findings can be overridden with `--force`; `dangerous` verdicts stay blocked | -### Slash Commands (Inside Chat) +### Update lifecycle -All the same commands work with `/skills` prefix: +The hub now tracks enough provenance to re-check upstream copies of installed skills: +```bash +hermes skills check # Report which installed hub skills changed upstream +hermes skills update # Reinstall only the skills with updates available +hermes skills update react # Update one specific installed hub skill ``` + +This uses the stored source identifier plus the current upstream bundle content hash to detect drift. + +### Slash commands (inside chat) + +All the same commands work with `/skills`: + +```text /skills browse -/skills search kubernetes -/skills install openai/skills/skill-creator +/skills search react --source skills-sh +/skills search https://mintlify.com/docs --source well-known +/skills inspect skills-sh/vercel-labs/json-render/json-render-react +/skills install openai/skills/skill-creator --force +/skills check +/skills update /skills list ``` + +Official optional skills still use identifiers like `official/security/1password` and `official/migration/openclaw-migration`.