feat(webui): add skills marketplace

This commit is contained in:
Xubin Ren
2026-07-30 01:13:37 +08:00
parent 129b74b4cf
commit ba0ba4749d
23 changed files with 3042 additions and 246 deletions
+128
View File
@@ -0,0 +1,128 @@
from pathlib import Path
from types import SimpleNamespace
import pytest
from nanobot.webui.skills_api import (
SkillManagementError,
delete_webui_skill,
set_webui_skill_enabled,
webui_skill_detail_payload,
webui_skills_payload,
)
def _write_skill(workspace: Path, name: str, *, metadata: str = "") -> Path:
directory = workspace / "skills" / name
directory.mkdir(parents=True)
(directory / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: {name} description.\n{metadata}---\n",
encoding="utf-8",
)
return directory
def _config(*disabled: str) -> SimpleNamespace:
return SimpleNamespace(
agents=SimpleNamespace(
defaults=SimpleNamespace(disabled_skills=list(disabled)),
)
)
def test_disabled_skills_remain_visible_and_loadable(tmp_path: Path) -> None:
_write_skill(tmp_path, "custom-skill")
payload = webui_skills_payload(tmp_path, disabled_skills={"custom-skill"})
skill = next(item for item in payload["skills"] if item["name"] == "custom-skill")
assert skill["enabled"] is False
assert skill["deletable"] is True
detail = webui_skill_detail_payload(
tmp_path,
"custom-skill",
disabled_skills={"custom-skill"},
)
assert detail is not None
assert detail["enabled"] is False
assert "custom-skill description" in detail["raw_markdown"]
def test_skill_detail_exposes_copyable_install_commands(tmp_path: Path) -> None:
_write_skill(
tmp_path,
"custom-skill",
metadata=(
'metadata: {"nanobot":{"requires":{"bins":["demo"]},'
'"install":[{"id":"brew","kind":"brew","formula":"acme/demo",'
'"label":"Install demo"}]}}\n'
),
)
detail = webui_skill_detail_payload(tmp_path, "custom-skill")
assert detail is not None
assert detail["install_options"] == [
{
"id": "brew",
"kind": "brew",
"label": "Install demo",
"command": "brew install acme/demo",
}
]
def test_set_webui_skill_enabled_persists_and_updates_runtime(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_write_skill(tmp_path, "custom-skill")
config = _config()
saved: list[object] = []
monkeypatch.setattr("nanobot.webui.skills_api.load_config", lambda: config)
monkeypatch.setattr("nanobot.webui.skills_api.save_config", saved.append)
disabled: set[str] = set()
action = set_webui_skill_enabled(
tmp_path,
"custom-skill",
enabled=False,
disabled_skills=disabled,
)
assert action == {
"name": "custom-skill",
"enabled": False,
"deleted": False,
}
assert config.agents.defaults.disabled_skills == ["custom-skill"]
assert disabled == {"custom-skill"}
assert saved == [config]
def test_delete_webui_skill_only_deletes_workspace_skills(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
directory = _write_skill(tmp_path, "custom-skill")
config = _config("custom-skill")
saved: list[object] = []
monkeypatch.setattr("nanobot.webui.skills_api.load_config", lambda: config)
monkeypatch.setattr("nanobot.webui.skills_api.save_config", saved.append)
disabled = {"custom-skill"}
action = delete_webui_skill(
tmp_path,
"custom-skill",
disabled_skills=disabled,
)
assert action["deleted"] is True
assert not directory.exists()
assert disabled == set()
assert config.agents.defaults.disabled_skills == []
assert saved == [config]
with pytest.raises(SkillManagementError) as exc_info:
delete_webui_skill(tmp_path, "cron", disabled_skills=disabled)
assert exc_info.value.status == 403
+300
View File
@@ -0,0 +1,300 @@
from pathlib import Path
from typing import Any
import httpx
import pytest
from nanobot.webui.skills_marketplace import (
SkillsMarketplaceError,
install_marketplace_skill,
marketplace_skill_trends,
search_marketplace_skills,
trending_marketplace_skills,
)
@pytest.mark.asyncio
async def test_search_marketplace_skills_filters_and_marks_installed(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
skill_dir = tmp_path / "skills" / "react-testing"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("---\nname: react-testing\n---\n", encoding="utf-8")
seen: dict[str, Any] = {}
class FakeResponse:
def raise_for_status(self) -> None:
pass
def json(self) -> dict[str, Any]:
return {
"skills": [
{
"name": "React Testing",
"skillId": "react-testing",
"source": "acme/agent-skills",
"installs": 42,
},
{"skillId": "../escape", "source": "acme/agent-skills"},
{"skillId": "valid-name", "source": "not-a-repository"},
]
}
class FakeClient:
async def __aenter__(self) -> "FakeClient":
return self
async def __aexit__(self, *_args: object) -> None:
pass
async def get(self, url: str, *, params: dict[str, object]) -> FakeResponse:
seen.update(url=url, params=params)
return FakeResponse()
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
lambda **_kwargs: FakeClient(),
)
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.skills_install_supported",
lambda: True,
)
payload = await search_marketplace_skills(" react testing ", tmp_path)
assert seen == {
"url": "https://skills.sh/api/search",
"params": {"q": "react testing", "limit": 20},
}
assert payload == {
"query": "react testing",
"install_supported": True,
"skills": [
{
"id": "acme/agent-skills/react-testing",
"skill_id": "react-testing",
"name": "React Testing",
"source": "acme/agent-skills",
"installs": 42,
"url": "https://skills.sh/acme/agent-skills/react-testing",
"installed": True,
}
],
}
@pytest.mark.asyncio
async def test_trending_marketplace_skills_diversifies_sources_and_keeps_rank(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
class FakeResponse:
def raise_for_status(self) -> None:
pass
def json(self) -> dict[str, Any]:
return {
"skills": [
{
"name": "First",
"skillId": "first",
"source": "acme/skills",
"installs": 50,
},
{
"name": "Second from same source",
"skillId": "second",
"source": "acme/skills",
"installs": 49,
},
{
"name": "Another",
"skillId": "another",
"source": "other/skills",
"installs": 30,
},
]
}
class FakeClient:
async def __aenter__(self) -> "FakeClient":
return self
async def __aexit__(self, *_args: object) -> None:
pass
async def get(self, url: str) -> FakeResponse:
assert url == "https://skills.sh/api/skills/trending/0"
return FakeResponse()
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
lambda **_kwargs: FakeClient(),
)
payload = await trending_marketplace_skills(tmp_path)
assert payload["period"] == "24h"
assert [(skill["name"], skill["rank"]) for skill in payload["skills"]] == [
("First", 1),
("Another", 3),
]
@pytest.mark.asyncio
async def test_marketplace_skill_trends_returns_history_separately(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class FakeResponse:
text = r'<script>\"values\":[3,5,8,13]</script>'
def raise_for_status(self) -> None:
pass
class FakeClient:
async def __aenter__(self) -> "FakeClient":
return self
async def __aexit__(self, *_args: object) -> None:
pass
async def get(self, url: str) -> FakeResponse:
assert url == "https://www.skills.sh/other/skills/second"
return FakeResponse()
async def weekly_installs(_client: object) -> dict[tuple[str, str], list[int]]:
return {
("acme/skills", "first"): [2, 4, 3, 8],
}
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
lambda **_kwargs: FakeClient(),
)
monkeypatch.setattr(
"nanobot.webui.skills_marketplace._load_weekly_installs",
weekly_installs,
)
assert await marketplace_skill_trends([
"acme/skills/first",
"other/skills/second",
"invalid",
]) == {
"trends": {
"acme/skills/first": [2, 4, 3, 8],
"other/skills/second": [3, 5, 8, 13],
}
}
@pytest.mark.asyncio
async def test_search_marketplace_skills_returns_safe_upstream_error(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
class FailingClient:
async def __aenter__(self) -> "FailingClient":
return self
async def __aexit__(self, *_args: object) -> None:
pass
async def get(self, *_args: object, **_kwargs: object) -> None:
raise httpx.ConnectError("private network detail")
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
lambda **_kwargs: FailingClient(),
)
with pytest.raises(SkillsMarketplaceError) as exc_info:
await search_marketplace_skills("react", tmp_path)
assert exc_info.value.status == 502
assert exc_info.value.message == "skills.sh search is temporarily unavailable"
@pytest.mark.asyncio
async def test_install_marketplace_skill_uses_official_cli_and_workspace(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
seen: dict[str, Any] = {}
class FakeProcess:
returncode = 0
async def communicate(self) -> tuple[bytes, None]:
skill_dir = tmp_path / "skills" / "react-testing"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: react-testing\n---\n",
encoding="utf-8",
)
return b"installed", None
def kill(self) -> None:
raise AssertionError("successful install must not be killed")
async def create_subprocess_exec(*command: str, **kwargs: object) -> FakeProcess:
seen.update(command=command, **kwargs)
return FakeProcess()
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.shutil.which",
lambda executable: "/usr/local/bin/npx" if executable == "npx" else None,
)
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.asyncio.create_subprocess_exec",
create_subprocess_exec,
)
result = await install_marketplace_skill(
"acme/agent-skills",
"react-testing",
tmp_path,
)
assert result == {
"installed": True,
"already_installed": False,
"name": "react-testing",
}
assert seen["command"] == (
"/usr/local/bin/npx",
"--yes",
"skills@latest",
"add",
"acme/agent-skills",
"--skill",
"react-testing",
"--agent",
"openclaw",
"--copy",
"--yes",
)
assert seen["cwd"] == str(tmp_path.resolve())
assert seen["env"]["DISABLE_TELEMETRY"] == "1"
@pytest.mark.asyncio
async def test_install_marketplace_skill_is_idempotent(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
skill_dir = tmp_path / "skills" / "already-here"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("---\nname: already-here\n---\n", encoding="utf-8")
launch = pytest.fail
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.asyncio.create_subprocess_exec",
launch,
)
result = await install_marketplace_skill("acme/agent-skills", "already-here", tmp_path)
assert result == {
"installed": True,
"already_installed": True,
"name": "already-here",
}