feat(skills): import SKILL.md bundles from public GitHub URLs (#2576)

* feat(skills): import SKILL.md bundles from public GitHub URLs

Supports GitHub tree/blob/raw links and skills.sh pages that resolve to GitHub.
Installs SKILL.md plus sibling text assets under data/skills/imported/.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(skills): admin-gate URL import and validate redirect hosts

- require_admin on POST /api/skills/import-from-url (matches other skill admin routes)
- reject cross-host redirects after httpx follow_redirects
- test for redirect host validation

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(skills): match Brain Add panel import/submit button styles

- Skill URL Import: theme-io-btn + download icon (same as memory Import)
- Add Skill submit: confirm-btn confirm-btn-primary

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(skills): allow api.github.com during directory import

Real imports hit the GitHub contents API after redirects; whitelist
api.github.com and add regression tests. Shrink Import button with flex:none.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(skills): align skill Import button with URL input row

Match memory-add-input height (28px) in memory-add-row and center the
download icon with flexbox instead of vertical-align hacks.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(skills): cancel modal-body margin on skill Import button

The skill Import button sits in .memory-add-row beside an input; the
global .modal-body button { margin-top: 6px } rule only affected buttons,
pushing Import down and misaligning the download icon. Reset margin-top
and match Memory Import SVG markup at 28px row height.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(skills): surface GitHub API errors on URL import

Pass through GitHub response messages (especially 403 rate limits) as
SkillImportError instead of a generic download failure.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Giulio Zelante
2026-06-05 19:48:23 +02:00
committed by GitHub
parent 977daf0643
commit b448119919
7 changed files with 597 additions and 2 deletions
+48
View File
@@ -381,6 +381,54 @@ class SkillsManager:
return sk.to_dict()
def import_bundle_from_files(
self,
files: Dict[str, str],
*,
owner: Optional[str] = None,
source_url: str = "",
category: str = "imported",
) -> Dict:
"""Install a fetched skill bundle (relative path → text) under skills/."""
from .skill_importer import SkillImportError, pick_skill_md, _safe_relpath
from core.atomic_io import atomic_write_text
if not files:
raise SkillImportError("empty bundle")
_rel, skill_md = pick_skill_md(files)
sk = Skill.from_markdown(skill_md)
nm = slugify(sk.name or _rel.split("/")[-2] or "skill")
cat = slugify(category or sk.category or "imported", fallback="imported")
existing = {s["name"] for s in self.load_all()}
base = nm
i = 2
while nm in existing:
nm = f"{base}-{i}"
i += 1
skill_dir = self._skill_dir(cat, nm)
os.makedirs(skill_dir, exist_ok=True)
# Preserve bundle layout (templates/, references/, etc.) under the skill dir.
for rel, content in files.items():
safe = _safe_relpath(rel)
dest = os.path.join(skill_dir, safe)
os.makedirs(os.path.dirname(dest), exist_ok=True)
atomic_write_text(dest, content)
sk.name = nm
sk.category = cat
sk.owner = owner
sk.source = "imported"
if source_url:
extra = (sk.body_extra or "").strip()
note = f"Imported from {source_url}"
sk.body_extra = f"{extra}\n\n{note}".strip() if extra else note
atomic_write_text(self._skill_file(cat, nm), sk.to_markdown())
sk.path = self._skill_file(cat, nm)
return sk.to_dict()
def update_skill(self, skill_id: str, updates: Dict, owner: Optional[str] = None) -> bool:
"""`skill_id` is the slug name. Allows updating any field plus
renames if `name` changes (file is moved on disk).