diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index 54adef6fdf..9e0286d85f 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -341,7 +341,13 @@ def do_search(query: str, source: str = "all", limit: int = 10, console: Optiona c.print(table) c.print("[dim]Use: hermes skills inspect to preview, " "hermes skills install to install " - "(--json for scripting)[/]\n") + "(--json for scripting)[/]") + if any(r.source in ("skills.sh", "skills-sh") for r in results): + c.print("[dim yellow]Note:[/] [dim]skills.sh results may include entries whose " + "upstream files were removed or renamed; a 'stale index entry' error " + "at install means the skill no longer exists there.[/]\n") + else: + c.print() def _rank_and_page(all_results, page: int, page_size: int): @@ -585,7 +591,16 @@ def _pinned_sources(c: Console, sources, source_id: Optional[str], identifier: s return None -def _print_fetch_failure(c: Console, sources, identifier: str) -> None: +def _print_fetch_failure(c: Console, sources, identifier: str, meta=None, source=None) -> None: + # Index hit but files gone (GitHub 404): a stale index entry, not a user + # typo — name it so users stop re-trying spellings (#3259). + if meta is not None: + src_id = getattr(source, "source_id", lambda: "the registry")() + c.print(f"[bold red]Error:[/] '{identifier}' is listed in the {src_id} index, " + f"but its files no longer exist upstream.") + c.print("[dim]Stale index entry: the skill was likely renamed or removed by " + "its author. Try `hermes skills search` for an alternative.[/]\n") + return rate_limited = any(getattr(src, "is_rate_limited", False) or getattr(getattr(src, "github", None), "is_rate_limited", False) for src in sources) @@ -663,7 +678,7 @@ def do_install(identifier: str, category: str = "", force: bool = False, c.print(f"\n[bold]Fetching:[/] {identifier}") meta, bundle, _matched_source = _resolve_source_meta_and_bundle(identifier, sources) if not bundle: - _print_fetch_failure(c, sources, identifier) + _print_fetch_failure(c, sources, identifier, meta=meta, source=_matched_source) return if not _resolve_url_bundle_name(c, bundle, meta, identifier, name_override, skip_confirm): return diff --git a/tests/hermes_cli/test_skills_hub.py b/tests/hermes_cli/test_skills_hub.py index b7f7cc2802..444dd38e98 100644 --- a/tests/hermes_cli/test_skills_hub.py +++ b/tests/hermes_cli/test_skills_hub.py @@ -496,3 +496,81 @@ def test_do_update_unmodified_skill_updates_normally(monkeypatch, tmp_path): assert installs == ["someone/hub-skill"] assert "Updated 1 skill(s)" in sink.getvalue() + + +# --------------------------------------------------------------------------- +# Stale index entry messages (#3259) +# --------------------------------------------------------------------------- + + +def _stale_env(monkeypatch): + """do_install where the index has metadata but the files are gone (404).""" + import hermes_cli.skills_hub as cli_hub + import tools.skills_hub as hub + + class StaleSource: + def source_id(self): + return "skills-sh" + + meta = type("Meta", (), {"identifier": "skills-sh/org/gone-skill"})() + monkeypatch.setattr(hub, "ensure_hub_dirs", lambda: None) + monkeypatch.setattr(cli_hub, "_sources", lambda: [StaleSource()]) + monkeypatch.setattr( + cli_hub, "_resolve_source_meta_and_bundle", + lambda identifier, sources: (meta, None, sources[0])) + sink = StringIO() + console = Console(file=sink, force_terminal=False, color_system=None) + return console, sink + + +def test_do_install_stale_index_names_the_problem(monkeypatch): + """Index hit + missing files reads as a stale entry, not a typo (#3259).""" + from hermes_cli.skills_hub import do_install + + console, sink = _stale_env(monkeypatch) + do_install("skills-sh/org/gone-skill", console=console, skip_confirm=True) + + out = sink.getvalue() + assert "Stale index entry" in out + assert "skills-sh" in out + assert "Could not fetch" not in out + + +def test_do_install_unknown_identifier_stays_generic(monkeypatch): + """No index hit at all keeps the original generic message.""" + import hermes_cli.skills_hub as cli_hub + import tools.skills_hub as hub + from hermes_cli.skills_hub import do_install + + monkeypatch.setattr(hub, "ensure_hub_dirs", lambda: None) + monkeypatch.setattr(cli_hub, "_sources", lambda: [object()]) + monkeypatch.setattr( + cli_hub, "_resolve_source_meta_and_bundle", + lambda identifier, sources: (None, None, None)) + sink = StringIO() + console = Console(file=sink, force_terminal=False, color_system=None) + do_install("nobody/nowhere/nothing", console=console, skip_confirm=True) + + out = sink.getvalue() + assert "Could not fetch" in out + assert "Stale index entry" not in out + + +def test_do_search_warns_about_skills_sh_staleness(monkeypatch): + """Search results from skills.sh carry the stale-index caveat.""" + import tools.skills_hub_search as hub_search + from hermes_cli.skills_hub import do_search + import hermes_cli.skills_hub as cli_hub + + row = type("Row", (), { + "name": "gone-skill", "description": "d", "source": "skills-sh", + "trust_level": "community", + "identifier": "skills-sh/org/gone-skill"})() + monkeypatch.setattr(cli_hub, "_sources", lambda: []) + monkeypatch.setattr(hub_search, "unified_search", + lambda query, sources, source_filter, limit: [row]) + sink = StringIO() + console = Console(file=sink, force_terminal=False, color_system=None) + do_search("gone", console=console) + + assert "stale index entry" in sink.getvalue()