mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-24 05:22:22 +02:00
439 lines
16 KiB
Python
439 lines
16 KiB
Python
import os
|
|
import json
|
|
import logging
|
|
import re
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
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]:
|
|
"""Read the skill index, never raising on a corrupt file. A truncated/garbled
|
|
index (e.g. a crash mid-write before atomic writes existed) is moved aside so
|
|
it's recoverable, and we start empty rather than bricking every skill op,
|
|
skills still list from their files with frontmatter/filename-derived names."""
|
|
if not os.path.exists(INDEX_PATH):
|
|
return {}
|
|
try:
|
|
with open(INDEX_PATH, encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
if isinstance(data, dict):
|
|
return data
|
|
logger.warning("skills index was not an object; ignoring")
|
|
except (OSError, ValueError):
|
|
logger.warning("skills index unreadable; preserving aside and starting empty", exc_info=True)
|
|
try:
|
|
os.replace(INDEX_PATH, INDEX_PATH + ".corrupt")
|
|
except OSError:
|
|
pass
|
|
return {}
|
|
|
|
|
|
# Guards the index write so an atomic replace is never interleaved by another writer. Today every index write runs on the single backend event-loop thread (no await between a load and its save, so no lost-update race), but this stays correct if a save ever moves to a thread pool the way settings' did.
|
|
p_index_write_lock = threading.Lock()
|
|
|
|
|
|
def save_index(index: dict[str, dict]):
|
|
"""Atomic index write: tmp file + os.replace so a crash mid-write can't leave
|
|
a truncated index. Mirrors the settings store's write discipline."""
|
|
with p_index_write_lock:
|
|
os.makedirs(SKILLS_DIR, exist_ok=True)
|
|
fd, tmp = tempfile.mkstemp(prefix=".skills_index.", suffix=".tmp", dir=SKILLS_DIR)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
json.dump(index, f, indent=2)
|
|
# Windows: Defender can briefly lock the destination; one retry covers it.
|
|
for attempt in range(2):
|
|
try:
|
|
os.replace(tmp, INDEX_PATH)
|
|
return
|
|
except PermissionError:
|
|
if attempt == 1:
|
|
raise
|
|
time.sleep(0.05)
|
|
except Exception:
|
|
try:
|
|
os.unlink(tmp)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
|
|
|
|
# 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 p_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 p_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 p_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:
|
|
p_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 skill_md_path(skill_id: str) -> tuple[str | None, str]:
|
|
"""Resolve where a skill's markdown lives: (path, kind).
|
|
|
|
A skill is either a folder (~/.claude/skills/<id>/SKILL.md, multi-file) or a
|
|
legacy flat file (~/.claude/skills/<id>.md). Folder wins if both exist. The
|
|
one place that knows the layout, so get/update/delete never re-guess it."""
|
|
folder_md = os.path.join(SKILLS_DIR, skill_id, "SKILL.md")
|
|
if os.path.isfile(folder_md):
|
|
return folder_md, "folder"
|
|
flat_md = os.path.join(SKILLS_DIR, f"{skill_id}.md")
|
|
if os.path.isfile(flat_md):
|
|
return flat_md, "flat"
|
|
return None, "flat"
|
|
|
|
|
|
def p_has_supporting_files(skill_dir: str) -> bool:
|
|
"""True if a skill folder ships anything beyond its SKILL.md (scripts, templates)."""
|
|
try:
|
|
return any(e != "SKILL.md" and not e.startswith(".") for e in os.listdir(skill_dir))
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def p_build_skill(skill_id: str, content: str, md_path: str, kind: str, index: dict) -> Skill:
|
|
"""Assemble a Skill from disk + index, falling back to SKILL.md frontmatter
|
|
for a folder skill the index hasn't catalogued (e.g. hand-dropped)."""
|
|
meta = dict(index.get(skill_id, {}))
|
|
if kind == "folder" and ("name" not in meta or "description" not in meta):
|
|
fm = p_parse_skill_frontmatter(content)
|
|
meta.setdefault("name", fm.get("name", ""))
|
|
meta.setdefault("description", fm.get("description", ""))
|
|
pretty = skill_id.replace("-", " ").replace("_", " ").title()
|
|
skill_dir = os.path.join(SKILLS_DIR, skill_id)
|
|
return Skill(
|
|
id=skill_id,
|
|
name=meta.get("name") or pretty,
|
|
description=meta.get("description", ""),
|
|
content=content,
|
|
file_path=md_path,
|
|
command=meta.get("command", skill_id),
|
|
built_in=bool(meta.get("built_in", False)),
|
|
dir_path=skill_dir if kind == "folder" else "",
|
|
has_supporting_files=(kind == "folder" and p_has_supporting_files(skill_dir)),
|
|
)
|
|
|
|
|
|
def sync_skills() -> list[Skill]:
|
|
"""Sync skills from the filesystem, updating the index. Reads both layouts:
|
|
legacy flat <id>.md files and multi-file <id>/SKILL.md folders."""
|
|
index = load_index()
|
|
result = []
|
|
seen: set[str] = set()
|
|
|
|
if not os.path.exists(SKILLS_DIR):
|
|
return result
|
|
|
|
for entry in os.listdir(SKILLS_DIR):
|
|
full = os.path.join(SKILLS_DIR, entry)
|
|
if os.path.isdir(full):
|
|
skill_id = entry
|
|
elif entry.endswith(".md"):
|
|
skill_id = entry[: -len(".md")]
|
|
else:
|
|
continue
|
|
if skill_id in seen:
|
|
continue
|
|
md_path, kind = skill_md_path(skill_id)
|
|
if not md_path:
|
|
continue
|
|
with open(md_path, encoding="utf-8") as f:
|
|
content = f.read()
|
|
seen.add(skill_id)
|
|
result.append(p_build_skill(skill_id, content, md_path, kind, index))
|
|
|
|
return result
|
|
|
|
|
|
@skills.router.get("/list")
|
|
async def list_skills():
|
|
return {"skills": [s.model_dump() for s in sync_skills()]}
|
|
|
|
|
|
def p_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 = p_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")
|
|
|
|
|
|
def p_safe_slug(raw: str) -> str:
|
|
slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", (raw or "").strip().lower()).strip("-")
|
|
return slug or "skill"
|
|
|
|
|
|
def p_skill_exists(slug: str) -> bool:
|
|
return (
|
|
slug in load_index()
|
|
or os.path.isfile(os.path.join(SKILLS_DIR, f"{slug}.md"))
|
|
or os.path.isdir(os.path.join(SKILLS_DIR, slug))
|
|
)
|
|
|
|
|
|
def unique_skill_slug(base: str) -> str:
|
|
"""A free slug for `base`, suffixing -2, -3, ... on collision. Lets a
|
|
registry install land beside a same-named skill instead of silently
|
|
overwriting the user's existing one."""
|
|
slug = p_safe_slug(base)
|
|
if not p_skill_exists(slug):
|
|
return slug
|
|
i = 2
|
|
while p_skill_exists(f"{slug}-{i}"):
|
|
i += 1
|
|
return f"{slug}-{i}"
|
|
|
|
|
|
def write_folder_skill(skill_id: str, files: dict[str, str], meta: dict) -> Skill:
|
|
"""Write a multi-file skill folder (relpath -> content) under SKILLS_DIR and
|
|
index it. `files` must include a 'SKILL.md'. Shared by registry install and
|
|
zip/.swarm import. Relpaths that try to escape the skill folder (../, abs
|
|
paths) are dropped, an untrusted registry archive can't write outside its
|
|
own dir."""
|
|
slug = p_safe_slug(skill_id)
|
|
base = os.path.join(SKILLS_DIR, slug)
|
|
base_abs = os.path.abspath(base)
|
|
# A folder write supersedes any legacy flat <slug>.md, so we never leave a phantom flat file shadowed by the folder (folder wins in skill_md_path).
|
|
legacy_flat = os.path.join(SKILLS_DIR, f"{slug}.md")
|
|
if os.path.isfile(legacy_flat):
|
|
try:
|
|
os.remove(legacy_flat)
|
|
except OSError:
|
|
pass
|
|
os.makedirs(base, exist_ok=True)
|
|
for rel, content in files.items():
|
|
dest = os.path.abspath(os.path.join(base, rel))
|
|
if os.path.commonpath([base_abs, dest]) != base_abs:
|
|
logger.warning("skill import: dropped path-escape entry %r", rel)
|
|
continue
|
|
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
with open(dest, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
|
|
index = load_index()
|
|
index[slug] = {
|
|
"name": meta.get("name") or slug,
|
|
"description": meta.get("description", ""),
|
|
"command": meta.get("command", slug),
|
|
}
|
|
save_index(index)
|
|
|
|
md_path, kind = skill_md_path(slug)
|
|
if not md_path:
|
|
raise HTTPException(status_code=400, detail="skill had no SKILL.md")
|
|
with open(md_path, encoding="utf-8") as f:
|
|
content = f.read()
|
|
return p_build_skill(slug, content, md_path, kind, index)
|
|
|
|
|
|
@skills.router.post("/create")
|
|
async def create_skill(body: SkillCreate):
|
|
# All user skills are folders now (<id>/SKILL.md); flat files stay readable but are no longer written, so a skill's on-disk shape no longer depends on how it was created vs imported.
|
|
meta = {"name": body.name, "description": body.description}
|
|
if body.command:
|
|
meta["command"] = body.command
|
|
skill = write_folder_skill(body.name, {"SKILL.md": body.content}, meta)
|
|
return {"ok": True, "skill": skill.model_dump()}
|
|
|
|
|
|
@skills.router.put("/{skill_id}")
|
|
async def update_skill(skill_id: str, body: SkillUpdate):
|
|
md_path, kind = skill_md_path(skill_id)
|
|
if not md_path:
|
|
raise HTTPException(status_code=404, detail="Skill not found")
|
|
|
|
if body.content is not None:
|
|
with open(md_path, "w", encoding="utf-8") 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(md_path, encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
skill = p_build_skill(skill_id, content, md_path, kind, index)
|
|
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)."
|
|
),
|
|
)
|
|
# Remove whichever layout exists: the whole folder, or the flat file.
|
|
import shutil
|
|
skill_dir = os.path.join(SKILLS_DIR, skill_id)
|
|
flat = os.path.join(SKILLS_DIR, f"{skill_id}.md")
|
|
if os.path.isdir(skill_dir):
|
|
shutil.rmtree(skill_dir, ignore_errors=True)
|
|
if os.path.isfile(flat):
|
|
os.remove(flat)
|
|
index.pop(skill_id, None)
|
|
save_index(index)
|
|
return {"ok": True}
|