diff --git a/backend/apps/skill_registry/skill_registry.py b/backend/apps/skill_registry/skill_registry.py index d1731dae..5822b64b 100644 --- a/backend/apps/skill_registry/skill_registry.py +++ b/backend/apps/skill_registry/skill_registry.py @@ -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"]) diff --git a/backend/tests/test_skill_registry_community.py b/backend/tests/test_skill_registry_community.py index 118ace42..b52b56be 100644 --- a/backend/tests/test_skill_registry_community.py +++ b/backend/tests/test_skill_registry_community.py @@ -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."""