From 00883d287e6e35e130e819ac29802b18569fa9ec Mon Sep 17 00:00:00 2001 From: Mibayy Date: Thu, 26 Mar 2026 21:17:18 +0000 Subject: [PATCH] fix(skills): better error message for stale skills.sh index entries Closes #3259 When a skill appears in the skills.sh search index but its GitHub files no longer exist (renamed, deleted, or moved by the author), the user previously received only a generic 'Could not fetch' error with no explanation. Changes: - do_install: when _resolve_source_meta_and_bundle returns metadata (index hit) but no bundle (GitHub 404), show a 'stale index entry' message explaining the file no longer exists at its listed path and may have been renamed or removed. The generic 'Could not fetch' message is preserved for unknown identifiers where even the index has no record. - do_search: when results include entries from the skills.sh source, append a dim Note explaining that the skills.sh index may contain entries whose GitHub files have since been removed, so a 'stale index entry' error during install means the skill is gone -- not a user error. Official-only results are not annotated. 4 new tests covering both error paths and both search annotation cases. --- hermes_cli/skills_hub.py | 26 +++++- tests/hermes_cli/test_skills_hub.py | 118 +++++++++++++++++++++++++++- 2 files changed, 141 insertions(+), 3 deletions(-) diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index a36ee78ce2de..ee618062a6d4 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -176,7 +176,17 @@ def do_search(query: str, source: str = "all", limit: int = 10, c.print(table) c.print("[dim]Use: hermes skills inspect to preview, " - "hermes skills install to install[/]\n") + "hermes skills install to install[/]") + skills_sh_count = sum(1 for r in results if r.source in ("skills.sh", "skills-sh")) + if skills_sh_count: + c.print( + f"[dim yellow]Note:[/] [dim]{skills_sh_count} result(s) come from the skills.sh index, " + "which may contain entries whose GitHub files have since been removed or renamed. " + "If installation fails with a 'stale index entry' error, the skill no longer exists " + "at its listed path.[/]\n" + ) + else: + c.print() def do_browse(page: int = 1, page_size: int = 20, source: str = "all", @@ -330,7 +340,19 @@ def do_install(identifier: str, category: str = "", force: bool = False, 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") + if meta is not None: + # Index returned metadata but the underlying GitHub files are gone — + # this is a stale index entry, not a typo from the user. + src_label = getattr(_matched_source, "source_id", lambda: "the registry")() + c.print( + f"[bold red]Error:[/] '[bold]{identifier}[/]' appears in the " + f"[bold]{src_label}[/] index but the skill files no longer exist " + f"in the underlying repository (GitHub returned 404).\n" + f"[dim]This is a stale index entry. " + f"The skill may have been renamed or removed by its author.[/]\n" + ) + else: + c.print(f"[bold red]Error:[/] Could not fetch '[bold]{identifier}[/]' from any source.\n") return # Auto-detect category for official skills (e.g. "official/autonomous-ai-agents/blackbox") diff --git a/tests/hermes_cli/test_skills_hub.py b/tests/hermes_cli/test_skills_hub.py index 0ef6c2d69a82..08f195b8a487 100644 --- a/tests/hermes_cli/test_skills_hub.py +++ b/tests/hermes_cli/test_skills_hub.py @@ -3,7 +3,7 @@ import pytest from rich.console import Console -from hermes_cli.skills_hub import do_check, do_install, do_list, do_update, handle_skills_slash +from hermes_cli.skills_hub import do_check, do_install, do_list, do_search, do_update, handle_skills_slash class _DummyLockFile: @@ -231,3 +231,119 @@ def _scan_skill(skill_path, source="community"): do_install("skils-sh/anthropics/skills/frontend-design", console=console, skip_confirm=True) assert scanned["source"] == canonical_identifier + + + +# --------------------------------------------------------------------------- +# Stale index entry error messages (#3259) +# --------------------------------------------------------------------------- + +def _make_stale_source(source_id_val="skills-sh"): + """A source that returns index metadata but no bundle (stale entry).""" + class StaleSource: + def source_id(self): + return source_id_val + def inspect(self, identifier): + return type("Meta", (), { + "name": "vercel-react-best-practices", + "description": "Vercel React best practices", + "source": "skills.sh", + "identifier": identifier, + "trust_level": "community", + "repo": "vercel-labs/agent-skills", + "path": "vercel-react-best-practices", + "tags": [], + "extra": {}, + })() + def fetch(self, identifier): + return None # 404 - file gone from GitHub + return StaleSource() + + +def test_do_install_stale_index_shows_helpful_message(monkeypatch, tmp_path, hub_env): + """When index has metadata but GitHub returns 404, show stale index entry message.""" + import tools.skills_hub as hub + + monkeypatch.setattr(hub, "ensure_hub_dirs", lambda: None) + monkeypatch.setattr(hub, "create_source_router", lambda auth: [_make_stale_source()]) + + sink = StringIO() + console = Console(file=sink, force_terminal=False, color_system=None) + + do_install("skills-sh/vercel-labs/agent-skills/vercel-react-best-practices", console=console) + + output = sink.getvalue() + assert "stale" in output.lower() or "no longer exist" in output or "removed" in output + + +def test_do_install_unknown_identifier_shows_generic_message(monkeypatch, tmp_path, hub_env): + """When neither meta nor bundle is found, show the generic Could not fetch message.""" + import tools.skills_hub as hub + + class EmptySource: + def source_id(self): return "github" + def inspect(self, identifier): return None + def fetch(self, identifier): return None + + monkeypatch.setattr(hub, "ensure_hub_dirs", lambda: None) + monkeypatch.setattr(hub, "create_source_router", lambda auth: [EmptySource()]) + + sink = StringIO() + console = Console(file=sink, force_terminal=False, color_system=None) + + do_install("github/nobody/nowhere/nonexistent-skill", console=console) + + output = sink.getvalue() + assert "Could not fetch" in output + assert "stale" not in output.lower() + + +def test_do_search_shows_skills_sh_caveat(monkeypatch): + """When search results include skills-sh entries, a caveat note is shown.""" + import tools.skills_hub as hub + + skills_sh_meta = type("Meta", (), { + "name": "vercel-react-best-practices", + "description": "Vercel React best practices", + "source": "skills-sh", + "identifier": "skills-sh/vercel-labs/agent-skills/vercel-react-best-practices", + "trust_level": "community", + "install_count": 251885, + })() + + monkeypatch.setattr(hub, "create_source_router", lambda auth: []) + monkeypatch.setattr(hub, "unified_search", lambda query, sources, source_filter, limit: [skills_sh_meta]) + + sink = StringIO() + console = Console(file=sink, force_terminal=False, color_system=None) + + do_search("react", console=console) + + output = sink.getvalue() + assert "skills.sh" in output + assert "stale" in output.lower() or "removed" in output.lower() or "renamed" in output.lower() + + +def test_do_search_no_caveat_for_official_only(monkeypatch): + """When all results are official, no skills.sh caveat is shown.""" + import tools.skills_hub as hub + + official_meta = type("Meta", (), { + "name": "python-dev", + "description": "Python development skill", + "source": "official", + "identifier": "official/development/python-dev", + "trust_level": "builtin", + })() + + monkeypatch.setattr(hub, "create_source_router", lambda auth: []) + monkeypatch.setattr(hub, "unified_search", lambda query, sources, source_filter, limit: [official_meta]) + + sink = StringIO() + console = Console(file=sink, force_terminal=False, color_system=None) + + do_search("python", console=console) + + output = sink.getvalue() + assert "stale" not in output.lower() + assert "removed" not in output.lower()