mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 04:40:45 +08:00
重构仓库目录为统一的 runtime 分层并清理历史 openclaw 残留。
本次迁移将网关/通道/工具/技能/脚本与协议资源集中到新结构,统一路径常量与脚本转发机制,减少顶层噪音并保证运行与测试行为一致。 Made-with: Cursor
This commit is contained in:
parent
ba3836f00f
commit
4a23b715a2
498 changed files with 2760 additions and 2200 deletions
|
|
@ -1,401 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from oclaw.openclaw_runtime.skills import (
|
||||
default_skills_root,
|
||||
discover_workspace_skill_manifests,
|
||||
load_skill_manifest,
|
||||
)
|
||||
|
||||
_DISABLED_SKILLS_KEY = "AIA_SKILL_DISABLED_NAMES"
|
||||
_AUTO_INSTALL_KEY = "AIA_SKILL_AUTO_INSTALL_ENABLED"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillInstallResult:
|
||||
ok: bool
|
||||
name: str
|
||||
target_dir: str
|
||||
detail: str = ""
|
||||
error_code: str = ""
|
||||
retryable: bool = False
|
||||
|
||||
|
||||
def _classify_install_detail(detail: str) -> tuple[str, bool]:
|
||||
d = str(detail or "").strip().lower()
|
||||
if not d:
|
||||
return ("unknown", False)
|
||||
if d.startswith("download_failed") or d.startswith("extract_failed"):
|
||||
return ("transport_or_extract_error", True)
|
||||
if d in {"archive_too_large", "empty_archive", "unsupported_archive_type", "archive_url_required", "unsupported_url_scheme"}:
|
||||
return ("invalid_archive", False)
|
||||
if d in {"already_exists"}:
|
||||
return ("already_exists", False)
|
||||
if d.startswith("unsafe_") or d.startswith("skill_md_missing") or d.startswith("manifest_parse_failed"):
|
||||
return ("invalid_skill_package", False)
|
||||
if d.startswith("rollback_after_error"):
|
||||
return ("runtime_error", True)
|
||||
if d == "installed" or d == "created":
|
||||
return ("ok", False)
|
||||
return ("unknown", False)
|
||||
|
||||
|
||||
def _truthy(v: str | None) -> bool:
|
||||
return str(v or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def skill_auto_install_enabled(store: Any) -> bool:
|
||||
try:
|
||||
raw = str(store.get_setting(_AUTO_INSTALL_KEY) or "").strip()
|
||||
except Exception:
|
||||
raw = ""
|
||||
if not raw:
|
||||
return True
|
||||
return _truthy(raw)
|
||||
|
||||
|
||||
def _get_disabled_names(store: Any) -> set[str]:
|
||||
try:
|
||||
raw = str(store.get_setting(_DISABLED_SKILLS_KEY) or "").strip()
|
||||
except Exception:
|
||||
raw = ""
|
||||
if not raw:
|
||||
return set()
|
||||
try:
|
||||
arr = json.loads(raw)
|
||||
if isinstance(arr, list):
|
||||
return {str(x).strip() for x in arr if str(x).strip()}
|
||||
except Exception:
|
||||
pass
|
||||
return set()
|
||||
|
||||
|
||||
def _set_disabled_names(store: Any, names: set[str]) -> None:
|
||||
vals = sorted({str(x).strip() for x in names if str(x).strip()})
|
||||
store.set_setting(_DISABLED_SKILLS_KEY, json.dumps(vals, ensure_ascii=False))
|
||||
|
||||
|
||||
def list_skills_with_status(*, store: Any, skills_root: str | Path | None = None) -> list[dict[str, Any]]:
|
||||
disabled = _get_disabled_names(store)
|
||||
out: list[dict[str, Any]] = []
|
||||
for m in discover_workspace_skill_manifests(skills_root):
|
||||
runtime = dict(m.runtime or {}) if isinstance(m.runtime, dict) else {}
|
||||
out.append(
|
||||
{
|
||||
"name": m.name,
|
||||
"description": m.description,
|
||||
"skill_dir": m.skill_dir,
|
||||
"skill_file": m.skill_file,
|
||||
"enabled": m.name not in disabled,
|
||||
"install_specs": [dict(id=s.id, kind=s.kind, payload=s.payload) for s in m.install],
|
||||
"metadata_openclaw": dict(m.metadata_openclaw),
|
||||
"runtime": runtime,
|
||||
"executable": bool(runtime),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def set_skill_enabled(*, store: Any, skill_name: str, enabled: bool) -> None:
|
||||
name = str(skill_name or "").strip()
|
||||
if not name:
|
||||
return
|
||||
disabled = _get_disabled_names(store)
|
||||
if enabled:
|
||||
disabled.discard(name)
|
||||
else:
|
||||
disabled.add(name)
|
||||
_set_disabled_names(store, disabled)
|
||||
|
||||
|
||||
def scan_skill_source_dir(src_dir: str | Path) -> tuple[bool, str]:
|
||||
src = Path(src_dir).resolve()
|
||||
if not src.exists() or not src.is_dir():
|
||||
return False, "source_dir_not_found"
|
||||
skill_md = src / "SKILL.md"
|
||||
if not skill_md.exists() or not skill_md.is_file():
|
||||
return False, "skill_md_missing"
|
||||
for p in src.rglob("*"):
|
||||
if not p.is_file():
|
||||
continue
|
||||
nm = p.name.lower()
|
||||
if nm.endswith((".exe", ".dll", ".bat", ".cmd", ".ps1")):
|
||||
return False, f"unsafe_file_detected:{p.name}"
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def _safe_archive_members(paths: list[str]) -> bool:
|
||||
for p in paths:
|
||||
v = str(p or "").replace("\\", "/")
|
||||
if not v or v.startswith("/") or ".." in v.split("/"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _extract_archive_to_temp(archive_path: Path) -> tuple[bool, str, Path | None]:
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="skill_archive_"))
|
||||
try:
|
||||
if zipfile.is_zipfile(archive_path):
|
||||
with zipfile.ZipFile(archive_path, "r") as zf:
|
||||
members = [str(x.filename or "") for x in zf.infolist()]
|
||||
if not _safe_archive_members(members):
|
||||
return False, "unsafe_archive_path", None
|
||||
zf.extractall(temp_dir)
|
||||
return True, "ok", temp_dir
|
||||
if tarfile.is_tarfile(archive_path):
|
||||
with tarfile.open(archive_path, "r:*") as tf:
|
||||
members = [str(x.name or "") for x in tf.getmembers()]
|
||||
if not _safe_archive_members(members):
|
||||
return False, "unsafe_archive_path", None
|
||||
tf.extractall(temp_dir)
|
||||
return True, "ok", temp_dir
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
return False, "unsupported_archive_type", None
|
||||
except Exception as exc:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
return False, f"extract_failed:{type(exc).__name__}", None
|
||||
|
||||
|
||||
def _resolve_registry_archive_url(url: str) -> str:
|
||||
raw = str(url or "").strip()
|
||||
if not raw:
|
||||
return raw
|
||||
parsed = urllib.parse.urlparse(raw)
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
return raw
|
||||
host = str(parsed.netloc or "").strip().lower()
|
||||
if host not in {"clawhub.ai", "www.clawhub.ai"}:
|
||||
return raw
|
||||
segs = [s for s in str(parsed.path or "").split("/") if s]
|
||||
if not segs:
|
||||
return raw
|
||||
candidates: list[str] = []
|
||||
if len(segs) >= 1:
|
||||
candidates.append(segs[-1])
|
||||
if len(segs) >= 2:
|
||||
candidates.append(f"{segs[0]}/{segs[1]}")
|
||||
try:
|
||||
from oclaw.tools.skills.clawhub_client import get_skill_detail
|
||||
|
||||
for slug in candidates:
|
||||
detail = get_skill_detail(slug)
|
||||
archive_url = str(detail.get("archiveUrl") or "").strip() if isinstance(detail, dict) else ""
|
||||
if archive_url:
|
||||
return archive_url
|
||||
return raw
|
||||
except Exception:
|
||||
return raw
|
||||
|
||||
|
||||
def install_skill_from_local_dir(
|
||||
*,
|
||||
store: Any,
|
||||
source_dir: str | Path,
|
||||
overwrite: bool = False,
|
||||
skills_root: str | Path | None = None,
|
||||
) -> SkillInstallResult:
|
||||
src = Path(source_dir).resolve()
|
||||
ok, detail = scan_skill_source_dir(src)
|
||||
if not ok:
|
||||
ec, rt = _classify_install_detail(detail)
|
||||
return SkillInstallResult(ok=False, name="", target_dir="", detail=detail, error_code=ec, retryable=rt)
|
||||
manifest = load_skill_manifest(src)
|
||||
if not manifest:
|
||||
ec, rt = _classify_install_detail("manifest_parse_failed")
|
||||
return SkillInstallResult(ok=False, name="", target_dir="", detail="manifest_parse_failed", error_code=ec, retryable=rt)
|
||||
root = Path(skills_root).resolve() if skills_root else default_skills_root()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
safe_name = str(manifest.name).strip()
|
||||
target = root / safe_name
|
||||
if target.exists():
|
||||
if not overwrite:
|
||||
ec, rt = _classify_install_detail("already_exists")
|
||||
return SkillInstallResult(ok=False, name=manifest.name, target_dir=str(target), detail="already_exists", error_code=ec, retryable=rt)
|
||||
shutil.rmtree(target)
|
||||
shutil.copytree(src, target)
|
||||
set_skill_enabled(store=store, skill_name=manifest.name, enabled=True)
|
||||
ec, rt = _classify_install_detail("installed")
|
||||
return SkillInstallResult(ok=True, name=manifest.name, target_dir=str(target), detail="installed", error_code=ec, retryable=rt)
|
||||
|
||||
|
||||
def install_skill_from_registry_archive(
|
||||
*,
|
||||
store: Any,
|
||||
archive_url: str,
|
||||
overwrite: bool = False,
|
||||
skills_root: str | Path | None = None,
|
||||
) -> SkillInstallResult:
|
||||
url = str(archive_url or "").strip()
|
||||
if not url:
|
||||
ec, rt = _classify_install_detail("archive_url_required")
|
||||
return SkillInstallResult(ok=False, name="", target_dir="", detail="archive_url_required", error_code=ec, retryable=rt)
|
||||
url = _resolve_registry_archive_url(url)
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if parsed.scheme not in {"https", "http", "file"}:
|
||||
ec, rt = _classify_install_detail("unsupported_url_scheme")
|
||||
return SkillInstallResult(ok=False, name="", target_dir="", detail="unsupported_url_scheme", error_code=ec, retryable=rt)
|
||||
tmp_file = Path(tempfile.mkstemp(prefix="skill_pkg_", suffix=".bin")[1])
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": "OpenClaw-SkillInstaller/1.0 (+https://clawhub.ai)",
|
||||
"Accept": "*/*",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
data = resp.read()
|
||||
if not data:
|
||||
ec, rt = _classify_install_detail("empty_archive")
|
||||
return SkillInstallResult(ok=False, name="", target_dir="", detail="empty_archive", error_code=ec, retryable=rt)
|
||||
if len(data) > 50 * 1024 * 1024:
|
||||
ec, rt = _classify_install_detail("archive_too_large")
|
||||
return SkillInstallResult(ok=False, name="", target_dir="", detail="archive_too_large", error_code=ec, retryable=rt)
|
||||
tmp_file.write_bytes(data)
|
||||
ok, detail, extracted = _extract_archive_to_temp(tmp_file)
|
||||
if not ok or extracted is None:
|
||||
ec, rt = _classify_install_detail(detail)
|
||||
return SkillInstallResult(ok=False, name="", target_dir="", detail=detail, error_code=ec, retryable=rt)
|
||||
candidates: list[Path] = []
|
||||
if (extracted / "SKILL.md").exists():
|
||||
candidates.append(extracted)
|
||||
for p in extracted.rglob("SKILL.md"):
|
||||
if p.is_file():
|
||||
candidates.append(p.parent)
|
||||
if not candidates:
|
||||
ec, rt = _classify_install_detail("skill_md_missing")
|
||||
return SkillInstallResult(ok=False, name="", target_dir="", detail="skill_md_missing", error_code=ec, retryable=rt)
|
||||
chosen = sorted(candidates, key=lambda x: len(x.parts))[0]
|
||||
return install_skill_from_local_dir(store=store, source_dir=chosen, overwrite=overwrite, skills_root=skills_root)
|
||||
except Exception as exc:
|
||||
detail = f"download_failed:{type(exc).__name__}"
|
||||
code = getattr(exc, "code", None)
|
||||
if isinstance(code, int):
|
||||
detail = f"download_failed:http_{code}"
|
||||
ec, rt = _classify_install_detail(detail)
|
||||
return SkillInstallResult(ok=False, name="", target_dir="", detail=detail, error_code=ec, retryable=rt)
|
||||
finally:
|
||||
try:
|
||||
if tmp_file.exists():
|
||||
tmp_file.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if "extracted" in locals() and isinstance(extracted, Path) and extracted.exists():
|
||||
shutil.rmtree(extracted, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def create_skill_from_template(
|
||||
*,
|
||||
store: Any,
|
||||
name: str,
|
||||
description: str,
|
||||
body_markdown: str = "",
|
||||
metadata_openclaw: dict[str, Any] | None = None,
|
||||
skills_root: str | Path | None = None,
|
||||
overwrite: bool = False,
|
||||
) -> SkillInstallResult:
|
||||
nm = str(name or "").strip()
|
||||
if not nm:
|
||||
ec, rt = _classify_install_detail("name_required")
|
||||
return SkillInstallResult(ok=False, name="", target_dir="", detail="name_required", error_code=ec, retryable=rt)
|
||||
root = Path(skills_root).resolve() if skills_root else default_skills_root()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
target = root / nm
|
||||
if target.exists() and not overwrite:
|
||||
ec, rt = _classify_install_detail("already_exists")
|
||||
return SkillInstallResult(ok=False, name=nm, target_dir=str(target), detail="already_exists", error_code=ec, retryable=rt)
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
oc = dict(metadata_openclaw or {})
|
||||
front = {
|
||||
"name": nm,
|
||||
"description": str(description or f"{nm} skill"),
|
||||
"user-invocable": "true",
|
||||
"disable-model-invocation": "false",
|
||||
"metadata": {"openclaw": oc},
|
||||
}
|
||||
text = (
|
||||
"---\n"
|
||||
f"name: {front['name']}\n"
|
||||
f"description: {front['description']}\n"
|
||||
f"user-invocable: {front['user-invocable']}\n"
|
||||
f"disable-model-invocation: {front['disable-model-invocation']}\n"
|
||||
f"metadata: {json.dumps(front['metadata'], ensure_ascii=False)}\n"
|
||||
"---\n\n"
|
||||
f"{str(body_markdown or '').strip()}\n"
|
||||
)
|
||||
(target / "SKILL.md").write_text(text, encoding="utf-8")
|
||||
set_skill_enabled(store=store, skill_name=nm, enabled=True)
|
||||
ec, rt = _classify_install_detail("created")
|
||||
return SkillInstallResult(ok=True, name=nm, target_dir=str(target), detail="created", error_code=ec, retryable=rt)
|
||||
|
||||
|
||||
def auto_install_skill_from_payload(
|
||||
*,
|
||||
store: Any,
|
||||
payload: dict[str, Any],
|
||||
skills_root: str | Path | None = None,
|
||||
) -> SkillInstallResult:
|
||||
if not skill_auto_install_enabled(store):
|
||||
ec, rt = _classify_install_detail("auto_install_disabled")
|
||||
return SkillInstallResult(ok=False, name="", target_dir="", detail="auto_install_disabled", error_code=ec, retryable=rt)
|
||||
name = str(payload.get("name") or "").strip()
|
||||
description = str(payload.get("description") or "").strip()
|
||||
body = str(payload.get("body_markdown") or "").strip()
|
||||
md = payload.get("metadata_openclaw")
|
||||
md = dict(md) if isinstance(md, dict) else {}
|
||||
root = Path(skills_root).resolve() if skills_root else default_skills_root()
|
||||
target = root / name
|
||||
before_disabled = _get_disabled_names(store)
|
||||
try:
|
||||
out = create_skill_from_template(
|
||||
store=store,
|
||||
name=name,
|
||||
description=description,
|
||||
body_markdown=body,
|
||||
metadata_openclaw=md,
|
||||
skills_root=skills_root,
|
||||
overwrite=False,
|
||||
)
|
||||
if bool(payload.get("force_error_for_test")):
|
||||
raise RuntimeError("forced_error_for_test")
|
||||
if not out.ok:
|
||||
return out
|
||||
return out
|
||||
except Exception as exc:
|
||||
try:
|
||||
if target.exists() and target.is_dir():
|
||||
shutil.rmtree(target)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_set_disabled_names(store, before_disabled)
|
||||
except Exception:
|
||||
pass
|
||||
detail = f"rollback_after_error:{type(exc).__name__}"
|
||||
ec, rt = _classify_install_detail(detail)
|
||||
return SkillInstallResult(ok=False, name=name, target_dir=str(target), detail=detail, error_code=ec, retryable=rt)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SkillInstallResult",
|
||||
"auto_install_skill_from_payload",
|
||||
"create_skill_from_template",
|
||||
"install_skill_from_local_dir",
|
||||
"install_skill_from_registry_archive",
|
||||
"list_skills_with_status",
|
||||
"set_skill_enabled",
|
||||
"skill_auto_install_enabled",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue