[eric] skills: curated install falls back to cached SKILL.md when GitHub is unreachable (restores offline install, fixes #113 regression)

This commit is contained in:
ciregenz
2026-06-30 00:00:22 -07:00
parent 37c8c89503
commit 7adfeda879
2 changed files with 74 additions and 4 deletions
+25 -4
View File
@@ -165,21 +165,42 @@ class p_CuratedInstallRequest(BaseModel):
folder: str
def p_cached_curated_fallback(folder: str) -> Optional[dict]:
"""Offline/rate-limited curated-install fallback: rebuild a single-SKILL.md install
payload from the warmed catalog (it already holds the SKILL.md body) so a curated
install still works when GitHub is unreachable, minus the folder's extra files.
Empty version means it's skipped by update checks until re-installed online."""
cached = next((s for s in p_cache.values() if s.get("folder") == folder), None)
if cached is None:
return None
name, description, body = cached.get("name", ""), cached.get("description", ""), cached.get("content", "")
return {
"skill_id": folder.rsplit("/", 1)[-1], "name": name, "description": description,
"files": {"SKILL.md": f"---\nname: {name}\ndescription: {description}\n---\n\n{body}"},
"scripts": [], "source": sources.REPO, "folder": folder, "version": "",
}
@skill_registry.router.post("/install-curated")
async def registry_install_curated(req: p_CuratedInstallRequest):
"""Install a curated (anthropics/skills) skill with its FULL folder, not just
SKILL.md, so scripts/assets land too (the old path wrote only SKILL.md, which
left multi-file skills like pdf/docx with dead script references). Curated is
the vetted source, so this is one-click; files are still written inert, never
executed. Needs network at install time (the catalog only caches SKILL.md)."""
executed. When GitHub is unreachable (offline / rate-limited) it falls back to the
catalog's cached SKILL.md, so the install still works (single file, no folder
extras), restoring the old offline behavior."""
try:
resolved = await sources.resolve_curated_skill(req.folder)
except RegistryRateLimited:
raise HTTPException(status_code=429, detail="GitHub rate limit hit fetching this skill; try again in a few minutes.")
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=502, detail=f"could not fetch skill: {e}")
resolved = p_cached_curated_fallback(req.folder)
if resolved is None:
if isinstance(e, RegistryRateLimited):
raise HTTPException(status_code=429, detail="GitHub rate limit hit and no cached copy of this skill; try again in a few minutes.")
raise HTTPException(status_code=502, detail=f"GitHub unreachable and no cached copy: {e}")
logger.info(f"curated install: GitHub unreachable ({type(e).__name__}); installing '{req.folder}' from cached SKILL.md (single file, no folder extras)")
from backend.apps.skills.skills import write_folder_skill, unique_skill_slug
slug = unique_skill_slug(resolved["skill_id"])
@@ -350,6 +350,55 @@ def test_curated_install_writes_full_folder(skills_dir, monkeypatch):
assert slug in listed and listed[slug]["has_supporting_files"] is True
def test_curated_install_falls_back_to_cached_skill_md_when_offline(skills_dir, monkeypatch):
"""Regression: when GitHub is unreachable (rate-limited/offline), /install-curated
falls back to the catalog's cached SKILL.md so the install still works (single file,
no folder extras) instead of erroring, restoring the old offline behavior."""
import secrets as p_secrets
from fastapi.testclient import TestClient
from backend.main import app
import backend.auth as auth_mod
import backend.apps.skill_registry.skill_registry as sr_routes
from backend.apps.skill_registry.skill_registry_github import RegistryRateLimited
if not auth_mod.TOKEN:
auth_mod.TOKEN = p_secrets.token_urlsafe(32)
client = TestClient(app, headers={"Authorization": f"Bearer {auth_mod.TOKEN}"})
async def boom(folder):
raise RegistryRateLimited()
monkeypatch.setattr("backend.apps.skill_registry.skill_registry_sources.resolve_curated_skill", boom)
monkeypatch.setattr(sr_routes, "p_cache", {"PDF": {
"name": "PDF", "description": "work with pdfs", "content": "Do PDF things.", "folder": "skills/pdf",
}})
r = client.post("/api/skill-registry/install-curated", json={"folder": "skills/pdf"})
assert r.status_code == 200 and r.json()["installed"] is True
assert r.json()["files"] == ["SKILL.md"] and r.json()["scripts"] == []
slug = r.json()["skill"]["id"]
md = (skills_dir / slug / "SKILL.md").read_text()
assert "Do PDF things." in md and "name: PDF" in md
def test_curated_install_no_cache_no_network_errors_honestly(skills_dir, monkeypatch):
"""If GitHub is unreachable AND nothing's cached, surface an honest error, not a
silent half-install."""
import secrets as p_secrets
from fastapi.testclient import TestClient
from backend.main import app
import backend.auth as auth_mod
import backend.apps.skill_registry.skill_registry as sr_routes
if not auth_mod.TOKEN:
auth_mod.TOKEN = p_secrets.token_urlsafe(32)
client = TestClient(app, headers={"Authorization": f"Bearer {auth_mod.TOKEN}"})
async def boom(folder):
raise RuntimeError("connection refused")
monkeypatch.setattr("backend.apps.skill_registry.skill_registry_sources.resolve_curated_skill", boom)
monkeypatch.setattr(sr_routes, "p_cache", {})
r = client.post("/api/skill-registry/install-curated", json={"folder": "skills/pdf"})
assert r.status_code == 502
def test_manual_rm_leaves_no_ghost_blocking_slug(skills_dir):
"""A folder deleted out-of-band (manual rm) must not keep squatting its slug via
a leftover index entry: existence is by files on disk, and a prune cleans the index."""