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
26 changes: 24 additions & 2 deletions hermes_cli/skills_hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <identifier> to preview, "
"hermes skills install <identifier> to install[/]\n")
"hermes skills install <identifier> 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",
Expand Down Expand Up @@ -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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

meta only proves that some source returned metadata. On current main the centralized index returns metadata without fetching, while GitHub fetch reduces 404s, rate limits, and other failures to None; please gate this message on a confirmed matching 404 and retain the existing rate-limit hint.

# 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")
Expand Down
118 changes: 117 additions & 1 deletion tests/hermes_cli/test_skills_hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Loading