feat(webui): add skills marketplace

This commit is contained in:
Xubin Ren 2026-07-27 21:19:42 +08:00
parent 129b74b4cf
commit ba0ba4749d
23 changed files with 3042 additions and 246 deletions

View File

@ -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,

View File

@ -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"(?<![\w$])\$([A-Za-z0-9_-]+)")
class SkillsLoader:
@ -109,6 +110,21 @@ class SkillsLoader:
]
return "\n\n---\n\n".join(parts)
def get_explicitly_invoked_skills(self, text: str) -> 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).

View File

@ -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

View File

@ -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,

View File

@ -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]:

View File

@ -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(

View File

@ -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

View File

@ -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 <workspace>/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 ``<workspace>/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:]

View File

@ -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")

View File

@ -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")

View File

@ -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 |) -----------------

View File

@ -0,0 +1,128 @@
from pathlib import Path
from types import SimpleNamespace
import pytest
from nanobot.webui.skills_api import (
SkillManagementError,
delete_webui_skill,
set_webui_skill_enabled,
webui_skill_detail_payload,
webui_skills_payload,
)
def _write_skill(workspace: Path, name: str, *, metadata: str = "") -> Path:
directory = workspace / "skills" / name
directory.mkdir(parents=True)
(directory / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: {name} description.\n{metadata}---\n",
encoding="utf-8",
)
return directory
def _config(*disabled: str) -> SimpleNamespace:
return SimpleNamespace(
agents=SimpleNamespace(
defaults=SimpleNamespace(disabled_skills=list(disabled)),
)
)
def test_disabled_skills_remain_visible_and_loadable(tmp_path: Path) -> None:
_write_skill(tmp_path, "custom-skill")
payload = webui_skills_payload(tmp_path, disabled_skills={"custom-skill"})
skill = next(item for item in payload["skills"] if item["name"] == "custom-skill")
assert skill["enabled"] is False
assert skill["deletable"] is True
detail = webui_skill_detail_payload(
tmp_path,
"custom-skill",
disabled_skills={"custom-skill"},
)
assert detail is not None
assert detail["enabled"] is False
assert "custom-skill description" in detail["raw_markdown"]
def test_skill_detail_exposes_copyable_install_commands(tmp_path: Path) -> None:
_write_skill(
tmp_path,
"custom-skill",
metadata=(
'metadata: {"nanobot":{"requires":{"bins":["demo"]},'
'"install":[{"id":"brew","kind":"brew","formula":"acme/demo",'
'"label":"Install demo"}]}}\n'
),
)
detail = webui_skill_detail_payload(tmp_path, "custom-skill")
assert detail is not None
assert detail["install_options"] == [
{
"id": "brew",
"kind": "brew",
"label": "Install demo",
"command": "brew install acme/demo",
}
]
def test_set_webui_skill_enabled_persists_and_updates_runtime(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_write_skill(tmp_path, "custom-skill")
config = _config()
saved: list[object] = []
monkeypatch.setattr("nanobot.webui.skills_api.load_config", lambda: config)
monkeypatch.setattr("nanobot.webui.skills_api.save_config", saved.append)
disabled: set[str] = set()
action = set_webui_skill_enabled(
tmp_path,
"custom-skill",
enabled=False,
disabled_skills=disabled,
)
assert action == {
"name": "custom-skill",
"enabled": False,
"deleted": False,
}
assert config.agents.defaults.disabled_skills == ["custom-skill"]
assert disabled == {"custom-skill"}
assert saved == [config]
def test_delete_webui_skill_only_deletes_workspace_skills(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
directory = _write_skill(tmp_path, "custom-skill")
config = _config("custom-skill")
saved: list[object] = []
monkeypatch.setattr("nanobot.webui.skills_api.load_config", lambda: config)
monkeypatch.setattr("nanobot.webui.skills_api.save_config", saved.append)
disabled = {"custom-skill"}
action = delete_webui_skill(
tmp_path,
"custom-skill",
disabled_skills=disabled,
)
assert action["deleted"] is True
assert not directory.exists()
assert disabled == set()
assert config.agents.defaults.disabled_skills == []
assert saved == [config]
with pytest.raises(SkillManagementError) as exc_info:
delete_webui_skill(tmp_path, "cron", disabled_skills=disabled)
assert exc_info.value.status == 403

View File

@ -0,0 +1,300 @@
from pathlib import Path
from typing import Any
import httpx
import pytest
from nanobot.webui.skills_marketplace import (
SkillsMarketplaceError,
install_marketplace_skill,
marketplace_skill_trends,
search_marketplace_skills,
trending_marketplace_skills,
)
@pytest.mark.asyncio
async def test_search_marketplace_skills_filters_and_marks_installed(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
skill_dir = tmp_path / "skills" / "react-testing"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("---\nname: react-testing\n---\n", encoding="utf-8")
seen: dict[str, Any] = {}
class FakeResponse:
def raise_for_status(self) -> None:
pass
def json(self) -> dict[str, Any]:
return {
"skills": [
{
"name": "React Testing",
"skillId": "react-testing",
"source": "acme/agent-skills",
"installs": 42,
},
{"skillId": "../escape", "source": "acme/agent-skills"},
{"skillId": "valid-name", "source": "not-a-repository"},
]
}
class FakeClient:
async def __aenter__(self) -> "FakeClient":
return self
async def __aexit__(self, *_args: object) -> None:
pass
async def get(self, url: str, *, params: dict[str, object]) -> FakeResponse:
seen.update(url=url, params=params)
return FakeResponse()
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
lambda **_kwargs: FakeClient(),
)
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.skills_install_supported",
lambda: True,
)
payload = await search_marketplace_skills(" react testing ", tmp_path)
assert seen == {
"url": "https://skills.sh/api/search",
"params": {"q": "react testing", "limit": 20},
}
assert payload == {
"query": "react testing",
"install_supported": True,
"skills": [
{
"id": "acme/agent-skills/react-testing",
"skill_id": "react-testing",
"name": "React Testing",
"source": "acme/agent-skills",
"installs": 42,
"url": "https://skills.sh/acme/agent-skills/react-testing",
"installed": True,
}
],
}
@pytest.mark.asyncio
async def test_trending_marketplace_skills_diversifies_sources_and_keeps_rank(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
class FakeResponse:
def raise_for_status(self) -> None:
pass
def json(self) -> dict[str, Any]:
return {
"skills": [
{
"name": "First",
"skillId": "first",
"source": "acme/skills",
"installs": 50,
},
{
"name": "Second from same source",
"skillId": "second",
"source": "acme/skills",
"installs": 49,
},
{
"name": "Another",
"skillId": "another",
"source": "other/skills",
"installs": 30,
},
]
}
class FakeClient:
async def __aenter__(self) -> "FakeClient":
return self
async def __aexit__(self, *_args: object) -> None:
pass
async def get(self, url: str) -> FakeResponse:
assert url == "https://skills.sh/api/skills/trending/0"
return FakeResponse()
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
lambda **_kwargs: FakeClient(),
)
payload = await trending_marketplace_skills(tmp_path)
assert payload["period"] == "24h"
assert [(skill["name"], skill["rank"]) for skill in payload["skills"]] == [
("First", 1),
("Another", 3),
]
@pytest.mark.asyncio
async def test_marketplace_skill_trends_returns_history_separately(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class FakeResponse:
text = r'<script>\"values\":[3,5,8,13]</script>'
def raise_for_status(self) -> None:
pass
class FakeClient:
async def __aenter__(self) -> "FakeClient":
return self
async def __aexit__(self, *_args: object) -> None:
pass
async def get(self, url: str) -> FakeResponse:
assert url == "https://www.skills.sh/other/skills/second"
return FakeResponse()
async def weekly_installs(_client: object) -> dict[tuple[str, str], list[int]]:
return {
("acme/skills", "first"): [2, 4, 3, 8],
}
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
lambda **_kwargs: FakeClient(),
)
monkeypatch.setattr(
"nanobot.webui.skills_marketplace._load_weekly_installs",
weekly_installs,
)
assert await marketplace_skill_trends([
"acme/skills/first",
"other/skills/second",
"invalid",
]) == {
"trends": {
"acme/skills/first": [2, 4, 3, 8],
"other/skills/second": [3, 5, 8, 13],
}
}
@pytest.mark.asyncio
async def test_search_marketplace_skills_returns_safe_upstream_error(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
class FailingClient:
async def __aenter__(self) -> "FailingClient":
return self
async def __aexit__(self, *_args: object) -> None:
pass
async def get(self, *_args: object, **_kwargs: object) -> None:
raise httpx.ConnectError("private network detail")
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.httpx.AsyncClient",
lambda **_kwargs: FailingClient(),
)
with pytest.raises(SkillsMarketplaceError) as exc_info:
await search_marketplace_skills("react", tmp_path)
assert exc_info.value.status == 502
assert exc_info.value.message == "skills.sh search is temporarily unavailable"
@pytest.mark.asyncio
async def test_install_marketplace_skill_uses_official_cli_and_workspace(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
seen: dict[str, Any] = {}
class FakeProcess:
returncode = 0
async def communicate(self) -> tuple[bytes, None]:
skill_dir = tmp_path / "skills" / "react-testing"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: react-testing\n---\n",
encoding="utf-8",
)
return b"installed", None
def kill(self) -> None:
raise AssertionError("successful install must not be killed")
async def create_subprocess_exec(*command: str, **kwargs: object) -> FakeProcess:
seen.update(command=command, **kwargs)
return FakeProcess()
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.shutil.which",
lambda executable: "/usr/local/bin/npx" if executable == "npx" else None,
)
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.asyncio.create_subprocess_exec",
create_subprocess_exec,
)
result = await install_marketplace_skill(
"acme/agent-skills",
"react-testing",
tmp_path,
)
assert result == {
"installed": True,
"already_installed": False,
"name": "react-testing",
}
assert seen["command"] == (
"/usr/local/bin/npx",
"--yes",
"skills@latest",
"add",
"acme/agent-skills",
"--skill",
"react-testing",
"--agent",
"openclaw",
"--copy",
"--yes",
)
assert seen["cwd"] == str(tmp_path.resolve())
assert seen["env"]["DISABLE_TELEMETRY"] == "1"
@pytest.mark.asyncio
async def test_install_marketplace_skill_is_idempotent(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
skill_dir = tmp_path / "skills" / "already-here"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("---\nname: already-here\n---\n", encoding="utf-8")
launch = pytest.fail
monkeypatch.setattr(
"nanobot.webui.skills_marketplace.asyncio.create_subprocess_exec",
launch,
)
result = await install_marketplace_skill("acme/agent-skills", "already-here", tmp_path)
assert result == {
"installed": True,
"already_installed": True,
"name": "already-here",
}

View File

@ -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<SkillSummary | null>(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 (
<div className="space-y-7">
<section className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<p className="max-w-[680px] text-[13px] leading-5 text-muted-foreground">
{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.",
})}
</p>
<span className="text-[12px] font-medium text-muted-foreground">
@ -31,31 +95,122 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
</span>
</section>
<section className="rounded-[22px] bg-settings-surface px-3 py-3 sm:px-4">
<div className="flex items-center justify-between border-b border-border/45 pb-3">
<h2 className="mb-2 px-1 text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
{t("settings.skills.featured", { defaultValue: "Agent skills" })}
</h2>
<span className="rounded-full bg-muted px-2.5 py-1 text-[12px] font-medium text-muted-foreground">
{skills.length}
</span>
</div>
{skills.length ? (
<div className="grid gap-x-10 gap-y-1 py-3 md:grid-cols-2">
{skills.map((skill) => (
<SkillCatalogRow
key={`${skill.source}:${skill.name}`}
skill={skill}
onSelect={setSelectedSkill}
<div
className="inline-flex rounded-[12px] bg-muted/65 p-1"
role="tablist"
aria-label={t("settings.skills.views", { defaultValue: "Skills views" })}
>
{(["installed", "discover"] as const).map((item) => (
<button
key={item}
type="button"
role="tab"
aria-selected={view === item}
onClick={() => setView(item)}
className={cn(
"inline-flex items-center gap-1.5 rounded-[9px] px-3.5 py-1.5 text-[13px] font-medium transition-colors",
view === item
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{item === "installed"
? t("settings.skills.installedTab", { defaultValue: "Installed" })
: (
<>
<VercelMark />
{t("settings.skills.discoverTab", { defaultValue: "Discover" })}
</>
)}
</button>
))}
</div>
{view === "installed" ? (
<section className="overflow-hidden rounded-[22px] bg-settings-surface">
<div className="flex flex-col gap-3 border-b border-border/45 px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-[320px]">
<Search
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
))}
<Input
value={installedQuery}
onChange={(event) => 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]"
/>
</div>
<div className="flex items-center gap-1 rounded-[10px] bg-muted/65 p-1">
{([
["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]) => (
<button
key={filter}
type="button"
onClick={() => setInstalledFilter(filter)}
className={cn(
"rounded-[8px] px-2.5 py-1 text-[11px] font-medium transition-colors",
installedFilter === filter
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{label} <span className="ml-0.5 tabular-nums opacity-65">{count}</span>
</button>
))}
</div>
</div>
) : (
<div className="px-3 py-12 text-center text-sm text-muted-foreground">
{t("settings.skills.empty", { defaultValue: "No skills are available." })}
</div>
)}
</section>
{groupedSkills.length ? (
<div className="pb-2">
{groupedSkills.map((group) => (
<section key={group.key}>
<div className="flex items-center gap-2 bg-muted/20 px-5 py-2.5">
<h2 className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{group.label}
</h2>
<span className="text-[11px] tabular-nums text-muted-foreground/60">
{group.skills.length}
</span>
</div>
<div className="divide-y divide-border/40 px-3 sm:px-4">
{group.skills.map((skill) => (
<SkillCatalogRow
key={`${skill.source}:${skill.name}`}
skill={skill}
onSelect={setSelectedSkill}
/>
))}
</div>
</section>
))}
</div>
) : (
<div className="px-3 py-12 text-center text-sm text-muted-foreground">
{t("settings.skills.noMatching", {
defaultValue: "No matching skills.",
})}
</div>
)}
</section>
) : (
<SkillsMarketplace installedSkills={skills} />
)}
<SkillDetailSheet
skill={selectedSkill}
@ -68,6 +223,14 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
);
}
function VercelMark() {
return (
<svg viewBox="0 0 16 16" className="h-3 w-3" aria-hidden>
<path d="M8 1 16 15H0L8 1Z" fill="currentColor" />
</svg>
);
}
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 (
<button
@ -91,42 +256,28 @@ function SkillCatalogRow({
})}
onClick={() => onSelect(skill)}
className={cn(
"group flex min-w-0 items-center gap-3 rounded-[16px] px-3 py-3 text-left transition-colors",
"group flex w-full min-w-0 items-center gap-3 rounded-[14px] px-2 py-3 text-left transition-colors",
"hover:bg-muted/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
!skill.available && "opacity-65",
!enabled && "opacity-60",
)}
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-[14px] bg-muted/70 text-muted-foreground">
<Brain className="h-5 w-5" strokeWidth={1.8} aria-hidden />
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<h3 className="truncate text-[15px] font-semibold leading-5 text-foreground">
{skill.name}
</h3>
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold leading-none text-muted-foreground">
{sourceLabel}
</span>
</div>
<p className="mt-1 line-clamp-2 text-[13px] leading-5 text-muted-foreground">
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">
{skill.name}
</h3>
<p className="mt-0.5 line-clamp-1 text-[12px] leading-5 text-muted-foreground">
{skill.description}
</p>
{!skill.available && skill.unavailable_reason ? (
<p className="mt-1 truncate text-[12px] leading-4 text-muted-foreground/80">
{t("settings.skills.unavailableReason", {
reason: skill.unavailable_reason,
defaultValue: "Missing: {{reason}}",
})}
</p>
) : null}
</div>
<span
title={!skill.available && skill.unavailable_reason ? skill.unavailable_reason : undefined}
title={enabled && !skill.available ? skill.unavailable_reason : undefined}
className={cn(
"hidden shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-[12px] font-medium sm:inline-flex",
skill.available
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "bg-muted text-muted-foreground",
"hidden w-[92px] shrink-0 items-center justify-end gap-1 text-[11px] font-medium sm:inline-flex",
!enabled
? "text-muted-foreground"
: skill.available
? "text-emerald-700 dark:text-emerald-300"
: "text-amber-700 dark:text-amber-300",
)}
>
<StatusIcon className="h-3.5 w-3.5" aria-hidden />
@ -150,6 +301,10 @@ function SkillDetailSheet({
const [detail, setDetail] = useState<SkillDetail | null>(null);
const [loading, setLoading] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
const [actionBusy, setActionBusy] = useState(false);
const [actionError, setActionError] = useState("");
const [deleteOpen, setDeleteOpen] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
useEffect(() => {
if (!open || !skill) return;
@ -157,6 +312,8 @@ function SkillDetailSheet({
setDetail(null);
setLoading(true);
setLoadFailed(false);
setActionError("");
setDeleteOpen(false);
fetchSkillDetail(token, skill.name)
.then((payload) => {
if (!cancelled) setDetail(payload);
@ -170,90 +327,228 @@ function SkillDetailSheet({
return () => {
cancelled = true;
};
}, [open, skill, token]);
}, [open, refreshKey, skill, token]);
if (!skill) return null;
const activeSkill = detail ?? skill;
const enabled = activeSkill.enabled !== false;
const deletable = activeSkill.deletable ?? activeSkill.source === "workspace";
const sourceLabel = skillSourceLabel(activeSkill.source, t);
const statusLabel = activeSkill.available
? t("settings.skills.statusAvailable", { defaultValue: "Available" })
: t("settings.skills.statusUnavailable", { defaultValue: "Unavailable" });
const statusLabel = !enabled
? t("settings.skills.statusDisabled", { defaultValue: "Disabled" })
: activeSkill.available
? t("settings.skills.statusEnabled", { defaultValue: "Enabled" })
: t("settings.skills.statusNeedsSetup", { defaultValue: "Needs setup" });
const toggleEnabled = async () => {
setActionBusy(true);
setActionError("");
try {
const payload = await updateSkillEnabled(token, activeSkill.name, !enabled);
notifySkillsChanged(payload);
const updated = payload.skills.find((item) => item.name === activeSkill.name);
if (updated) {
setDetail((current) => current ? { ...current, ...updated } : current);
}
} catch (reason) {
setActionError(
reason instanceof Error
? reason.message
: t("settings.skills.updateFailed", {
defaultValue: "Could not update this skill.",
}),
);
} finally {
setActionBusy(false);
}
};
const removeSkill = async () => {
setDeleteOpen(false);
setActionBusy(true);
setActionError("");
try {
const payload = await deleteSkill(token, activeSkill.name);
notifySkillsChanged(payload);
onOpenChange(false);
} catch (reason) {
setActionError(
reason instanceof Error
? reason.message
: t("settings.skills.deleteFailed", {
defaultValue: "Could not delete this skill.",
}),
);
} finally {
setActionBusy(false);
}
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-[min(34rem,calc(100vw-1rem))] max-w-none gap-0 overflow-hidden p-0 sm:max-w-none"
>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<div className="flex items-start gap-3 pr-8">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-[15px] bg-muted/70 text-muted-foreground">
<Brain className="h-5 w-5" strokeWidth={1.8} aria-hidden />
</div>
<div className="min-w-0">
<SheetTitle className="truncate text-[20px] font-semibold">
{activeSkill.name}
</SheetTitle>
<SheetDescription className="sr-only">
{t("settings.skills.detailDescription", {
name: activeSkill.name,
defaultValue: "Details for {{name}}.",
})}
</SheetDescription>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[12px] text-muted-foreground">
<Pill>{sourceLabel}</Pill>
<Pill tone={activeSkill.available ? "success" : "muted"}>{statusLabel}</Pill>
</div>
</div>
</div>
{loading ? (
<div className="mt-8 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
{t("settings.skills.loadingDetail", { defaultValue: "Loading skill details..." })}
</div>
) : loadFailed ? (
<div className="mt-8 rounded-[16px] bg-destructive/10 px-3 py-3 text-sm text-destructive">
{t("settings.skills.loadFailed", { defaultValue: "Could not load skill details." })}
</div>
) : (
<div className="mt-7 space-y-6">
<DetailSection title={t("settings.skills.descriptionTitle", { defaultValue: "Description" })}>
<p className="text-[14px] leading-6 text-muted-foreground">{activeSkill.description}</p>
</DetailSection>
<div className="grid grid-cols-2 gap-2">
<MetaItem
label={t("settings.skills.source", { defaultValue: "Source" })}
value={sourceLabel}
/>
<MetaItem
label={t("settings.skills.status", { defaultValue: "Status" })}
value={statusLabel}
/>
</div>
{!activeSkill.available && activeSkill.unavailable_reason ? (
<DetailSection
title={t("settings.skills.unavailableReasonLabel", {
defaultValue: "Unavailable reason",
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-[min(34rem,calc(100vw-1rem))] max-w-none gap-0 overflow-hidden p-0 sm:max-w-none"
>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<div className="flex items-start gap-3 pr-8">
<div className="min-w-0 flex-1">
<SheetTitle className="truncate text-[20px] font-semibold">
{activeSkill.name}
</SheetTitle>
<SheetDescription className="sr-only">
{t("settings.skills.detailDescription", {
name: activeSkill.name,
defaultValue: "Details for {{name}}.",
})}
>
<p className="text-[13px] leading-5 text-destructive/85">
{activeSkill.unavailable_reason}
</p>
</DetailSection>
) : null}
{detail ? <RequirementsSection detail={detail} /> : null}
{detail ? <RawInstructionsBlock markdown={detail.raw_markdown} /> : null}
</SheetDescription>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[12px] text-muted-foreground">
<Pill>{sourceLabel}</Pill>
<Pill
tone={
!enabled
? "muted"
: activeSkill.available
? "success"
: "warning"
}
>
{statusLabel}
</Pill>
</div>
<p className="mt-3 text-[13px] leading-5 text-muted-foreground">
{activeSkill.description}
</p>
</div>
</div>
)}
</div>
</SheetContent>
</Sheet>
{loading ? (
<div className="mt-8 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
{t("settings.skills.loadingDetail", { defaultValue: "Loading skill details..." })}
</div>
) : loadFailed ? (
<div className="mt-8 rounded-[16px] bg-destructive/10 px-3 py-3 text-sm text-destructive">
{t("settings.skills.loadFailed", { defaultValue: "Could not load skill details." })}
</div>
) : (
<div className="mt-6 space-y-5">
<div className="flex items-center justify-between gap-4 border-y border-border/45 px-1 py-3.5">
<div>
<p className="text-[13px] font-medium text-foreground">
{t("settings.skills.enabledControl", { defaultValue: "Use this skill" })}
</p>
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
{t("settings.skills.enabledDescription", {
defaultValue:
"Allow the agent to load this skill when its requirements are ready.",
})}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label={t("settings.skills.toggleEnabled", {
name: activeSkill.name,
defaultValue: enabled ? "Disable {{name}}" : "Enable {{name}}",
})}
disabled={actionBusy}
onClick={() => void toggleEnabled()}
className={cn(
"relative h-6 w-11 shrink-0 rounded-full transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-wait disabled:opacity-60",
enabled ? "bg-foreground" : "bg-muted-foreground/30",
)}
>
<span
className={cn(
"absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-background shadow-sm transition-transform",
enabled ? "translate-x-5" : "translate-x-0",
)}
/>
</button>
</div>
{actionError ? (
<div className="rounded-[14px] bg-destructive/10 px-3 py-2.5 text-[13px] text-destructive">
{actionError}
</div>
) : null}
{detail && enabled ? (
<RequirementsSection
detail={detail}
onRefresh={() => setRefreshKey((value) => value + 1)}
/>
) : null}
{detail ? <RawInstructionsBlock markdown={detail.raw_markdown} /> : null}
{deletable ? (
<div className="flex items-center justify-between gap-4 border-t border-border/45 pt-5">
<div>
<p className="text-[13px] font-medium text-foreground">
{t("settings.skills.deleteTitle", { defaultValue: "Delete skill" })}
</p>
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
{t("settings.skills.deleteDescription", {
defaultValue: "Remove this skill from the current workspace.",
})}
</p>
</div>
<Button
type="button"
variant="destructive"
size="sm"
disabled={actionBusy}
onClick={() => setDeleteOpen(true)}
className="shrink-0 rounded-full"
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{t("settings.skills.deleteAction", { defaultValue: "Delete" })}
</Button>
</div>
) : null}
</div>
)}
</div>
</SheetContent>
</Sheet>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent className="rounded-[20px]">
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.skills.deleteConfirmTitle", {
name: activeSkill.name,
defaultValue: "Delete {{name}}?",
})}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.skills.deleteConfirmDescription", {
defaultValue:
"This removes the skill files from the current workspace. This action cannot be undone.",
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel", { defaultValue: "Cancel" })}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => void removeSkill()}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t("settings.skills.deleteConfirmAction", { defaultValue: "Delete skill" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@ -267,8 +562,13 @@ function RawInstructionsBlock({ markdown }: { markdown: string }) {
return (
<details className="group rounded-[18px] border border-border/45 bg-muted/20 px-3 py-3">
<summary className="cursor-pointer select-none text-[13px] font-medium text-foreground/90 transition-colors hover:text-foreground">
{t("settings.skills.rawInstructions", { defaultValue: "Raw SKILL.md" })}
<summary className="flex cursor-pointer select-none items-center justify-between gap-3 text-[13px] font-medium text-foreground/90 transition-colors hover:text-foreground">
<span>
{t("settings.skills.instructionsTitle", { defaultValue: "Skill instructions" })}
</span>
<code className="font-mono text-[10px] font-normal text-muted-foreground">
SKILL.md
</code>
</summary>
<div className="mt-3 overflow-hidden rounded-[14px] border border-border/35 bg-background/70">
<pre
@ -287,100 +587,131 @@ function RawInstructionsBlock({ markdown }: { markdown: string }) {
);
}
function MetaItem({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-[16px] bg-muted/35 px-3 py-2.5">
<div className="text-[11px] text-muted-foreground">{label}</div>
<div className="mt-0.5 truncate text-[13px] font-medium text-foreground">{value}</div>
</div>
);
}
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<string | null>(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 (
<DetailSection title={t("settings.skills.requirements", { defaultValue: "Requirements" })}>
{hasRequirements ? (
<div className="space-y-3">
{missing_bins.length ? (
<RequirementLine
title={t("settings.skills.missingCommands", { defaultValue: "Missing CLI" })}
items={missing_bins}
tone="danger"
icon={<Terminal className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
{missing_env.length ? (
<RequirementLine
title={t("settings.skills.missingEnvironment", { defaultValue: "Missing ENV" })}
items={missing_env}
tone="danger"
icon={<KeyRound className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
{bins.length ? (
<RequirementLine
title={t("settings.skills.commands", { defaultValue: "Commands" })}
items={bins}
icon={<Terminal className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
{env.length ? (
<RequirementLine
title={t("settings.skills.environment", { defaultValue: "Environment variables" })}
items={env}
icon={<KeyRound className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
<section className="rounded-[18px] border border-amber-500/20 bg-amber-500/[0.06] p-4">
<div className="flex items-start gap-2.5">
<CircleAlert
className="mt-0.5 h-4 w-4 shrink-0 text-amber-700 dark:text-amber-300"
aria-hidden
/>
<div className="min-w-0 flex-1">
<h3 className="text-[13px] font-semibold text-foreground">
{t("settings.skills.setupRequired", { defaultValue: "Setup required" })}
</h3>
<p className="mt-1 text-[12px] leading-5 text-muted-foreground">
{t("settings.skills.setupDescription", {
defaultValue:
"Install the missing dependency on the machine running nanobot, then check again.",
})}
</p>
</div>
) : (
<p className="text-[13px] text-muted-foreground">
{t("settings.skills.noRequirements", { defaultValue: "No explicit requirements." })}
</p>
)}
</DetailSection>
);
}
</div>
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
return (
<section>
<h3 className="mb-2 text-[12px] font-medium text-muted-foreground">{title}</h3>
{children}
<div className="mt-3 space-y-2">
{installOptions.map((option) => (
<div
key={`${option.id}:${option.command}`}
className="flex min-w-0 items-center gap-2 rounded-[12px] bg-background/80 px-3 py-2"
>
<Terminal className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[11px] text-foreground/80">
{option.command}
</code>
<button
type="button"
aria-label={t("settings.skills.copySetupCommand", {
defaultValue: "Copy setup command",
})}
title={option.label}
onClick={() => void copyCommand(option.command)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[8px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
{copiedCommand === option.command ? (
<Check className="h-3.5 w-3.5 text-emerald-600" aria-hidden />
) : (
<Copy className="h-3.5 w-3.5" aria-hidden />
)}
</button>
</div>
))}
{!installOptions.length && missing_bins.length ? (
<SetupRequirement
icon={<Terminal className="h-3.5 w-3.5" aria-hidden />}
label={t("settings.skills.missingCommands", { defaultValue: "Missing CLI" })}
items={missing_bins}
/>
) : null}
{missing_env.length ? (
<SetupRequirement
icon={<KeyRound className="h-3.5 w-3.5" aria-hidden />}
label={t("settings.skills.missingEnvironment", { defaultValue: "Missing ENV" })}
items={missing_env}
/>
) : null}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={onRefresh}
className="mt-3 h-8 rounded-full bg-background/60 px-3 text-[11px]"
>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{t("settings.skills.checkAgain", { defaultValue: "Check again" })}
</Button>
</section>
);
}
function RequirementLine({
title,
function SetupRequirement({
label,
items,
icon,
tone = "muted",
}: {
title: string;
label: string;
items: string[];
icon: ReactNode;
tone?: "muted" | "danger";
}) {
return (
<div className="space-y-1.5">
<div
className={cn(
"flex items-center gap-1.5 text-[12px]",
tone === "danger" ? "text-destructive" : "text-muted-foreground",
)}
>
<div className="flex flex-wrap items-center gap-1.5 px-1 py-1 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
{icon}
{title}
</div>
<div className="flex flex-wrap gap-1.5">
{items.map((item) => (
<Pill key={item}>{item}</Pill>
))}
</div>
{label}
</span>
{items.map((item) => (
<code
key={item}
className="rounded-full bg-background/80 px-2 py-0.5 font-mono text-[10px] text-foreground/70"
>
{item}
</code>
))}
</div>
);
}
@ -390,7 +721,7 @@ function Pill({
tone = "muted",
}: {
children: ReactNode;
tone?: "muted" | "success";
tone?: "muted" | "success" | "warning";
}) {
return (
<span
@ -398,7 +729,9 @@ function Pill({
"inline-flex max-w-full items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
tone === "success"
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "bg-muted text-muted-foreground",
: tone === "warning"
? "bg-amber-500/10 text-amber-700 dark:text-amber-300"
: "bg-muted text-muted-foreground",
)}
>
{children}

View File

@ -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<MarketplaceSkillSummary[]>([]);
const [trending, setTrending] = useState<MarketplaceSkillSummary[]>([]);
const [trends, setTrends] = useState<Record<string, number[]>>({});
const [loading, setLoading] = useState(false);
const [trendingLoading, setTrendingLoading] = useState(true);
const [error, setError] = useState("");
const [installSupported, setInstallSupported] = useState<boolean | null>(null);
const [selected, setSelected] = useState<MarketplaceSkillSummary | null>(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 (
<section className="space-y-4">
<div className="relative">
<Search
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
value={query}
onChange={(event) => 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 ? (
<span
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground"
role="status"
aria-label={t("settings.skills.marketplaceSearching", {
defaultValue: "Searching",
})}
>
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
</span>
) : null}
</div>
{error ? (
<div className="rounded-[14px] bg-destructive/10 px-3 py-2.5 text-[13px] text-destructive">
{error}
</div>
) : null}
{query.trim().length < 2 ? (
<section className="overflow-hidden rounded-[22px] bg-settings-surface">
<div className="flex items-center justify-between border-b border-border/45 px-5 py-4">
<div>
<h2 className="text-[14px] font-semibold">
{t("settings.skills.marketplaceTrendingTitle", {
defaultValue: "Trending today",
})}
</h2>
<p className="mt-0.5 text-[12px] text-muted-foreground">
{t("settings.skills.marketplaceTrendingDescription", {
defaultValue:
"Most installed across sources in 24h · curves show the 8-week trend",
})}
</p>
</div>
<a
href="https://skills.sh/trending"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground"
>
{t("settings.skills.marketplaceViewAll", { defaultValue: "View all" })}
<ExternalLink className="h-3 w-3" aria-hidden />
</a>
</div>
{trendingLoading ? (
<TrendingSkeleton />
) : trending.length ? (
<MarketplaceSkillList
skills={trending}
installedNames={installedNames}
installing={installing}
installSupported={installSupported}
metric="24h"
trends={trends}
onSelect={setSelected}
/>
) : (
<div className="px-5 py-10 text-center text-[13px] text-muted-foreground">
{t("settings.skills.marketplaceTrendingUnavailable", {
defaultValue: "Trending skills are temporarily unavailable.",
})}
</div>
)}
</section>
) : !loading && results.length === 0 && !error ? (
<div className="rounded-[22px] bg-settings-surface px-5 py-12 text-center text-sm text-muted-foreground">
{t("settings.skills.marketplaceEmpty", {
query: query.trim(),
defaultValue: "No skills found for “{{query}}”.",
})}
</div>
) : (
<div className="overflow-hidden rounded-[22px] bg-settings-surface">
<MarketplaceSkillList
skills={results}
installedNames={installedNames}
installing={installing}
installSupported={installSupported}
metric="total"
trends={trends}
onSelect={setSelected}
/>
</div>
)}
<AlertDialog
open={selected !== null}
onOpenChange={(open) => {
if (!open) setSelected(null);
}}
>
<AlertDialogContent className="rounded-[20px]">
<AlertDialogHeader>
<div className="mb-1 flex h-10 w-10 items-center justify-center rounded-[12px] bg-amber-500/10 text-amber-700 dark:text-amber-300">
<ShieldAlert className="h-5 w-5" aria-hidden />
</div>
<AlertDialogTitle>
{t("settings.skills.marketplaceConfirmTitle", {
name: selected?.name ?? "",
defaultValue: "Install {{name}}?",
})}
</AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<span className="block">
{t("settings.skills.marketplaceConfirmDescription", {
source: selected?.source ?? "",
defaultValue:
"This third-party skill comes from {{source}} and may include instructions or executable scripts.",
})}
</span>
<code className="block rounded-md bg-muted px-2 py-1 text-[12px] text-foreground">
{selected?.source}
</code>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel", { defaultValue: "Cancel" })}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (selected) void install(selected);
}}
>
{t("settings.skills.marketplaceConfirmInstall", {
defaultValue: "Install skill",
})}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
function MarketplaceSkillList({
skills,
installedNames,
installing,
installSupported,
metric,
trends,
onSelect,
}: {
skills: MarketplaceSkillSummary[];
installedNames: Set<string>;
installing: string;
installSupported: boolean | null;
metric: "total" | "24h";
trends: Record<string, number[]>;
onSelect: (skill: MarketplaceSkillSummary) => void;
}) {
return (
<div className="divide-y divide-border/45 px-3 sm:px-4">
{skills.map((skill) => (
<MarketplaceSkillRow
key={skill.id}
skill={skill}
installed={skill.installed || installedNames.has(skill.skill_id)}
isInstalling={installing === skill.skill_id}
installBusy={Boolean(installing)}
installSupported={installSupported}
metric={metric}
trend={trends[skill.id]}
onSelect={onSelect}
/>
))}
</div>
);
}
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 (
<div className="flex min-w-0 items-center gap-3 px-2 py-3.5">
{skill.rank ? (
<span className="w-7 shrink-0 text-right font-mono text-[12px] tabular-nums text-muted-foreground/65">
#{skill.rank}
</span>
) : null}
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<h3 className="truncate text-[14px] font-semibold text-foreground">
{skill.name}
</h3>
<a
href={skill.url}
target="_blank"
rel="noreferrer"
aria-label={t("settings.skills.marketplaceOpen", {
name: skill.name,
defaultValue: "Open {{name}} on skills.sh",
})}
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
</a>
</div>
<p className="mt-1 truncate text-[12px] text-muted-foreground">
{skill.source}
<span className="mx-1.5">·</span>
{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",
})}
</p>
</div>
<TrendSparkline values={trend} />
<Button
type="button"
size="sm"
variant={installed ? "secondary" : "default"}
disabled={installed || installBusy || installSupported === false}
onClick={() => onSelect(skill)}
className={cn("min-w-[92px] rounded-full", installed && "text-emerald-700")}
title={
installSupported === false
? t("settings.skills.marketplaceNpxRequired", {
defaultValue: "Node.js with npx is required",
})
: undefined
}
>
{isInstalling ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : installed ? (
<Check className="mr-1.5 h-3.5 w-3.5" aria-hidden />
) : (
<Download className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{isInstalling
? t("settings.skills.marketplaceInstalling", {
defaultValue: "Installing",
})
: installed
? t("settings.skills.marketplaceInstalled", {
defaultValue: "Installed",
})
: t("settings.skills.marketplaceInstall", {
defaultValue: "Install",
})}
</Button>
</div>
);
}
function TrendSparkline({ values }: { values?: number[] }) {
const { t } = useTranslation();
if (values === undefined) {
return <span className="hidden h-[30px] w-24 shrink-0 sm:block" aria-hidden />;
}
if (values.length < 2) {
return (
<span className="hidden w-24 shrink-0 text-center text-[11px] text-muted-foreground/60 sm:block">
{t("settings.skills.marketplaceNoTrend", { defaultValue: "No trend yet" })}
</span>
);
}
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 (
<svg
viewBox={`0 0 ${width} ${height}`}
className="hidden h-[30px] w-24 shrink-0 overflow-visible text-foreground/40 sm:block"
role="img"
aria-label="8-week install trend"
>
<title>8-week install trend</title>
<path d={area} fill="currentColor" opacity="0.06" />
<path
d={line}
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function TrendingSkeleton() {
return (
<div className="divide-y divide-border/45 px-5" aria-hidden>
{Array.from({ length: 5 }, (_, index) => (
<div key={index} className="flex items-center gap-3 py-4">
<div className="h-3 w-5 animate-pulse rounded bg-muted" />
<div className="flex-1 space-y-2">
<div className="h-3.5 w-48 max-w-[55%] animate-pulse rounded bg-muted" />
<div className="h-3 w-32 max-w-[40%] animate-pulse rounded bg-muted/70" />
</div>
<div className="h-8 w-[92px] animate-pulse rounded-full bg-muted" />
</div>
))}
</div>
);
}

View File

@ -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

View File

@ -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<unknown>).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]);

View File

@ -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<SkillActionPayload> {
const params = new URLSearchParams({ name, enabled: String(enabled) });
return request<SkillActionPayload>(
`${base}/api/webui/skills/update?${params}`,
token,
);
}
export async function deleteSkill(
token: string,
name: string,
base: string = "",
): Promise<SkillActionPayload> {
const params = new URLSearchParams({ name });
return request<SkillActionPayload>(
`${base}/api/webui/skills/delete?${params}`,
token,
);
}
export async function searchMarketplaceSkills(
token: string,
query: string,
base: string = "",
): Promise<SkillsSearchPayload> {
const params = new URLSearchParams({ q: query });
return request<SkillsSearchPayload>(
`${base}/api/webui/skills/search?${params}`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function fetchTrendingMarketplaceSkills(
token: string,
base: string = "",
): Promise<SkillsTrendingPayload> {
return request<SkillsTrendingPayload>(
`${base}/api/webui/skills/trending`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function fetchMarketplaceSkillTrends(
token: string,
skillIds: string[],
base: string = "",
): Promise<SkillsTrendsPayload> {
const params = new URLSearchParams();
skillIds.forEach((id) => params.append("id", id));
return request<SkillsTrendsPayload>(
`${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<SkillInstallPayload> {
const params = new URLSearchParams({ source, skill });
return request<SkillInstallPayload>(
`${base}/api/webui/skills/install?${params}`,
token,
undefined,
150_000,
);
}
export async function deleteSession(
token: string,
key: string,

View File

@ -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<SkillsPayload>(SKILLS_CHANGED_EVENT, {
detail: payload,
}));
}

View File

@ -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<string, number[]>;
}
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;

View File

@ -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");

View File

@ -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(<App />);
@ -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(<App />);
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(<App />);
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 () => {

View File

@ -1478,12 +1478,29 @@ describe("ThreadComposer", () => {
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
skills={[{
name: skillName,
description: "Fetch and summarize the latest AI research papers every day",
source: "builtin",
available: true,
}]}
skills={[
{
name: skillName,
description: "Fetch and summarize the latest AI research papers every day",
source: "builtin",
enabled: true,
available: true,
},
{
name: "arxiv-disabled",
description: "Disabled research workflow",
source: "builtin",
enabled: false,
available: true,
},
{
name: "arxiv-unavailable",
description: "Unavailable research workflow",
source: "builtin",
enabled: true,
available: false,
},
]}
slashCommands={COMMANDS}
/>,
);
@ -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" });