feat: add clawhub skill registry source

This commit is contained in:
jxxghp
2026-04-22 16:22:10 +08:00
parent cefb60ba2c
commit 89bf89c02d
4 changed files with 502 additions and 10 deletions

View File

@@ -585,7 +585,7 @@ class SkillsChain(ChainBase):
sources = self.skillhelper.get_market_sources()
source_lines = []
for index, source in enumerate(sources, start=1):
source_lines.append(f"{index}. {source}")
source_lines.append(f"{index}. {self.skillhelper.describe_market_source(source)}")
text_lines = [
f"已安装技能:{len(local_skills)}",
@@ -708,6 +708,16 @@ class SkillsChain(ChainBase):
self._truncate(skill.description),
]
)
if skill.source_type == "registry":
text_lines.append("社区源,安装前请自行甄别安全性")
if any(skill.source_type == "registry" for skill in page_items):
text_lines.extend(
[
"",
"提示ClawHub 属于社区注册表,技能质量与安全性需要自行甄别。",
]
)
text_lines.extend(
[

View File

@@ -423,6 +423,7 @@ class ConfigModel(BaseModel):
# ==================== 技能配置 ====================
# 技能市场仓库地址,多个地址使用,分隔
SKILL_MARKET: str = (
"https://clawhub.ai,"
"https://github.com/openai/skills,"
"https://github.com/anthropics/skills,"
"https://github.com/vercel-labs/agent-skills"

View File

@@ -6,7 +6,7 @@ from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from urllib.parse import urlparse
from urllib.parse import urlencode, urlparse
from app.agent.middleware.skills import _parse_skill_metadata
from app.core.cache import cached, fresh
@@ -19,6 +19,13 @@ from app.utils.url import UrlUtils
_SOURCE_META_FILENAME = ".moviepilot-skill-source.json"
_DEFAULT_BRANCHES = ("main", "master")
_MARKET_CACHE_TTL = 60 * 30
_CLAWHUB_HOSTS = {"clawhub.ai", "www.clawhub.ai"}
_OFFICIAL_SKILL_REPOS = {
"openai/skills",
"anthropics/skills",
"vercel-labs/agent-skills",
"NousResearch/hermes-agent",
}
@dataclass
@@ -33,6 +40,10 @@ class SkillInfo:
repo_url: Optional[str] = None
repo_name: Optional[str] = None
skill_path: Optional[str] = None
registry_url: Optional[str] = None
registry_name: Optional[str] = None
registry_slug: Optional[str] = None
download_url: Optional[str] = None
installed: bool = False
removable: bool = False
@@ -74,6 +85,41 @@ class SkillHelper(metaclass=WeakSingleton):
skill_dir.mkdir(parents=True, exist_ok=True)
return skill_dir
@staticmethod
def _build_repo_source_label(repo_name: Optional[str]) -> str:
"""
根据仓库名称生成展示标签。
"""
repo_name = (repo_name or "").strip()
if not repo_name:
return "仓库来源"
if repo_name in _OFFICIAL_SKILL_REPOS:
return f"官方仓库 · {repo_name}"
return f"仓库来源 · {repo_name}"
@staticmethod
def _build_registry_source_label(registry_name: Optional[str]) -> str:
"""
根据注册表名称生成展示标签。
"""
registry_name = (registry_name or "").strip()
if not registry_name:
return "社区注册表"
return f"社区注册表 · {registry_name}"
def describe_market_source(self, source: str) -> str:
"""
将配置中的市场源地址转换为更适合用户阅读的描述。
"""
registry = self._parse_market_registry(source)
if registry:
return self._build_registry_source_label(registry.get("registry_name"))
repo = self._parse_market_repo(source)
if repo:
return self._build_repo_source_label(repo.get("repo_name"))
return source
@staticmethod
def _normalize_repo_url(repo_url: str) -> Optional[str]:
"""
@@ -121,6 +167,32 @@ class SkillHelper(metaclass=WeakSingleton):
"root_path": root_path.strip("/") or "skills",
}
@staticmethod
def _parse_market_registry(source_url: str) -> Optional[dict]:
"""
解析注册表市场地址,目前支持 ClawHub。
"""
normalized = (source_url or "").strip()
if not normalized.startswith(("http://", "https://")):
return None
parsed = urlparse(normalized)
if parsed.netloc.lower() not in _CLAWHUB_HOSTS:
return None
base_url = f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
path = parsed.path.rstrip("/")
api_base = (
f"{base_url}{path}"
if path.endswith("/api/v1")
else f"{base_url}/api/v1"
)
return {
"registry_url": base_url,
"registry_name": "ClawHub",
"api_base": api_base.rstrip("/"),
}
@staticmethod
def _read_source_meta(skill_dir: Path) -> dict:
"""
@@ -181,8 +253,13 @@ class SkillHelper(metaclass=WeakSingleton):
source_meta = self._read_source_meta(path)
source_type = "bundled" if bundled else source_meta.get("source", "local")
if source_type == "market":
repo_name = source_meta.get("repo_name") or source_meta.get("repo_url")
source_label = f"市场 · {repo_name}" if repo_name else "市场"
source_label = self._build_repo_source_label(
source_meta.get("repo_name") or source_meta.get("repo_url")
)
elif source_type == "registry":
source_label = self._build_registry_source_label(
source_meta.get("registry_name") or source_meta.get("registry_url")
)
elif source_type == "bundled":
source_label = "内置"
else:
@@ -200,6 +277,10 @@ class SkillHelper(metaclass=WeakSingleton):
repo_url=source_meta.get("repo_url"),
repo_name=source_meta.get("repo_name"),
skill_path=source_meta.get("skill_path"),
registry_url=source_meta.get("registry_url"),
registry_name=source_meta.get("registry_name"),
registry_slug=source_meta.get("registry_slug"),
download_url=source_meta.get("download_url"),
installed=True,
removable=not bundled,
)
@@ -218,7 +299,7 @@ class SkillHelper(metaclass=WeakSingleton):
deduped: Dict[str, SkillInfo] = {}
for source in self.get_market_sources():
with fresh(force):
market_skills = self._list_market_repo_skills(source)
market_skills = self._list_market_source_skills(source)
for skill in market_skills:
key = skill.name or skill.id
if key in deduped:
@@ -230,11 +311,21 @@ class SkillHelper(metaclass=WeakSingleton):
deduped.values(),
key=lambda item: (
item.installed,
(item.repo_name or "").lower(),
(item.repo_name or item.registry_name or "").lower(),
item.name.lower(),
),
)
@cached(maxsize=24, ttl=_MARKET_CACHE_TTL, skip_empty=True)
def _list_market_source_skills(self, source: str) -> List[SkillInfo]:
"""
根据市场源类型分发到仓库扫描或注册表读取。
"""
registry = self._parse_market_registry(source)
if registry:
return self._list_market_registry_skills(registry)
return self._list_market_repo_skills(source)
@cached(maxsize=16, ttl=_MARKET_CACHE_TTL, skip_empty=True)
def _list_market_repo_skills(self, repo_url: str) -> List[SkillInfo]:
"""
@@ -297,7 +388,9 @@ class SkillHelper(metaclass=WeakSingleton):
version=metadata["version"],
path=f"{repo['repo_url']}/tree/{repo['branch']}/{skill_dir}",
source_type="market",
source_label=f"市场 · {repo['repo_name']}",
source_label=self._build_repo_source_label(
repo["repo_name"]
),
repo_url=repo["repo_url"],
repo_name=repo["repo_name"],
skill_path=skill_dir,
@@ -310,13 +403,30 @@ class SkillHelper(metaclass=WeakSingleton):
logger.error("解析技能市场压缩包失败:%s", e)
return []
def _list_market_registry_skills(self, registry: dict) -> List[SkillInfo]:
"""
从注册表拉取技能列表,目前用于 ClawHub。
"""
response = self._request_registry(
url=f"{registry['api_base']}/skills",
params={"limit": 200, "sort": "installsAllTime"},
)
if not response:
return []
payload = self._load_json_response(response)
items = self._extract_registry_items(payload)
results: List[SkillInfo] = []
for item in items:
skill = self._build_registry_skill(item, registry)
if skill:
results.append(skill)
return results
def install_market_skill(self, skill: SkillInfo) -> Tuple[bool, str]:
"""
将市场技能安装到用户技能目录,并记录来源元数据。
"""
if not skill.repo_url or not skill.skill_path:
return False, "技能来源信息不完整,无法安装"
target_root = self._ensure_user_skills_dir()
target_dir = target_root / skill.id
if target_dir.exists():
@@ -324,6 +434,12 @@ class SkillHelper(metaclass=WeakSingleton):
if self._is_bundled_skill(skill.id):
return False, f"技能 {skill.id} 是 MoviePilot 内置技能,不能覆盖安装"
if skill.registry_url:
return self._install_registry_skill(skill, target_dir)
if not skill.repo_url or not skill.skill_path:
return False, "技能来源信息不完整,无法安装"
repo = self._parse_market_repo(skill.repo_url)
if not repo:
return False, "技能市场地址无效"
@@ -382,6 +498,50 @@ class SkillHelper(metaclass=WeakSingleton):
logger.error("安装市场技能失败:%s", e)
return False, f"安装技能失败:{e}"
def _install_registry_skill(
self, skill: SkillInfo, target_dir: Path
) -> Tuple[bool, str]:
"""
从注册表下载并安装技能包。
"""
if not skill.registry_url or not (skill.registry_slug or skill.id):
return False, "注册表技能来源信息不完整,无法安装"
archive_bytes = self._download_registry_archive(skill)
if not archive_bytes:
return False, "下载注册表技能失败,请检查网络连接"
try:
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as zf:
if not zf.namelist():
return False, "注册表技能压缩包为空"
target_dir.mkdir(parents=True, exist_ok=False)
try:
wrote = self._extract_skill_archive(zf, target_dir)
if not wrote or not (target_dir / "SKILL.md").exists():
shutil.rmtree(target_dir, ignore_errors=True)
return False, "注册表技能内容不完整,安装失败"
self._write_source_meta(
target_dir,
{
"source": "registry",
"registry_url": skill.registry_url,
"registry_name": skill.registry_name,
"registry_slug": skill.registry_slug or skill.id,
"download_url": skill.download_url,
"installed_at": datetime.now(timezone.utc).isoformat(),
},
)
return True, f"技能 {skill.id} 已安装到 {target_dir}"
except Exception:
shutil.rmtree(target_dir, ignore_errors=True)
raise
except Exception as e:
logger.error("安装注册表技能失败:%s", e)
return False, f"安装技能失败:{e}"
def remove_local_skill(self, skill_id: str) -> Tuple[bool, str]:
"""
删除一个本地技能目录,内置技能会被显式拦截。
@@ -404,6 +564,165 @@ class SkillHelper(metaclass=WeakSingleton):
logger.error("删除技能失败:%s", e)
return False, f"删除技能失败:{e}"
@staticmethod
def _load_json_response(response) -> dict:
"""
读取 HTTP 响应中的 JSON 数据,异常时回退为空对象。
"""
try:
payload = response.json()
return payload if isinstance(payload, dict) else {"items": payload}
except Exception as e:
logger.warning("解析技能市场 JSON 响应失败:%s", e)
return {}
@staticmethod
def _extract_registry_items(payload: dict) -> List[dict]:
"""
从不同响应结构中提取技能列表。
"""
if isinstance(payload, list):
return [item for item in payload if isinstance(item, dict)]
for key in ("items", "skills", "results"):
items = payload.get(key)
if isinstance(items, list):
return [item for item in items if isinstance(item, dict)]
for key in ("data", "result"):
nested = payload.get(key)
if not isinstance(nested, dict):
continue
for list_key in ("items", "skills", "results"):
items = nested.get(list_key)
if isinstance(items, list):
return [item for item in items if isinstance(item, dict)]
return []
def _build_registry_skill(
self, item: dict, registry: dict
) -> Optional[SkillInfo]:
"""
将注册表返回的条目转换为统一的 SkillInfo。
"""
slug = (
item.get("slug")
or item.get("id")
or item.get("name")
or item.get("title")
)
if not slug:
return None
name = item.get("name") or item.get("title") or slug
description = (
item.get("description")
or item.get("summary")
or item.get("excerpt")
or ""
)
owner_handle = self._extract_registry_owner_handle(item)
page_path = f"/{owner_handle}/{slug}" if owner_handle else f"/skills/{slug}"
return SkillInfo(
id=str(slug),
name=str(name),
description=str(description),
version=0,
path=f"{registry['registry_url']}{page_path}",
source_type="registry",
source_label=self._build_registry_source_label(
registry["registry_name"]
),
registry_url=registry["registry_url"],
registry_name=registry["registry_name"],
registry_slug=str(slug),
download_url=item.get("downloadUrl")
or self._build_registry_download_url(registry["api_base"], str(slug)),
installed=False,
removable=False,
)
@staticmethod
def _extract_registry_owner_handle(item: dict) -> Optional[str]:
"""
尽量从注册表条目中提取作者/拥有者 handle。
"""
for key in ("ownerHandle", "authorHandle", "handle", "username"):
value = item.get(key)
if isinstance(value, str) and value.strip():
return value.strip().lstrip("@")
for key in ("owner", "author", "user", "publisher"):
nested = item.get(key)
if not isinstance(nested, dict):
continue
for nested_key in ("handle", "username", "login", "name"):
value = nested.get(nested_key)
if isinstance(value, str) and value.strip():
return value.strip().lstrip("@")
return None
@staticmethod
def _build_registry_download_url(api_base: str, slug: str) -> str:
"""
根据官方文档约定构造注册表 ZIP 下载地址。
"""
query = urlencode({"slug": slug})
return f"{api_base.rstrip('/')}/download?{query}"
def _download_registry_archive(self, skill: SkillInfo) -> Optional[bytes]:
"""
下载注册表技能包 ZIP。
"""
download_url = skill.download_url or self._build_registry_download_url(
f"{skill.registry_url.rstrip('/')}/api/v1",
skill.registry_slug or skill.id,
)
response = self._request_registry(url=download_url)
if response is None or response.status_code != 200:
logger.warning("下载注册表技能失败:%s", download_url)
return None
return response.content
@staticmethod
def _extract_skill_archive(zf: zipfile.ZipFile, target_dir: Path) -> bool:
"""
从技能压缩包中提取单个技能目录。
兼容 `package/`、`skill-name/` 或直接根目录三种常见打包形式。
"""
names = zf.namelist()
skill_md_names = [
name
for name in names
if name.endswith("SKILL.md") and "/.system/" not in f"/{name}/"
]
if not skill_md_names:
return False
skill_md_name = min(skill_md_names, key=lambda name: (name.count("/"), len(name)))
prefix = skill_md_name[: -len("SKILL.md")]
wrote = False
for archive_name in names:
if prefix and not archive_name.startswith(prefix):
continue
rel_name = archive_name[len(prefix):] if prefix else archive_name
if not rel_name:
continue
output_path = target_dir / rel_name
if archive_name.endswith("/"):
output_path.mkdir(parents=True, exist_ok=True)
continue
output_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(archive_name, "r") as src, open(output_path, "wb") as dst:
shutil.copyfileobj(src, dst)
wrote = True
return wrote
def _download_repo_archive(self, repo: dict) -> Optional[bytes]:
"""
下载市场仓库压缩包,并在缺省分支之间做回退尝试。
@@ -425,6 +744,33 @@ class SkillHelper(metaclass=WeakSingleton):
logger.warning("下载技能市场仓库失败:%s", repo["repo_url"])
return None
@staticmethod
def _request_registry(
url: str,
params: Optional[dict] = None,
timeout: int = 30,
):
"""
请求注册表 API兼容代理和直连场景。
"""
strategies = []
if settings.PROXY_HOST:
strategies.append(({"proxies": settings.PROXY, "timeout": timeout}, url))
strategies.append(({"timeout": timeout}, url))
for kwargs, target_url in strategies:
try:
response = RequestUtils(**kwargs).get_res(
url=target_url,
params=params,
raise_exception=True,
)
if response is not None and response.status_code == 200:
return response
except Exception as e:
logger.warning("请求注册表技能市场失败:%s - %s", target_url, e)
return None
@staticmethod
def _request_github(
url: str,

View File

@@ -37,6 +37,16 @@ def _build_skill_zip(skill_dir: str, skill_name: str) -> bytes:
return buf.getvalue()
class _FakeResponse:
def __init__(self, payload=None, content: bytes = b"", status_code: int = 200):
self._payload = payload
self.content = content
self.status_code = status_code
def json(self):
return self._payload
class TestSkillsCommand(unittest.TestCase):
def tearDown(self):
skills_interaction_manager.clear()
@@ -148,6 +158,131 @@ class TestSkillsCommand(unittest.TestCase):
self.assertFalse(removed)
self.assertIn("内置技能", remove_message)
def test_skillhelper_lists_clawhub_registry_skills(self):
helper = SkillHelper()
response = _FakeResponse(
payload={
"items": [
{
"slug": "weather-forecast",
"name": "Weather Forecast",
"summary": "Forecast weather from ClawHub",
"owner": {"handle": "openclaw"},
}
]
}
)
with patch.object(helper, "_request_registry", return_value=response):
skills = helper._list_market_source_skills("https://clawhub.ai")
self.assertEqual(len(skills), 1)
self.assertEqual(skills[0].id, "weather-forecast")
self.assertEqual(skills[0].source_type, "registry")
self.assertEqual(skills[0].registry_name, "ClawHub")
self.assertEqual(skills[0].source_label, "社区注册表 · ClawHub")
self.assertIn("/openclaw/weather-forecast", skills[0].path)
def test_skillhelper_installs_registry_skill(self):
helper = SkillHelper()
skill = SkillInfo(
id="registry-demo",
name="Registry Demo",
description="registry demo",
source_type="registry",
source_label="注册表 · ClawHub",
registry_url="https://clawhub.ai",
registry_name="ClawHub",
registry_slug="registry-demo",
download_url="https://clawhub.ai/api/v1/download?slug=registry-demo",
)
zip_bytes = _build_skill_zip("package", "registry-demo")
with tempfile.TemporaryDirectory() as tempdir:
user_root = Path(tempdir) / "user-skills"
bundled_root = Path(tempdir) / "bundled-skills"
user_root.mkdir(parents=True, exist_ok=True)
bundled_root.mkdir(parents=True, exist_ok=True)
with patch.object(
SkillHelper, "get_user_skills_dir", return_value=user_root
), patch.object(
SkillHelper, "get_bundled_skills_dir", return_value=bundled_root
), patch.object(
helper, "_request_registry", return_value=_FakeResponse(content=zip_bytes)
):
success, message = helper.install_market_skill(skill)
self.assertTrue(success, message)
self.assertTrue((user_root / "registry-demo" / "SKILL.md").exists())
self.assertTrue(
(
user_root
/ "registry-demo"
/ ".moviepilot-skill-source.json"
).exists()
)
local_skills = helper.list_local_skills()
self.assertEqual(len(local_skills), 1)
self.assertEqual(local_skills[0].source_type, "registry")
self.assertEqual(local_skills[0].registry_name, "ClawHub")
self.assertEqual(local_skills[0].source_label, "社区注册表 · ClawHub")
def test_skills_chain_market_view_marks_clawhub_as_community_source(self):
chain = SkillsChain()
request = skills_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
username="tester",
)
request.view = "market"
with patch.object(
chain.skillhelper,
"list_market_skills",
return_value=[
SkillInfo(
id="weather-forecast",
name="Weather Forecast",
description="Forecast weather from ClawHub",
source_type="registry",
source_label="社区注册表 · ClawHub",
registry_name="ClawHub",
registry_url="https://clawhub.ai",
registry_slug="weather-forecast",
)
],
):
title, text, _buttons = chain._build_market_view(request=request)
self.assertEqual(title, "技能市场")
self.assertIn("社区注册表 · ClawHub", text)
self.assertIn("社区源,安装前请自行甄别安全性", text)
self.assertIn("ClawHub 属于社区注册表", text)
def test_skills_chain_root_view_uses_friendly_source_labels(self):
chain = SkillsChain()
request = skills_interaction_manager.create_or_replace(
user_id="10001",
channel=MessageChannel.Telegram,
source="telegram-test",
username="tester",
)
with patch.object(chain.skillhelper, "list_local_skills", return_value=[]), patch.object(
chain.skillhelper, "list_market_skills", return_value=[]
), patch.object(
chain.skillhelper,
"get_market_sources",
return_value=["https://clawhub.ai", "https://github.com/openai/skills"],
):
title, text, _buttons = chain._build_root_view(request=request)
self.assertEqual(title, "技能管理")
self.assertIn("社区注册表 · ClawHub", text)
self.assertIn("官方仓库 · openai/skills", text)
def test_skills_chain_updates_buttons_via_edit_message(self):
chain = SkillsChain()
buttons = [[{"text": "安装 1", "callback_data": "skills:req:install:1"}]]