mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 04:40:45 +08:00
统一技能安装与 Skills 市场接入链路。
新增公开安装工具与多来源 provider 支持,补齐管理端与目录加载逻辑,并同步更新相关测试与文档以保证可见性和路径安全。 Made-with: Cursor
This commit is contained in:
parent
d5e30542aa
commit
4d9232f3b3
66 changed files with 7369 additions and 1099 deletions
|
|
@ -17,6 +17,13 @@ from oclaw.runtime.tools.skills_runtime.materialize_skill_tools import materiali
|
|||
from oclaw.runtime.skills import SkillSpec, materialize_skills_from_tool_specs
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
# Tools hidden from model-facing registry to enforce auto-install only policy.
|
||||
_MODEL_TOOLS_DENYLIST = frozenset(
|
||||
{
|
||||
"skill_market_install",
|
||||
"skill_registry_install",
|
||||
}
|
||||
)
|
||||
|
||||
# Legacy export: some modules still import TOOL_FACTORIES. Tools are now intentionally
|
||||
# restricted to a single safe builtin (`system_time`), so this is left empty.
|
||||
|
|
@ -114,6 +121,16 @@ def _resolve_tool_conflicts(collected: list[tuple[str, ToolSpec]]) -> list[ToolS
|
|||
|
||||
|
||||
def _skill_management_tools(store: SqliteStore) -> list[ToolSpec]:
|
||||
# Lazy imports to avoid circular dependency at module import time.
|
||||
from oclaw.runtime.skill_installer import (
|
||||
auto_install_skill_from_payload,
|
||||
create_skill_from_template,
|
||||
install_skill_from_registry_archive,
|
||||
list_skills_with_status,
|
||||
)
|
||||
from oclaw.runtime.skills import default_skills_root
|
||||
from oclaw.runtime.skills_market import get_market_adapter, normalize_skill_market_provider_setting
|
||||
|
||||
def _create_skill_handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
out = create_skill_from_template(
|
||||
store=store,
|
||||
|
|
@ -126,14 +143,60 @@ def _skill_management_tools(store: SqliteStore) -> list[ToolSpec]:
|
|||
return {"ok": bool(out.ok), "name": out.name, "target_dir": out.target_dir, "detail": out.detail}
|
||||
|
||||
def _auto_install_skill_handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
archive_url = str(args.get("archive_url") or "").strip()
|
||||
slug = str(args.get("slug") or "").strip()
|
||||
version = str(args.get("version") or "").strip() or None
|
||||
overwrite = bool(args.get("overwrite"))
|
||||
provider = normalize_skill_market_provider_setting(
|
||||
str(args.get("provider") or store.get_setting("AIA_SKILL_MARKET_PROVIDER") or "")
|
||||
)
|
||||
if not archive_url and slug:
|
||||
try:
|
||||
adapter = get_market_adapter(provider)
|
||||
archive_url, _chosen_version = adapter.resolve_archive_url(slug=slug, version=version)
|
||||
except Exception:
|
||||
archive_url = ""
|
||||
if archive_url:
|
||||
out = install_skill_from_registry_archive(
|
||||
store=store,
|
||||
archive_url=archive_url,
|
||||
overwrite=overwrite,
|
||||
skills_root=default_skills_root() / "_workspace",
|
||||
auto_bind=True,
|
||||
)
|
||||
return {
|
||||
"ok": bool(out.ok),
|
||||
"name": out.name,
|
||||
"target_dir": out.target_dir,
|
||||
"detail": out.detail,
|
||||
"error_code": out.error_code,
|
||||
"retryable": bool(out.retryable),
|
||||
"auto_enabled": bool(getattr(out, "auto_enabled", False)),
|
||||
"binding_applied_roles": list(getattr(out, "binding_applied_roles", ()) or []),
|
||||
"provider": provider,
|
||||
}
|
||||
|
||||
payload = {
|
||||
"name": str(args.get("name") or "").strip(),
|
||||
"description": str(args.get("description") or "").strip(),
|
||||
"body_markdown": str(args.get("body_markdown") or "").strip(),
|
||||
"metadata_oclaw": dict(args.get("metadata_oclaw") or {}) if isinstance(args.get("metadata_oclaw"), dict) else {},
|
||||
}
|
||||
if not payload["name"]:
|
||||
return {"ok": False, "error_code": "name_required", "error": "name_required"}
|
||||
if not payload["description"]:
|
||||
payload["description"] = f"{payload['name']} skill"
|
||||
out = auto_install_skill_from_payload(store=store, payload=payload)
|
||||
return {"ok": bool(out.ok), "name": out.name, "target_dir": out.target_dir, "detail": out.detail}
|
||||
return {
|
||||
"ok": bool(out.ok),
|
||||
"name": out.name,
|
||||
"target_dir": out.target_dir,
|
||||
"detail": out.detail,
|
||||
"error_code": out.error_code,
|
||||
"retryable": bool(out.retryable),
|
||||
"auto_enabled": bool(getattr(out, "auto_enabled", False)),
|
||||
"binding_applied_roles": list(getattr(out, "binding_applied_roles", ()) or []),
|
||||
}
|
||||
|
||||
def _list_skills_handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
del args
|
||||
|
|
@ -146,9 +209,13 @@ def _skill_management_tools(store: SqliteStore) -> list[ToolSpec]:
|
|||
"description": {"type": "string"},
|
||||
"body_markdown": {"type": "string"},
|
||||
"metadata_oclaw": {"type": "object"},
|
||||
"slug": {"type": "string"},
|
||||
"provider": {"type": "string"},
|
||||
"version": {"type": "string"},
|
||||
"archive_url": {"type": "string"},
|
||||
"overwrite": {"type": "boolean"},
|
||||
},
|
||||
"required": ["name", "description"],
|
||||
"required": [],
|
||||
}
|
||||
return [
|
||||
ToolSpec(
|
||||
|
|
@ -167,7 +234,7 @@ def _skill_management_tools(store: SqliteStore) -> list[ToolSpec]:
|
|||
handler=_auto_install_skill_handler,
|
||||
tags=frozenset({"skill", "oclaw", "installer"}),
|
||||
risk_level="high",
|
||||
timeout_s=20.0,
|
||||
timeout_s=120.0,
|
||||
),
|
||||
ToolSpec(
|
||||
name="skill_list",
|
||||
|
|
@ -199,6 +266,9 @@ def materialize_tool_specs(
|
|||
_ = factories
|
||||
collected: list[tuple[str, ToolSpec]] = []
|
||||
|
||||
def _hidden_from_model(name: str) -> bool:
|
||||
return str(name or "").strip() in _MODEL_TOOLS_DENYLIST
|
||||
|
||||
def _risk_allowed(spec: ToolSpec) -> bool:
|
||||
# Optional safety gate for public tools.
|
||||
# Default: only allow low risk public tools to be visible to all roles.
|
||||
|
|
@ -213,6 +283,9 @@ def materialize_tool_specs(
|
|||
for spec in list(materialize_public_tools()):
|
||||
if not isinstance(spec, ToolSpec):
|
||||
continue
|
||||
if _hidden_from_model(str(spec.name or "")):
|
||||
logger.info("public tool hidden from model registry: %s", str(spec.name or ""))
|
||||
continue
|
||||
if not _risk_allowed(spec):
|
||||
logger.warning("public tool blocked by risk gate: %s", str(spec.name or ""))
|
||||
continue
|
||||
|
|
@ -225,6 +298,9 @@ def materialize_tool_specs(
|
|||
for spec in materialize_tools_for_expert(str(expert or "").strip() or None):
|
||||
if not isinstance(spec, ToolSpec):
|
||||
continue
|
||||
if _hidden_from_model(str(spec.name or "")):
|
||||
logger.info("expert tool hidden from model registry: %s", str(spec.name or ""))
|
||||
continue
|
||||
collected.append(("expert", spec))
|
||||
except Exception as exc:
|
||||
logger.warning("expert tool load skipped: %s", exc)
|
||||
|
|
@ -235,6 +311,9 @@ def materialize_tool_specs(
|
|||
for spec in materialize_executable_skill_tools(store=store):
|
||||
if not isinstance(spec, ToolSpec):
|
||||
continue
|
||||
if _hidden_from_model(str(spec.name or "")):
|
||||
logger.info("skill runtime tool hidden from model registry: %s", str(spec.name or ""))
|
||||
continue
|
||||
collected.append(("skill_runtime", spec))
|
||||
except Exception as exc:
|
||||
logger.warning("skill runtime tool load skipped: %s", exc)
|
||||
|
|
@ -260,6 +339,9 @@ def materialize_tool_specs(
|
|||
path_policy_user_id=path_policy_user_id,
|
||||
):
|
||||
if isinstance(spec, ToolSpec):
|
||||
if _hidden_from_model(str(spec.name or "")):
|
||||
logger.info("mcp tool hidden from model registry: %s", str(spec.name or ""))
|
||||
continue
|
||||
collected.append(("mcp", spec))
|
||||
except Exception as exc:
|
||||
logger.warning("mcp tool load skipped: %s", exc)
|
||||
|
|
@ -293,6 +375,9 @@ def materialize_tool_specs(
|
|||
continue
|
||||
tags_raw = row.get("tags")
|
||||
tags = frozenset(str(x).strip() for x in (tags_raw or []) if str(x).strip())
|
||||
if _hidden_from_model(name):
|
||||
logger.info("plugin tool hidden from model registry: %s", name)
|
||||
continue
|
||||
collected.append((
|
||||
"plugin",
|
||||
ToolSpec(
|
||||
|
|
|
|||
|
|
@ -172,6 +172,36 @@ def run_command_tool() -> ToolSpec:
|
|||
command, normalized_cd_removed = _strip_leading_cd_chain(command)
|
||||
command, command_rewritten = _rewrite_workspace_absolute_refs(command, workdir=workdir)
|
||||
command, script_path_rewritten = _rewrite_python_script_arg(command, workdir=workdir)
|
||||
|
||||
def _external_skill_install_cli_blocked(raw_cmd: str) -> bool:
|
||||
s = str(raw_cmd or "").strip()
|
||||
if not s:
|
||||
return False
|
||||
low = s.lower()
|
||||
if re.match(r"^\s*cocoloop(?:\.cmd|\.exe)?\s+install(?:\s|$)", s, flags=re.IGNORECASE):
|
||||
return True
|
||||
if re.match(r"^\s*clawhub(?:\.cmd|\.exe)?\s+install(?:\s|$)", s, flags=re.IGNORECASE):
|
||||
return True
|
||||
if re.search(r"\bnpx\b", low) and "clawhub" in low:
|
||||
return True
|
||||
if re.match(r"^\s*npm(?:\.cmd|\.exe)?\s+install\b", low) and "clawhub" in low:
|
||||
return True
|
||||
return False
|
||||
|
||||
if _external_skill_install_cli_blocked(str(command or "")):
|
||||
return {
|
||||
"ok": False,
|
||||
"error_code": "skill_install_cli_blocked",
|
||||
"error": "skill_install_cli_blocked",
|
||||
"hint": "Oclaw has no shell skill installer. Use Admin POST /admin/api/skills/market/install or install-registry, or skill_auto_install.",
|
||||
"command": command,
|
||||
"cwd": str(workdir),
|
||||
"normalized_cd_removed": bool(normalized_cd_removed),
|
||||
"cwd_redirected_to_sandbox": bool(cwd_redirected_to_sandbox),
|
||||
"command_rewritten": bool(command_rewritten),
|
||||
"script_path_rewritten": bool(script_path_rewritten),
|
||||
"original_command": original_command,
|
||||
}
|
||||
try:
|
||||
os.makedirs(workdir, exist_ok=True)
|
||||
except Exception:
|
||||
|
|
|
|||
141
runtime/tools/public/skills_install_tool.py
Normal file
141
runtime/tools/public/skills_install_tool.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from oclaw.platform.config.paths import db_path
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.runtime.skill_installer import install_skill_from_registry_archive
|
||||
from oclaw.runtime.skills import default_skills_root
|
||||
from oclaw.runtime.skills_market import get_market_adapter, normalize_skill_market_provider_setting
|
||||
from oclaw.runtime.tools.base import ToolSpec
|
||||
|
||||
|
||||
def _store() -> SqliteStore:
|
||||
return SqliteStore(db_path())
|
||||
|
||||
|
||||
def _agent_workspace_skills_root() -> Path:
|
||||
# Agent-origin installs are isolated under _workspace lane.
|
||||
return default_skills_root() / "_workspace"
|
||||
|
||||
|
||||
def skill_market_install_tool() -> ToolSpec:
|
||||
def _handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
payload = args if isinstance(args, dict) else {}
|
||||
slug = str(payload.get("slug") or "").strip()
|
||||
if not slug:
|
||||
return {"ok": False, "error_code": "slug_required", "error": "slug_required"}
|
||||
version = str(payload.get("version") or "").strip() or None
|
||||
overwrite = bool(payload.get("overwrite"))
|
||||
store = _store()
|
||||
provider_arg = str(payload.get("provider") or "").strip()
|
||||
if provider_arg:
|
||||
provider = normalize_skill_market_provider_setting(provider_arg)
|
||||
else:
|
||||
provider = normalize_skill_market_provider_setting(str(store.get_setting("AIA_SKILL_MARKET_PROVIDER") or ""))
|
||||
try:
|
||||
adapter = get_market_adapter(provider)
|
||||
archive_url, chosen_version = adapter.resolve_archive_url(slug=slug, version=version)
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"error_code": "market_resolve_failed",
|
||||
"error": f"market_resolve_failed:{type(exc).__name__}",
|
||||
"provider": provider,
|
||||
"slug": slug,
|
||||
}
|
||||
if not str(archive_url or "").strip():
|
||||
return {
|
||||
"ok": False,
|
||||
"error_code": "archive_url_unavailable",
|
||||
"error": "archive_url_unavailable",
|
||||
"provider": provider,
|
||||
"slug": slug,
|
||||
}
|
||||
out = install_skill_from_registry_archive(
|
||||
store=store,
|
||||
archive_url=str(archive_url),
|
||||
overwrite=overwrite,
|
||||
skills_root=_agent_workspace_skills_root(),
|
||||
)
|
||||
return {
|
||||
"ok": bool(out.ok),
|
||||
"result": {
|
||||
"name": out.name,
|
||||
"target_dir": out.target_dir,
|
||||
"detail": out.detail,
|
||||
"error_code": out.error_code,
|
||||
"retryable": bool(out.retryable),
|
||||
},
|
||||
"provider": provider,
|
||||
"slug": slug,
|
||||
"version": str(chosen_version or version or ""),
|
||||
}
|
||||
|
||||
return ToolSpec(
|
||||
name="skill_market_install",
|
||||
description="Install a skill from configured market by slug/version.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": {"type": "string"},
|
||||
"provider": {"type": "string", "description": "Optional provider override: clawhub or cocoloop."},
|
||||
"version": {"type": "string"},
|
||||
"overwrite": {"type": "boolean"},
|
||||
},
|
||||
"required": ["slug"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
handler=_handler,
|
||||
tags=frozenset({"skill", "installer", "market"}),
|
||||
risk_level="medium",
|
||||
timeout_s=120.0,
|
||||
)
|
||||
|
||||
|
||||
def skill_registry_install_tool() -> ToolSpec:
|
||||
def _handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
payload = args if isinstance(args, dict) else {}
|
||||
archive_url = str(payload.get("archive_url") or "").strip()
|
||||
if not archive_url:
|
||||
return {"ok": False, "error_code": "archive_url_required", "error": "archive_url_required"}
|
||||
overwrite = bool(payload.get("overwrite"))
|
||||
out = install_skill_from_registry_archive(
|
||||
store=_store(),
|
||||
archive_url=archive_url,
|
||||
overwrite=overwrite,
|
||||
skills_root=_agent_workspace_skills_root(),
|
||||
)
|
||||
return {
|
||||
"ok": bool(out.ok),
|
||||
"result": {
|
||||
"name": out.name,
|
||||
"target_dir": out.target_dir,
|
||||
"detail": out.detail,
|
||||
"error_code": out.error_code,
|
||||
"retryable": bool(out.retryable),
|
||||
},
|
||||
}
|
||||
|
||||
return ToolSpec(
|
||||
name="skill_registry_install",
|
||||
description="Install a skill from archive URL (registry/market artifact).",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"archive_url": {"type": "string"},
|
||||
"overwrite": {"type": "boolean"},
|
||||
},
|
||||
"required": ["archive_url"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
handler=_handler,
|
||||
tags=frozenset({"skill", "installer", "registry"}),
|
||||
risk_level="medium",
|
||||
timeout_s=120.0,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["skill_market_install_tool", "skill_registry_install_tool"]
|
||||
|
||||
187
runtime/tools/skills/cocoloop_client.py
Normal file
187
runtime/tools/skills/cocoloop_client.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
"""CocoLoop 技能商店 HTTP 客户端(与 ClawHub 并列,供 `skills_market` 使用)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def _strip_trailing_slash(url: str) -> str:
|
||||
return str(url or "").strip().rstrip("/")
|
||||
|
||||
|
||||
def _join_url(base: str, path: str) -> str:
|
||||
b = _strip_trailing_slash(base)
|
||||
p = str(path or "").strip()
|
||||
if not p:
|
||||
return b
|
||||
if not p.startswith("/"):
|
||||
p = "/" + p
|
||||
return b + p
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CocoloopConfig:
|
||||
api_base_url: str = "https://api.cocoloop.com"
|
||||
|
||||
|
||||
def load_cocoloop_config() -> CocoloopConfig:
|
||||
base = str(os.getenv("AIA_COCOLOOP_API_BASE") or os.getenv("COCOLOOP_API_BASE") or "https://api.cocoloop.com").strip()
|
||||
return CocoloopConfig(api_base_url=_strip_trailing_slash(base))
|
||||
|
||||
|
||||
def _default_headers() -> dict[str, str]:
|
||||
return {
|
||||
"User-Agent": "Oclaw-SkillMarket/1.0 (+https://github.com/oclaw)",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _get_json(url: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
with httpx.Client(timeout=12.0, follow_redirects=True) as c:
|
||||
r = c.get(url, params=params or {}, headers=_default_headers())
|
||||
if r.status_code != 200:
|
||||
return {}
|
||||
obj = r.json()
|
||||
return obj if isinstance(obj, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _list_items(cfg: CocoloopConfig, *, keyword: str, page: int, page_size: int) -> list[dict[str, Any]]:
|
||||
url = _join_url(cfg.api_base_url, "/api/v1/store/skills")
|
||||
blob = _get_json(
|
||||
url,
|
||||
params={
|
||||
"page": max(1, int(page)),
|
||||
"page_size": max(1, min(int(page_size), 100)),
|
||||
"keyword": str(keyword or "").strip(),
|
||||
"sort": "downloads",
|
||||
},
|
||||
)
|
||||
data = blob.get("data") if isinstance(blob.get("data"), dict) else {}
|
||||
items = data.get("items")
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
return [x for x in items if isinstance(x, dict)]
|
||||
|
||||
|
||||
def _normalize_list_row(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
slug = str(raw.get("name") or "").strip()
|
||||
dl = str(raw.get("download_url") or "").strip()
|
||||
ver = str(raw.get("version") or "").strip() or "latest"
|
||||
return {
|
||||
"source": "cocoloop",
|
||||
"slug": slug,
|
||||
"name": str(raw.get("subtitle") or raw.get("summary") or slug),
|
||||
"description": str(raw.get("brief") or raw.get("summary") or raw.get("original_desc") or ""),
|
||||
"version": ver,
|
||||
"owner": str(raw.get("author") or ""),
|
||||
"updatedAt": "",
|
||||
"downloads": _parse_count(raw.get("downloads")),
|
||||
"stars": _parse_count(raw.get("github_stars")),
|
||||
"homepage": f"https://hub.cocoloop.cn/skills/{raw.get('id')}" if raw.get("id") else "",
|
||||
"archiveUrl": dl,
|
||||
"raw": raw,
|
||||
}
|
||||
|
||||
|
||||
def _parse_count(v: Any) -> int:
|
||||
if isinstance(v, int):
|
||||
return v
|
||||
s = str(v or "").strip().lower().replace(",", "")
|
||||
if not s:
|
||||
return 0
|
||||
mult = 1
|
||||
if s.endswith("k"):
|
||||
mult = 1000
|
||||
s = s[:-1]
|
||||
if s.endswith("m"):
|
||||
mult = 1_000_000
|
||||
s = s[:-1]
|
||||
try:
|
||||
return int(float(s) * mult)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def search_store_skills(query: str, *, limit: int = 20, cfg: CocoloopConfig | None = None) -> list[dict[str, Any]]:
|
||||
cfg = cfg or load_cocoloop_config()
|
||||
lim = max(1, min(int(limit or 20), 100))
|
||||
rows = _list_items(cfg, keyword=str(query or "").strip(), page=1, page_size=lim)
|
||||
return [_normalize_list_row(r) for r in rows if str(r.get("name") or "").strip()]
|
||||
|
||||
|
||||
def get_skill_detail_by_slug(slug: str, *, cfg: CocoloopConfig | None = None) -> dict[str, Any]:
|
||||
"""按商店 `name`(slug)解析技能;必要时用数字 id 直查。"""
|
||||
cfg = cfg or load_cocoloop_config()
|
||||
s = str(slug or "").strip()
|
||||
if not s:
|
||||
return {}
|
||||
if s.isdigit():
|
||||
return _detail_from_id(cfg, int(s))
|
||||
rows = _list_items(cfg, keyword=s, page=1, page_size=80)
|
||||
want = s.lower()
|
||||
hit: dict[str, Any] | None = None
|
||||
for r in rows:
|
||||
if str(r.get("name") or "").strip().lower() == want:
|
||||
hit = r
|
||||
break
|
||||
if hit is None:
|
||||
for r in rows:
|
||||
nm = str(r.get("name") or "").strip().lower()
|
||||
if want in nm or nm in want:
|
||||
hit = r
|
||||
break
|
||||
if hit is None:
|
||||
return {"slug": s, "source": "cocoloop"}
|
||||
return _detail_from_list_row(cfg, hit)
|
||||
|
||||
|
||||
def _detail_from_id(cfg: CocoloopConfig, skill_id: int) -> dict[str, Any]:
|
||||
url = _join_url(cfg.api_base_url, f"/api/v1/store/skills/{int(skill_id)}")
|
||||
blob = _get_json(url)
|
||||
data = blob.get("data") if isinstance(blob.get("data"), dict) else {}
|
||||
if not data:
|
||||
return {"slug": str(skill_id), "source": "cocoloop"}
|
||||
return _detail_from_list_row(cfg, data)
|
||||
|
||||
|
||||
def _detail_from_list_row(cfg: CocoloopConfig, row: dict[str, Any]) -> dict[str, Any]:
|
||||
slug = str(row.get("name") or "").strip()
|
||||
dl = str(row.get("download_url") or "").strip()
|
||||
if not dl and slug:
|
||||
asset = str(row.get("asset_name") or f"{slug}.zip").strip()
|
||||
if not asset.endswith(".zip"):
|
||||
asset = f"{asset}.zip"
|
||||
dl = f"https://dl.cocoloop.cn/bss/skills/{asset.lstrip('/')}"
|
||||
ver = str(row.get("version") or "").strip() or "latest"
|
||||
ver_clean = ver.lstrip("vV") if ver not in {"", "latest"} else ver
|
||||
versions: list[dict[str, Any]] = [{"version": ver_clean or "latest", "changelog": "", "createdAt": "", "archiveUrl": dl, "raw": row}]
|
||||
return {
|
||||
"source": "cocoloop",
|
||||
"slug": slug,
|
||||
"name": str(row.get("subtitle") or row.get("summary") or slug),
|
||||
"description": str(row.get("brief") or row.get("summary") or row.get("original_desc") or ""),
|
||||
"owner": str(row.get("author") or ""),
|
||||
"updatedAt": "",
|
||||
"homepage": f"https://hub.cocoloop.cn/skills/{row.get('id')}" if row.get("id") else "",
|
||||
"latestVersion": ver_clean if ver_clean else "latest",
|
||||
"archiveUrl": dl,
|
||||
"downloads": _parse_count(row.get("downloads")),
|
||||
"stars": _parse_count(row.get("github_stars")),
|
||||
"versions": versions,
|
||||
"raw": row,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CocoloopConfig",
|
||||
"load_cocoloop_config",
|
||||
"search_store_skills",
|
||||
"get_skill_detail_by_slug",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue