From ba0ba4749dc4641efeb1800f70695c20964eb08d Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:19:42 +0800 Subject: [PATCH] feat(webui): add skills marketplace --- nanobot/agent/context.py | 24 +- nanobot/agent/skills.py | 16 + nanobot/channels/manager.py | 3 + .../tests/test_websocket_http_routes.py | 208 +++++ nanobot/cli/commands.py | 6 + nanobot/webui/gateway_services.py | 2 + nanobot/webui/skills_api.py | 168 +++- nanobot/webui/skills_marketplace.py | 395 +++++++++ nanobot/webui/ws_http.py | 167 +++- tests/agent/test_context_builder.py | 29 + tests/agent/test_skills_loader.py | 31 + tests/webui/test_skills_api.py | 128 +++ tests/webui/test_skills_marketplace.py | 300 +++++++ .../settings/SkillsCatalogSettings.tsx | 763 +++++++++++++----- .../components/settings/SkillsMarketplace.tsx | 520 ++++++++++++ .../src/components/thread/ThreadComposer.tsx | 2 +- webui/src/hooks/useSkills.ts | 17 +- webui/src/lib/api.ts | 86 ++ webui/src/lib/skill-events.ts | 18 + webui/src/lib/types.ts | 53 ++ webui/src/tests/api.test.ts | 72 ++ webui/src/tests/app-layout.test.tsx | 249 +++++- webui/src/tests/thread-composer.test.tsx | 31 +- 23 files changed, 3042 insertions(+), 246 deletions(-) create mode 100644 nanobot/webui/skills_marketplace.py create mode 100644 tests/webui/test_skills_api.py create mode 100644 tests/webui/test_skills_marketplace.py create mode 100644 webui/src/components/settings/SkillsMarketplace.tsx create mode 100644 webui/src/lib/skill-events.ts diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 7713917ea..4c9d092e9 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -70,6 +70,7 @@ class ContextBuilder: def build_system_prompt( self, *, + active_skill_names: Sequence[str] | None = None, channel: str | None = None, session_summary: str | None = None, workspace: Path | None = None, @@ -91,13 +92,18 @@ class ContextBuilder: if memory and not self._is_template_content(memory, "memory/MEMORY.md"): parts.append(f"# Memory\n\n## Long-term Memory\n{memory}") - always_skills = self.skills.get_always_skills() - if always_skills: - always_content = self.skills.load_skills_for_context(always_skills) - if always_content: - parts.append(f"# Active Skills\n\n{always_content}") + active_skills = self.skills.get_always_skills() + active_skills.extend( + name + for name in (active_skill_names or ()) + if name not in active_skills + ) + if active_skills: + active_content = self.skills.load_skills_for_context(active_skills) + if active_content: + parts.append(f"# Active Skills\n\n{active_content}") - skills_summary = self.skills.build_skills_summary(exclude=set(always_skills)) + skills_summary = self.skills.build_skills_summary(exclude=set(active_skills)) if skills_summary: parts.append(render_template("agent/skills_section.md", skills_summary=skills_summary)) @@ -214,6 +220,11 @@ class ContextBuilder: ) -> list[dict[str, Any]]: """Build the complete message list for an LLM call.""" root = workspace or self.workspace + active_skill_names = ( + self.skills.get_explicitly_invoked_skills(current_message) + if current_role == "user" + else [] + ) user_content = self.build_user_content(current_message, image_paths=media) blocks = list(runtime_context_blocks or ()) if current_role == "user" else [] merged, runtime_context_meta = append_runtime_context(user_content, blocks) @@ -221,6 +232,7 @@ class ContextBuilder: { "role": "system", "content": self.build_system_prompt( + active_skill_names=active_skill_names, channel=channel, session_summary=session_summary, workspace=root, diff --git a/nanobot/agent/skills.py b/nanobot/agent/skills.py index 31a81c086..fa20a98aa 100644 --- a/nanobot/agent/skills.py +++ b/nanobot/agent/skills.py @@ -17,6 +17,7 @@ _STRIP_SKILL_FRONTMATTER = re.compile( r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?", re.DOTALL, ) +_SKILL_REFERENCE = re.compile(r"(? list[str]: + """Resolve ``$skill-name`` references to enabled, available skills.""" + if not text: + return [] + available = { + entry["name"] + for entry in self.list_skills(filter_unavailable=True) + } + invoked: list[str] = [] + for match in _SKILL_REFERENCE.finditer(text): + name = match.group(1) + if name in available and name not in invoked: + invoked.append(name) + return invoked + def build_skills_summary(self, exclude: set[str] | None = None) -> str: """ Build a summary of all skills (name, description, path, availability). diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 7c8d392b2..27d9352cb 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -100,6 +100,7 @@ class ChannelManager: webui_static_dist: bool = True, webui_runtime_surface: str = "browser", webui_runtime_capabilities: dict[str, Any] | None = None, + webui_skill_state_action: Callable[[set[str]], None] | None = None, ): self.config = config self.bus = bus @@ -112,6 +113,7 @@ class ChannelManager: self._webui_static_dist = webui_static_dist self._webui_runtime_surface = webui_runtime_surface self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {}) + self._webui_skill_state_action = webui_skill_state_action self.channels: dict[str, BaseChannel] = {} self._channel_owners: dict[str, str] = {} self._channel_runtime_specs: dict[str, tuple[str, str]] = {} @@ -178,6 +180,7 @@ class ChannelManager: local_trigger_pending_ids=self._webui_local_trigger_pending_ids, channel_feature_action=self.apply_channel_feature_action, channel_runtime_status=self.get_status, + skill_state_action=self._webui_skill_state_action, logger=logger, ) kwargs["gateway"] = gateway diff --git a/nanobot/channels/websocket/tests/test_websocket_http_routes.py b/nanobot/channels/websocket/tests/test_websocket_http_routes.py index a1f51ce0a..8420704cd 100644 --- a/nanobot/channels/websocket/tests/test_websocket_http_routes.py +++ b/nanobot/channels/websocket/tests/test_websocket_http_routes.py @@ -519,6 +519,8 @@ async def test_webui_skills_route_requires_token_and_hides_paths( "name": "workspace-skill", "description": "Workspace skill.", "source": "workspace", + "enabled": True, + "deletable": True, "available": True, "unavailable_reason": "", } @@ -548,6 +550,212 @@ async def test_webui_skills_route_requires_token_and_hides_paths( await server_task +@pytest.mark.asyncio +async def test_webui_skill_management_routes( + bus: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + skill_dir = tmp_path / "skills" / "custom-skill" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\nname: custom-skill\ndescription: Custom skill.\n---\n", + encoding="utf-8", + ) + + def set_enabled( + workspace: Path, + name: str, + *, + enabled: bool, + disabled_skills: set[str], + ) -> dict[str, Any]: + assert workspace == tmp_path + assert name == "custom-skill" + assert enabled is False + disabled_skills.add(name) + return {"name": name, "enabled": enabled, "deleted": False} + + def delete( + workspace: Path, + name: str, + *, + disabled_skills: set[str], + ) -> dict[str, Any]: + assert workspace == tmp_path + assert name == "custom-skill" + disabled_skills.discard(name) + for child in skill_dir.iterdir(): + child.unlink() + skill_dir.rmdir() + return {"name": name, "enabled": False, "deleted": True} + + monkeypatch.setattr("nanobot.webui.ws_http.set_webui_skill_enabled", set_enabled) + monkeypatch.setattr("nanobot.webui.ws_http.delete_webui_skill", delete) + + port = _free_port() + channel = _ch( + bus, + session_manager=_seed_session(tmp_path), + workspace_path=tmp_path, + port=port, + ) + server_task = asyncio.create_task(channel.start()) + try: + token = channel.gateway.tokens.issue_api_token(300) + headers = {"Authorization": f"Bearer {token}"} + update_response = await _http_get( + f"http://127.0.0.1:{port}/api/webui/skills/update" + "?name=custom-skill&enabled=false", + headers=headers, + ) + assert update_response.status_code == 200 + assert update_response.json()["last_action"]["enabled"] is False + custom = next( + item + for item in update_response.json()["skills"] + if item["name"] == "custom-skill" + ) + assert custom["enabled"] is False + + delete_response = await _http_get( + f"http://127.0.0.1:{port}/api/webui/skills/delete" + "?name=custom-skill", + headers=headers, + ) + assert delete_response.status_code == 200 + assert delete_response.json()["last_action"]["deleted"] is True + assert all( + item["name"] != "custom-skill" + for item in delete_response.json()["skills"] + ) + finally: + await channel.stop() + await server_task + + +@pytest.mark.asyncio +async def test_webui_skills_marketplace_routes_search_and_install( + bus: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + search = AsyncMock(return_value={ + "query": "react", + "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": False, + }], + }) + trending = AsyncMock(return_value={ + "period": "24h", + "install_supported": True, + "skills": [{ + "id": "acme/agent-skills/react-testing", + "skill_id": "react-testing", + "name": "React Testing", + "source": "acme/agent-skills", + "installs": 12, + "url": "https://skills.sh/acme/agent-skills/react-testing", + "installed": False, + "rank": 1, + }], + }) + trends = AsyncMock(return_value={ + "trends": {"acme/agent-skills/react-testing": [2, 4, 3, 8]}, + }) + + async def install(source: str, skill_id: str, workspace: Path) -> dict[str, Any]: + assert source == "acme/agent-skills" + assert skill_id == "react-testing" + assert workspace == tmp_path + skill_dir = workspace / "skills" / skill_id + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\nname: react-testing\ndescription: Test React apps.\n---\n", + encoding="utf-8", + ) + return {"installed": True, "already_installed": False, "name": skill_id} + + install_mock = AsyncMock(side_effect=install) + monkeypatch.setattr("nanobot.webui.ws_http.search_marketplace_skills", search) + monkeypatch.setattr("nanobot.webui.ws_http.trending_marketplace_skills", trending) + monkeypatch.setattr("nanobot.webui.ws_http.marketplace_skill_trends", trends) + monkeypatch.setattr("nanobot.webui.ws_http.install_marketplace_skill", install_mock) + + port = _free_port() + channel = _ch( + bus, + session_manager=_seed_session(tmp_path), + workspace_path=tmp_path, + port=port, + ) + server_task = asyncio.create_task(channel.start()) + try: + denied = await _http_get( + f"http://127.0.0.1:{port}/api/webui/skills/search?q=react" + ) + assert denied.status_code == 401 + + token = channel.gateway.tokens.issue_api_token(300) + headers = {"Authorization": f"Bearer {token}"} + search_response = await _http_get( + f"http://127.0.0.1:{port}/api/webui/skills/search?q=react", + headers=headers, + ) + assert search_response.status_code == 200 + assert search_response.json()["skills"][0]["skill_id"] == "react-testing" + search.assert_awaited_once_with("react", tmp_path) + + trending_response = await _http_get( + f"http://127.0.0.1:{port}/api/webui/skills/trending", + headers=headers, + ) + assert trending_response.status_code == 200 + assert trending_response.json()["period"] == "24h" + trending.assert_awaited_once_with(tmp_path) + + trends_response = await _http_get( + f"http://127.0.0.1:{port}/api/webui/skills/trends" + "?id=acme%2Fagent-skills%2Freact-testing", + headers=headers, + ) + assert trends_response.status_code == 200 + assert trends_response.json()["trends"] == { + "acme/agent-skills/react-testing": [2, 4, 3, 8], + } + trends.assert_awaited_once_with(["acme/agent-skills/react-testing"]) + + params = urlencode({ + "source": "acme/agent-skills", + "skill": "react-testing", + }) + install_response = await _http_get( + f"http://127.0.0.1:{port}/api/webui/skills/install?{params}", + headers=headers, + ) + assert install_response.status_code == 200 + body = install_response.json() + assert body["last_action"] == { + "installed": True, + "already_installed": False, + "name": "react-testing", + } + assert next( + skill for skill in body["skills"] if skill["name"] == "react-testing" + )["source"] == "workspace" + install_mock.assert_awaited_once() + finally: + await channel.stop() + await server_task + + @pytest.mark.asyncio async def test_cli_apps_routes_require_token_and_return_payload( bus: MagicMock, diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 98eb0f34b..8f00728c3 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -2139,6 +2139,11 @@ def _run_gateway( def _webui_runtime_model_name() -> str | None: return agent.model.strip() or None + def _webui_skill_state_action(disabled_skills: set[str]) -> None: + config.agents.defaults.disabled_skills = sorted(disabled_skills) + agent.context.skills.disabled_skills = set(disabled_skills) + agent.subagents.disabled_skills = set(disabled_skills) + # Create channel manager (forwards SessionManager so the WebSocket channel # can serve the embedded webui's REST surface). channels = ChannelManager( @@ -2153,6 +2158,7 @@ def _run_gateway( webui_static_dist=webui_static_dist, webui_runtime_surface=webui_runtime_surface, webui_runtime_capabilities=webui_runtime_capabilities, + webui_skill_state_action=_webui_skill_state_action, ) def _pick_heartbeat_target() -> tuple[str, str]: diff --git a/nanobot/webui/gateway_services.py b/nanobot/webui/gateway_services.py index eb944d766..5bb6702bc 100644 --- a/nanobot/webui/gateway_services.py +++ b/nanobot/webui/gateway_services.py @@ -58,6 +58,7 @@ def build_gateway_services( local_trigger_pending_ids: Callable[[str], set[str]] | None = None, channel_feature_action: Callable[..., Any] | None = None, channel_runtime_status: Callable[[], dict[str, Any]] | None = None, + skill_state_action: Callable[[set[str]], None] | None = None, logger: Any = default_logger, ) -> GatewayServices: tokens = GatewayTokenStore() @@ -101,6 +102,7 @@ def build_gateway_services( local_trigger_pending_ids=local_trigger_pending_ids, channel_feature_action=channel_feature_action, channel_runtime_status=channel_runtime_status, + skill_state_action=skill_state_action, log=logger, ) return GatewayServices( diff --git a/nanobot/webui/skills_api.py b/nanobot/webui/skills_api.py index 6473dbb39..819723c9f 100644 --- a/nanobot/webui/skills_api.py +++ b/nanobot/webui/skills_api.py @@ -2,10 +2,23 @@ from __future__ import annotations +import json +import shlex +import shutil from pathlib import Path from typing import Any from nanobot.agent.skills import SkillsLoader +from nanobot.config.loader import load_config, save_config + + +class SkillManagementError(Exception): + """A safe skill-management error for the WebUI.""" + + def __init__(self, message: str, *, status: int = 400) -> None: + super().__init__(message) + self.message = message + self.status = status def webui_skills_payload( @@ -14,12 +27,17 @@ def webui_skills_payload( disabled_skills: set[str] | None = None, ) -> dict[str, Any]: """Return agent skills without leaking local filesystem paths.""" - loader = SkillsLoader(workspace_path, disabled_skills=disabled_skills) + loader = SkillsLoader(workspace_path) entries = sorted( loader.list_skills(filter_unavailable=False), key=lambda entry: (entry.get("source") != "workspace", entry["name"]), ) - return {"skills": [_skill_payload(loader, entry) for entry in entries]} + return { + "skills": [ + _skill_payload(loader, entry, disabled_skills=disabled_skills) + for entry in entries + ] + } def webui_skill_detail_payload( @@ -29,26 +47,114 @@ def webui_skill_detail_payload( disabled_skills: set[str] | None = None, ) -> dict[str, Any] | None: """Return a single skill's safe detail payload.""" - loader = SkillsLoader(workspace_path, disabled_skills=disabled_skills) + loader = SkillsLoader(workspace_path) entries = loader.list_skills(filter_unavailable=False) entry = next((item for item in entries if item["name"] == name), None) if entry is None: return None + metadata = loader.get_skill_metadata(name) return { - **_skill_payload(loader, entry), + **_skill_payload( + loader, + entry, + metadata=metadata, + disabled_skills=disabled_skills, + ), "requirements": loader.get_skill_requirements(name), + "install_options": _install_options(metadata), "raw_markdown": loader.load_skill(name) or "", } -def _skill_payload(loader: SkillsLoader, entry: dict[str, str]) -> dict[str, Any]: +def set_webui_skill_enabled( + workspace_path: Path, + name: str, + *, + enabled: bool, + disabled_skills: set[str], +) -> dict[str, Any]: + """Persist and apply one skill's enabled state.""" + _require_skill_entry(workspace_path, name) + config = load_config() + next_disabled = set(config.agents.defaults.disabled_skills) + if enabled: + next_disabled.discard(name) + else: + next_disabled.add(name) + if next_disabled != set(config.agents.defaults.disabled_skills): + config.agents.defaults.disabled_skills = sorted(next_disabled) + save_config(config) + disabled_skills.clear() + disabled_skills.update(next_disabled) + return {"name": name, "enabled": enabled, "deleted": False} + + +def delete_webui_skill( + workspace_path: Path, + name: str, + *, + disabled_skills: set[str], +) -> dict[str, Any]: + """Delete one workspace skill and remove its disabled-state entry.""" + entry = _require_skill_entry(workspace_path, name) + if entry.get("source") != "workspace": + raise SkillManagementError("built-in skills cannot be deleted", status=403) + + skills_root = (workspace_path.expanduser().resolve() / "skills").resolve() + target = skills_root / name + if target.parent != skills_root: + raise SkillManagementError("invalid skill name") + if target.is_symlink(): + target.unlink() + elif target.is_dir(): + shutil.rmtree(target) + else: + raise SkillManagementError("skill directory was not found", status=404) + + config = load_config() + next_disabled = set(config.agents.defaults.disabled_skills) + if name in next_disabled: + next_disabled.remove(name) + config.agents.defaults.disabled_skills = sorted(next_disabled) + save_config(config) + disabled_skills.clear() + disabled_skills.update(next_disabled) + return {"name": name, "enabled": False, "deleted": True} + + +def _require_skill_entry(workspace_path: Path, name: str) -> dict[str, str]: + if not name or "/" in name or "\\" in name: + raise SkillManagementError("invalid skill name") + entry = next( + ( + item + for item in SkillsLoader(workspace_path).list_skills(filter_unavailable=False) + if item["name"] == name + ), + None, + ) + if entry is None: + raise SkillManagementError("skill not found", status=404) + return entry + + +def _skill_payload( + loader: SkillsLoader, + entry: dict[str, str], + *, + metadata: dict[str, Any] | None = None, + disabled_skills: set[str] | None = None, +) -> dict[str, Any]: name = entry["name"] - metadata = loader.get_skill_metadata(name) + metadata = metadata if metadata is not None else loader.get_skill_metadata(name) available, unavailable_reason = loader.get_skill_availability(name) + source = entry.get("source", "unknown") return { "name": name, "description": _description(metadata, name), - "source": entry.get("source", "unknown"), + "source": source, + "enabled": name not in (disabled_skills or set()), + "deletable": source == "workspace", "available": available, "unavailable_reason": unavailable_reason, } @@ -59,3 +165,51 @@ def _description(metadata: dict[str, Any] | None, fallback: str) -> str: return fallback value = metadata.get("description") return value.strip() if isinstance(value, str) and value.strip() else fallback + + +def _nanobot_metadata(metadata: dict[str, Any] | None) -> dict[str, Any]: + if metadata is None: + return {} + raw = metadata.get("metadata") + if isinstance(raw, str): + try: + raw = json.loads(raw) + except (json.JSONDecodeError, TypeError): + return {} + if not isinstance(raw, dict): + return {} + payload = raw.get("nanobot", raw.get("openclaw", {})) + return payload if isinstance(payload, dict) else {} + + +def _install_options(metadata: dict[str, Any] | None) -> list[dict[str, str]]: + """Return safe, copyable setup commands declared by a skill.""" + install = _nanobot_metadata(metadata).get("install") + if not isinstance(install, list): + return [] + + options: list[dict[str, str]] = [] + for item in install: + if not isinstance(item, dict): + continue + kind = item.get("kind") + package = item.get("formula") if kind == "brew" else item.get("package") + if not isinstance(package, str) or not package.strip(): + continue + if kind == "brew": + command = f"brew install {shlex.quote(package.strip())}" + elif kind == "apt": + command = f"sudo apt-get install -y {shlex.quote(package.strip())}" + else: + continue + option_id = item.get("id") + label = item.get("label") + options.append( + { + "id": option_id if isinstance(option_id, str) else kind, + "kind": kind, + "label": label if isinstance(label, str) else f"Install with {kind}", + "command": command, + } + ) + return options diff --git a/nanobot/webui/skills_marketplace.py b/nanobot/webui/skills_marketplace.py new file mode 100644 index 000000000..9425a5051 --- /dev/null +++ b/nanobot/webui/skills_marketplace.py @@ -0,0 +1,395 @@ +"""Search and install skills from the skills.sh catalog.""" + +from __future__ import annotations + +import asyncio +import os +import re +import shutil +import time +from pathlib import Path +from typing import Any + +import httpx + +from nanobot.agent.skills import SkillsLoader +from nanobot.security.network import PinnedDNSAsyncTransport + +_SEARCH_URL = "https://skills.sh/api/search" +_TRENDING_URL = "https://skills.sh/api/skills/trending/0" +_SKILL_PAGE_BASE_URL = "https://www.skills.sh" +_ALL_TIME_URLS = ( + "https://skills.sh/api/skills/all-time/0", + "https://skills.sh/api/skills/all-time/1", +) +_TREND_VALUES_RE = re.compile(r'\\"values\\":\s*\[([0-9,\s]+)\]') +_SOURCE_RE = re.compile( + r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?/" + r"[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,98}[A-Za-z0-9])?$" +) +_SKILL_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +_ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +_INSTALL_TIMEOUT_SECONDS = 120 +_WEEKLY_CACHE_TTL_SECONDS = 300 +# The skills CLI's OpenClaw adapter copies into /skills, nanobot's layout too. +_CLI_AGENT = "openclaw" +_weekly_cache: dict[tuple[str, str], list[int]] = {} +_weekly_cache_expires_at = 0.0 + + +class SkillsMarketplaceError(Exception): + """A safe error that can be returned to the WebUI.""" + + def __init__(self, message: str, *, status: int = 400) -> None: + super().__init__(message) + self.message = message + self.status = status + + +def skills_install_supported() -> bool: + """Return whether the official skills CLI can be launched.""" + return shutil.which("npx") is not None + + +async def trending_marketplace_skills( + workspace_path: Path, + *, + limit: int = 8, +) -> dict[str, Any]: + """Return a source-diverse snapshot of skills.sh's real 24-hour leaderboard.""" + try: + async with _skills_client() as client: + response = await client.get(_TRENDING_URL) + response.raise_for_status() + payload = response.json() + except (httpx.HTTPError, ValueError) as exc: + raise SkillsMarketplaceError( + "skills.sh trending skills are temporarily unavailable", + status=502, + ) from exc + + installed = _installed_skill_names(workspace_path) + rows = payload.get("skills", []) if isinstance(payload, dict) else [] + skills: list[dict[str, Any]] = [] + seen_sources: set[str] = set() + for rank, row in enumerate(rows, start=1): + if not isinstance(row, dict): + continue + source = row.get("source") + if not isinstance(source, str) or source in seen_sources: + continue + skill = _marketplace_skill(row, installed, rank=rank) + if skill is None: + continue + seen_sources.add(source) + skills.append(skill) + if len(skills) >= min(max(limit, 1), 20): + break + + return { + "skills": skills, + "period": "24h", + "install_supported": skills_install_supported(), + } + + +async def search_marketplace_skills( + query: str, + workspace_path: Path, + *, + limit: int = 20, +) -> dict[str, Any]: + """Search skills.sh and annotate results already installed in this workspace.""" + normalized = " ".join(query.split()) + if len(normalized) < 2: + raise SkillsMarketplaceError("search query must contain at least 2 characters") + if len(normalized) > 100: + raise SkillsMarketplaceError("search query is too long") + + try: + async with _skills_client() as client: + response = await client.get( + _SEARCH_URL, + params={"q": normalized, "limit": min(max(limit, 1), 50)}, + ) + response.raise_for_status() + payload = response.json() + except (httpx.HTTPError, ValueError) as exc: + raise SkillsMarketplaceError( + "skills.sh search is temporarily unavailable", + status=502, + ) from exc + + installed = _installed_skill_names(workspace_path) + rows = payload.get("skills", []) if isinstance(payload, dict) else [] + skills = [] + for row in rows: + if not isinstance(row, dict): + continue + skill = _marketplace_skill(row, installed) + if skill is not None: + skills.append(skill) + + return { + "query": normalized, + "skills": skills, + "install_supported": skills_install_supported(), + } + + +async def marketplace_skill_trends( + skill_ids: list[str] | None = None, +) -> dict[str, dict[str, list[int]]]: + """Return install history independently, filling requested cache misses.""" + requested = _valid_skill_refs(skill_ids or []) + async with _skills_client() as client: + weekly_installs = await _load_weekly_installs(client) + missing = [ref for ref in requested if ref not in weekly_installs] + if missing: + weekly_installs.update(await _load_skill_page_trends(client, missing)) + + selected = requested or list(weekly_installs) + return { + "trends": { + f"{source}/{skill_id}": values + for source, skill_id in selected + if (values := weekly_installs.get((source, skill_id))) is not None + } + } + + +async def install_marketplace_skill( + source: str, + skill_id: str, + workspace_path: Path, +) -> dict[str, Any]: + """Install one skills.sh result into ``/skills``.""" + if not _SOURCE_RE.fullmatch(source): + raise SkillsMarketplaceError("invalid skill source") + if not _valid_skill_id(skill_id): + raise SkillsMarketplaceError("invalid skill name") + + loader = SkillsLoader(workspace_path) + existing = { + entry["name"]: entry + for entry in loader.list_skills(filter_unavailable=False) + } + if skill_id in existing: + return {"installed": True, "already_installed": True, "name": skill_id} + + npx = shutil.which("npx") + if npx is None: + raise SkillsMarketplaceError( + "Node.js with npx is required to install skills", + status=503, + ) + + workspace = workspace_path.expanduser().resolve() + workspace.mkdir(parents=True, exist_ok=True) + env = os.environ.copy() + env["DISABLE_TELEMETRY"] = "1" + command = ( + npx, + "--yes", + "skills@latest", + "add", + source, + "--skill", + skill_id, + "--agent", + _CLI_AGENT, + "--copy", + "--yes", + ) + + process = await asyncio.create_subprocess_exec( + *command, + cwd=str(workspace), + env=env, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + try: + output, _ = await asyncio.wait_for( + process.communicate(), + timeout=_INSTALL_TIMEOUT_SECONDS, + ) + except TimeoutError as exc: + process.kill() + await process.communicate() + raise SkillsMarketplaceError("skill installation timed out", status=504) from exc + + if process.returncode != 0: + detail = _safe_output_tail(output) + message = "skill installation failed" + if detail: + message = f"{message}: {detail}" + raise SkillsMarketplaceError(message, status=502) + + installed = next( + ( + entry + for entry in loader.list_skills(filter_unavailable=False) + if entry["source"] == "workspace" and entry["name"] == skill_id + ), + None, + ) + if installed is None: + raise SkillsMarketplaceError( + "installer completed but the skill was not found in this workspace", + status=502, + ) + return {"installed": True, "already_installed": False, "name": skill_id} + + +def _skills_client() -> httpx.AsyncClient: + return httpx.AsyncClient( + transport=PinnedDNSAsyncTransport(), + timeout=10.0, + follow_redirects=False, + ) + + +def _installed_skill_names(workspace_path: Path) -> set[str]: + return { + entry["name"] + for entry in SkillsLoader(workspace_path).list_skills(filter_unavailable=False) + } + + +def _marketplace_skill( + row: dict[str, Any], + installed: set[str], + *, + rank: int | None = None, +) -> dict[str, Any] | None: + source = row.get("source") + skill_id = row.get("skillId") + if not isinstance(source, str) or not _SOURCE_RE.fullmatch(source): + return None + if not isinstance(skill_id, str) or not _valid_skill_id(skill_id): + return None + display_name = row.get("name") + if not isinstance(display_name, str) or not display_name.strip(): + display_name = skill_id + installs = row.get("installs") + skill: dict[str, Any] = { + "id": f"{source}/{skill_id}", + "skill_id": skill_id, + "name": display_name.strip(), + "source": source, + "installs": installs if isinstance(installs, int) and installs >= 0 else 0, + "url": f"https://skills.sh/{source}/{skill_id}", + "installed": skill_id in installed, + } + if rank is not None: + skill["rank"] = rank + return skill + + +async def _load_weekly_installs( + client: httpx.AsyncClient, +) -> dict[tuple[str, str], list[int]]: + global _weekly_cache, _weekly_cache_expires_at + + now = time.monotonic() + if now < _weekly_cache_expires_at: + return _weekly_cache + + responses = await asyncio.gather( + *(client.get(url) for url in _ALL_TIME_URLS), + return_exceptions=True, + ) + history: dict[tuple[str, str], list[int]] = {} + successful = False + for response in responses: + if isinstance(response, BaseException): + continue + try: + response.raise_for_status() + payload = response.json() + except (httpx.HTTPError, ValueError): + continue + successful = True + rows = payload.get("skills", []) if isinstance(payload, dict) else [] + for row in rows: + if not isinstance(row, dict): + continue + source = row.get("source") + skill_id = row.get("skillId") + values = row.get("weeklyInstalls") + if ( + isinstance(source, str) + and isinstance(skill_id, str) + and isinstance(values, list) + ): + clean = [ + value + for value in values + if isinstance(value, int) and not isinstance(value, bool) and value >= 0 + ] + if len(clean) >= 2: + history[(source, skill_id)] = clean + + if successful: + _weekly_cache = history + _weekly_cache_expires_at = now + _WEEKLY_CACHE_TTL_SECONDS + return history + + +def _valid_skill_refs(skill_ids: list[str]) -> list[tuple[str, str]]: + refs: list[tuple[str, str]] = [] + for value in skill_ids[:20]: + if not isinstance(value, str) or "/" not in value: + continue + source, skill_id = value.rsplit("/", 1) + ref = (source, skill_id) + if ( + _SOURCE_RE.fullmatch(source) + and _valid_skill_id(skill_id) + and ref not in refs + ): + refs.append(ref) + return refs + + +async def _load_skill_page_trends( + client: httpx.AsyncClient, + refs: list[tuple[str, str]], +) -> dict[tuple[str, str], list[int]]: + semaphore = asyncio.Semaphore(6) + + async def fetch(ref: tuple[str, str]) -> tuple[tuple[str, str], list[int]]: + source, skill_id = ref + try: + async with semaphore: + response = await client.get( + f"{_SKILL_PAGE_BASE_URL}/{source}/{skill_id}" + ) + response.raise_for_status() + except httpx.HTTPError: + return ref, [] + + match = _TREND_VALUES_RE.search(response.text) + if match is None: + return ref, [] + values = [ + int(value) + for value in match.group(1).split(",") + if value.strip() + ] + return ref, values if len(values) >= 2 else [] + + return dict(await asyncio.gather(*(fetch(ref) for ref in refs))) + + +def _valid_skill_id(value: str) -> bool: + return len(value) <= 64 and _SKILL_RE.fullmatch(value) is not None + + +def _safe_output_tail(output: bytes | None) -> str: + if not output: + return "" + text = _ANSI_RE.sub("", output.decode("utf-8", errors="replace")) + lines = [line.strip() for line in text.splitlines() if line.strip()] + return " · ".join(lines[-3:])[-600:] diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index e82e53b2a..1a98cf965 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -87,7 +87,20 @@ from nanobot.webui.sidebar_state import ( read_webui_sidebar_state, write_webui_sidebar_state, ) -from nanobot.webui.skills_api import webui_skill_detail_payload, webui_skills_payload +from nanobot.webui.skills_api import ( + SkillManagementError, + delete_webui_skill, + set_webui_skill_enabled, + webui_skill_detail_payload, + webui_skills_payload, +) +from nanobot.webui.skills_marketplace import ( + SkillsMarketplaceError, + install_marketplace_skill, + marketplace_skill_trends, + search_marketplace_skills, + trending_marketplace_skills, +) from nanobot.webui.thread_disk import delete_webui_thread from nanobot.webui.transcript import build_webui_thread_response from nanobot.webui.workspaces import WebUIWorkspaceController @@ -171,6 +184,7 @@ class GatewayHTTPHandler: local_trigger_pending_ids: Callable[[str], set[str]] | None = None, channel_feature_action: Callable[..., Any] | None = None, channel_runtime_status: Callable[[], dict[str, Any]] | None = None, + skill_state_action: Callable[[set[str]], None] | None = None, log: Any = logger, ) -> None: self.config = config @@ -183,7 +197,8 @@ class GatewayHTTPHandler: self.ingress = ingress self.workspaces = workspaces self.skills_workspace_path = skills_workspace_path - self.disabled_skills = disabled_skills or set() + self.disabled_skills = disabled_skills if disabled_skills is not None else set() + self.skill_state_action = skill_state_action self.cron_service = cron_service self.local_trigger_store = local_trigger_store self.cron_pending_job_ids = cron_pending_job_ids @@ -795,6 +810,18 @@ class GatewayHTTPHandler: return self._handle_commands(request) if got == "/api/workspaces": return self._handle_workspaces(connection, request) + if got == "/api/webui/skills/search": + return await self._handle_webui_skills_search(request) + if got == "/api/webui/skills/trending": + return await self._handle_webui_skills_trending(request) + if got == "/api/webui/skills/trends": + return await self._handle_webui_skill_trends(request) + if got == "/api/webui/skills/install": + return await self._handle_webui_skill_install(connection, request) + if got == "/api/webui/skills/update": + return self._handle_webui_skill_update(request) + if got == "/api/webui/skills/delete": + return self._handle_webui_skill_delete(connection, request) if got == "/api/webui/skills": return self._handle_webui_skills(request) m = re.match(r"^/api/webui/skills/([^/]+)$", got) @@ -830,6 +857,142 @@ class GatewayHTTPHandler: ) ) + async def _handle_webui_skills_search(self, request: WsRequest) -> Response: + if not self.check_api_token(request): + return _http_error(401, "Unauthorized") + query = _query_first(_parse_query(request.path), "q") or "" + try: + payload = await search_marketplace_skills(query, self.skills_workspace_path) + except SkillsMarketplaceError as exc: + return _http_error(exc.status, exc.message) + except Exception: + self._log.exception("skills.sh search failed") + return _http_error(500, "skills.sh search failed") + return _http_json_response(payload) + + async def _handle_webui_skills_trending(self, request: WsRequest) -> Response: + if not self.check_api_token(request): + return _http_error(401, "Unauthorized") + try: + payload = await trending_marketplace_skills(self.skills_workspace_path) + except SkillsMarketplaceError as exc: + return _http_error(exc.status, exc.message) + except Exception: + self._log.exception("skills.sh trending lookup failed") + return _http_error(500, "skills.sh trending lookup failed") + return _http_json_response(payload) + + async def _handle_webui_skill_trends(self, request: WsRequest) -> Response: + if not self.check_api_token(request): + return _http_error(401, "Unauthorized") + skill_ids = _parse_query(request.path).get("id", []) + try: + payload = await marketplace_skill_trends(skill_ids) + except Exception: + self._log.exception("skills.sh trend history lookup failed") + return _http_error(500, "skills.sh trend history lookup failed") + return _http_json_response(payload) + + async def _handle_webui_skill_install( + self, + connection: Any, + request: WsRequest, + ) -> Response: + if not self.check_api_token(request): + return _http_error(401, "Unauthorized") + if not self._allow_webui_package_install(connection, request): + return _http_error(403, "remote skill installation is disabled") + + query = _parse_query(request.path) + source = _query_first(query, "source") or "" + skill_id = _query_first(query, "skill") or "" + try: + action = await install_marketplace_skill( + source, + skill_id, + self.skills_workspace_path, + ) + except SkillsMarketplaceError as exc: + return _http_error(exc.status, exc.message) + except Exception: + self._log.exception("skill installation failed") + return _http_error(500, "skill installation failed") + return _http_json_response({ + **webui_skills_payload( + self.skills_workspace_path, + disabled_skills=self.disabled_skills, + ), + "last_action": action, + }) + + def _allow_webui_package_install(self, connection: Any, request: WsRequest) -> bool: + if _is_local_browser_request(connection, request.headers): + return True + try: + from nanobot.config.loader import load_config + + return bool(load_config().tools.webui_allow_remote_package_install) + except Exception: + self._log.exception("failed to load remote package install policy") + return False + + def _handle_webui_skill_update(self, request: WsRequest) -> Response: + if not self.check_api_token(request): + return _http_error(401, "Unauthorized") + query = _parse_query(request.path) + name = _query_first(query, "name") or "" + raw_enabled = (_query_first(query, "enabled") or "").lower() + if raw_enabled not in {"true", "false"}: + return _http_error(400, "enabled must be true or false") + try: + action = set_webui_skill_enabled( + self.skills_workspace_path, + name, + enabled=raw_enabled == "true", + disabled_skills=self.disabled_skills, + ) + except SkillManagementError as exc: + return _http_error(exc.status, exc.message) + self._apply_skill_state() + return _http_json_response({ + **webui_skills_payload( + self.skills_workspace_path, + disabled_skills=self.disabled_skills, + ), + "last_action": action, + }) + + def _handle_webui_skill_delete( + self, + connection: Any, + request: WsRequest, + ) -> Response: + if not self.check_api_token(request): + return _http_error(401, "Unauthorized") + if not self._allow_webui_package_install(connection, request): + return _http_error(403, "remote skill deletion is disabled") + name = _query_first(_parse_query(request.path), "name") or "" + try: + action = delete_webui_skill( + self.skills_workspace_path, + name, + disabled_skills=self.disabled_skills, + ) + except SkillManagementError as exc: + return _http_error(exc.status, exc.message) + self._apply_skill_state() + return _http_json_response({ + **webui_skills_payload( + self.skills_workspace_path, + disabled_skills=self.disabled_skills, + ), + "last_action": action, + }) + + def _apply_skill_state(self) -> None: + if self.skill_state_action is not None: + self.skill_state_action(set(self.disabled_skills)) + def _handle_webui_skill_detail(self, request: WsRequest, raw_name: str) -> Response: if not self.check_api_token(request): return _http_error(401, "Unauthorized") diff --git a/tests/agent/test_context_builder.py b/tests/agent/test_context_builder.py index ecc488506..0ecb29635 100644 --- a/tests/agent/test_context_builder.py +++ b/tests/agent/test_context_builder.py @@ -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") diff --git a/tests/agent/test_skills_loader.py b/tests/agent/test_skills_loader.py index 5229efacf..88a1a0eaf 100644 --- a/tests/agent/test_skills_loader.py +++ b/tests/agent/test_skills_loader.py @@ -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 |) ----------------- diff --git a/tests/webui/test_skills_api.py b/tests/webui/test_skills_api.py new file mode 100644 index 000000000..07fbe508d --- /dev/null +++ b/tests/webui/test_skills_api.py @@ -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 diff --git a/tests/webui/test_skills_marketplace.py b/tests/webui/test_skills_marketplace.py new file mode 100644 index 000000000..1d10e1161 --- /dev/null +++ b/tests/webui/test_skills_marketplace.py @@ -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'' + + 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", + } diff --git a/webui/src/components/settings/SkillsCatalogSettings.tsx b/webui/src/components/settings/SkillsCatalogSettings.tsx index 13b335836..f9f28228c 100644 --- a/webui/src/components/settings/SkillsCatalogSettings.tsx +++ b/webui/src/components/settings/SkillsCatalogSettings.tsx @@ -1,25 +1,89 @@ import { useEffect, useState, type ReactNode } from "react"; import type { TFunction } from "i18next"; -import { Brain, Check, CircleAlert, KeyRound, Loader2, Terminal } from "lucide-react"; +import { + Check, + CircleAlert, + Copy, + KeyRound, + Loader2, + PowerOff, + RefreshCw, + Search, + Terminal, + Trash2, +} from "lucide-react"; import { useTranslation } from "react-i18next"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet"; -import { fetchSkillDetail } from "@/lib/api"; +import { SkillsMarketplace } from "@/components/settings/SkillsMarketplace"; +import { deleteSkill, fetchSkillDetail, updateSkillEnabled } from "@/lib/api"; +import { notifySkillsChanged } from "@/lib/skill-events"; import type { SkillDetail, SkillSummary } from "@/lib/types"; import { cn } from "@/lib/utils"; import { useClient } from "@/providers/ClientProvider"; export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) { const { t } = useTranslation(); - const availableCount = skills.filter((skill) => skill.available).length; + const availableCount = skills.filter( + (skill) => skill.enabled !== false && skill.available, + ).length; const [selectedSkill, setSelectedSkill] = useState(null); + const [view, setView] = useState<"installed" | "discover">("installed"); + const [installedQuery, setInstalledQuery] = useState(""); + const [installedFilter, setInstalledFilter] = useState<"all" | "enabled" | "disabled">( + "all", + ); + const normalizedQuery = installedQuery.trim().toLowerCase(); + const filteredSkills = skills.filter((skill) => { + const enabled = skill.enabled !== false; + if (installedFilter === "enabled" && !enabled) return false; + if (installedFilter === "disabled" && enabled) return false; + return ( + !normalizedQuery + || skill.name.toLowerCase().includes(normalizedQuery) + || skill.description.toLowerCase().includes(normalizedQuery) + ); + }); + const groupedSkills = [ + { + key: "workspace", + label: t("settings.skills.customGroup", { defaultValue: "Custom" }), + skills: filteredSkills.filter((skill) => skill.source === "workspace"), + }, + { + key: "builtin", + label: t("settings.skills.builtinGroup", { defaultValue: "Built-in" }), + skills: filteredSkills.filter((skill) => skill.source === "builtin"), + }, + { + key: "other", + label: t("settings.skills.otherGroup", { defaultValue: "Other" }), + skills: filteredSkills.filter( + (skill) => skill.source !== "workspace" && skill.source !== "builtin", + ), + }, + ].filter((group) => group.skills.length); + const disabledCount = skills.filter((skill) => skill.enabled === false).length; return (

{t("settings.skills.description", { - defaultValue: "Review the instruction skills this agent can load during a conversation.", + defaultValue: + "Review installed skills or discover new capabilities from the skills.sh catalog.", })}

@@ -31,31 +95,122 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
-
-
-

- {t("settings.skills.featured", { defaultValue: "Agent skills" })} -

- - {skills.length} - -
- {skills.length ? ( -
- {skills.map((skill) => ( - + {(["installed", "discover"] as const).map((item) => ( + + ))} +
+ + {view === "installed" ? ( +
+
+
+ - ))} + setInstalledQuery(event.target.value)} + placeholder={t("settings.skills.searchInstalled", { + defaultValue: "Search installed skills", + })} + aria-label={t("settings.skills.searchInstalled", { + defaultValue: "Search installed skills", + })} + className="h-9 rounded-[11px] bg-background pl-9 text-[13px]" + /> +
+
+ {([ + ["all", t("settings.skills.filterAll", { defaultValue: "All" }), skills.length], + [ + "enabled", + t("settings.skills.filterEnabled", { defaultValue: "Enabled" }), + skills.length - disabledCount, + ], + [ + "disabled", + t("settings.skills.filterDisabled", { defaultValue: "Disabled" }), + disabledCount, + ], + ] as const).map(([filter, label, count]) => ( + + ))} +
- ) : ( -
- {t("settings.skills.empty", { defaultValue: "No skills are available." })} -
- )} -
+ {groupedSkills.length ? ( +
+ {groupedSkills.map((group) => ( +
+
+

+ {group.label} +

+ + {group.skills.length} + +
+
+ {group.skills.map((skill) => ( + + ))} +
+
+ ))} +
+ ) : ( +
+ {t("settings.skills.noMatching", { + defaultValue: "No matching skills.", + })} +
+ )} +
+ ) : ( + + )} + + + ); +} + function SkillCatalogRow({ skill, onSelect, @@ -76,11 +239,13 @@ function SkillCatalogRow({ onSelect: (skill: SkillSummary) => void; }) { const { t } = useTranslation(); - const sourceLabel = skillSourceLabel(skill.source, t); - const StatusIcon = skill.available ? Check : CircleAlert; - const statusLabel = skill.available - ? t("settings.skills.statusAvailable", { defaultValue: "Available" }) - : t("settings.skills.statusUnavailable", { defaultValue: "Unavailable" }); + const enabled = skill.enabled !== false; + const StatusIcon = !enabled ? PowerOff : skill.available ? Check : CircleAlert; + const statusLabel = !enabled + ? t("settings.skills.statusDisabled", { defaultValue: "Disabled" }) + : skill.available + ? t("settings.skills.statusEnabled", { defaultValue: "Enabled" }) + : t("settings.skills.statusNeedsSetup", { defaultValue: "Needs setup" }); return ( +
+ + {actionError ? ( +
+ {actionError} +
+ ) : null} + + {detail && enabled ? ( + setRefreshKey((value) => value + 1)} + /> + ) : null} + + {detail ? : null} + + {deletable ? ( +
+
+

+ {t("settings.skills.deleteTitle", { defaultValue: "Delete skill" })} +

+

+ {t("settings.skills.deleteDescription", { + defaultValue: "Remove this skill from the current workspace.", + })} +

+
+ +
+ ) : null} + + )} + + + + + + + + + {t("settings.skills.deleteConfirmTitle", { + name: activeSkill.name, + defaultValue: "Delete {{name}}?", + })} + + + {t("settings.skills.deleteConfirmDescription", { + defaultValue: + "This removes the skill files from the current workspace. This action cannot be undone.", + })} + + + + + {t("common.cancel", { defaultValue: "Cancel" })} + + void removeSkill()} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {t("settings.skills.deleteConfirmAction", { defaultValue: "Delete skill" })} + + + + + ); } @@ -267,8 +562,13 @@ function RawInstructionsBlock({ markdown }: { markdown: string }) { return (
- - {t("settings.skills.rawInstructions", { defaultValue: "Raw SKILL.md" })} + + + {t("settings.skills.instructionsTitle", { defaultValue: "Skill instructions" })} + + + SKILL.md +
-      
{label}
-
{value}
-
- ); -} - -function RequirementsSection({ detail }: { detail: SkillDetail }) { +function RequirementsSection({ + detail, + onRefresh, +}: { + detail: SkillDetail; + onRefresh: () => void; +}) { const { t } = useTranslation(); - const { bins, env, missing_bins, missing_env } = detail.requirements; - const hasRequirements = bins.length > 0 || env.length > 0; + const [copiedCommand, setCopiedCommand] = useState(null); + const { missing_bins, missing_env } = detail.requirements; + const hasMissing = missing_bins.length > 0 || missing_env.length > 0; + + if (!hasMissing) return null; + + const installOptions = detail.install_options ?? []; + const copyCommand = async (command: string) => { + try { + await navigator.clipboard.writeText(command); + setCopiedCommand(command); + } catch { + setCopiedCommand(null); + } + }; return ( - - {hasRequirements ? ( -
- {missing_bins.length ? ( - } - /> - ) : null} - {missing_env.length ? ( - } - /> - ) : null} - {bins.length ? ( - } - /> - ) : null} - {env.length ? ( - } - /> - ) : null} +
+
+ +
+

+ {t("settings.skills.setupRequired", { defaultValue: "Setup required" })} +

+

+ {t("settings.skills.setupDescription", { + defaultValue: + "Install the missing dependency on the machine running nanobot, then check again.", + })} +

- ) : ( -

- {t("settings.skills.noRequirements", { defaultValue: "No explicit requirements." })} -

- )} - - ); -} +
-function DetailSection({ title, children }: { title: string; children: ReactNode }) { - return ( -
-

{title}

- {children} +
+ {installOptions.map((option) => ( +
+ + + {option.command} + + +
+ ))} + + {!installOptions.length && missing_bins.length ? ( + } + label={t("settings.skills.missingCommands", { defaultValue: "Missing CLI" })} + items={missing_bins} + /> + ) : null} + {missing_env.length ? ( + } + label={t("settings.skills.missingEnvironment", { defaultValue: "Missing ENV" })} + items={missing_env} + /> + ) : null} +
+ +
); } -function RequirementLine({ - title, +function SetupRequirement({ + label, items, icon, - tone = "muted", }: { - title: string; + label: string; items: string[]; icon: ReactNode; - tone?: "muted" | "danger"; }) { return ( -
-
+
+ {icon} - {title} -
-
- {items.map((item) => ( - {item} - ))} -
+ {label} + + {items.map((item) => ( + + {item} + + ))}
); } @@ -390,7 +721,7 @@ function Pill({ tone = "muted", }: { children: ReactNode; - tone?: "muted" | "success"; + tone?: "muted" | "success" | "warning"; }) { return ( {children} diff --git a/webui/src/components/settings/SkillsMarketplace.tsx b/webui/src/components/settings/SkillsMarketplace.tsx new file mode 100644 index 000000000..f0df0574e --- /dev/null +++ b/webui/src/components/settings/SkillsMarketplace.tsx @@ -0,0 +1,520 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Check, + Download, + ExternalLink, + Loader2, + Search, + ShieldAlert, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + fetchMarketplaceSkillTrends, + fetchTrendingMarketplaceSkills, + installMarketplaceSkill, + searchMarketplaceSkills, +} from "@/lib/api"; +import { notifySkillsChanged } from "@/lib/skill-events"; +import type { MarketplaceSkillSummary, SkillSummary } from "@/lib/types"; +import { cn } from "@/lib/utils"; +import { useClient } from "@/providers/ClientProvider"; + +export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillSummary[] }) { + const { token } = useClient(); + const { t } = useTranslation(); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [trending, setTrending] = useState([]); + const [trends, setTrends] = useState>({}); + const [loading, setLoading] = useState(false); + const [trendingLoading, setTrendingLoading] = useState(true); + const [error, setError] = useState(""); + const [installSupported, setInstallSupported] = useState(null); + const [selected, setSelected] = useState(null); + const [installing, setInstalling] = useState(""); + const installedNames = useMemo( + () => new Set(installedSkills.map((skill) => skill.name)), + [installedSkills], + ); + + useEffect(() => { + let cancelled = false; + setTrendingLoading(true); + fetchTrendingMarketplaceSkills(token) + .then((payload) => { + if (cancelled) return; + setTrending(payload.skills); + setInstallSupported(payload.install_supported); + }) + .catch(() => { + if (!cancelled) setTrending([]); + }) + .finally(() => { + if (!cancelled) setTrendingLoading(false); + }); + return () => { + cancelled = true; + }; + }, [token]); + + useEffect(() => { + const skills = query.trim().length < 2 ? trending : results; + const unresolved = skills.filter((skill) => !(skill.id in trends)); + if (!unresolved.length) return; + + let cancelled = false; + fetchMarketplaceSkillTrends(token, unresolved.map((skill) => skill.id)) + .then((payload) => { + if (!cancelled) { + setTrends((current) => ({ ...current, ...payload.trends })); + } + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [query, results, token, trending, trends]); + + useEffect(() => { + const normalized = query.trim(); + if (normalized.length < 2) { + setResults([]); + setLoading(false); + setError(""); + return; + } + + let cancelled = false; + const timer = window.setTimeout(() => { + setLoading(true); + setError(""); + searchMarketplaceSkills(token, normalized) + .then((payload) => { + if (cancelled) return; + setResults(payload.skills); + setInstallSupported(payload.install_supported); + }) + .catch((reason: unknown) => { + if (cancelled) return; + setResults([]); + setError( + reason instanceof Error + ? reason.message + : t("settings.skills.marketplaceSearchFailed", { + defaultValue: "Could not search skills.sh.", + }), + ); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + }, 300); + + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [query, t, token]); + + const install = async (skill: MarketplaceSkillSummary) => { + setSelected(null); + setInstalling(skill.skill_id); + setError(""); + try { + const payload = await installMarketplaceSkill(token, skill.source, skill.skill_id); + notifySkillsChanged(payload); + setResults((current) => + current.map((item) => + item.id === skill.id ? { ...item, installed: true } : item, + ), + ); + setTrending((current) => + current.map((item) => + item.id === skill.id ? { ...item, installed: true } : item, + ), + ); + } catch (reason) { + setError( + reason instanceof Error + ? reason.message + : t("settings.skills.marketplaceInstallFailed", { + defaultValue: "Could not install this skill.", + }), + ); + } finally { + setInstalling(""); + } + }; + + return ( +
+
+ + setQuery(event.target.value)} + placeholder={t("settings.skills.marketplaceSearchPlaceholder", { + defaultValue: "Search skills.sh", + })} + aria-label={t("settings.skills.marketplaceSearchLabel", { + defaultValue: "Search skills.sh", + })} + className="h-11 rounded-[14px] bg-settings-surface pl-9" + /> + {loading ? ( + + + + ) : null} +
+ + {error ? ( +
+ {error} +
+ ) : null} + + {query.trim().length < 2 ? ( +
+
+
+

+ {t("settings.skills.marketplaceTrendingTitle", { + defaultValue: "Trending today", + })} +

+

+ {t("settings.skills.marketplaceTrendingDescription", { + defaultValue: + "Most installed across sources in 24h · curves show the 8-week trend", + })} +

+
+ + {t("settings.skills.marketplaceViewAll", { defaultValue: "View all" })} + + +
+ {trendingLoading ? ( + + ) : trending.length ? ( + + ) : ( +
+ {t("settings.skills.marketplaceTrendingUnavailable", { + defaultValue: "Trending skills are temporarily unavailable.", + })} +
+ )} +
+ ) : !loading && results.length === 0 && !error ? ( +
+ {t("settings.skills.marketplaceEmpty", { + query: query.trim(), + defaultValue: "No skills found for “{{query}}”.", + })} +
+ ) : ( +
+ +
+ )} + + { + if (!open) setSelected(null); + }} + > + + +
+ +
+ + {t("settings.skills.marketplaceConfirmTitle", { + name: selected?.name ?? "", + defaultValue: "Install {{name}}?", + })} + + + + {t("settings.skills.marketplaceConfirmDescription", { + source: selected?.source ?? "", + defaultValue: + "This third-party skill comes from {{source}} and may include instructions or executable scripts.", + })} + + + {selected?.source} + + +
+ + + {t("common.cancel", { defaultValue: "Cancel" })} + + { + if (selected) void install(selected); + }} + > + {t("settings.skills.marketplaceConfirmInstall", { + defaultValue: "Install skill", + })} + + +
+
+
+ ); +} + +function MarketplaceSkillList({ + skills, + installedNames, + installing, + installSupported, + metric, + trends, + onSelect, +}: { + skills: MarketplaceSkillSummary[]; + installedNames: Set; + installing: string; + installSupported: boolean | null; + metric: "total" | "24h"; + trends: Record; + onSelect: (skill: MarketplaceSkillSummary) => void; +}) { + return ( +
+ {skills.map((skill) => ( + + ))} +
+ ); +} + +function MarketplaceSkillRow({ + skill, + installed, + isInstalling, + installBusy, + installSupported, + metric, + trend, + onSelect, +}: { + skill: MarketplaceSkillSummary; + installed: boolean; + isInstalling: boolean; + installBusy: boolean; + installSupported: boolean | null; + metric: "total" | "24h"; + trend?: number[]; + onSelect: (skill: MarketplaceSkillSummary) => void; +}) { + const { t } = useTranslation(); + + return ( +
+ {skill.rank ? ( + + #{skill.rank} + + ) : null} +
+
+

+ {skill.name} +

+ + + +
+

+ {skill.source} + · + {metric === "24h" + ? t("settings.skills.marketplaceInstalls24h", { + count: skill.installs, + formattedCount: skill.installs.toLocaleString(), + defaultValue: "{{formattedCount}} installs / 24h", + }) + : t("settings.skills.marketplaceInstalls", { + count: skill.installs, + formattedCount: skill.installs.toLocaleString(), + defaultValue: "{{formattedCount}} installs", + })} +

+
+ + +
+ ); +} + +function TrendSparkline({ values }: { values?: number[] }) { + const { t } = useTranslation(); + + if (values === undefined) { + return ; + } + if (values.length < 2) { + return ( + + {t("settings.skills.marketplaceNoTrend", { defaultValue: "No trend yet" })} + + ); + } + + const width = 96; + const height = 30; + const padding = 2; + const min = Math.min(...values); + const max = Math.max(...values); + const range = Math.max(max - min, 1); + const points = values.map((value, index) => ({ + x: padding + (index / (values.length - 1)) * (width - padding * 2), + y: padding + ((max - value) / range) * (height - padding * 2), + })); + const line = points.slice(1).reduce((path, point, index) => { + const previous = points[index]; + const middle = (previous.x + point.x) / 2; + return `${path} C ${middle} ${previous.y}, ${middle} ${point.y}, ${point.x} ${point.y}`; + }, `M ${points[0].x} ${points[0].y}`); + const area = `${line} L ${points.at(-1)?.x ?? width} ${height} L ${points[0].x} ${height} Z`; + + return ( + + 8-week install trend + + + + ); +} + +function TrendingSkeleton() { + return ( +
+ {Array.from({ length: 5 }, (_, index) => ( +
+
+
+
+
+
+
+
+ ))} +
+ ); +} diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index 4541ef0bf..00d5aa310 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -1038,7 +1038,7 @@ export function ThreadComposer({ if (skillQuery !== null) { const query = skillQuery.text; return skills - .filter((skill) => skill.available) + .filter((skill) => skill.enabled !== false && skill.available) .flatMap((skill) => { const matchRank = skillMatchRank(skill, query); return matchRank === null diff --git a/webui/src/hooks/useSkills.ts b/webui/src/hooks/useSkills.ts index 9144b61cb..c22a3dfb3 100644 --- a/webui/src/hooks/useSkills.ts +++ b/webui/src/hooks/useSkills.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { fetchSkills } from "@/lib/api"; +import { isSkillsPayload, SKILLS_CHANGED_EVENT } from "@/lib/skill-events"; import type { SkillSummary } from "@/lib/types"; export function useSkills(token: string): SkillSummary[] { @@ -8,11 +9,21 @@ export function useSkills(token: string): SkillSummary[] { useEffect(() => { let cancelled = false; - fetchSkills(token) - .then(({ skills: nextSkills }) => !cancelled && setSkills(nextSkills)) - .catch(() => !cancelled && setSkills([])); + const refresh = () => { + fetchSkills(token) + .then(({ skills: nextSkills }) => !cancelled && setSkills(nextSkills)) + .catch(() => !cancelled && setSkills([])); + }; + const onSkillsChanged = (event: Event) => { + const payload = (event as CustomEvent).detail; + if (!cancelled && isSkillsPayload(payload)) setSkills(payload.skills); + }; + + refresh(); + window.addEventListener(SKILLS_CHANGED_EVENT, onSkillsChanged); return () => { cancelled = true; + window.removeEventListener(SKILLS_CHANGED_EVENT, onSkillsChanged); }; }, [token]); diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 1a4501d32..b5c0ff407 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -26,7 +26,12 @@ import type { SettingsUpdate, SidebarStatePayload, SkillDetail, + SkillActionPayload, + SkillInstallPayload, SkillsPayload, + SkillsSearchPayload, + SkillsTrendsPayload, + SkillsTrendingPayload, SlashCommand, SlashCommandLifecycle, TranscriptionSettingsUpdate, @@ -310,6 +315,87 @@ export async function fetchSkillDetail( ); } +export async function updateSkillEnabled( + token: string, + name: string, + enabled: boolean, + base: string = "", +): Promise { + const params = new URLSearchParams({ name, enabled: String(enabled) }); + return request( + `${base}/api/webui/skills/update?${params}`, + token, + ); +} + +export async function deleteSkill( + token: string, + name: string, + base: string = "", +): Promise { + const params = new URLSearchParams({ name }); + return request( + `${base}/api/webui/skills/delete?${params}`, + token, + ); +} + +export async function searchMarketplaceSkills( + token: string, + query: string, + base: string = "", +): Promise { + const params = new URLSearchParams({ q: query }); + return request( + `${base}/api/webui/skills/search?${params}`, + token, + undefined, + API_READ_TIMEOUT_MS, + ); +} + +export async function fetchTrendingMarketplaceSkills( + token: string, + base: string = "", +): Promise { + return request( + `${base}/api/webui/skills/trending`, + token, + undefined, + API_READ_TIMEOUT_MS, + ); +} + +export async function fetchMarketplaceSkillTrends( + token: string, + skillIds: string[], + base: string = "", +): Promise { + const params = new URLSearchParams(); + skillIds.forEach((id) => params.append("id", id)); + return request( + `${base}/api/webui/skills/trends?${params}`, + token, + undefined, + API_READ_TIMEOUT_MS, + ); +} + +export async function installMarketplaceSkill( + token: string, + source: string, + skill: string, + base: string = "", +): Promise { + const params = new URLSearchParams({ source, skill }); + return request( + `${base}/api/webui/skills/install?${params}`, + token, + undefined, + 150_000, + ); +} + export async function deleteSession( token: string, key: string, diff --git a/webui/src/lib/skill-events.ts b/webui/src/lib/skill-events.ts new file mode 100644 index 000000000..8ef4a4d64 --- /dev/null +++ b/webui/src/lib/skill-events.ts @@ -0,0 +1,18 @@ +import type { SkillsPayload } from "@/lib/types"; + +export const SKILLS_CHANGED_EVENT = "nanobot:skills-changed"; + +export function isSkillsPayload(value: unknown): value is SkillsPayload { + return ( + !!value + && typeof value === "object" + && Array.isArray((value as { skills?: unknown }).skills) + ); +} + +export function notifySkillsChanged(payload: SkillsPayload): void { + if (typeof window === "undefined") return; + window.dispatchEvent(new CustomEvent(SKILLS_CHANGED_EVENT, { + detail: payload, + })); +} diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 1f4cb1180..1d422fab2 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -178,6 +178,8 @@ export interface SkillSummary { name: string; description: string; source: "workspace" | "builtin" | string; + enabled?: boolean; + deletable?: boolean; available: boolean; unavailable_reason?: string; } @@ -189,13 +191,64 @@ export interface SkillRequirements { missing_env: string[]; } +export interface SkillInstallOption { + id: string; + kind: string; + label: string; + command: string; +} + export interface SkillDetail extends SkillSummary { requirements: SkillRequirements; + install_options?: SkillInstallOption[]; raw_markdown: string; } export interface SkillsPayload { skills: SkillSummary[]; } +export interface SkillActionPayload extends SkillsPayload { + last_action: { + name: string; + enabled: boolean; + deleted: boolean; + }; +} + +export interface MarketplaceSkillSummary { + id: string; + skill_id: string; + name: string; + source: string; + installs: number; + url: string; + installed: boolean; + rank?: number; +} + +export interface SkillsSearchPayload { + query: string; + skills: MarketplaceSkillSummary[]; + install_supported: boolean; +} + +export interface SkillsTrendingPayload { + skills: MarketplaceSkillSummary[]; + period: "24h"; + install_supported: boolean; +} + +export interface SkillsTrendsPayload { + trends: Record; +} + +export interface SkillInstallPayload extends SkillsPayload { + last_action: { + installed: boolean; + already_installed: boolean; + name: string; + }; +} + /** Structured UI blob on ``progress`` WS frames; channels may add more ``kind`` values later. */ export interface AgentUIBlob { kind: string; diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index e620dc58c..7d7e4d891 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -5,6 +5,7 @@ import { completeProviderOAuth, createModelConfiguration, createProviderSettings, + deleteSkill, deleteModelConfiguration, deleteSession, fetchFilePreview, @@ -14,6 +15,7 @@ import { fetchCliApps, fetchInstalledCliApps, fetchMcpPresets, + fetchMarketplaceSkillTrends, fetchNanobotFeatures, fetchProviderModels, fetchSessionAutomations, @@ -21,9 +23,11 @@ import { fetchSidebarState, fetchSkillDetail, fetchSkills, + fetchTrendingMarketplaceSkills, fetchWebuiThread, fetchWorkspaces, importMcpConfig, + installMarketplaceSkill, listSessions, listSlashCommands, loginProviderOAuth, @@ -35,6 +39,7 @@ import { runCliAppAction, runMcpPresetAction, saveCustomMcpServer, + searchMarketplaceSkills, startApiService, stopApiService, cancelChannelConnect, @@ -49,6 +54,7 @@ import { updateNetworkSafetySettings, updateProviderSettings, updateSettings, + updateSkillEnabled, updateWebSearchSettings, validateChannel, } from "@/lib/api"; @@ -287,6 +293,72 @@ describe("webui API helpers", () => { ); }); + it("encodes skills.sh search queries", async () => { + await searchMarketplaceSkills("tok", "React & testing"); + + expect(fetch).toHaveBeenCalledWith( + "/api/webui/skills/search?q=React+%26+testing", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + ); + }); + + it("fetches the skills.sh 24-hour leaderboard", async () => { + await fetchTrendingMarketplaceSkills("tok"); + + expect(fetch).toHaveBeenCalledWith( + "/api/webui/skills/trending", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + ); + }); + + it("fetches skills.sh trend history independently", async () => { + await fetchMarketplaceSkillTrends("tok", [ + "vercel-labs/skills/find-skills", + "acme/skills/react", + ]); + + expect(fetch).toHaveBeenCalledWith( + "/api/webui/skills/trends?id=vercel-labs%2Fskills%2Ffind-skills&id=acme%2Fskills%2Freact", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + ); + }); + + it("encodes skills.sh install coordinates", async () => { + await installMarketplaceSkill("tok", "vercel-labs/agent-skills", "react-testing"); + + expect(fetch).toHaveBeenCalledWith( + "/api/webui/skills/install?source=vercel-labs%2Fagent-skills&skill=react-testing", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + ); + }); + + it("updates and deletes installed skills with encoded names", async () => { + await updateSkillEnabled("tok", "custom skill", false); + + expect(fetch).toHaveBeenCalledWith( + "/api/webui/skills/update?name=custom+skill&enabled=false", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + ); + + await deleteSkill("tok", "custom skill"); + expect(fetch).toHaveBeenCalledWith( + "/api/webui/skills/delete?name=custom+skill", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + ); + }); + it("percent-encodes websocket keys when deleting a session", async () => { await deleteSession("tok", "websocket:chat-1"); diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 63f48cdee..993c915a3 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -384,20 +384,39 @@ describe("App layout", () => { "/api/settings/mcp-presets": { presets: [], installed_count: 0 }, "/api/webui/skills": { skills: [ - { name: "cron", description: "Schedule reminders.", source: "builtin", available: true }, + { + name: "cron", + description: "Schedule reminders.", + source: "builtin", + enabled: true, + deletable: false, + available: true, + }, { name: "github", description: "Work with GitHub.", source: "builtin", + enabled: true, + deletable: false, available: false, unavailable_reason: "CLI: gh", }, + { + name: "custom-skill", + description: "A workspace skill.", + source: "workspace", + enabled: true, + deletable: true, + available: true, + }, ], }, "/api/webui/skills/github": { name: "github", description: "Work with GitHub.", source: "builtin", + enabled: true, + deletable: false, available: false, unavailable_reason: "CLI: gh", requirements: { @@ -406,8 +425,48 @@ describe("App layout", () => { missing_bins: ["gh"], missing_env: [], }, + install_options: [{ + id: "brew", + kind: "brew", + label: "Install GitHub CLI (brew)", + command: "brew install gh", + }], raw_markdown: "---\nname: github\n---\nUse GitHub CLI.", }, + "/api/webui/skills/update?name=github&enabled=false": { + skills: [ + { + name: "cron", + description: "Schedule reminders.", + source: "builtin", + enabled: true, + deletable: false, + available: true, + }, + { + name: "github", + description: "Work with GitHub.", + source: "builtin", + enabled: false, + deletable: false, + available: false, + unavailable_reason: "CLI: gh", + }, + { + name: "custom-skill", + description: "A workspace skill.", + source: "workspace", + enabled: true, + deletable: true, + available: true, + }, + ], + last_action: { + name: "github", + enabled: false, + deleted: false, + }, + }, }); render(); @@ -419,9 +478,12 @@ describe("App layout", () => { fireEvent.click(skillsButton); expect(await screen.findByRole("heading", { name: "Skills" })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Search installed skills" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Custom" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Built-in" })).toBeInTheDocument(); expect(screen.getByText("cron")).toBeInTheDocument(); expect(screen.getByText("github")).toBeInTheDocument(); - expect(screen.getByText("Missing: CLI: gh")).toBeInTheDocument(); + expect(screen.getByText("Needs setup")).toBeInTheDocument(); expect(screen.getByRole("navigation", { name: "Sidebar navigation" })).toBeInTheDocument(); expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument(); expect(within(sidebar).getByRole("button", { name: "Skills" })).toHaveAttribute( @@ -439,11 +501,186 @@ describe("App layout", () => { fireEvent.click(screen.getByRole("button", { name: "Open details for github" })); expect(await screen.findByRole("heading", { name: "github" })).toBeInTheDocument(); - expect(screen.getByText("Unavailable reason")).toBeInTheDocument(); - expect(screen.getAllByText("CLI: gh").length).toBeGreaterThan(0); - expect(screen.getByText("Missing CLI")).toBeInTheDocument(); - fireEvent.click(screen.getByText("Raw SKILL.md")); + expect(screen.getByText("Setup required")).toBeInTheDocument(); + expect(screen.getByText("brew install gh")).toBeInTheDocument(); + expect(screen.queryByText("Unavailable reason")).not.toBeInTheDocument(); + expect(screen.queryByText("Missing CLI")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Check again" })).toBeInTheDocument(); + fireEvent.click(screen.getByText("Skill instructions")); expect(screen.getByText(/Use GitHub CLI/)).toBeInTheDocument(); + const enabledSwitch = screen.getByRole("switch", { name: "Disable github" }); + expect(enabledSwitch).toHaveAttribute("aria-checked", "true"); + fireEvent.click(enabledSwitch); + await waitFor(() => { + expect(screen.getByRole("switch", { name: "Enable github" })).toHaveAttribute( + "aria-checked", + "false", + ); + }); + }); + + it("deletes a custom skill from its detail sheet", async () => { + mockFetchRoutes({ + "/api/settings": baseSettingsPayload(), + "/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" }, + "/api/settings/mcp-presets": { presets: [], installed_count: 0 }, + "/api/webui/skills": { + skills: [ + { + name: "custom-skill", + description: "A workspace skill.", + source: "workspace", + enabled: true, + deletable: true, + available: true, + }, + ], + }, + "/api/webui/skills/custom-skill": { + name: "custom-skill", + description: "A workspace skill.", + source: "workspace", + enabled: true, + deletable: true, + available: true, + requirements: { + bins: [], + env: [], + missing_bins: [], + missing_env: [], + }, + raw_markdown: "---\nname: custom-skill\n---\nWorkspace instructions.", + }, + "/api/webui/skills/delete?name=custom-skill": { + skills: [], + last_action: { + name: "custom-skill", + enabled: false, + deleted: true, + }, + }, + }); + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + fireEvent.click(within(sidebar).getByRole("button", { name: "Skills" })); + fireEvent.click( + await screen.findByRole("button", { name: "Open details for custom-skill" }), + ); + expect(await screen.findByRole("heading", { name: "custom-skill" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + expect(screen.getByRole("heading", { name: "Delete custom-skill?" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Delete skill" })); + + await waitFor(() => { + expect( + screen.queryByRole("button", { name: "Open details for custom-skill" }), + ).not.toBeInTheDocument(); + }); + expect(screen.getByText("No matching skills.")).toBeInTheDocument(); + }); + + it("discovers and installs a skill from skills.sh", async () => { + mockFetchRoutes({ + "/api/settings": baseSettingsPayload(), + "/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" }, + "/api/settings/mcp-presets": { presets: [], installed_count: 0 }, + "/api/webui/skills": { + skills: [ + { name: "cron", description: "Schedule reminders.", source: "builtin", available: true }, + ], + }, + "/api/webui/skills/trending": { + period: "24h", + install_supported: true, + skills: [{ + id: "vercel-labs/skills/find-skills", + skill_id: "find-skills", + name: "find-skills", + source: "vercel-labs/skills", + installs: 14_481, + url: "https://skills.sh/vercel-labs/skills/find-skills", + installed: false, + rank: 18, + }], + }, + "/api/webui/skills/trends?id=vercel-labs%2Fskills%2Ffind-skills": { + trends: { + "vercel-labs/skills/find-skills": [20, 32, 28, 45, 41, 50, 62, 58], + }, + }, + "/api/webui/skills/search?q=React": { + query: "React", + 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: false, + }], + }, + "/api/webui/skills/trends?id=acme%2Fagent-skills%2Freact-testing": { + trends: { "acme/agent-skills/react-testing": [] }, + }, + "/api/webui/skills/install?source=acme%2Fagent-skills&skill=react-testing": { + skills: [ + { + name: "react-testing", + description: "Test React apps.", + source: "workspace", + available: true, + }, + { name: "cron", description: "Schedule reminders.", source: "builtin", available: true }, + ], + last_action: { + installed: true, + already_installed: false, + name: "react-testing", + }, + }, + }); + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + fireEvent.click(within(sidebar).getByRole("button", { name: "Skills" })); + fireEvent.click(await screen.findByRole("tab", { name: "Discover" })); + expect(await screen.findByRole("heading", { name: "Trending today" })).toBeInTheDocument(); + expect(screen.getByText("find-skills")).toBeInTheDocument(); + expect(screen.getByText(/14,481 installs \/ 24h/)).toBeInTheDocument(); + expect( + await screen.findByRole("img", { name: "8-week install trend" }), + ).toBeInTheDocument(); + fireEvent.change(screen.getByRole("textbox", { name: "Search skills.sh" }), { + target: { value: "React" }, + }); + + expect(await screen.findByText("React Testing")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Install" })); + expect( + await screen.findByRole("heading", { name: "Install React Testing?" }), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Install skill" })); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + "/api/webui/skills/install?source=acme%2Fagent-skills&skill=react-testing", + expect.objectContaining({ + headers: { Authorization: expect.any(String) }, + }), + ); + }); + expect(await screen.findByRole("button", { name: "Installed" })).toBeDisabled(); + + fireEvent.click(screen.getByRole("tab", { name: "Installed" })); + expect(screen.getByText("react-testing")).toBeInTheDocument(); }); it("opens Automations from the main sidebar", async () => { diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index b821ee4e2..545a7233c 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -1478,12 +1478,29 @@ describe("ThreadComposer", () => { , ); @@ -1499,6 +1516,8 @@ describe("ThreadComposer", () => { const name = within(option).getByText(skillName); expect(name).not.toHaveClass("truncate"); expect(within(option).queryByText(`$${skillName}`)).not.toBeInTheDocument(); + expect(within(palette).queryByText("arxiv-disabled")).not.toBeInTheDocument(); + expect(within(palette).queryByText("arxiv-unavailable")).not.toBeInTheDocument(); expect(within(palette).queryByText("/model")).not.toBeInTheDocument(); fireEvent.keyDown(input, { key: "Tab" });