[eric] skills: per-skill enable toggle gates prompt list, Skill tool, and cloud runs; files endpoint feeds the detail picker

This commit is contained in:
ciregenz
2026-08-05 08:38:02 -07:00
parent e1b8d4b17d
commit a519354cc4
6 changed files with 102 additions and 4 deletions
@@ -397,7 +397,7 @@ def build_installed_skills_catalog() -> Optional[str]:
return None
try:
from backend.apps.skills.skills import sync_skills
skills = [s for s in sync_skills() if not s.built_in]
skills = [s for s in sync_skills() if not s.built_in and s.enabled]
except Exception:
return None
if not skills:
@@ -118,7 +118,7 @@ def register_builtin_mcp_servers(
if not skill_denied:
try:
from backend.apps.skills.skills import sync_skills
has_loadable_skill = any(not s.built_in for s in sync_skills())
has_loadable_skill = any(not s.built_in and s.enabled for s in sync_skills())
except Exception:
has_loadable_skill = False
if has_loadable_skill:
+3
View File
@@ -19,6 +19,8 @@ class Skill(BaseModel):
source: str = ""
folder: str = ""
version: str = ""
# The detail-page toggle: a disabled skill stays installed but leaves the agent's skill list, the Skill tool refuses to load it, and cloud runs skip it.
enabled: bool = True
class SkillCreate(BaseModel):
@@ -33,6 +35,7 @@ class SkillUpdate(BaseModel):
description: Optional[str] = None
content: Optional[str] = None
command: Optional[str] = None
enabled: Optional[bool] = None
class SkillLoadRequest(BaseModel):
+33 -1
View File
@@ -247,6 +247,7 @@ def p_build_skill(skill_id: str, content: str, md_path: str, kind: str, index: d
source=meta.get("source", ""),
folder=meta.get("folder", ""),
version=meta.get("version", ""),
enabled=bool(meta.get("enabled", True)),
)
@@ -321,7 +322,9 @@ async def load_skill(body: SkillLoadRequest):
skills_list = sync_skills()
target = p_resolve_skill(body.id, skills_list)
if target is None:
return {"ok": False, "error": "unknown_skill", "available": [s.id for s in skills_list]}
return {"ok": False, "error": "unknown_skill", "available": [s.id for s in skills_list if s.enabled]}
if not target.enabled:
return {"ok": False, "error": "skill_disabled", "available": [s.id for s in skills_list if s.enabled]}
folder = target.dir_path if (target.dir_path and target.has_supporting_files) else None
return {"ok": True, "text": format_skill_for_prompt(target.name, target.content, folder)}
@@ -477,6 +480,33 @@ def write_folder_skill(skill_id: str, files: dict[str, str], meta: dict) -> Skil
return p_build_skill(slug, content, md_path, kind, index)
@skills.router.get("/{skill_id}/files")
async def list_skill_files(skill_id: str):
"""The detail page's file picker: every text file in a folder skill, SKILL.md first."""
md_path, kind = skill_md_path(skill_id)
if not md_path:
raise HTTPException(status_code=404, detail="Skill not found")
if kind != "folder":
with open(md_path, encoding="utf-8") as f:
return {"files": [{"path": "SKILL.md", "content": f.read()}]}
base_abs = os.path.abspath(os.path.join(SKILLS_DIR, skill_id))
out: list[dict] = []
for root, dirs, names in os.walk(base_abs):
dirs[:] = [d for d in dirs if not d.startswith(".")]
for n in sorted(names):
path = os.path.join(root, n)
rel = os.path.relpath(path, base_abs)
if n.startswith(".") or os.path.getsize(path) > 512_000:
continue
try:
with open(path, encoding="utf-8") as f:
out.append({"path": rel, "content": f.read()})
except (UnicodeDecodeError, OSError):
continue
out.sort(key=lambda e: (e["path"] != "SKILL.md", e["path"]))
return {"files": out}
@skills.router.post("/upload")
async def upload_skill(body: SkillUpload):
"""The Directory's Upload skill drop zone: a bare SKILL .md, or a .zip/.skill archive
@@ -555,6 +585,8 @@ async def update_skill(skill_id: str, body: SkillUpdate):
meta["description"] = body.description
if body.command is not None:
meta["command"] = body.command
if body.enabled is not None:
meta["enabled"] = body.enabled
index[skill_id] = meta
save_index(index)
@@ -113,7 +113,7 @@ def portable_skills() -> List[PortableSkill]:
out: List[PortableSkill] = []
budget = MAX_TOTAL_SKILL_CHARS
for skill in sync_skills():
if skill.built_in or len(out) >= MAX_SKILLS:
if skill.built_in or not skill.enabled or len(out) >= MAX_SKILLS:
continue
# The id becomes a directory name in the container, so it has to survive being one.
slug = safe_slug(skill.id)
@@ -0,0 +1,63 @@
"""The detail-page chrome's backend: the enable toggle must actually gate the agent-facing
surfaces (Skill tool load + sync list), and the file picker endpoint lists a folder skill's
text files with SKILL.md first."""
from __future__ import annotations
import pytest
from fastapi import HTTPException
import backend.apps.skills.skills as skills_mod
from backend.apps.skills.models import SkillUpdate
@pytest.fixture
def isolated_skills(tmp_path, monkeypatch):
d = tmp_path / "skills"
d.mkdir()
monkeypatch.setattr(skills_mod, "SKILLS_DIR", str(d))
monkeypatch.setattr(skills_mod, "INDEX_PATH", str(d / ".skills_index.json"))
return d
def seed(name: str, extra: dict[str, str] | None = None) -> str:
files = {"SKILL.md": f"---\nname: {name}\ndescription: d\n---\nbody"}
files.update(extra or {})
skill = skills_mod.write_folder_skill(skills_mod.safe_slug(name), files, {"name": name, "description": "d"})
return skill.id
@pytest.mark.asyncio
async def test_disable_gates_load_and_listing(isolated_skills):
sid = seed("Togglable")
assert all(s.enabled for s in skills_mod.sync_skills())
await skills_mod.update_skill(sid, SkillUpdate(enabled=False))
target = next(s for s in skills_mod.sync_skills() if s.id == sid)
assert target.enabled is False
res = await skills_mod.load_skill(skills_mod.SkillLoadRequest(id=sid))
assert res["ok"] is False
assert res["error"] == "skill_disabled"
assert sid not in res["available"]
await skills_mod.update_skill(sid, SkillUpdate(enabled=True))
res = await skills_mod.load_skill(skills_mod.SkillLoadRequest(id=sid))
assert res["ok"] is True
@pytest.mark.asyncio
async def test_files_endpoint_lists_skill_md_first(isolated_skills):
sid = seed("Multi", {"scripts/run.py": "print('hi')", "notes.txt": "n"})
res = await skills_mod.list_skill_files(sid)
paths = [f["path"] for f in res["files"]]
assert paths[0] == "SKILL.md"
assert "scripts/run.py" in paths
assert "notes.txt" in paths
@pytest.mark.asyncio
async def test_files_endpoint_404_for_unknown(isolated_skills):
with pytest.raises(HTTPException) as e:
await skills_mod.list_skill_files("nope")
assert e.value.status_code == 404