diff --git a/backend/apps/agents/manager/prompt/prompt_context.py b/backend/apps/agents/manager/prompt/prompt_context.py index e9d8a75e..c92c3a44 100644 --- a/backend/apps/agents/manager/prompt/prompt_context.py +++ b/backend/apps/agents/manager/prompt/prompt_context.py @@ -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: diff --git a/backend/apps/agents/manager/register_builtin_mcp_servers.py b/backend/apps/agents/manager/register_builtin_mcp_servers.py index aad257b2..f0a69348 100644 --- a/backend/apps/agents/manager/register_builtin_mcp_servers.py +++ b/backend/apps/agents/manager/register_builtin_mcp_servers.py @@ -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: diff --git a/backend/apps/skills/models.py b/backend/apps/skills/models.py index 4b7bf08a..0770b0cb 100644 --- a/backend/apps/skills/models.py +++ b/backend/apps/skills/models.py @@ -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): diff --git a/backend/apps/skills/skills.py b/backend/apps/skills/skills.py index 1e629604..42ef4532 100644 --- a/backend/apps/skills/skills.py +++ b/backend/apps/skills/skills.py @@ -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) diff --git a/backend/apps/workflows/cloud/portable_context.py b/backend/apps/workflows/cloud/portable_context.py index dcf07a13..e854b66a 100644 --- a/backend/apps/workflows/cloud/portable_context.py +++ b/backend/apps/workflows/cloud/portable_context.py @@ -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) diff --git a/backend/tests/test_skill_enable_and_files.py b/backend/tests/test_skill_enable_and_files.py new file mode 100644 index 00000000..b2b153d2 --- /dev/null +++ b/backend/tests/test_skill_enable_and_files.py @@ -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