[eric] skills: /upload endpoint takes a bare SKILL .md or a .zip/.skill archive, shallowest SKILL.md wins

This commit is contained in:
ciregenz
2026-08-05 01:20:17 -07:00
parent d269a9f3c7
commit 99fd1ff82e
4 changed files with 151 additions and 223 deletions
+5
View File
@@ -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
+55 -1
View File
@@ -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.
+91
View File
@@ -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;