Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions hermes_cli/skills_hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <identifier> to preview, "
"hermes skills install <identifier> 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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions tests/hermes_cli/test_skills_hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()