mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-02 13:48:45 +02:00
133 lines
4.8 KiB
Python
133 lines
4.8 KiB
Python
"""SkillExportable: skills are leaves (no deps, no requirements). A skill is
|
|
either a single markdown file or a folder (SKILL.md + supporting files like
|
|
scripts/templates), so this powers both the .swarm round-trip AND the generic
|
|
"import a .md or a zip-of-SKILL.md" path. Folder skills ride the entity files()
|
|
channel so their supporting files survive export/import. Nothing here is secret,
|
|
but the body still rides the central scrub in case someone pasted a token in."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
|
|
from backend.apps.skills import skills as store
|
|
from backend.apps.swarm.exportable import DepRef, ExportContext, RemapTable
|
|
from backend.apps.swarm.models import EntityType, Requirement
|
|
|
|
|
|
class SkillExportable:
|
|
type = EntityType.skill
|
|
|
|
def __init__(self, local_id: str, name: str, payload: dict, files: dict[str, bytes] | None = None):
|
|
self.local_id = local_id
|
|
self.name = name
|
|
self.payload = payload
|
|
self.p_files = files or {}
|
|
|
|
@classmethod
|
|
def load(cls, local_id: str) -> "SkillExportable | None":
|
|
md_path, kind = store.skill_md_path(local_id)
|
|
if not md_path:
|
|
return None
|
|
with open(md_path, encoding="utf-8") as f:
|
|
content = f.read()
|
|
meta = store.load_index().get(local_id, {})
|
|
name = meta.get("name") or local_id.replace("-", " ").replace("_", " ").title()
|
|
payload = {
|
|
"slug": local_id,
|
|
"name": name,
|
|
"description": meta.get("description", ""),
|
|
"command": meta.get("command", local_id),
|
|
"content": content,
|
|
"builtin": bool(meta.get("built_in", False)),
|
|
}
|
|
files: dict[str, bytes] = {}
|
|
if kind == "folder":
|
|
files = p_read_supporting_files(os.path.join(store.SKILLS_DIR, local_id))
|
|
return cls(local_id, name, payload, files)
|
|
|
|
def serialize(self, ctx: ExportContext) -> dict:
|
|
return dict(self.payload)
|
|
|
|
def files(self) -> dict[str, bytes]:
|
|
return dict(self.p_files)
|
|
|
|
def dependencies(self) -> list[DepRef]:
|
|
return []
|
|
|
|
def requirements(self) -> list[Requirement]:
|
|
return []
|
|
|
|
@classmethod
|
|
def conflict(cls, payload: dict) -> str | None:
|
|
slug = payload.get("slug") or ""
|
|
if slug and p_slug_taken(slug):
|
|
return "already exists; will be added as a copy"
|
|
return None
|
|
|
|
@classmethod
|
|
def import_(cls, payload: dict, files: dict[str, bytes], remap: RemapTable) -> str:
|
|
base = (payload.get("slug") or payload.get("name") or "skill").lower().replace(" ", "-")
|
|
slug = p_free_slug(base)
|
|
meta = {
|
|
"name": payload.get("name", slug),
|
|
"description": payload.get("description", ""),
|
|
"command": payload.get("command", slug),
|
|
}
|
|
# Every imported skill lands as a folder (SKILL.md + any supporting files), one path for one-file and multi-file skills alike. write_folder_skill is path-traversal-safe, so an untrusted bundle can't escape the skill dir.
|
|
bundle = {"SKILL.md": payload.get("content", "")}
|
|
for rel, data in files.items():
|
|
bundle[rel] = data.decode("utf-8", errors="replace")
|
|
skill = store.write_folder_skill(slug, bundle, meta)
|
|
return skill.id
|
|
|
|
@classmethod
|
|
def rollback(cls, local_id: str) -> None:
|
|
skill_dir = os.path.join(store.SKILLS_DIR, local_id)
|
|
flat = os.path.join(store.SKILLS_DIR, f"{local_id}.md")
|
|
if os.path.isdir(skill_dir):
|
|
shutil.rmtree(skill_dir, ignore_errors=True)
|
|
if os.path.isfile(flat):
|
|
os.remove(flat)
|
|
index = store.load_index()
|
|
if local_id in index:
|
|
index.pop(local_id, None)
|
|
store.save_index(index)
|
|
|
|
|
|
def p_read_supporting_files(skill_dir: str) -> dict[str, bytes]:
|
|
"""Every file in a skill folder except SKILL.md, as {relpath: bytes}."""
|
|
out: dict[str, bytes] = {}
|
|
for root, p_dirs, names in os.walk(skill_dir):
|
|
for n in names:
|
|
full = os.path.join(root, n)
|
|
rel = os.path.relpath(full, skill_dir)
|
|
if rel == "SKILL.md" or n.startswith("."):
|
|
continue
|
|
try:
|
|
with open(full, "rb") as f:
|
|
out[rel] = f.read()
|
|
except OSError:
|
|
continue
|
|
return out
|
|
|
|
|
|
def p_slug_taken(slug: str) -> bool:
|
|
return (
|
|
slug in store.load_index()
|
|
or os.path.isfile(os.path.join(store.SKILLS_DIR, f"{slug}.md"))
|
|
or os.path.isdir(os.path.join(store.SKILLS_DIR, slug))
|
|
)
|
|
|
|
|
|
def p_free_slug(base: str) -> str:
|
|
base = base or "skill"
|
|
if not p_slug_taken(base):
|
|
return base
|
|
cand = f"{base}-imported"
|
|
if not p_slug_taken(cand):
|
|
return cand
|
|
i = 2
|
|
while p_slug_taken(f"{base}-imported-{i}"):
|
|
i += 1
|
|
return f"{base}-imported-{i}"
|