mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 00:40:45 +08:00
补充历史压缩与会话诊断链路,强化 tool pairing 与空响应兜底观测;完善 Admin MCP 导入/绑定与相关前端展示;新增 web_search_fast/web_fetch_clean 及多项测试与文档更新,并同步技能安装与角色绑定策略改进。 Made-with: Cursor
859 lines
33 KiB
Python
859 lines
33 KiB
Python
from __future__ import annotations
|
|
|
|
import ast
|
|
import importlib.util
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
import time
|
|
import urllib.parse
|
|
import urllib.error
|
|
import urllib.request
|
|
import zipfile
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from oclaw.runtime.skills_market import get_market_adapter
|
|
from oclaw.runtime.skill_role_binding import (
|
|
SKILL_ROLE_BINDING_ENABLED_SETTING,
|
|
SKILL_ROLE_BINDING_KEY,
|
|
normalize_skill_role_binding,
|
|
ordered_binding_roles,
|
|
)
|
|
from oclaw.runtime.skills import (
|
|
default_skills_root,
|
|
discover_workspace_skill_manifests,
|
|
load_skill_manifest,
|
|
)
|
|
from oclaw.runtime.skills_workspace_lane import skills_home_containing_workspace_lane
|
|
|
|
_DISABLED_SKILLS_KEY = "AIA_SKILL_DISABLED_NAMES"
|
|
_AUTO_INSTALL_KEY = "AIA_SKILL_AUTO_INSTALL_ENABLED"
|
|
_AUTO_ENABLE_TRUSTED_KEY = "AIA_SKILL_AUTO_ENABLE_TRUSTED"
|
|
_AUTO_INSTALL_DEPS_KEY = "AIA_SKILL_AUTO_INSTALL_DEPS_ENABLED"
|
|
_ARCHIVE_DOWNLOAD_TIMEOUT_S = 45
|
|
_ARCHIVE_DOWNLOAD_MAX_RETRIES = 3
|
|
|
|
|
|
def _download_archive_bytes(url: str) -> bytes:
|
|
req = urllib.request.Request(
|
|
url,
|
|
headers={
|
|
"User-Agent": "Oclaw-SkillInstaller/1.0 (+https://clawhub.ai)",
|
|
"Accept": "*/*",
|
|
},
|
|
)
|
|
last_exc: Exception | None = None
|
|
for attempt in range(1, _ARCHIVE_DOWNLOAD_MAX_RETRIES + 1):
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=_ARCHIVE_DOWNLOAD_TIMEOUT_S) as resp:
|
|
return resp.read()
|
|
except Exception as exc:
|
|
last_exc = exc
|
|
code = getattr(exc, "code", None)
|
|
retryable_http = isinstance(code, int) and code in {408, 429, 500, 502, 503, 504}
|
|
retryable_net = isinstance(exc, urllib.error.URLError) or isinstance(exc, TimeoutError)
|
|
if attempt >= _ARCHIVE_DOWNLOAD_MAX_RETRIES or not (retryable_http or retryable_net):
|
|
raise
|
|
time.sleep(0.8 * attempt)
|
|
raise last_exc or RuntimeError("download_failed_unknown")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SkillInstallResult:
|
|
ok: bool
|
|
name: str
|
|
target_dir: str
|
|
detail: str = ""
|
|
error_code: str = ""
|
|
retryable: bool = False
|
|
auto_enabled: bool = False
|
|
binding_applied_roles: tuple[str, ...] = ()
|
|
|
|
|
|
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 in {"installed", "created", "removed"}:
|
|
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 skill_auto_enable_trusted_enabled(store: Any) -> bool:
|
|
try:
|
|
raw = str(store.get_setting(_AUTO_ENABLE_TRUSTED_KEY) or "").strip()
|
|
except Exception:
|
|
raw = ""
|
|
# Default ON for workspace/auto-install lane; tenant can disable explicitly.
|
|
if not raw:
|
|
return True
|
|
return _truthy(raw)
|
|
|
|
|
|
def skill_auto_install_deps_enabled(store: Any) -> bool:
|
|
try:
|
|
raw = str(store.get_setting(_AUTO_INSTALL_DEPS_KEY) or "").strip()
|
|
except Exception:
|
|
raw = ""
|
|
# Default ON to reduce first-run dependency issues for newly installed skills.
|
|
if not raw:
|
|
return True
|
|
return _truthy(raw)
|
|
|
|
|
|
def _scan_dependency_manifests(skill_dir: Path) -> dict[str, list[Path]]:
|
|
req_files: list[Path] = []
|
|
pkg_files: list[Path] = []
|
|
try:
|
|
for p in skill_dir.rglob("requirements.txt"):
|
|
if p.is_file():
|
|
req_files.append(p)
|
|
for p in skill_dir.rglob("package.json"):
|
|
if p.is_file():
|
|
pkg_files.append(p)
|
|
except Exception:
|
|
return {"requirements": [], "package_json": []}
|
|
req_files = sorted(req_files)
|
|
pkg_files = sorted(pkg_files)
|
|
return {"requirements": req_files, "package_json": pkg_files}
|
|
|
|
|
|
def _run_dep_install(command: list[str], *, cwd: Path) -> tuple[bool, str]:
|
|
try:
|
|
cp = subprocess.run(
|
|
command,
|
|
cwd=str(cwd),
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=600,
|
|
check=False,
|
|
)
|
|
except Exception as exc:
|
|
return False, f"{type(exc).__name__}"
|
|
if int(cp.returncode) == 0:
|
|
return True, ""
|
|
err = str(cp.stderr or cp.stdout or "").strip()
|
|
if err:
|
|
err = err[:160].replace("\n", " ")
|
|
return False, err or f"exit_{cp.returncode}"
|
|
|
|
|
|
def _auto_install_skill_dependencies(*, store: Any, skill_dir: Path) -> tuple[bool, str]:
|
|
if not skill_auto_install_deps_enabled(store):
|
|
return True, ""
|
|
manifests = _scan_dependency_manifests(skill_dir)
|
|
req_files = manifests.get("requirements") or []
|
|
pkg_files = manifests.get("package_json") or []
|
|
if not req_files and not pkg_files:
|
|
return True, ""
|
|
|
|
failures: list[str] = []
|
|
# Python dependencies
|
|
for req in req_files:
|
|
ok, detail = _run_dep_install([sys.executable, "-m", "pip", "install", "-r", str(req)], cwd=req.parent)
|
|
if not ok:
|
|
failures.append(f"pip:{req.name}:{detail}")
|
|
|
|
# Node dependencies (only when package.json declares deps)
|
|
for pkg in pkg_files:
|
|
try:
|
|
obj = json.loads(pkg.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
obj = {}
|
|
deps = obj.get("dependencies") if isinstance(obj, dict) else None
|
|
if not isinstance(deps, dict) or not deps:
|
|
continue
|
|
ok, detail = _run_dep_install(["npm", "install", "--omit=dev"], cwd=pkg.parent)
|
|
if not ok:
|
|
failures.append(f"npm:{pkg.name}:{detail}")
|
|
if failures:
|
|
return False, "; ".join(failures[:4])
|
|
return True, ""
|
|
|
|
|
|
def _collect_python_import_roots(skill_dir: Path) -> set[str]:
|
|
names: set[str] = set()
|
|
for py in skill_dir.rglob("*.py"):
|
|
if not py.is_file():
|
|
continue
|
|
try:
|
|
tree = ast.parse(py.read_text(encoding="utf-8"), filename=str(py))
|
|
except Exception:
|
|
continue
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
for alias in node.names:
|
|
root = str(alias.name or "").split(".", 1)[0].strip()
|
|
if root:
|
|
names.add(root)
|
|
elif isinstance(node, ast.ImportFrom):
|
|
if int(node.level or 0) > 0:
|
|
continue
|
|
root = str(node.module or "").split(".", 1)[0].strip()
|
|
if root:
|
|
names.add(root)
|
|
return names
|
|
|
|
|
|
def _local_python_module_roots(skill_dir: Path) -> set[str]:
|
|
roots: set[str] = set()
|
|
for py in skill_dir.rglob("*.py"):
|
|
if py.is_file():
|
|
roots.add(py.stem)
|
|
for p in skill_dir.rglob("*"):
|
|
if p.is_dir() and (p / "__init__.py").exists():
|
|
roots.add(p.name)
|
|
return {x for x in roots if x and x != "__init__"}
|
|
|
|
|
|
def _auto_probe_and_install_python_imports(*, store: Any, skill_dir: Path) -> tuple[bool, str]:
|
|
if not skill_auto_install_deps_enabled(store):
|
|
return True, ""
|
|
imports = _collect_python_import_roots(skill_dir)
|
|
if not imports:
|
|
return True, ""
|
|
local_roots = _local_python_module_roots(skill_dir)
|
|
stdlib = set(getattr(sys, "stdlib_module_names", set()) or set())
|
|
missing: list[str] = []
|
|
for name in sorted(imports):
|
|
if name in local_roots or name in stdlib:
|
|
continue
|
|
if importlib.util.find_spec(name) is None:
|
|
missing.append(name)
|
|
if not missing:
|
|
return True, ""
|
|
ok, detail = _run_dep_install([sys.executable, "-m", "pip", "install", *missing], cwd=skill_dir)
|
|
if not ok:
|
|
return False, f"probe_pip:{detail}"
|
|
return True, ""
|
|
|
|
|
|
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 _apply_auto_enable_binding(*, store: Any, skill_name: str, skills_root: str | Path | None = None) -> tuple[bool, tuple[str, ...]]:
|
|
nm = str(skill_name or "").strip()
|
|
if not nm:
|
|
return False, ()
|
|
if not skill_auto_enable_trusted_enabled(store):
|
|
return False, ()
|
|
roles = ordered_binding_roles()
|
|
valid = {str(m.name).strip() for m in discover_workspace_skill_manifests(skills_root) if str(m.name or "").strip()}
|
|
# If discovery lags for any reason, still include the newly created skill as valid candidate.
|
|
valid.add(nm)
|
|
try:
|
|
raw = str(store.get_setting(SKILL_ROLE_BINDING_KEY) or "").strip()
|
|
mapping_raw = json.loads(raw) if raw else {}
|
|
except Exception:
|
|
mapping_raw = {}
|
|
mapping = normalize_skill_role_binding(
|
|
mapping_raw=mapping_raw if isinstance(mapping_raw, dict) else {},
|
|
valid_skill_names=valid,
|
|
available_roles=roles,
|
|
)
|
|
changed = False
|
|
for role in roles:
|
|
cur = list(mapping.get(role) or [])
|
|
if nm not in cur:
|
|
cur.append(nm)
|
|
mapping[role] = cur
|
|
changed = True
|
|
if changed:
|
|
store.set_setting(SKILL_ROLE_BINDING_KEY, json.dumps(mapping, ensure_ascii=False))
|
|
# Ensure role filter is active when we auto-attach to all roles.
|
|
store.set_setting(SKILL_ROLE_BINDING_ENABLED_SETTING, "1")
|
|
return True, tuple(roles)
|
|
|
|
|
|
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_oclaw": dict(m.metadata_oclaw),
|
|
"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 uninstall_skill(
|
|
*,
|
|
store: Any,
|
|
skill_name: str,
|
|
skills_root: str | Path | None = None,
|
|
) -> SkillInstallResult:
|
|
name = str(skill_name or "").strip()
|
|
if not name:
|
|
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()
|
|
# Prefer manifest directory resolution to handle workspace lanes and public lane.
|
|
target: Path | None = None
|
|
try:
|
|
manifests = list(discover_workspace_skill_manifests(root))
|
|
m = next((x for x in manifests if str(x.name or "").strip() == name), None)
|
|
if m and str(m.skill_dir or "").strip():
|
|
target = Path(str(m.skill_dir)).resolve()
|
|
except Exception:
|
|
target = None
|
|
if target is None:
|
|
# Fallback to legacy lane guesses.
|
|
candidates = [root / name, root / "_workspace" / name, root / "_workspace" / "public" / name]
|
|
target = next((p for p in candidates if p.exists() and p.is_dir()), None)
|
|
if target is None:
|
|
ec, rt = _classify_install_detail("not_found")
|
|
return SkillInstallResult(ok=False, name=name, target_dir="", detail="not_found", error_code=ec, retryable=rt)
|
|
# Safety: only allow uninstall within the configured skills root.
|
|
try:
|
|
root_r = root.resolve()
|
|
ws_r = (root_r / "_workspace").resolve()
|
|
if not (str(target).startswith(str(root_r)) or str(target).startswith(str(ws_r))):
|
|
ec, rt = _classify_install_detail("runtime_error")
|
|
return SkillInstallResult(ok=False, name=name, target_dir=str(target), detail="remove_failed:path_restricted", error_code=ec, retryable=rt)
|
|
except Exception:
|
|
ec, rt = _classify_install_detail("runtime_error")
|
|
return SkillInstallResult(ok=False, name=name, target_dir=str(target), detail="remove_failed:path_restricted", error_code=ec, retryable=rt)
|
|
try:
|
|
shutil.rmtree(target)
|
|
except Exception as exc:
|
|
ec, rt = _classify_install_detail("runtime_error")
|
|
return SkillInstallResult(ok=False, name=name, target_dir=str(target), detail=f"remove_failed:{type(exc).__name__}", error_code=ec, retryable=rt)
|
|
set_skill_enabled(store=store, skill_name=name, enabled=True)
|
|
ec, rt = _classify_install_detail("removed")
|
|
return SkillInstallResult(ok=True, name=name, target_dir=str(target), detail="removed", error_code=ec, retryable=rt)
|
|
|
|
|
|
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
|
|
# Clawhub page URLs: https://clawhub.ai/<author>/<slug>
|
|
# Resolve via clawhub client first (matches test + avoids adapter drift).
|
|
try:
|
|
if len(segs) >= 2:
|
|
from oclaw.runtime.tools.skills.clawhub_client import get_skill_detail
|
|
|
|
slug = str(segs[-1] or "").strip()
|
|
if slug:
|
|
detail = get_skill_detail(slug)
|
|
archive_url = str((detail or {}).get("archiveUrl") or "").strip()
|
|
if archive_url:
|
|
return archive_url
|
|
except Exception:
|
|
pass
|
|
candidates: list[str] = []
|
|
if len(segs) >= 1:
|
|
candidates.append(segs[-1])
|
|
if len(segs) >= 2:
|
|
candidates.append(f"{segs[0]}/{segs[1]}")
|
|
try:
|
|
adapter = get_market_adapter("clawhub")
|
|
for slug in candidates:
|
|
archive_url, _ = adapter.resolve_archive_url(slug=slug, version=None)
|
|
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,
|
|
auto_bind: bool = False,
|
|
) -> 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)
|
|
auto_enabled = False
|
|
binding_roles: tuple[str, ...] = ()
|
|
if auto_bind:
|
|
binding_root = skills_home_containing_workspace_lane(root)
|
|
auto_enabled, binding_roles = _apply_auto_enable_binding(store=store, skill_name=manifest.name, skills_root=binding_root)
|
|
deps_ok, deps_detail = _auto_install_skill_dependencies(store=store, skill_dir=target)
|
|
if not deps_ok:
|
|
# Keep install successful; surface dependency warning for operator follow-up.
|
|
ec, rt = _classify_install_detail("installed")
|
|
return SkillInstallResult(
|
|
ok=True,
|
|
name=manifest.name,
|
|
target_dir=str(target),
|
|
detail=f"installed_with_dependency_warnings:{deps_detail}",
|
|
error_code=ec,
|
|
retryable=rt,
|
|
auto_enabled=auto_enabled,
|
|
binding_applied_roles=binding_roles,
|
|
)
|
|
probe_ok, probe_detail = _auto_probe_and_install_python_imports(store=store, skill_dir=target)
|
|
if not probe_ok:
|
|
ec, rt = _classify_install_detail("installed")
|
|
return SkillInstallResult(
|
|
ok=True,
|
|
name=manifest.name,
|
|
target_dir=str(target),
|
|
detail=f"installed_with_dependency_warnings:{probe_detail}",
|
|
error_code=ec,
|
|
retryable=rt,
|
|
auto_enabled=auto_enabled,
|
|
binding_applied_roles=binding_roles,
|
|
)
|
|
ec, rt = _classify_install_detail("installed")
|
|
return SkillInstallResult(
|
|
ok=True,
|
|
name=manifest.name,
|
|
target_dir=str(target),
|
|
detail="installed",
|
|
error_code=ec,
|
|
retryable=rt,
|
|
auto_enabled=auto_enabled,
|
|
binding_applied_roles=binding_roles,
|
|
)
|
|
|
|
|
|
def install_skill_from_registry_archive(
|
|
*,
|
|
store: Any,
|
|
archive_url: str,
|
|
overwrite: bool = False,
|
|
skills_root: str | Path | None = None,
|
|
auto_bind: bool = False,
|
|
) -> 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:
|
|
data = _download_archive_bytes(url)
|
|
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,
|
|
auto_bind=auto_bind,
|
|
)
|
|
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_oclaw: 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_oclaw or {})
|
|
front = {
|
|
"name": nm,
|
|
"description": str(description or f"{nm} skill"),
|
|
"user-invocable": "true",
|
|
"disable-model-invocation": "false",
|
|
"metadata": {"oclaw": 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 create_workspace_skill(
|
|
*,
|
|
store: Any,
|
|
name: str,
|
|
description: str,
|
|
runtime_type: str = "python",
|
|
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)
|
|
rt_type = str(runtime_type or "python").strip().lower()
|
|
if rt_type not in {"python", "shell", "node"}:
|
|
ec, rt = _classify_install_detail("unsupported_runtime_type")
|
|
return SkillInstallResult(ok=False, name=nm, target_dir="", detail="unsupported_runtime_type", error_code=ec, retryable=rt)
|
|
root = Path(skills_root).resolve() if skills_root else default_skills_root()
|
|
target = root / "_workspace" / 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)
|
|
scripts_dir = target / "scripts"
|
|
scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
entry = "scripts/run.py" if rt_type == "python" else ("scripts/run.sh" if rt_type == "shell" else "scripts/run.js")
|
|
script_path = target / entry
|
|
if rt_type == "python":
|
|
script_path.write_text(
|
|
"import json\nimport sys\n\n"
|
|
"def main():\n"
|
|
" data = json.loads(sys.stdin.read() or '{}')\n"
|
|
" args = data.get('args') or {}\n"
|
|
" print(json.dumps({'ok': True, 'echo': args}, ensure_ascii=False))\n\n"
|
|
"if __name__ == '__main__':\n"
|
|
" main()\n",
|
|
encoding="utf-8",
|
|
)
|
|
elif rt_type == "shell":
|
|
script_path.write_text("#!/usr/bin/env bash\ncat\n", encoding="utf-8")
|
|
else:
|
|
script_path.write_text("const fs=require('fs'); const input=JSON.parse(fs.readFileSync(0,'utf8')||'{}'); console.log(JSON.stringify({ok:true,echo:input.args||{}}));\n", encoding="utf-8")
|
|
metadata_oclaw = {
|
|
"source": {"kind": "workspace", "provider": "local", "version": "workspace"},
|
|
"runtime": {
|
|
"type": rt_type,
|
|
"entry": entry,
|
|
"schema": {"type": "object", "additionalProperties": True},
|
|
"permissions": {"fs_write": False, "net": False, "process": True},
|
|
},
|
|
}
|
|
out = create_skill_from_template(
|
|
store=store,
|
|
name=nm,
|
|
description=description,
|
|
body_markdown="Workspace authored skill.",
|
|
metadata_oclaw=metadata_oclaw,
|
|
skills_root=target.parent,
|
|
overwrite=True,
|
|
)
|
|
if not out.ok:
|
|
return out
|
|
auto_enabled, roles = _apply_auto_enable_binding(store=store, skill_name=nm, skills_root=root)
|
|
return SkillInstallResult(
|
|
ok=out.ok,
|
|
name=out.name,
|
|
target_dir=out.target_dir,
|
|
detail=out.detail,
|
|
error_code=out.error_code,
|
|
retryable=out.retryable,
|
|
auto_enabled=auto_enabled,
|
|
binding_applied_roles=roles,
|
|
)
|
|
|
|
|
|
def auto_install_skill_from_payload(
|
|
*,
|
|
store: Any,
|
|
payload: dict[str, Any],
|
|
skills_root: str | Path | None = None,
|
|
workspace_install_parent: str | Path | None = None,
|
|
auto_bind: bool | 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_oclaw")
|
|
md = dict(md) if isinstance(md, dict) else {}
|
|
skills_home = Path(skills_root).resolve() if skills_root else default_skills_root()
|
|
if workspace_install_parent is not None:
|
|
workspace_root = Path(workspace_install_parent).resolve()
|
|
else:
|
|
workspace_root = skills_home / "_workspace"
|
|
bind = True if auto_bind is None else bool(auto_bind)
|
|
# Keep agent-authored/auto-installed skills isolated from primary managed skills.
|
|
target = workspace_root / name
|
|
before_disabled = _get_disabled_names(store)
|
|
try:
|
|
out = create_skill_from_template(
|
|
store=store,
|
|
name=name,
|
|
description=description,
|
|
body_markdown=body,
|
|
metadata_oclaw=md,
|
|
skills_root=workspace_root,
|
|
overwrite=False,
|
|
)
|
|
if bool(payload.get("force_error_for_test")):
|
|
raise RuntimeError("forced_error_for_test")
|
|
if not out.ok:
|
|
return out
|
|
if bind:
|
|
auto_enabled, roles = _apply_auto_enable_binding(
|
|
store=store, skill_name=name, skills_root=skills_home_containing_workspace_lane(workspace_root)
|
|
)
|
|
else:
|
|
auto_enabled, roles = False, ()
|
|
return SkillInstallResult(
|
|
ok=out.ok,
|
|
name=out.name,
|
|
target_dir=out.target_dir,
|
|
detail=out.detail,
|
|
error_code=out.error_code,
|
|
retryable=out.retryable,
|
|
auto_enabled=auto_enabled,
|
|
binding_applied_roles=roles,
|
|
)
|
|
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)
|
|
|
|
|
|
def repair_skill_dependencies(
|
|
*,
|
|
store: Any,
|
|
skill_name: str,
|
|
skills_root: str | Path | None = None,
|
|
) -> dict[str, Any]:
|
|
nm = str(skill_name or "").strip()
|
|
if not nm:
|
|
return {"ok": False, "error_code": "name_required", "error": "name_required"}
|
|
manifests = list(discover_workspace_skill_manifests(skills_root))
|
|
mf = next((m for m in manifests if str(m.name or "").strip() == nm), None)
|
|
if mf is None:
|
|
return {"ok": False, "error_code": "skill_not_found", "error": "skill_not_found", "name": nm}
|
|
skill_dir = Path(str(mf.skill_dir or "")).resolve()
|
|
deps_ok, deps_detail = _auto_install_skill_dependencies(store=store, skill_dir=skill_dir)
|
|
probe_ok, probe_detail = _auto_probe_and_install_python_imports(store=store, skill_dir=skill_dir)
|
|
warnings: list[str] = []
|
|
if not deps_ok and deps_detail:
|
|
warnings.append(f"manifest:{deps_detail}")
|
|
if not probe_ok and probe_detail:
|
|
warnings.append(f"probe:{probe_detail}")
|
|
return {
|
|
"ok": True,
|
|
"name": nm,
|
|
"skill_dir": str(skill_dir),
|
|
"warnings": warnings,
|
|
"detail": "ok" if not warnings else f"warnings:{'; '.join(warnings[:4])}",
|
|
}
|
|
|
|
|
|
__all__ = [
|
|
"SkillInstallResult",
|
|
"auto_install_skill_from_payload",
|
|
"create_skill_from_template",
|
|
"create_workspace_skill",
|
|
"install_skill_from_local_dir",
|
|
"install_skill_from_registry_archive",
|
|
"list_skills_with_status",
|
|
"repair_skill_dependencies",
|
|
"set_skill_enabled",
|
|
"skill_auto_install_enabled",
|
|
"uninstall_skill",
|
|
]
|