mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-22 09:34:53 +02:00
308 lines
10 KiB
Python
308 lines
10 KiB
Python
import os
|
|
import json
|
|
import logging
|
|
import re
|
|
from contextlib import asynccontextmanager
|
|
from fastapi import HTTPException
|
|
from backend.config.Apps import SubApp
|
|
from backend.apps.skills.models import Skill, SkillCreate, SkillUpdate, SkillWorkspaceSeedRequest
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SKILLS_DIR = os.path.expanduser("~/.claude/skills")
|
|
INDEX_PATH = os.path.join(SKILLS_DIR, ".skills_index.json")
|
|
|
|
from backend.config.paths import SKILLS_WORKSPACE_DIR
|
|
|
|
|
|
def _load_index() -> dict[str, dict]:
|
|
if os.path.exists(INDEX_PATH):
|
|
with open(INDEX_PATH) as f:
|
|
return json.load(f)
|
|
return {}
|
|
|
|
|
|
def _save_index(index: dict[str, dict]):
|
|
with open(INDEX_PATH, "w") as f:
|
|
json.dump(index, f, indent=2)
|
|
|
|
|
|
# Built-in skills shipped with OpenSwarm itself. Each entry describes a
|
|
# skill file we copy into ~/.claude/skills/ on first boot and tag with
|
|
# `built_in: true` in the index. Users can edit the content (their
|
|
# changes flow through to the matching agent's prompt on the next turn),
|
|
# but they can't delete the file; the DELETE endpoint refuses with 409.
|
|
def _built_in_skill_registry() -> list[dict]:
|
|
# Imported lazily so this module stays cheap to import from
|
|
# everywhere (the skills outputs module pulls in pydantic+fastapi
|
|
# transitively and we don't want a cycle).
|
|
from backend.apps.outputs.view_builder_templates import (
|
|
APP_BUILDER_SKILL_SOURCE_PATH,
|
|
SWARM_DEBUG_SKILL_SOURCE_PATH,
|
|
)
|
|
return [
|
|
{
|
|
"id": "app_builder_skill",
|
|
"name": "App Builder",
|
|
"description": (
|
|
"Reference doc the App Builder agent reads on every turn. "
|
|
"Edit this to change how every App Builder agent behaves; "
|
|
"your edits take effect on the next turn, no restart. "
|
|
"Built-in: can be edited but not deleted."
|
|
),
|
|
"command": "app-builder-skill",
|
|
"source_path": APP_BUILDER_SKILL_SOURCE_PATH,
|
|
},
|
|
{
|
|
"id": "swarm_debug_skill",
|
|
"name": "swarm-debug Logger",
|
|
"description": (
|
|
"How to use `swarm_debug.debug()` in an App backend; the "
|
|
"colored frame-aware logger that lands in the App Builder's "
|
|
"Terminal pane under [BACKEND]. Edit to teach your debugging "
|
|
"conventions to the App Builder agent. Built-in: editable, "
|
|
"not deletable."
|
|
),
|
|
"command": "swarm-debug-skill",
|
|
"source_path": SWARM_DEBUG_SKILL_SOURCE_PATH,
|
|
},
|
|
]
|
|
|
|
|
|
def _seed_built_in_skills() -> None:
|
|
"""Copy each built-in skill into SKILLS_DIR if not already present, and
|
|
ensure the index has the `built_in: true` flag so the UI and DELETE
|
|
endpoint know to treat it specially. Idempotent; safe to call on
|
|
every boot. Doesn't overwrite the file once it exists (so user edits
|
|
are preserved across restarts and upgrades)."""
|
|
index = _load_index()
|
|
dirty = False
|
|
for entry in _built_in_skill_registry():
|
|
skill_id = entry["id"]
|
|
fpath = os.path.join(SKILLS_DIR, f"{skill_id}.md")
|
|
if not os.path.exists(fpath):
|
|
try:
|
|
with open(entry["source_path"], encoding="utf-8") as src:
|
|
content = src.read()
|
|
with open(fpath, "w", encoding="utf-8") as dst:
|
|
dst.write(content)
|
|
except FileNotFoundError:
|
|
logger.warning("built-in skill source missing: %s", entry["source_path"])
|
|
continue
|
|
# Refresh index metadata. Existing user-changed name/description
|
|
# in the index stays, but built_in always gets re-asserted in case
|
|
# the index was created before this mechanism existed.
|
|
meta = dict(index.get(skill_id, {}))
|
|
meta.setdefault("name", entry["name"])
|
|
meta.setdefault("description", entry["description"])
|
|
meta.setdefault("command", entry["command"])
|
|
if not meta.get("built_in"):
|
|
meta["built_in"] = True
|
|
dirty = True
|
|
if index.get(skill_id) != meta:
|
|
index[skill_id] = meta
|
|
dirty = True
|
|
if dirty:
|
|
_save_index(index)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def skills_lifespan():
|
|
os.makedirs(SKILLS_DIR, exist_ok=True)
|
|
os.makedirs(SKILLS_WORKSPACE_DIR, exist_ok=True)
|
|
try:
|
|
_seed_built_in_skills()
|
|
except Exception:
|
|
# Don't block app startup on a skill-seed failure; the worst
|
|
# case is the user has to manually paste the skill in once.
|
|
logger.exception("failed to seed built-in skills")
|
|
yield
|
|
|
|
|
|
skills = SubApp("skills", skills_lifespan)
|
|
|
|
|
|
def _sync_skills() -> list[Skill]:
|
|
"""Sync skills from the filesystem, updating the index."""
|
|
index = _load_index()
|
|
result = []
|
|
|
|
if os.path.exists(SKILLS_DIR):
|
|
for fname in os.listdir(SKILLS_DIR):
|
|
if fname.endswith(".md"):
|
|
fpath = os.path.join(SKILLS_DIR, fname)
|
|
with open(fpath) as f:
|
|
content = f.read()
|
|
|
|
skill_id = fname.replace(".md", "")
|
|
meta = index.get(skill_id, {})
|
|
skill = Skill(
|
|
id=skill_id,
|
|
name=meta.get("name", fname.replace(".md", "").replace("-", " ").replace("_", " ").title()),
|
|
description=meta.get("description", ""),
|
|
content=content,
|
|
file_path=fpath,
|
|
command=meta.get("command", fname.replace(".md", "")),
|
|
built_in=bool(meta.get("built_in", False)),
|
|
)
|
|
result.append(skill)
|
|
|
|
return result
|
|
|
|
|
|
@skills.router.get("/list")
|
|
async def list_skills():
|
|
return {"skills": [s.model_dump() for s in _sync_skills()]}
|
|
|
|
|
|
def _parse_skill_frontmatter(raw: str) -> dict:
|
|
"""Extract YAML frontmatter fields from a SKILL.md file."""
|
|
if not raw.startswith("---"):
|
|
return {}
|
|
end = raw.find("---", 3)
|
|
if end == -1:
|
|
return {}
|
|
fm_block = raw[3:end].strip()
|
|
meta: dict = {}
|
|
for line in fm_block.splitlines():
|
|
m = re.match(r"^(\w[\w_-]*)\s*:\s*(.+)$", line)
|
|
if m:
|
|
meta[m.group(1).strip()] = m.group(2).strip().strip('"').strip("'")
|
|
return meta
|
|
|
|
|
|
@skills.router.post("/workspace/seed")
|
|
async def seed_skill_workspace(body: SkillWorkspaceSeedRequest):
|
|
folder = os.path.join(SKILLS_WORKSPACE_DIR, body.workspace_id)
|
|
os.makedirs(folder, exist_ok=True)
|
|
|
|
if body.skill_content:
|
|
with open(os.path.join(folder, "SKILL.md"), "w") as f:
|
|
f.write(body.skill_content)
|
|
if body.meta:
|
|
with open(os.path.join(folder, "meta.json"), "w") as f:
|
|
json.dump(body.meta, f, indent=2)
|
|
|
|
return {"path": os.path.abspath(folder)}
|
|
|
|
|
|
@skills.router.get("/workspace/{workspace_id}")
|
|
async def read_skill_workspace(workspace_id: str):
|
|
folder = os.path.join(SKILLS_WORKSPACE_DIR, workspace_id)
|
|
if not os.path.isdir(folder):
|
|
raise HTTPException(status_code=404, detail="Workspace not found")
|
|
|
|
skill_content = None
|
|
skill_path = os.path.join(folder, "SKILL.md")
|
|
if os.path.isfile(skill_path):
|
|
with open(skill_path) as f:
|
|
skill_content = f.read()
|
|
|
|
meta = None
|
|
meta_path = os.path.join(folder, "meta.json")
|
|
if os.path.isfile(meta_path):
|
|
try:
|
|
with open(meta_path) as f:
|
|
meta = json.load(f)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
frontmatter = _parse_skill_frontmatter(skill_content) if skill_content else {}
|
|
|
|
return {
|
|
"skill_content": skill_content,
|
|
"meta": meta,
|
|
"frontmatter": frontmatter,
|
|
}
|
|
|
|
|
|
@skills.router.get("/{skill_id}")
|
|
async def get_skill(skill_id: str):
|
|
for s in _sync_skills():
|
|
if s.id == skill_id:
|
|
return s.model_dump()
|
|
raise HTTPException(status_code=404, detail="Skill not found")
|
|
|
|
|
|
@skills.router.post("/create")
|
|
async def create_skill(body: SkillCreate):
|
|
slug = body.name.lower().replace(" ", "-")
|
|
fpath = os.path.join(SKILLS_DIR, f"{slug}.md")
|
|
|
|
with open(fpath, "w") as f:
|
|
f.write(body.content)
|
|
|
|
index = _load_index()
|
|
index[slug] = {
|
|
"name": body.name,
|
|
"description": body.description,
|
|
"command": body.command or slug,
|
|
}
|
|
_save_index(index)
|
|
|
|
skill = Skill(
|
|
id=slug,
|
|
name=body.name,
|
|
description=body.description,
|
|
content=body.content,
|
|
file_path=fpath,
|
|
command=body.command or slug,
|
|
)
|
|
pass
|
|
return {"ok": True, "skill": skill.model_dump()}
|
|
|
|
|
|
@skills.router.put("/{skill_id}")
|
|
async def update_skill(skill_id: str, body: SkillUpdate):
|
|
fpath = os.path.join(SKILLS_DIR, f"{skill_id}.md")
|
|
if not os.path.exists(fpath):
|
|
raise HTTPException(status_code=404, detail="Skill not found")
|
|
|
|
if body.content is not None:
|
|
with open(fpath, "w") as f:
|
|
f.write(body.content)
|
|
|
|
index = _load_index()
|
|
meta = index.get(skill_id, {})
|
|
if body.name is not None:
|
|
meta["name"] = body.name
|
|
if body.description is not None:
|
|
meta["description"] = body.description
|
|
if body.command is not None:
|
|
meta["command"] = body.command
|
|
index[skill_id] = meta
|
|
_save_index(index)
|
|
|
|
with open(fpath) as f:
|
|
content = f.read()
|
|
|
|
skill = Skill(
|
|
id=skill_id,
|
|
name=meta.get("name", skill_id),
|
|
description=meta.get("description", ""),
|
|
content=content,
|
|
file_path=fpath,
|
|
command=meta.get("command", skill_id),
|
|
)
|
|
return {"ok": True, "skill": skill.model_dump()}
|
|
|
|
|
|
@skills.router.delete("/{skill_id}")
|
|
async def delete_skill(skill_id: str):
|
|
index = _load_index()
|
|
if index.get(skill_id, {}).get("built_in"):
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=(
|
|
f"'{skill_id}' is a built-in skill and can't be deleted "
|
|
"(edit its content instead; your edits take effect on "
|
|
"the next agent turn)."
|
|
),
|
|
)
|
|
fpath = os.path.join(SKILLS_DIR, f"{skill_id}.md")
|
|
if os.path.exists(fpath):
|
|
os.remove(fpath)
|
|
index.pop(skill_id, None)
|
|
_save_index(index)
|
|
return {"ok": True}
|