From 7d0e4510b8644e504bad890df40eec6f68b5f574 Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 22 Mar 2026 04:03:28 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20skills=20hub=20inspect/resolve=20?= =?UTF-8?q?=E2=80=94=204=20bugs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked from PR #2122 by @AtlasMeridia. 1. do_inspect bytes crash: bundle.files returns bytes for official skills, .split() expected str. Added decode guard. 2. GitHub redirects: three httpx.get calls missing follow_redirects=True, causing silent 301 failures on renamed orgs. 3. Skill discovery fallback: scan repo root directories when standard paths (skills/, .agents/skills/, .claude/skills/) miss. 4. tap list KeyError: t['repo'] crashes for local taps. Use safe .get(). --- hermes_cli/skills_hub.py | 5 ++++- tools/skills_hub.py | 48 +++++++++++++++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index 20654182..43725fda 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -455,6 +455,8 @@ def do_inspect(identifier: str, console: Optional[Console] = None) -> None: if bundle and "SKILL.md" in bundle.files: content = bundle.files["SKILL.md"] + if isinstance(content, bytes): + content = content.decode("utf-8", errors="replace") # Show first 50 lines as preview lines = content.split("\n") preview = "\n".join(lines[:50]) @@ -640,7 +642,8 @@ def do_tap(action: str, repo: str = "", console: Optional[Console] = None) -> No table.add_column("Repo", style="bold cyan") table.add_column("Path", style="dim") for t in taps: - table.add_row(t["repo"], t.get("path", "skills/")) + label = t.get("repo") or t.get("name") or t.get("path", "unknown") + table.add_row(label, t.get("path", "skills/")) c.print(table) c.print() diff --git a/tools/skills_hub.py b/tools/skills_hub.py index bf200ea5..5f9f10c2 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -375,7 +375,7 @@ class GitHubSource(SkillSource): url = f"https://api.github.com/repos/{repo}/contents/{path.rstrip('/')}" try: - resp = httpx.get(url, headers=self.auth.get_headers(), timeout=15) + resp = httpx.get(url, headers=self.auth.get_headers(), timeout=15, follow_redirects=True) if resp.status_code != 200: return [] except httpx.HTTPError: @@ -407,7 +407,7 @@ class GitHubSource(SkillSource): """Recursively download all text files from a GitHub directory.""" url = f"https://api.github.com/repos/{repo}/contents/{path.rstrip('/')}" try: - resp = httpx.get(url, headers=self.auth.get_headers(), timeout=15) + resp = httpx.get(url, headers=self.auth.get_headers(), timeout=15, follow_redirects=True) if resp.status_code != 200: return {} except httpx.HTTPError: @@ -441,7 +441,7 @@ class GitHubSource(SkillSource): resp = httpx.get( url, headers={**self.auth.get_headers(), "Accept": "application/vnd.github.v3.raw"}, - timeout=15, + timeout=15, follow_redirects=True, ) if resp.status_code == 200: return resp.text @@ -961,8 +961,8 @@ class SkillsShSource(SkillSource): 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] + skill_token=parts[2].split("/")[-1] + tokens=[skill_token] if isinstance(detail, dict): tokens.extend([ detail.get("install_skill", ""), @@ -970,7 +970,10 @@ class SkillsShSource(SkillSource): detail.get("body_title", ""), ]) - for base_path in ("skills/", ".agents/skills/", ".claude/skills/"): + # Standard skill paths + base_paths = ["skills/", ".agents/skills/", ".claude/skills/"] + + for base_path in base_paths: try: skills = self.github._list_skills_in_repo(repo, base_path) except Exception: @@ -978,6 +981,39 @@ class SkillsShSource(SkillSource): for meta in skills: if self._matches_skill_tokens(meta, tokens): return meta.identifier + + # Fallback: scan repo root for directories that might contain skills + try: + root_url = f"https://api.github.com/repos/{repo}/contents/" + resp = httpx.get(root_url, headers=self.github.auth.get_headers(), + timeout=15, follow_redirects=True) + if resp.status_code == 200: + entries = resp.json() + if isinstance(entries, list): + for entry in entries: + if entry.get("type") != "dir": + continue + dir_name = entry["name"] + if dir_name.startswith(".") or dir_name.startswith("_"): + continue + if dir_name in ("skills", ".agents", ".claude"): + continue # already tried + # Try direct: repo/dir/skill_token + direct_id = f"{repo}/{dir_name}/{skill_token}" + meta = self.github.inspect(direct_id) + if meta: + return meta.identifier + # Try listing skills in this directory + try: + skills = self.github._list_skills_in_repo(repo, dir_name + "/") + except Exception: + continue + for meta in skills: + if self._matches_skill_tokens(meta, tokens): + return meta.identifier + except Exception: + pass + return None def _finalize_inspect_meta(self, meta: SkillMeta, canonical: str, detail: Optional[dict]) -> SkillMeta: