mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] skills: /upload endpoint takes a bare SKILL .md or a .zip/.skill archive, shallowest SKILL.md wins
This commit is contained in:
@@ -39,6 +39,11 @@ class SkillLoadRequest(BaseModel):
|
||||
id: str
|
||||
|
||||
|
||||
class SkillUpload(BaseModel):
|
||||
filename: str
|
||||
content_b64: str
|
||||
|
||||
|
||||
class SkillWorkspaceSeedRequest(BaseModel):
|
||||
workspace_id: str
|
||||
skill_content: Optional[str] = None
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import base64
|
||||
import binascii
|
||||
import io
|
||||
import os
|
||||
import hashlib
|
||||
import json
|
||||
@@ -6,10 +9,11 @@ import re
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import HTTPException
|
||||
from backend.config.Apps import SubApp
|
||||
from backend.apps.skills.models import Skill, SkillCreate, SkillLoadRequest, SkillUpdate, SkillWorkspaceSeedRequest
|
||||
from backend.apps.skills.models import Skill, SkillCreate, SkillLoadRequest, SkillUpdate, SkillUpload, SkillWorkspaceSeedRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -473,6 +477,56 @@ 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.post("/upload")
|
||||
async def upload_skill(body: SkillUpload):
|
||||
"""The Directory's Upload skill drop zone: a bare SKILL .md, or a .zip/.skill archive
|
||||
whose shallowest SKILL.md marks the skill root; sibling files ride along as folder extras."""
|
||||
name_l = body.filename.lower()
|
||||
try:
|
||||
raw = base64.b64decode(body.content_b64)
|
||||
except (binascii.Error, ValueError):
|
||||
raise HTTPException(status_code=400, detail="upload was not valid base64")
|
||||
|
||||
if name_l.endswith(".md"):
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
meta = p_parse_skill_frontmatter(text)
|
||||
if not meta.get("name") or not meta.get("description"):
|
||||
raise HTTPException(status_code=400, detail=".md file must contain skill name and description formatted in YAML")
|
||||
skill = write_folder_skill(unique_skill_slug(meta["name"]), {"SKILL.md": text}, meta)
|
||||
return {"ok": True, "skill": skill.model_dump()}
|
||||
|
||||
if name_l.endswith(".zip") or name_l.endswith(".skill"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(raw))
|
||||
except zipfile.BadZipFile:
|
||||
raise HTTPException(status_code=400, detail="file is not a valid zip archive")
|
||||
entries = [n for n in zf.namelist() if not n.endswith("/")]
|
||||
md_entries = [n for n in entries if n.split("/")[-1] == "SKILL.md"]
|
||||
if not md_entries:
|
||||
raise HTTPException(status_code=400, detail=".zip or .skill file must include a SKILL.md file")
|
||||
md_entry = min(md_entries, key=lambda n: n.count("/"))
|
||||
root = md_entry[: -len("SKILL.md")]
|
||||
files: dict[str, str] = {}
|
||||
for n in entries:
|
||||
if not n.startswith(root):
|
||||
continue
|
||||
rel = n[len(root):]
|
||||
if not rel:
|
||||
continue
|
||||
try:
|
||||
files[rel] = zf.read(n).decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
# Binary assets are skipped; the skill contract is text (SKILL.md + scripts).
|
||||
logger.warning("skill upload: skipped binary entry %r", n)
|
||||
meta = p_parse_skill_frontmatter(files.get("SKILL.md", ""))
|
||||
if not meta.get("name"):
|
||||
meta["name"] = re.sub(r"\.(zip|skill)$", "", body.filename, flags=re.IGNORECASE)
|
||||
skill = write_folder_skill(unique_skill_slug(meta["name"]), files, meta)
|
||||
return {"ok": True, "skill": skill.model_dump()}
|
||||
|
||||
raise HTTPException(status_code=400, detail="unsupported file type: upload a .md, .zip, or .skill file")
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""The Directory's Upload skill endpoint: a bare SKILL .md needs YAML name+description,
|
||||
a .zip/.skill archive needs a SKILL.md (shallowest wins, siblings ride along), and
|
||||
anything else is refused with a readable reason."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
import backend.apps.skills.skills as skills_mod
|
||||
from backend.apps.skills.models import SkillUpload
|
||||
|
||||
|
||||
@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 b64(data: bytes) -> str:
|
||||
return base64.b64encode(data).decode()
|
||||
|
||||
|
||||
def make_zip(entries: dict[str, str]) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
for name, content in entries.items():
|
||||
zf.writestr(name, content)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_md_upload_creates_skill(isolated_skills):
|
||||
md = "---\nname: Test Upload\ndescription: A test\n---\n\n# Test Upload\n"
|
||||
res = await skills_mod.upload_skill(SkillUpload(filename="test.md", content_b64=b64(md.encode())))
|
||||
assert res["ok"] is True
|
||||
assert res["skill"]["name"] == "Test Upload"
|
||||
assert (isolated_skills / "test-upload" / "SKILL.md").is_file()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_md_without_frontmatter_rejected(isolated_skills):
|
||||
with pytest.raises(HTTPException) as e:
|
||||
await skills_mod.upload_skill(SkillUpload(filename="x.md", content_b64=b64(b"no yaml")))
|
||||
assert e.value.status_code == 400
|
||||
assert ".md file must contain skill name and description formatted in YAML" in e.value.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zip_with_nested_skill_md(isolated_skills):
|
||||
raw = make_zip({
|
||||
"my-skill/SKILL.md": "---\nname: Zipped\ndescription: d\n---\nbody",
|
||||
"my-skill/scripts/run.py": "print('hi')",
|
||||
"unrelated/readme.txt": "not part of the skill",
|
||||
})
|
||||
res = await skills_mod.upload_skill(SkillUpload(filename="my-skill.zip", content_b64=b64(raw)))
|
||||
assert res["ok"] is True
|
||||
base = isolated_skills / "zipped"
|
||||
assert (base / "SKILL.md").is_file()
|
||||
assert (base / "scripts" / "run.py").is_file()
|
||||
assert not (base / "readme.txt").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zip_without_skill_md_rejected(isolated_skills):
|
||||
raw = make_zip({"folder/notes.md": "just notes"})
|
||||
with pytest.raises(HTTPException) as e:
|
||||
await skills_mod.upload_skill(SkillUpload(filename="x.zip", content_b64=b64(raw)))
|
||||
assert e.value.status_code == 400
|
||||
assert ".zip or .skill file must include a SKILL.md file" in e.value.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsupported_extension_rejected(isolated_skills):
|
||||
with pytest.raises(HTTPException) as e:
|
||||
await skills_mod.upload_skill(SkillUpload(filename="x.tar.gz", content_b64=b64(b"whatever")))
|
||||
assert e.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bad_base64_rejected(isolated_skills):
|
||||
with pytest.raises(HTTPException) as e:
|
||||
await skills_mod.upload_skill(SkillUpload(filename="x.md", content_b64="!!!not-base64!!!"))
|
||||
assert e.value.status_code == 400
|
||||
@@ -1,222 +0,0 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Button from '@mui/material/Button';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { EmptyState } from '@/app/components/feedback/Loading';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import {
|
||||
searchCommunitySkills,
|
||||
installCommunitySkill,
|
||||
CommunitySkill,
|
||||
InstallDisclosure,
|
||||
} from '@/shared/state/skillRegistrySlice';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onInstalled: (name: string) => void;
|
||||
}
|
||||
|
||||
// The skills.sh wild registry is unvetted community code (skills can ship scripts). So this dialog never installs blind: picking a skill fetches a disclosure (files + scripts) the user confirms before anything lands on disk.
|
||||
const CommunitySkillsDialog: React.FC<Props> = ({ open, onClose, onInstalled }) => {
|
||||
const c = useClaudeTokens();
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<CommunitySkill[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selected, setSelected] = useState<CommunitySkill | null>(null);
|
||||
const [disclosure, setDisclosure] = useState<InstallDisclosure | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Monotonic request tokens: a slow response from an earlier search/preview must not overwrite the state a newer one already set (out-of-order network).
|
||||
const searchSeq = useRef(0);
|
||||
const previewSeq = useRef(0);
|
||||
|
||||
const runSearch = useCallback(async (q: string) => {
|
||||
const seq = ++searchSeq.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await searchCommunitySkills(q);
|
||||
if (seq !== searchSeq.current) return;
|
||||
setResults(res);
|
||||
} catch (e) {
|
||||
if (seq !== searchSeq.current) return;
|
||||
setError(e instanceof Error ? e.message : 'Search failed');
|
||||
setResults([]);
|
||||
} finally {
|
||||
if (seq === searchSeq.current) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => runSearch(query.trim()), 300);
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||
}, [query, open, runSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery(''); setResults([]); setSelected(null); setDisclosure(null); setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const preview = async (skill: CommunitySkill) => {
|
||||
const seq = ++previewSeq.current;
|
||||
setSelected(skill);
|
||||
setDisclosure(null);
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await installCommunitySkill(skill.source, skill.skillId, false);
|
||||
if (seq !== previewSeq.current) return;
|
||||
setDisclosure(res.disclosure);
|
||||
} catch (e) {
|
||||
if (seq !== previewSeq.current) return;
|
||||
setError(e instanceof Error ? e.message : 'Could not load skill');
|
||||
setSelected(null);
|
||||
} finally {
|
||||
if (seq === previewSeq.current) setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmInstall = async () => {
|
||||
if (!selected) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await installCommunitySkill(selected.source, selected.skillId, true);
|
||||
onInstalled(disclosure?.name || selected.name);
|
||||
setSelected(null);
|
||||
setDisclosure(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Install failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth
|
||||
PaperProps={{ sx: { bgcolor: c.bg.secondary, borderRadius: `${c.radius.md}px` } }}>
|
||||
<DialogTitle sx={{ color: c.text.primary, fontSize: '1rem', fontWeight: 700, pb: 0.5 }}>
|
||||
Browse community skills
|
||||
<Typography sx={{ fontSize: '0.75rem', color: c.text.tertiary, fontWeight: 400 }}>
|
||||
From the skills.sh registry. Community-published and unvetted; you'll see exactly what installs before it lands.
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, minHeight: 360 }}>
|
||||
{error && <Alert severity="error" sx={{ fontSize: '0.8125rem' }}>{error}</Alert>}
|
||||
|
||||
{!selected && (
|
||||
<>
|
||||
<TextField
|
||||
autoFocus
|
||||
placeholder="Search skills.sh (e.g. pdf, slides, video)…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
size="small"
|
||||
InputProps={{ startAdornment: (<InputAdornment position="start"><SearchIcon sx={{ fontSize: 18, color: c.text.tertiary }} /></InputAdornment>) }}
|
||||
/>
|
||||
{loading && <Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}><CircularProgress size={22} /></Box>}
|
||||
{!loading && results.length === 0 && (
|
||||
<EmptyState title={query.trim() ? 'No matching skills.' : 'Type to search the community registry.'} />
|
||||
)}
|
||||
{!loading && results.map((s) => (
|
||||
<Box key={`${s.source}/${s.skillId}`}
|
||||
onClick={() => preview(s)}
|
||||
sx={{
|
||||
px: 1.5, py: 1, borderRadius: `${c.radius.sm}px`, cursor: 'pointer',
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
'&:hover': { borderColor: c.accent.primary, bgcolor: `${c.accent.primary}08` },
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1 }}>
|
||||
<Typography sx={{ fontSize: '0.875rem', fontWeight: 600, color: c.text.primary }}>{s.name}</Typography>
|
||||
<Chip label={`${s.installs.toLocaleString()} installs`} size="small"
|
||||
sx={{ height: 18, fontSize: '0.6875rem', bgcolor: c.bg.elevated, color: c.text.tertiary }} />
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: '0.75rem', color: c.text.tertiary, fontFamily: c.font.mono }}>{s.source}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Button onClick={() => { previewSeq.current++; setSelected(null); setDisclosure(null); }} size="small"
|
||||
sx={{ alignSelf: 'flex-start', textTransform: 'none', color: c.text.tertiary, fontSize: '0.75rem' }}>
|
||||
← Back to results
|
||||
</Button>
|
||||
{busy && !disclosure && <Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}><CircularProgress size={22} /></Box>}
|
||||
{disclosure && (
|
||||
<>
|
||||
<Typography sx={{ fontSize: '1rem', fontWeight: 700, color: c.text.primary }}>{disclosure.name}</Typography>
|
||||
{disclosure.description && (
|
||||
<Typography sx={{ fontSize: '0.8125rem', color: c.text.secondary }}>{disclosure.description}</Typography>
|
||||
)}
|
||||
<Box
|
||||
component="a" href={disclosure.repo_url} target="_blank" rel="noreferrer"
|
||||
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, fontSize: '0.75rem', color: c.accent.primary, textDecoration: 'none', fontFamily: c.font.mono }}>
|
||||
{selected.source} <OpenInNewIcon sx={{ fontSize: 13 }} />
|
||||
</Box>
|
||||
|
||||
{/* The real risk for an agent platform: SKILL.md is injected as instructions the
|
||||
agent follows, with its full tool surface. Say that plainly, not just "scripts". */}
|
||||
<Alert severity="info" icon={<WarningAmberIcon fontSize="small" />} sx={{ fontSize: '0.75rem', py: 0 }}>
|
||||
This is an unvetted community skill. Its SKILL.md becomes instructions your agent will follow, and it can use your agent's tools (files, browser, settings). Only install from a source you trust, read it below first.
|
||||
</Alert>
|
||||
|
||||
{disclosure.secret_findings.length > 0 && (
|
||||
<Alert severity="error" icon={<WarningAmberIcon fontSize="small" />} sx={{ fontSize: '0.75rem', py: 0 }}>
|
||||
{disclosure.secret_findings.length} file{disclosure.secret_findings.length === 1 ? '' : 's'} contain secret-shaped text ({disclosure.secret_findings.slice(0, 3).join(', ')}{disclosure.secret_findings.length > 3 ? '…' : ''}). A trustworthy skill shouldn't ship credentials; treat this as a red flag.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{disclosure.has_scripts && (
|
||||
<Alert severity="warning" icon={<WarningAmberIcon fontSize="small" />} sx={{ fontSize: '0.75rem', py: 0 }}>
|
||||
Includes {disclosure.scripts.length} script file{disclosure.scripts.length === 1 ? '' : 's'} that can run code when an agent uses this skill. Installing only writes the files; nothing runs until an agent does, and that still goes through normal command approval.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Typography sx={{ fontSize: '0.75rem', color: c.text.tertiary, mt: 0.5 }}>
|
||||
{disclosure.files.length} file{disclosure.files.length === 1 ? '' : 's'} will be installed:
|
||||
</Typography>
|
||||
<Box sx={{ maxHeight: 120, overflow: 'auto', border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.sm}px`, p: 1 }}>
|
||||
{disclosure.files.map((f) => (
|
||||
<Typography key={f} sx={{ fontSize: '0.75rem', fontFamily: c.font.mono, color: disclosure.scripts.includes(f) ? c.status.warning : c.text.secondary }}>
|
||||
{disclosure.scripts.includes(f) ? '⚙ ' : ''}{f}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={onClose} sx={{ textTransform: 'none', color: c.text.tertiary }}>Close</Button>
|
||||
{selected && disclosure && (
|
||||
<Button onClick={confirmInstall} disabled={busy} variant="contained"
|
||||
sx={{ textTransform: 'none', bgcolor: c.accent.primary, '&:hover': { bgcolor: c.accent.primary } }}>
|
||||
{busy ? 'Installing…' : 'Install skill'}
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommunitySkillsDialog;
|
||||
Reference in New Issue
Block a user