mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-17 02:05:22 -04:00
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:
@@ -0,0 +1,178 @@
|
||||
"""Skill URL importer — GitHub path parsing."""
|
||||
import pytest
|
||||
|
||||
from services.memory.skill_importer import (
|
||||
ResolvedSource,
|
||||
SkillImportError,
|
||||
_assert_github_url,
|
||||
_fetch_bytes,
|
||||
_list_github_dir,
|
||||
parse_skill_source,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_github_blob_skill_md():
|
||||
src = parse_skill_source(
|
||||
"https://github.com/anthropics/skills/blob/main/skills/pdf/SKILL.md"
|
||||
)
|
||||
assert src.owner == "anthropics"
|
||||
assert src.repo == "skills"
|
||||
assert src.ref == "main"
|
||||
assert src.path.endswith("skills/pdf/SKILL.md")
|
||||
|
||||
|
||||
def test_parse_github_tree_directory():
|
||||
src = parse_skill_source(
|
||||
"https://github.com/example/my-skills/tree/develop/caveman-skill"
|
||||
)
|
||||
assert src.owner == "example"
|
||||
assert src.repo == "my-skills"
|
||||
assert src.ref == "develop"
|
||||
assert src.path == "caveman-skill"
|
||||
|
||||
|
||||
def test_parse_raw_github():
|
||||
src = parse_skill_source(
|
||||
"https://raw.githubusercontent.com/o/r/main/path/SKILL.md"
|
||||
)
|
||||
assert src.owner == "o"
|
||||
assert src.repo == "r"
|
||||
assert src.ref == "main"
|
||||
assert src.path == "path/SKILL.md"
|
||||
|
||||
|
||||
def test_rejects_non_github():
|
||||
with pytest.raises(SkillImportError):
|
||||
parse_skill_source("https://example.com/skill.md")
|
||||
|
||||
|
||||
def test_fetch_bytes_rejects_cross_host_redirect(monkeypatch):
|
||||
class _Resp:
|
||||
url = "https://evil.example/secret"
|
||||
status_code = 200
|
||||
content = b"x"
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
class _Client:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def get(self, url, headers=None):
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr("services.memory.skill_importer.httpx.Client", _Client)
|
||||
monkeypatch.setattr(
|
||||
"services.memory.skill_importer.check_outbound_url",
|
||||
lambda url: (True, ""),
|
||||
)
|
||||
with pytest.raises(SkillImportError, match="redirect target"):
|
||||
_fetch_bytes("https://raw.githubusercontent.com/o/r/main/SKILL.md")
|
||||
|
||||
|
||||
def test_assert_github_url_allows_api_host():
|
||||
_assert_github_url(
|
||||
"https://api.github.com/repos/o/r/contents?ref=main",
|
||||
context="redirect target",
|
||||
)
|
||||
|
||||
|
||||
def test_list_github_dir_accepts_api_github_response(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"services.memory.skill_importer._fetch_text",
|
||||
lambda url: "# skill\n",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.memory.skill_importer.check_outbound_url",
|
||||
lambda url: (True, ""),
|
||||
)
|
||||
|
||||
class _Resp:
|
||||
url = "https://api.github.com/repos/o/r/contents?ref=main"
|
||||
status_code = 200
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return [{
|
||||
"name": "SKILL.md",
|
||||
"type": "file",
|
||||
"download_url": "https://raw.githubusercontent.com/o/r/main/SKILL.md",
|
||||
}]
|
||||
|
||||
class _Client:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def get(self, url, headers=None):
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr("services.memory.skill_importer.httpx.Client", _Client)
|
||||
|
||||
out = {}
|
||||
src = ResolvedSource(owner="o", repo="r", ref="main", path="")
|
||||
_list_github_dir(src, "", out)
|
||||
assert "SKILL.md" in out
|
||||
|
||||
|
||||
def _mock_httpx_client(monkeypatch, response):
|
||||
class _Client:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def get(self, url, headers=None):
|
||||
return response
|
||||
|
||||
monkeypatch.setattr("services.memory.skill_importer.httpx.Client", _Client)
|
||||
monkeypatch.setattr(
|
||||
"services.memory.skill_importer.check_outbound_url",
|
||||
lambda url: (True, ""),
|
||||
)
|
||||
|
||||
|
||||
def test_list_github_dir_surfaces_rate_limit(monkeypatch):
|
||||
class _Resp:
|
||||
url = "https://api.github.com/repos/o/r/contents?ref=main"
|
||||
status_code = 403
|
||||
|
||||
def json(self):
|
||||
return {"message": "API rate limit exceeded for 203.0.113.1"}
|
||||
|
||||
_mock_httpx_client(monkeypatch, _Resp())
|
||||
src = ResolvedSource(owner="o", repo="r", ref="main", path="")
|
||||
with pytest.raises(SkillImportError, match="rate limit"):
|
||||
_list_github_dir(src, "", {})
|
||||
|
||||
|
||||
def test_fetch_bytes_surfaces_github_error_detail(monkeypatch):
|
||||
class _Resp:
|
||||
url = "https://raw.githubusercontent.com/o/r/main/SKILL.md"
|
||||
status_code = 403
|
||||
content = b""
|
||||
|
||||
def json(self):
|
||||
return {"message": "Forbidden"}
|
||||
|
||||
_mock_httpx_client(monkeypatch, _Resp())
|
||||
with pytest.raises(SkillImportError, match="GitHub request failed \\(403\\): Forbidden"):
|
||||
_fetch_bytes("https://raw.githubusercontent.com/o/r/main/SKILL.md")
|
||||
Reference in New Issue
Block a user