mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 21:08:34 +03:00
feat(webui): add skills marketplace
This commit is contained in:
@@ -388,6 +388,35 @@ class TestBuildMessages:
|
||||
assert "user-only runtime context" not in messages[-1]["content"]
|
||||
assert "_meta" not in messages[-1]
|
||||
|
||||
def test_explicit_skill_reference_loads_full_instructions_for_this_turn(self, tmp_path):
|
||||
skill_dir = tmp_path / "skills" / "review"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\n"
|
||||
"name: review\n"
|
||||
"description: Review changes.\n"
|
||||
"---\n\n"
|
||||
"# Review workflow\n\nFollow the unique review checklist.",
|
||||
encoding="utf-8",
|
||||
)
|
||||
builder = _builder(tmp_path)
|
||||
|
||||
messages = builder.build_messages([], "Please $review this patch and use $review carefully.")
|
||||
|
||||
system_prompt = messages[0]["content"]
|
||||
assert "# Active Skills" in system_prompt
|
||||
assert "### Skill: review" in system_prompt
|
||||
assert "Follow the unique review checklist." in system_prompt
|
||||
assert system_prompt.count("### Skill: review") == 1
|
||||
assert messages[-1]["content"] == (
|
||||
"Please $review this patch and use $review carefully."
|
||||
)
|
||||
|
||||
def test_unknown_skill_reference_does_not_change_active_skills(self, tmp_path):
|
||||
messages = _builder(tmp_path).build_messages([], "Keep the shell literal $HOME.")
|
||||
|
||||
assert "# Active Skills" not in messages[0]["content"]
|
||||
|
||||
def test_runtime_context_is_not_injected_by_default(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
messages = builder.build_messages([], "hello", channel="cli")
|
||||
|
||||
@@ -351,6 +351,37 @@ def test_disabled_skills_excluded_from_get_always_skills(tmp_path: Path) -> None
|
||||
assert "beta" in always
|
||||
|
||||
|
||||
def test_explicit_skill_references_resolve_available_enabled_names_in_order(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
workspace = tmp_path / "ws"
|
||||
skills_root = workspace / "skills"
|
||||
skills_root.mkdir(parents=True)
|
||||
_write_skill(skills_root, "alpha", body="# Alpha")
|
||||
_write_skill(skills_root, "beta", body="# Beta")
|
||||
_write_skill(
|
||||
skills_root,
|
||||
"blocked",
|
||||
metadata_json={"requires": {"env": ["MISSING_SKILL_TEST_ENV"]}},
|
||||
body="# Blocked",
|
||||
)
|
||||
builtin = tmp_path / "builtin"
|
||||
builtin.mkdir()
|
||||
monkeypatch.delenv("MISSING_SKILL_TEST_ENV", raising=False)
|
||||
loader = SkillsLoader(
|
||||
workspace,
|
||||
builtin_skills_dir=builtin,
|
||||
disabled_skills={"beta"},
|
||||
)
|
||||
|
||||
invoked = loader.get_explicitly_invoked_skills(
|
||||
"Use $alpha, then $unknown, $alpha again, $beta, and $blocked."
|
||||
)
|
||||
|
||||
assert invoked == ["alpha"]
|
||||
|
||||
|
||||
# -- multiline description tests (YAML folded > and literal |) -----------------
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
Reference in New Issue
Block a user