mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 08:28:36 +00:00
223 lines
7.8 KiB
Python
223 lines
7.8 KiB
Python
"""Translate Pi and OpenClaw package metadata into the nanobot manifest."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from nanobot.extensions.codec import MANIFEST_FILENAME, load_manifest
|
|
from nanobot.extensions.manifest import (
|
|
ContributionKind,
|
|
DependencyKind,
|
|
ExtensionContribution,
|
|
ExtensionDependency,
|
|
ExtensionManifest,
|
|
ExtensionPermission,
|
|
ExtensionRuntime,
|
|
)
|
|
|
|
_ID_CHARS = re.compile(r"[^a-z0-9._-]+")
|
|
_OPENCLAW_CONTRACT_KINDS = {
|
|
"tools": ContributionKind.TOOL,
|
|
"realtimeTranscriptionProviders": ContributionKind.TRANSCRIPTION_PROVIDER,
|
|
"imageGenerationProviders": ContributionKind.IMAGE_GENERATION_PROVIDER,
|
|
"webSearchProviders": ContributionKind.WEB_SEARCH_PROVIDER,
|
|
}
|
|
_TYPESCRIPT_SUFFIXES = (".ts", ".tsx", ".cts", ".mts")
|
|
_JITI_DEPENDENCY = ExtensionDependency(
|
|
kind=DependencyKind.NPM,
|
|
name="jiti",
|
|
specifier="^2.4.2",
|
|
)
|
|
_NODE_RUNTIME_PERMISSION = ExtensionPermission(
|
|
name="runtime.node",
|
|
reason="Run third-party JavaScript or TypeScript in a Node.js process.",
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class AdaptedPackage:
|
|
"""Canonical metadata plus honest compatibility diagnostics."""
|
|
|
|
manifest: ExtensionManifest
|
|
diagnostics: tuple[str, ...] = ()
|
|
generated: bool = False
|
|
|
|
|
|
def adapt_package(root: Path) -> AdaptedPackage:
|
|
"""Load native metadata or adapt one supported JavaScript package."""
|
|
root = root.resolve()
|
|
canonical = root / MANIFEST_FILENAME
|
|
if canonical.is_file():
|
|
return AdaptedPackage(load_manifest(canonical))
|
|
|
|
package = _read_json(root / "package.json", "package.json")
|
|
if isinstance(package.get("pi"), dict):
|
|
return _adapt_pi(package)
|
|
if isinstance(package.get("openclaw"), dict):
|
|
return _adapt_openclaw(root, package)
|
|
raise ValueError(
|
|
f"{root} is not a nanobot, Pi, or OpenClaw extension package"
|
|
)
|
|
|
|
|
|
def _adapt_pi(package: dict[str, Any]) -> AdaptedPackage:
|
|
pi = package["pi"]
|
|
entries = _string_list(pi.get("extensions"), "pi.extensions")
|
|
name = str(package.get("name") or "pi-extension")
|
|
return AdaptedPackage(
|
|
ExtensionManifest(
|
|
id=f"pi.{_identifier(name)}",
|
|
name=str(package.get("displayName") or name),
|
|
version=str(package.get("version") or "0.0.0"),
|
|
runtime=ExtensionRuntime.PI,
|
|
entries=tuple(entries),
|
|
dependencies=(_JITI_DEPENDENCY,) if _has_typescript(entries) else (),
|
|
permissions=(_NODE_RUNTIME_PERMISSION,),
|
|
description=str(package.get("description") or ""),
|
|
homepage=_homepage(package),
|
|
license=str(package.get("license") or ""),
|
|
),
|
|
generated=True,
|
|
)
|
|
|
|
|
|
def _adapt_openclaw(root: Path, package: dict[str, Any]) -> AdaptedPackage:
|
|
openclaw = package["openclaw"]
|
|
entries = _optional_string_list(
|
|
openclaw.get("runtimeExtensions"),
|
|
"openclaw.runtimeExtensions",
|
|
) or _string_list(openclaw.get("extensions"), "openclaw.extensions")
|
|
plugin_path = root / "openclaw.plugin.json"
|
|
plugin = _read_json(plugin_path, "openclaw.plugin.json") if plugin_path.is_file() else {}
|
|
plugin_id = str(plugin.get("id") or package.get("name") or "openclaw-plugin")
|
|
contributions = _openclaw_contributions(plugin)
|
|
diagnostics: list[str] = []
|
|
contracts = plugin.get("contracts")
|
|
if isinstance(contracts, dict):
|
|
unsupported = sorted(
|
|
key for key, value in contracts.items()
|
|
if value and key not in _OPENCLAW_CONTRACT_KINDS
|
|
)
|
|
if unsupported:
|
|
diagnostics.append(
|
|
"OpenClaw capabilities retained as metadata but not executable: "
|
|
+ ", ".join(unsupported)
|
|
)
|
|
return AdaptedPackage(
|
|
ExtensionManifest(
|
|
id=f"openclaw.{_identifier(plugin_id)}",
|
|
name=str(plugin.get("name") or package.get("name") or plugin_id),
|
|
version=str(package.get("version") or plugin.get("version") or "0.0.0"),
|
|
runtime=ExtensionRuntime.OPENCLAW,
|
|
entries=tuple(entries),
|
|
contributions=contributions,
|
|
dependencies=_openclaw_dependencies(package, openclaw),
|
|
permissions=(_NODE_RUNTIME_PERMISSION,),
|
|
description=str(
|
|
plugin.get("description") or package.get("description") or ""
|
|
),
|
|
homepage=_homepage(package),
|
|
license=str(package.get("license") or ""),
|
|
),
|
|
diagnostics=tuple(diagnostics),
|
|
generated=True,
|
|
)
|
|
|
|
|
|
def _openclaw_dependencies(
|
|
package: dict[str, Any],
|
|
openclaw: dict[str, Any],
|
|
) -> tuple[ExtensionDependency, ...]:
|
|
build = openclaw.get("build")
|
|
version = build.get("openclawVersion") if isinstance(build, dict) else None
|
|
peers = package.get("peerDependencies")
|
|
peer_version = peers.get("openclaw") if isinstance(peers, dict) else None
|
|
return (
|
|
ExtensionDependency(
|
|
kind=DependencyKind.NPM,
|
|
name="openclaw",
|
|
specifier=str(version or peer_version or "latest"),
|
|
),
|
|
)
|
|
|
|
|
|
def _openclaw_contributions(
|
|
plugin: dict[str, Any],
|
|
) -> tuple[ExtensionContribution, ...]:
|
|
rows: list[ExtensionContribution] = []
|
|
direct = {
|
|
"channels": ContributionKind.CHANNEL,
|
|
"providers": ContributionKind.LLM_PROVIDER,
|
|
"skills": ContributionKind.SKILL,
|
|
}
|
|
for field, kind in direct.items():
|
|
for name in _optional_string_list(plugin.get(field), field):
|
|
rows.append(ExtensionContribution(kind=kind, name=_identifier(name)))
|
|
contracts = plugin.get("contracts")
|
|
if isinstance(contracts, dict):
|
|
for field, kind in _OPENCLAW_CONTRACT_KINDS.items():
|
|
for name in _optional_string_list(contracts.get(field), field):
|
|
rows.append(ExtensionContribution(kind=kind, name=_identifier(name)))
|
|
for alias in plugin.get("commandAliases", []):
|
|
if isinstance(alias, dict) and isinstance(alias.get("name"), str):
|
|
rows.append(
|
|
ExtensionContribution(
|
|
kind=ContributionKind.COMMAND,
|
|
name=_identifier(alias["name"]),
|
|
)
|
|
)
|
|
return tuple(dict.fromkeys(rows))
|
|
|
|
|
|
def _read_json(path: Path, label: str) -> dict[str, Any]:
|
|
try:
|
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
raise ValueError(f"cannot read {label}: {exc}") from exc
|
|
if not isinstance(value, dict):
|
|
raise ValueError(f"{label} must contain a JSON object")
|
|
return value
|
|
|
|
|
|
def _string_list(value: object, label: str) -> list[str]:
|
|
rows = _optional_string_list(value, label)
|
|
if not rows:
|
|
raise ValueError(f"{label} must contain at least one entry")
|
|
return rows
|
|
|
|
|
|
def _optional_string_list(value: object, label: str) -> list[str]:
|
|
if value is None:
|
|
return []
|
|
if not isinstance(value, list) or not all(
|
|
isinstance(item, str) and item for item in value
|
|
):
|
|
raise ValueError(f"{label} must be an array of non-empty strings")
|
|
return value
|
|
|
|
|
|
def _identifier(value: str) -> str:
|
|
normalized = value.lower().replace("@", "").replace("/", ".")
|
|
normalized = _ID_CHARS.sub("-", normalized).strip(".-_")
|
|
return normalized or "extension"
|
|
|
|
|
|
def _homepage(package: dict[str, Any]) -> str:
|
|
homepage = package.get("homepage")
|
|
if isinstance(homepage, str):
|
|
return homepage
|
|
repository = package.get("repository")
|
|
if isinstance(repository, str):
|
|
return repository
|
|
if isinstance(repository, dict) and isinstance(repository.get("url"), str):
|
|
return repository["url"]
|
|
return ""
|
|
|
|
|
|
def _has_typescript(entries: list[str]) -> bool:
|
|
return any(entry.lower().endswith(_TYPESCRIPT_SUFFIXES) for entry in entries)
|