Files
skills/cli/skills_store_cli.py
T

1552 lines
55 KiB
Python

#!/usr/bin/env python3
"""Minimal local skills store CLI.
Features:
- Reads a local index JSON from file:// URI (or plain path).
- Search skills by keyword.
- Install a skill zip into a target directory.
- List locally installed skills from a lock file.
- Upgrade installed skills from update manifest defined in skill config.json.
- Self-upgrade the CLI binary/script from an update manifest URL in config.json.
"""
import argparse
import hashlib
import json
import os
import shutil
import subprocess
import sys
import tarfile
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
import zipfile
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from skills_upgrade import cmd_upgrade as run_skills_upgrade
DEFAULT_INSTALL_ROOT = "./skills"
LOCKFILE_NAME = ".skills_store_lock.json"
SKILL_CONFIG_NAME = "config.json"
SKILL_META_NAME = "_meta.json"
CLI_CONFIG_NAME = "config.json"
CLI_VERSION_FILE_NAME = "version.json"
CLI_METADATA_FILE_NAME = "metadata.json"
CLI_VERSION_FALLBACK = "2026.3.3"
DEFAULT_INDEX_URI_FALLBACK = "https://skillhub-1388575217.cos.ap-guangzhou.myqcloud.com/skills.json"
DEFAULT_SEARCH_URL_FALLBACK = "https://lightmake.site/api/v1/search"
SELF_UPGRADE_CHECK_TIMEOUT_SECONDS = 2
DEFAULT_CLI_HOME = "~/.skillhub"
SELF_UPGRADE_REEXEC_ENV = "SKILLHUB_SELF_UPGRADE_REEXEC"
SKIP_SELF_UPGRADE_ENV = "SKILLHUB_SKIP_SELF_UPGRADE"
SKIP_WORKSPACE_SKILLS_ENV = "SKILLHUB_SKIP_WORKSPACE_SKILLS"
DEFAULT_SELF_UPDATE_MANIFEST_URL_FALLBACK = "https://skillhub-1388575217.cos.ap-guangzhou.myqcloud.com/version.json"
DEFAULT_SKILLS_DOWNLOAD_URL_TEMPLATE_FALLBACK = (
"https://skillhub-1388575217.cos.ap-guangzhou.myqcloud.com/skills/{slug}.zip"
)
DEFAULT_PRIMARY_DOWNLOAD_URL_TEMPLATE_FALLBACK = (
"https://lightmake.site/api/v1/download?slug={slug}"
)
DEFAULT_OPENCLAW_CONFIG_PATH = "~/.openclaw/openclaw.json"
DEFAULT_OPENCLAW_WORKSPACE_PATH = "~/.openclaw/workspace"
DEFAULT_OPENCLAW_PLUGIN_DIR = "~/.openclaw/extensions/skillhub"
LEGACY_OPENCLAW_PLUGIN_FILES = (
"index.ts",
"openclaw.plugin.json",
)
POST_UPGRADE_SKILL_MIGRATION_MIN_VERSION = (3, 13)
FIND_SKILLS_SLUG = "find-skills"
SKILLHUB_PREFERENCE_SLUG = "skillhub-preference"
def load_cli_version(base_dir: Path) -> str:
version_path = base_dir / CLI_VERSION_FILE_NAME
if not version_path.exists():
return CLI_VERSION_FALLBACK
try:
raw = json.loads(version_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return CLI_VERSION_FALLBACK
if not isinstance(raw, dict):
return CLI_VERSION_FALLBACK
value = raw.get("version")
if isinstance(value, str) and value.strip():
return value.strip()
return CLI_VERSION_FALLBACK
def load_cli_metadata(base_dir: Path) -> Dict[str, str]:
metadata_path = base_dir / CLI_METADATA_FILE_NAME
if not metadata_path.exists():
return {}
try:
raw = json.loads(metadata_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return {}
if not isinstance(raw, dict):
return {}
out: Dict[str, str] = {}
for key in (
"skills_index_url",
"skills_download_url_template",
"self_update_manifest_url",
"skills_search_url",
"skills_primary_download_url_template",
):
value = raw.get(key)
if isinstance(value, str) and value.strip():
out[key] = value.strip()
return out
CLI_VERSION = load_cli_version(Path(__file__).resolve().parent)
CLI_METADATA = load_cli_metadata(Path(__file__).resolve().parent)
DEFAULT_INDEX_URI = CLI_METADATA.get("skills_index_url", DEFAULT_INDEX_URI_FALLBACK)
DEFAULT_SELF_UPDATE_MANIFEST_URL = CLI_METADATA.get(
"self_update_manifest_url",
DEFAULT_SELF_UPDATE_MANIFEST_URL_FALLBACK,
)
DEFAULT_SKILLS_DOWNLOAD_URL_TEMPLATE = CLI_METADATA.get(
"skills_download_url_template",
DEFAULT_SKILLS_DOWNLOAD_URL_TEMPLATE_FALLBACK,
)
DEFAULT_SEARCH_URL = os.environ.get("SKILLHUB_SEARCH_URL", "").strip() or CLI_METADATA.get(
"skills_search_url",
DEFAULT_SEARCH_URL_FALLBACK,
)
DEFAULT_PRIMARY_DOWNLOAD_URL_TEMPLATE = (
os.environ.get("SKILLHUB_PRIMARY_DOWNLOAD_URL_TEMPLATE", "").strip()
or CLI_METADATA.get(
"skills_primary_download_url_template",
DEFAULT_PRIMARY_DOWNLOAD_URL_TEMPLATE_FALLBACK,
)
)
CLI_USER_AGENT = f"skills-store-cli/{CLI_VERSION}"
def verbose_enabled() -> bool:
return os.environ.get("LOG", "") == "VERBOSE"
def verbose_log(message: str) -> None:
if verbose_enabled():
print(f"[self-upgrade][verbose] {message}")
def die(message: str, code: int = 1) -> None:
print(f"Error: {message}", file=sys.stderr)
raise SystemExit(code)
def normalize_file_uri(uri_or_path: str) -> Path:
parsed = urllib.parse.urlparse(uri_or_path)
if parsed.scheme == "file":
# Support:
# - file:///abs/path
# - file://localhost/abs/path
# - file://./relative/path
if parsed.netloc in ("", "localhost"):
combined = parsed.path
else:
combined = f"{parsed.netloc}{parsed.path}"
raw_path = urllib.request.url2pathname(combined)
if not raw_path.strip():
die(f"Invalid file URI: {uri_or_path}")
candidate = Path(raw_path).expanduser()
if not candidate.is_absolute():
candidate = Path.cwd() / candidate
return candidate.resolve()
if parsed.scheme:
die(f"Only file:// is supported for --index. Got: {uri_or_path}")
return Path(uri_or_path).expanduser().resolve()
def parse_path_like_uri(uri_or_path: str) -> Path:
parsed = urllib.parse.urlparse(uri_or_path)
if parsed.scheme == "file":
return normalize_file_uri(uri_or_path)
if parsed.scheme:
die(f"Only file:// or local paths are supported here. Got: {uri_or_path}")
return Path(uri_or_path).expanduser().resolve()
def append_slug_zip(base_uri_or_path: str, slug: str) -> str:
base = base_uri_or_path.strip()
if not base:
return ""
if "{slug}" in base:
return base.replace("{slug}", urllib.parse.quote(slug))
parsed = urllib.parse.urlparse(base)
suffix = f"{urllib.parse.quote(slug)}.zip"
if parsed.scheme in ("http", "https"):
return urllib.parse.urljoin(base.rstrip("/") + "/", suffix)
base_path = parse_path_like_uri(base)
return (base_path / f"{slug}.zip").resolve().as_uri()
def fill_slug_template(url_template: str, slug: str) -> str:
raw = str(url_template or "").strip()
if not raw:
return ""
if "{slug}" not in raw:
return raw
return raw.replace("{slug}", urllib.parse.quote(slug))
def read_json_from_uri(uri_or_path: str, timeout: int = 20) -> Dict[str, Any]:
parsed = urllib.parse.urlparse(uri_or_path)
if parsed.scheme in ("", "file"):
path = parse_path_like_uri(uri_or_path)
if not path.exists():
raise RuntimeError(f"JSON source not found: {path}")
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise RuntimeError(f"Invalid JSON in {path}: {exc}") from exc
elif parsed.scheme in ("http", "https"):
req = urllib.request.Request(
uri_or_path,
headers={
"User-Agent": CLI_USER_AGENT,
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as response:
payload = response.read().decode("utf-8")
raw = json.loads(payload)
except urllib.error.HTTPError as exc:
raise RuntimeError(f"Failed to fetch JSON ({exc.code}) from {uri_or_path}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"Failed to fetch JSON from {uri_or_path}: {exc.reason}") from exc
except json.JSONDecodeError as exc:
raise RuntimeError(f"Invalid JSON from {uri_or_path}: {exc}") from exc
else:
raise RuntimeError(f"Unsupported URI scheme for JSON source: {uri_or_path}")
if not isinstance(raw, dict):
raise RuntimeError(f"JSON source must be an object: {uri_or_path}")
return raw
def as_dict(value: Any) -> Dict[str, Any]:
return value if isinstance(value, dict) else {}
def first_non_empty_string(obj: Dict[str, Any], keys: List[str]) -> str:
for key in keys:
value = obj.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def normalize_version_text(v: str) -> str:
return v.strip()
def parse_version_key(version: str) -> Optional[Tuple[int, ...]]:
raw = version.strip().lower()
if raw.startswith("v"):
raw = raw[1:]
if not raw:
return None
core = raw.split("-", 1)[0].split("+", 1)[0]
parts = core.split(".")
out: List[int] = []
for part in parts:
if not part.isdigit():
return None
out.append(int(part))
return tuple(out) if out else None
def version_is_newer(candidate: str, current: str) -> bool:
candidate = candidate.strip()
current = current.strip()
if not candidate:
return False
if not current:
return True
a = parse_version_key(candidate)
b = parse_version_key(current)
if a is not None and b is not None:
return a > b
return candidate != current
def parse_bool_like(value: Any) -> Optional[bool]:
if isinstance(value, bool):
return value
if isinstance(value, int):
return bool(value)
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in ("1", "true", "yes", "on"):
return True
if normalized in ("0", "false", "no", "off"):
return False
return None
def self_update_url_from_config(config: Dict[str, Any]) -> str:
direct = first_non_empty_string(
config,
["self_update_url", "selfUpdateUrl", "update_url", "updateUrl", "manifest_url", "manifestUrl"],
)
if direct:
return direct
for key in ("self_update", "selfUpdate", "update", "upgrade"):
nested = as_dict(config.get(key))
url_value = first_non_empty_string(nested, ["url", "uri", "manifest", "manifest_url", "manifestUrl"])
if url_value:
return url_value
return ""
def self_update_enabled_from_config(config: Dict[str, Any]) -> Optional[bool]:
for key in ("auto_self_upgrade", "autoSelfUpgrade", "self_update_auto", "selfUpdateAuto"):
parsed = parse_bool_like(config.get(key))
if parsed is not None:
return parsed
for key in ("self_update", "selfUpdate", "update", "upgrade"):
nested = as_dict(config.get(key))
for nested_key in ("auto", "enabled", "auto_upgrade", "autoUpgrade", "enabled_auto_upgrade"):
parsed = parse_bool_like(nested.get(nested_key))
if parsed is not None:
return parsed
return None
def resolve_self_update_manifest_url(config_path: Path) -> str:
if config_path.exists():
verbose_log(f"reading config: {config_path}")
try:
raw = json.loads(config_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
verbose_log("config JSON invalid; fallback to default manifest URL")
raw = {}
if isinstance(raw, dict):
manifest_url_raw = self_update_url_from_config(raw)
if manifest_url_raw:
verbose_log(f"manifest URL from config: {manifest_url_raw}")
return resolve_uri_with_base(manifest_url_raw, config_path.parent)
else:
verbose_log(f"config not found: {config_path}; use default manifest URL")
verbose_log(f"using default manifest URL: {DEFAULT_SELF_UPDATE_MANIFEST_URL}")
return DEFAULT_SELF_UPDATE_MANIFEST_URL
def should_run_startup_self_upgrade(config_path: Path) -> bool:
env_override = parse_bool_like(os.environ.get(SKIP_SELF_UPGRADE_ENV, ""))
if env_override is True:
verbose_log(f"startup check skipped by env {SKIP_SELF_UPGRADE_ENV}=true")
return False
if not config_path.exists():
return True
try:
raw = json.loads(config_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
verbose_log("startup check: config JSON invalid; keep default auto upgrade")
return True
if isinstance(raw, dict):
enabled = self_update_enabled_from_config(raw)
if enabled is False:
verbose_log("startup check skipped by config auto_self_upgrade=false")
return False
return True
def find_cli_script_in_extracted(root: Path) -> Optional[Path]:
direct = root / "skills_store_cli.py"
if direct.exists():
return direct
nested = root / "cli" / "skills_store_cli.py"
if nested.exists():
return nested
matches = list(root.rglob("skills_store_cli.py"))
return matches[0] if matches else None
def find_peer_file_in_extracted(root: Path, filename: str) -> Optional[Path]:
direct = root / filename
if direct.exists():
return direct
nested = root / "cli" / filename
if nested.exists():
return nested
matches = list(root.rglob(filename))
return matches[0] if matches else None
def find_skill_file_in_extracted(root: Path, filename: str) -> Optional[Path]:
direct = root / "skill" / filename
if direct.exists():
return direct
nested = root / "cli" / "skill" / filename
if nested.exists():
return nested
for match in root.rglob(filename):
if match.parent.name == "skill":
return match
return None
def version_at_least(version: str, minimum: Tuple[int, ...]) -> bool:
parsed = parse_version_key(version)
if parsed is None:
return False
return parsed >= minimum
def resolve_openclaw_config_path() -> Path:
override = os.environ.get("OPENCLAW_CONFIG_PATH", "").strip()
if override:
return Path(override).expanduser().resolve()
return Path(DEFAULT_OPENCLAW_CONFIG_PATH).expanduser().resolve()
def resolve_skillhub_config_path() -> Path:
override = os.environ.get("SKILLHUB_CONFIG_PATH", "").strip()
if override:
return Path(override).expanduser().resolve()
return Path(f"{DEFAULT_CLI_HOME}/{CLI_CONFIG_NAME}").expanduser().resolve()
def cleanup_legacy_openclaw_plugin_files(plugin_dir: Optional[Path] = None) -> None:
base_dir = plugin_dir if plugin_dir is not None else Path(DEFAULT_OPENCLAW_PLUGIN_DIR).expanduser().resolve()
for name in LEGACY_OPENCLAW_PLUGIN_FILES:
target = base_dir / name
try:
if target.is_dir():
shutil.rmtree(target, ignore_errors=True)
else:
target.unlink()
verbose_log(f"removed legacy skillhub plugin file: {target}")
except FileNotFoundError:
continue
except Exception as exc:
verbose_log(f"failed to remove legacy skillhub plugin file {target}: {exc}")
def read_json_object(path: Path) -> Dict[str, Any]:
if not path.exists():
return {}
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
if not isinstance(raw, dict):
return {}
return raw
def should_install_workspace_skills() -> bool:
env_override = parse_bool_like(os.environ.get(SKIP_WORKSPACE_SKILLS_ENV, ""))
if env_override is True:
verbose_log(f"workspace skills install skipped by env {SKIP_WORKSPACE_SKILLS_ENV}=true")
return False
if env_override is False:
return True
config = read_json_object(resolve_skillhub_config_path())
configured = parse_bool_like(config.get("install_workspace_skills"))
if configured is not None:
return configured
return True
def openclaw_config_has_skillhub_entry(config: Dict[str, Any]) -> bool:
plugins = as_dict(config.get("plugins"))
entries = as_dict(plugins.get("entries"))
return "skillhub" in entries
def skillhub_plugin_dir_present() -> bool:
plugin_dir = Path(DEFAULT_OPENCLAW_PLUGIN_DIR).expanduser().resolve()
if not plugin_dir.exists() or not plugin_dir.is_dir():
return False
try:
next(plugin_dir.iterdir())
return True
except StopIteration:
return False
except Exception:
return False
def detect_skillhub_plugin_behavior(config_path: Path) -> Tuple[bool, Dict[str, Any]]:
config = read_json_object(config_path)
config_has_entry = openclaw_config_has_skillhub_entry(config)
plugin_dir_exists = skillhub_plugin_dir_present()
return plugin_dir_exists or config_has_entry, config
def resolve_openclaw_bin() -> str:
from_path = shutil.which("openclaw")
if from_path:
return from_path
fallback = Path("~/.local/share/pnpm/openclaw").expanduser().resolve()
if fallback.exists() and os.access(fallback, os.X_OK):
return str(fallback)
return ""
def disable_skillhub_plugin_via_openclaw(openclaw_bin: str) -> bool:
if not openclaw_bin:
return False
try:
result = subprocess.run(
[openclaw_bin, "config", "unset", "plugins.entries.skillhub"],
check=False,
capture_output=True,
text=True,
)
except Exception as exc:
verbose_log(f"disable plugin by openclaw failed: {exc}")
return False
if result.returncode == 0:
verbose_log("removed skillhub plugin config via openclaw config unset")
return True
err = (result.stderr or result.stdout or "").strip()
if "config path not found" in err.lower():
verbose_log("skillhub plugin config already absent")
return True
if err:
verbose_log(f"openclaw config unset failed: {err}")
return False
def resolve_openclaw_workspace_path(config: Dict[str, Any]) -> Path:
env_workspace = os.environ.get("OPENCLAW_WORKSPACE", "").strip()
if env_workspace:
return Path(env_workspace).expanduser().resolve()
for key in ("workspace", "workspace_dir", "workspaceDir", "workspace_path", "workspacePath"):
value = config.get(key)
if isinstance(value, str) and value.strip():
return Path(value.strip()).expanduser().resolve()
paths = as_dict(config.get("paths"))
for key in ("workspace", "workspaceDir", "workspace_path", "workspacePath"):
value = paths.get(key)
if isinstance(value, str) and value.strip():
return Path(value.strip()).expanduser().resolve()
return Path(DEFAULT_OPENCLAW_WORKSPACE_PATH).expanduser().resolve()
def read_skill_template(template_path: Optional[Path]) -> str:
if template_path and template_path.exists():
try:
content = template_path.read_text(encoding="utf-8").strip()
if content:
return content
except Exception:
pass
return ""
def install_workspace_skill(workspace_path: Path, slug: str, content: str) -> Path:
target = workspace_path / "skills" / slug / "SKILL.md"
target.parent.mkdir(parents=True, exist_ok=True)
payload = content if content.endswith("\n") else (content + "\n")
target.write_text(payload, encoding="utf-8")
return target
def run_post_upgrade_plugin_migration(
latest_version: str,
find_skill_template: Optional[Path],
preference_skill_template: Optional[Path],
) -> None:
# This migration belongs to the OTA self-upgrade path and runs only after
# a successful CLI upgrade.
if not version_at_least(latest_version, POST_UPGRADE_SKILL_MIGRATION_MIN_VERSION):
verbose_log(f"post-upgrade migration skipped; version<{POST_UPGRADE_SKILL_MIGRATION_MIN_VERSION}")
return
config_path = resolve_openclaw_config_path()
has_plugin_behavior, config = detect_skillhub_plugin_behavior(config_path)
if not has_plugin_behavior:
verbose_log("post-upgrade migration skipped; no skillhub plugin behavior detected")
return
verbose_log("skillhub plugin behavior detected; run migration to workspace skills")
openclaw_bin = resolve_openclaw_bin()
disabled = disable_skillhub_plugin_via_openclaw(openclaw_bin) if openclaw_bin else False
if not disabled:
verbose_log("skip plugin-disable fallback; openclaw command unavailable or failed")
if not should_install_workspace_skills():
verbose_log("workspace skills install disabled by config/env; skip install")
return
config_after = read_json_object(config_path)
workspace_path = resolve_openclaw_workspace_path(config_after if config_after else config)
# Template sources are package files in plain text:
# skill/SKILL.md and skill/SKILL.skillhub-preference.md.
find_skill_text = read_skill_template(find_skill_template)
preference_skill_text = read_skill_template(preference_skill_template)
if find_skill_text:
find_target = install_workspace_skill(workspace_path, FIND_SKILLS_SLUG, find_skill_text)
verbose_log(f"installed migrated skill: {find_target}")
else:
verbose_log("find-skills template missing in package; skip install")
if preference_skill_text:
preference_target = install_workspace_skill(
workspace_path,
SKILLHUB_PREFERENCE_SLUG,
preference_skill_text,
)
verbose_log(f"installed migrated skill: {preference_target}")
else:
verbose_log("skillhub-preference template missing in package; skip install")
def resolve_uri_with_base(raw: str, base_dir: Path) -> str:
value = raw.strip()
if not value:
return ""
parsed = urllib.parse.urlparse(value)
if parsed.scheme in ("http", "https"):
return value
if parsed.scheme == "file":
return parse_path_like_uri(value).as_uri()
if parsed.scheme != "":
die(f"Unsupported URI scheme: {value}")
return (base_dir / value).resolve().as_uri()
def extract_update_manifest_info(manifest: Dict[str, Any]) -> Tuple[str, str, str]:
candidates = [manifest]
for key in ("latest", "release", "data", "skill", "package"):
nested = manifest.get(key)
if isinstance(nested, dict):
candidates.append(nested)
latest_version = ""
package_uri = ""
sha256 = ""
for item in candidates:
if not latest_version:
latest_version = first_non_empty_string(item, ["version", "latest_version", "latestVersion"])
if not package_uri:
package_uri = first_non_empty_string(
item,
["zip_url", "zipUrl", "download_url", "downloadUrl", "package_url", "packageUrl", "url"],
)
if not sha256:
sha256 = first_non_empty_string(item, ["sha256", "sha_256", "checksum"])
return latest_version, package_uri, sha256.lower()
def install_zip_to_target(
slug: str,
zip_uri: str,
target_dir: Path,
force: bool,
expected_sha256: str = "",
) -> None:
if target_dir.exists() and not force:
die(f"Target exists: {target_dir} (use --force to overwrite)")
with tempfile.TemporaryDirectory(prefix="skills-store-cli-") as tmp:
zip_path = Path(tmp) / f"{slug}.zip"
stage_dir = Path(tmp) / "stage"
stage_dir.mkdir(parents=True, exist_ok=True)
print(f"Downloading: {zip_uri}")
download_file(zip_uri, zip_path)
if expected_sha256:
actual_sha256 = sha256_file(zip_path).lower()
if actual_sha256 != expected_sha256:
die(
f"SHA256 mismatch for {slug}: expected {expected_sha256}, got {actual_sha256}"
)
try:
safe_extract_zip(zip_path, stage_dir)
except zipfile.BadZipFile:
die(f"Downloaded file is not a valid zip archive: {zip_uri}")
if target_dir.exists():
if not force:
die(f"Target exists: {target_dir} (use --force to overwrite)")
shutil.rmtree(target_dir)
target_dir.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(stage_dir), str(target_dir))
def install_zip_to_target_with_fallback(
slug: str,
zip_uris: List[str],
target_dir: Path,
force: bool,
expected_sha256: str = "",
) -> None:
candidates = [str(x).strip() for x in zip_uris if str(x).strip()]
seen = set()
ordered: List[str] = []
for x in candidates:
if x in seen:
continue
seen.add(x)
ordered.append(x)
if not ordered:
die(f'No download URL candidates for "{slug}"')
if target_dir.exists() and not force:
die(f"Target exists: {target_dir} (use --force to overwrite)")
with tempfile.TemporaryDirectory(prefix="skills-store-cli-") as tmp:
zip_path = Path(tmp) / f"{slug}.zip"
stage_dir = Path(tmp) / "stage"
stage_dir.mkdir(parents=True, exist_ok=True)
last_err = ""
used_uri = ""
for idx, zip_uri in enumerate(ordered):
try:
print(f"Downloading: {zip_uri}")
download_file_or_raise(zip_uri, zip_path)
used_uri = zip_uri
last_err = ""
break
except Exception as exc:
last_err = str(exc)
if idx + 1 < len(ordered):
print(f"Download failed, fallback next source: {exc}", file=sys.stderr)
continue
if last_err:
die(last_err)
if expected_sha256:
actual_sha256 = sha256_file(zip_path).lower()
if actual_sha256 != expected_sha256:
die(
f"SHA256 mismatch for {slug}: expected {expected_sha256}, got {actual_sha256}"
)
try:
safe_extract_zip(zip_path, stage_dir)
except zipfile.BadZipFile:
die(f"Downloaded file is not a valid zip archive: {used_uri or ordered[0]}")
if target_dir.exists():
if not force:
die(f"Target exists: {target_dir} (use --force to overwrite)")
shutil.rmtree(target_dir)
target_dir.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(stage_dir), str(target_dir))
def normalize_skills_payload(data: Any) -> Dict[str, Any]:
if isinstance(data, dict):
skills = data.get("skills")
if isinstance(skills, list):
return data
die('Index JSON must include a "skills" array.')
if isinstance(data, list):
return {"skills": data}
die("Index JSON must be an object or array.")
return {"skills": []}
def load_index(index_uri: str) -> Dict[str, Any]:
try:
data = read_json_from_uri(index_uri, timeout=20)
except Exception as exc:
die(str(exc))
return normalize_skills_payload(data)
def index_local_path_or_none(index_uri: str) -> Optional[Path]:
parsed = urllib.parse.urlparse(index_uri)
if parsed.scheme in ("", "file"):
return parse_path_like_uri(index_uri)
return None
def skill_zip_uri(
skill: Dict[str, Any],
slug: str,
index_path: Optional[Path],
files_base_uri: str,
download_url_template: str,
) -> str:
if files_base_uri.strip():
from_base = append_slug_zip(files_base_uri, slug)
if from_base:
return from_base
if index_path is not None:
sibling_files = (index_path.parent / "files" / f"{slug}.zip").resolve()
if sibling_files.exists():
return sibling_files.as_uri()
for key in ("zip_url", "zipUrl", "archive_url", "archiveUrl", "file_url", "fileUrl"):
raw = str(skill.get(key, "")).strip()
if raw:
if urllib.parse.urlparse(raw).scheme:
return raw
return Path(raw).expanduser().resolve().as_uri()
if download_url_template.strip():
return append_slug_zip(download_url_template, slug)
die(
f'Skill "{slug}" has no zip_url and no local archive found. '
"Use --files-base-uri or --download-url-template."
)
return ""
def load_lockfile(install_root: Path) -> Dict[str, Any]:
lock_path = install_root / LOCKFILE_NAME
if not lock_path.exists():
return {"version": 1, "skills": {}}
try:
raw = json.loads(lock_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return {"version": 1, "skills": {}}
if not isinstance(raw, dict):
return {"version": 1, "skills": {}}
if not isinstance(raw.get("skills"), dict):
raw["skills"] = {}
return raw
def save_lockfile(install_root: Path, lock: Dict[str, Any]) -> None:
install_root.mkdir(parents=True, exist_ok=True)
lock_path = install_root / LOCKFILE_NAME
lock_path.write_text(json.dumps(lock, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def resolve_clawhub_lock_path() -> Path:
override = os.environ.get("SKILLHUB_CLAWHUB_LOCK_PATH", "").strip()
if override:
return Path(override).expanduser().resolve()
return Path("~/.openclaw/workspace/.clawhub/lock.json").expanduser().resolve()
def update_clawhub_lock_v1(slug: str, version: str) -> None:
lock_path = resolve_clawhub_lock_path()
if not lock_path.exists():
verbose_log(f"clawhub lock not found, skip sync: {lock_path}")
return
try:
raw = json.loads(lock_path.read_text(encoding="utf-8"))
except Exception:
verbose_log(f"clawhub lock invalid JSON, skip sync: {lock_path}")
return
if not isinstance(raw, dict) or raw.get("version") != 1:
verbose_log(f"clawhub lock version is not 1, skip sync: {lock_path}")
return
skills = raw.get("skills")
if not isinstance(skills, dict):
skills = {}
raw["skills"] = skills
skills[slug] = {
"version": version,
"installedAt": int(time.time() * 1000),
}
try:
lock_path.parent.mkdir(parents=True, exist_ok=True)
lock_path.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
verbose_log(f"synced clawhub lock entry: {slug} -> {lock_path}")
except Exception:
verbose_log(f"failed to write clawhub lock, skip: {lock_path}")
def skill_text(skill: Dict[str, Any]) -> str:
tags = skill.get("tags") or []
if not isinstance(tags, list):
tags = []
categories = skill.get("categories") or []
if not isinstance(categories, list):
categories = []
text = " ".join(
[
str(skill.get("slug", "")),
str(skill.get("name", "")),
str(skill.get("description", "")),
str(skill.get("summary", "")),
str(skill.get("version", "")),
" ".join(str(tag) for tag in tags),
" ".join(str(category) for category in categories),
]
)
return text.lower()
def normalize_source_label(value: Any) -> str:
source = str(value or "").strip()
if not source or source.lower() == "unknown":
return "skillhub"
return source
def is_clawhub_url(value: str) -> bool:
try:
host = urllib.parse.urlparse(value).netloc.lower()
except Exception:
return False
return host == "clawhub.ai" or host.endswith(".clawhub.ai")
def fetch_remote_search_results(
search_url: str,
query: str,
limit: int,
timeout: int,
) -> Optional[List[Dict[str, Any]]]:
base = str(search_url or "").strip()
q = str(query or "").strip()
if not base or not q:
return None
try:
parsed = urllib.parse.urlparse(base)
if parsed.scheme not in ("http", "https"):
return None
params = urllib.parse.urlencode({"q": q, "limit": max(1, int(limit))})
full_url = urllib.parse.urlunparse(
(parsed.scheme, parsed.netloc, parsed.path, parsed.params, params, parsed.fragment)
)
req = urllib.request.Request(
full_url,
headers={
"User-Agent": CLI_USER_AGENT,
"Accept": "application/json",
},
)
with urllib.request.urlopen(req, timeout=max(1, int(timeout))) as response:
payload = response.read().decode("utf-8")
raw = json.loads(payload)
if not isinstance(raw, dict):
return None
results = raw.get("results")
if not isinstance(results, list):
return None
out: List[Dict[str, Any]] = []
for item in results:
if not isinstance(item, dict):
continue
slug = str(item.get("slug", "")).strip()
if not slug:
continue
out.append(
{
"slug": slug,
"name": str(item.get("displayName") or item.get("name") or slug).strip() or slug,
"description": str(item.get("summary") or item.get("description") or "").strip(),
"summary": str(item.get("summary") or "").strip(),
"version": str(item.get("version") or "").strip(),
}
)
hard_limit = max(1, int(limit))
if len(out) > hard_limit:
out = out[:hard_limit]
return out
except Exception:
return None
def cmd_search(args: argparse.Namespace) -> None:
query_parts = args.query if isinstance(args.query, list) else [args.query]
query = " ".join(str(part) for part in query_parts).lower().strip()
if not query:
die("search query is required")
remote = fetch_remote_search_results(
search_url=args.search_url,
query=query,
limit=args.search_limit,
timeout=args.search_timeout,
)
if remote is None:
die(f"remote search unavailable: {args.search_url}")
if bool(getattr(args, "json_output", False)):
print(
json.dumps(
{
"query": query,
"count": len(remote),
"results": remote,
},
ensure_ascii=False,
)
)
return
if not remote:
print("No skills found.")
return
print('You can use "skillhub install [skill]" to install.')
for skill in remote:
slug = skill.get("slug", "<unknown>")
name = skill.get("name", slug)
description = skill.get("description", "")
version = skill.get("version", "")
print(f"{slug} {name}")
if description:
print(f" - {description}")
if version:
print(f" - version: {version}")
def download_file_or_raise(url: str, dest: Path) -> None:
parsed = urllib.parse.urlparse(url)
if parsed.scheme == "file":
source_path = parse_path_like_uri(url)
if not source_path.exists():
raise RuntimeError(f"Download failed: local file not found: {source_path}")
shutil.copyfile(source_path, dest)
return
if parsed.scheme == "":
source_path = Path(url).expanduser().resolve()
if source_path.exists():
shutil.copyfile(source_path, dest)
return
req = urllib.request.Request(
url,
headers={
"User-Agent": CLI_USER_AGENT,
"Accept": "application/zip,application/octet-stream,*/*",
},
)
try:
with urllib.request.urlopen(req, timeout=30) as response:
if response.status and response.status >= 400:
raise RuntimeError(f"Download failed ({response.status}) for {url}")
with dest.open("wb") as out:
shutil.copyfileobj(response, out)
except urllib.error.HTTPError as exc:
detail = f"HTTP {exc.code}"
if exc.code == 429:
detail += " (rate limited)"
raise RuntimeError(f"Download failed: {detail} for {url}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"Download failed: {exc.reason} for {url}") from exc
def download_file(url: str, dest: Path) -> None:
try:
download_file_or_raise(url, dest)
except Exception as exc:
die(str(exc))
def sha256_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def safe_extract_zip(zip_path: Path, target_dir: Path) -> None:
with zipfile.ZipFile(zip_path, "r") as zf:
for member in zf.infolist():
member_path = Path(member.filename)
if member_path.is_absolute() or ".." in member_path.parts:
die(f"Unsafe zip path entry detected: {member.filename}")
zf.extractall(target_dir)
def safe_extract_tar(tar_path: Path, target_dir: Path) -> None:
with tarfile.open(tar_path, "r:*") as tf:
for member in tf.getmembers():
member_path = Path(member.name)
if member_path.is_absolute() or ".." in member_path.parts:
die(f"Unsafe tar path entry detected: {member.name}")
try:
tf.extractall(target_dir, filter="data")
except TypeError:
tf.extractall(target_dir)
def find_skill(data: Dict[str, Any], slug: str) -> Optional[Dict[str, Any]]:
for item in data["skills"]:
if isinstance(item, dict) and str(item.get("slug", "")).strip() == slug:
return item
return None
def cmd_install(args: argparse.Namespace) -> None:
data: Dict[str, Any] = {"skills": []}
try:
data = load_index(args.index)
except SystemExit:
print(f"warn: failed to load index ({args.index}), continue with remote/direct install", file=sys.stderr)
skill = find_skill(data, args.slug)
if not skill:
remote = fetch_remote_search_results(
search_url=args.search_url,
query=args.slug,
limit=args.search_limit,
timeout=args.search_timeout,
)
if remote:
exact = next((x for x in remote if str(x.get("slug", "")).strip() == args.slug), None)
if exact:
skill = exact
print(f'info: "{args.slug}" not in index, using remote registry exact match', file=sys.stderr)
else:
print(
f'info: "{args.slug}" not in index, and remote search has no exact slug match; '
"try direct download by slug",
file=sys.stderr,
)
if not skill:
skill = {"slug": args.slug, "name": args.slug, "version": "", "source": "skillhub"}
print(f'info: "{args.slug}" not in index/remote search, try direct download by slug', file=sys.stderr)
primary_zip_url = fill_slug_template(args.primary_download_url_template, args.slug)
if not primary_zip_url:
die("Primary download URL template resolved empty URL")
install_root = Path(args.dir).expanduser().resolve()
target_dir = install_root / args.slug
expected_sha256 = str(skill.get("sha256", "")).strip().lower()
install_zip_to_target_with_fallback(
slug=args.slug,
zip_uris=[primary_zip_url],
target_dir=target_dir,
force=args.force,
expected_sha256=expected_sha256,
)
lock = load_lockfile(install_root)
skills_lock = lock.setdefault("skills", {})
skills_lock[args.slug] = {
"name": skill.get("name", args.slug),
"zip_url": primary_zip_url,
"source": normalize_source_label(skill.get("source")),
"version": str(skill.get("version", "")).strip(),
}
save_lockfile(install_root, lock)
update_clawhub_lock_v1(args.slug, str(skill.get("version", "")).strip())
print(f"Installed: {args.slug} -> {target_dir}")
def cmd_upgrade(args: argparse.Namespace) -> None:
code = run_skills_upgrade(
args,
{
"load_lockfile": load_lockfile,
"save_lockfile": save_lockfile,
"read_json_from_uri": read_json_from_uri,
"extract_update_manifest_info": extract_update_manifest_info,
"resolve_uri_with_base": resolve_uri_with_base,
"version_is_newer": version_is_newer,
"install_zip_to_target": install_zip_to_target,
"skill_config_name": SKILL_CONFIG_NAME,
"skill_meta_name": SKILL_META_NAME,
},
)
if code != 0:
raise SystemExit(code)
def cmd_self_upgrade(args: argparse.Namespace) -> None:
config_path = Path(args.config).expanduser().resolve()
target_path = Path(args.target).expanduser().resolve() if args.target else Path(__file__).resolve()
try:
upgraded, current_version, latest_version = run_self_upgrade_flow(
config_path=config_path,
target_path=target_path,
current_version=args.current_version or CLI_VERSION,
timeout=args.timeout,
check_only=args.check_only,
quiet=False,
)
except Exception as exc:
die(str(exc))
if not upgraded and not args.check_only:
print(f"CLI is up-to-date: current={current_version} latest={latest_version}")
def run_self_upgrade_flow(
config_path: Path,
target_path: Path,
current_version: str,
timeout: int,
check_only: bool,
quiet: bool,
) -> Tuple[bool, str, str]:
manifest_url = resolve_self_update_manifest_url(config_path)
verbose_log(f"fetching manifest: {manifest_url} (timeout={timeout}s)")
manifest = read_json_from_uri(manifest_url, timeout=timeout)
latest_version, package_uri_raw, expected_sha = extract_update_manifest_info(manifest)
if not latest_version:
raise RuntimeError(f"Self-update manifest missing version: {manifest_url}")
if not package_uri_raw:
raise RuntimeError(f"Self-update manifest missing package URL: {manifest_url}")
current = normalize_version_text(current_version or CLI_VERSION)
latest = normalize_version_text(latest_version)
verbose_log(f"version compare: current={current} latest={latest}")
if not version_is_newer(latest, current):
verbose_log("no upgrade needed")
return False, current, latest
package_uri = resolve_uri_with_base(package_uri_raw, config_path.parent)
verbose_log(f"resolved package URI: {package_uri}")
if not quiet:
print(
f"Self-upgrade available: current={current} latest={latest}\n"
f"Manifest: {manifest_url}\n"
f"Package: {package_uri}\n"
f"Target: {target_path}"
)
if check_only:
verbose_log("check-only mode; skip install")
return False, current, latest
with tempfile.TemporaryDirectory(prefix="skillhub-self-upgrade-") as tmp:
package_path = Path(tmp) / "package.bin"
verbose_log(f"downloading package to temp: {package_path}")
download_file_or_raise(package_uri, package_path)
if expected_sha:
verbose_log("sha256 present; verifying package checksum")
actual_sha = sha256_file(package_path).lower()
if actual_sha != expected_sha:
raise RuntimeError(f"Self-upgrade SHA256 mismatch: expected {expected_sha}, got {actual_sha}")
else:
verbose_log("sha256 empty/missing; skip checksum verification")
source_script: Path
source_upgrade_module = None # type: Optional[Path]
source_version_file = None # type: Optional[Path]
source_metadata_file = None # type: Optional[Path]
source_find_skill_template = None # type: Optional[Path]
source_preference_skill_template = None # type: Optional[Path]
if zipfile.is_zipfile(package_path):
extract_dir = Path(tmp) / "extract"
extract_dir.mkdir(parents=True, exist_ok=True)
safe_extract_zip(package_path, extract_dir)
found = find_cli_script_in_extracted(extract_dir)
if not found:
raise RuntimeError("Self-upgrade zip does not contain skills_store_cli.py")
source_script = found
source_upgrade_module = find_peer_file_in_extracted(extract_dir, "skills_upgrade.py")
source_version_file = find_peer_file_in_extracted(extract_dir, CLI_VERSION_FILE_NAME)
source_metadata_file = find_peer_file_in_extracted(extract_dir, CLI_METADATA_FILE_NAME)
source_find_skill_template = find_skill_file_in_extracted(extract_dir, "SKILL.md")
source_preference_skill_template = find_skill_file_in_extracted(
extract_dir,
"SKILL.skillhub-preference.md",
)
elif tarfile.is_tarfile(package_path):
extract_dir = Path(tmp) / "extract"
extract_dir.mkdir(parents=True, exist_ok=True)
safe_extract_tar(package_path, extract_dir)
found = find_cli_script_in_extracted(extract_dir)
if not found:
raise RuntimeError("Self-upgrade tar package does not contain skills_store_cli.py")
source_script = found
source_upgrade_module = find_peer_file_in_extracted(extract_dir, "skills_upgrade.py")
source_version_file = find_peer_file_in_extracted(extract_dir, CLI_VERSION_FILE_NAME)
source_metadata_file = find_peer_file_in_extracted(extract_dir, CLI_METADATA_FILE_NAME)
source_find_skill_template = find_skill_file_in_extracted(extract_dir, "SKILL.md")
source_preference_skill_template = find_skill_file_in_extracted(
extract_dir,
"SKILL.skillhub-preference.md",
)
else:
source_script = package_path
try:
raw = source_script.read_text(encoding="utf-8")
except UnicodeDecodeError as exc:
raise RuntimeError(f"Self-upgrade package is not a text python script: {exc}") from exc
if "def main()" not in raw:
raise RuntimeError("Self-upgrade package content check failed (missing def main())")
backup_path = target_path.with_suffix(target_path.suffix + ".bak")
if target_path.exists():
verbose_log(f"writing backup: {backup_path}")
shutil.copyfile(target_path, backup_path)
verbose_log(f"replacing target script: {target_path}")
shutil.copyfile(source_script, target_path)
target_path.chmod(0o755)
target_upgrade_module = target_path.parent / "skills_upgrade.py"
if source_upgrade_module and source_upgrade_module.exists():
verbose_log(f"updating companion module: {target_upgrade_module}")
shutil.copyfile(source_upgrade_module, target_upgrade_module)
target_metadata_file = target_path.parent / CLI_METADATA_FILE_NAME
if source_metadata_file and source_metadata_file.exists():
verbose_log(f"updating metadata file from package: {target_metadata_file}")
shutil.copyfile(source_metadata_file, target_metadata_file)
version_file_path = target_path.parent / CLI_VERSION_FILE_NAME
if source_version_file and source_version_file.exists():
verbose_log(f"updating version file from package: {version_file_path}")
shutil.copyfile(source_version_file, version_file_path)
else:
verbose_log(f"updating version file: {version_file_path} -> {latest}")
version_file_path.write_text(
json.dumps({"version": latest}, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
try:
run_post_upgrade_plugin_migration(
latest_version=latest,
find_skill_template=source_find_skill_template,
preference_skill_template=source_preference_skill_template,
)
except Exception as exc:
verbose_log(f"post-upgrade migration failed; continue: {exc}")
if not quiet:
print(f"Self-upgrade complete: {target_path} -> version {latest}")
print(f"Backup saved at: {backup_path}")
return True, current, latest
def startup_self_upgrade_check(config_path: Optional[Path] = None) -> bool:
if config_path is None:
config_path = Path(f"{DEFAULT_CLI_HOME}/{CLI_CONFIG_NAME}").expanduser().resolve()
if not config_path.exists():
verbose_log(f"startup check: config not found at {config_path}; will use default manifest")
try:
upgraded, _, _ = run_self_upgrade_flow(
config_path=config_path,
target_path=Path(__file__).resolve(),
current_version=CLI_VERSION,
timeout=SELF_UPGRADE_CHECK_TIMEOUT_SECONDS,
check_only=False,
quiet=True,
)
verbose_log(f"startup check result: upgraded={upgraded}")
return upgraded
except BaseException:
verbose_log("startup check failed; continue without upgrade")
return False
def cmd_list(args: argparse.Namespace) -> None:
install_root = Path(args.dir).expanduser().resolve()
lock = load_lockfile(install_root)
skills = lock.get("skills", {})
if not skills:
print("No installed skills.")
return
for slug, meta in sorted(skills.items()):
if isinstance(meta, dict):
version = str(meta.get("version", "")).strip()
print(f"{slug} {version}")
else:
print(f"{slug} ")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Minimal local skills store CLI")
parser.add_argument(
"-v",
"--version",
action="version",
version=f"skillhub {CLI_VERSION}",
help="Show skillhub CLI version and exit",
)
parser.add_argument(
"--index",
default=DEFAULT_INDEX_URI,
help=(
"Skills index JSON path/URI. Supports http://, https://, file://, or local paths "
'(default from metadata.json, e.g. "https://.../skills.json").'
),
)
parser.add_argument(
"--dir",
default=DEFAULT_INSTALL_ROOT,
help='Install root directory (default: "./skills")',
)
parser.add_argument(
"--skip-self-upgrade",
action="store_true",
help="Skip startup self-upgrade check for this run",
)
subparsers = parser.add_subparsers(dest="command", required=True)
search = subparsers.add_parser("search", help="Search skills")
search.add_argument("query", nargs="*", help="Search query words")
search.add_argument(
"--search-url",
default=DEFAULT_SEARCH_URL,
help=(
"Remote search API URL (default from SKILLHUB_SEARCH_URL / metadata / built-in). "
'Example: "http://.../api/v1/search".'
),
)
search.add_argument(
"--search-limit",
type=int,
default=20,
help="Remote search limit (default: 20)",
)
search.add_argument(
"--search-timeout",
type=int,
default=6,
help="Remote search timeout seconds (default: 6)",
)
search.add_argument(
"--json",
dest="json_output",
action="store_true",
help="Print search results as JSON",
)
search.set_defaults(func=cmd_search)
install = subparsers.add_parser("install", help="Install a skill by slug")
install.add_argument("slug", help="Skill slug")
install.add_argument(
"--files-base-uri",
default="",
help=(
"Base URI/path for local archives. Supports file://, local paths, or "
"URL template with {slug} (examples: file://./cli/files, ./cli/files, "
"https://example.com/files/{slug}.zip)."
),
)
install.add_argument(
"--download-url-template",
default=DEFAULT_SKILLS_DOWNLOAD_URL_TEMPLATE,
help=(
"Fallback download URL template when zip_url/local file is missing "
'(default from metadata.json, e.g. "https://.../skills/{slug}.zip").'
),
)
install.add_argument(
"--primary-download-url-template",
default=DEFAULT_PRIMARY_DOWNLOAD_URL_TEMPLATE,
help=(
"Primary download URL template for install (supports {slug}). "
"This is the only remote source used by install."
),
)
install.add_argument(
"--search-url",
default=DEFAULT_SEARCH_URL,
help="Remote search API URL used when slug is not found in index.",
)
install.add_argument(
"--search-limit",
type=int,
default=20,
help="Remote search limit for install fallback (default: 20)",
)
install.add_argument(
"--search-timeout",
type=int,
default=6,
help="Remote search timeout for install fallback in seconds (default: 6)",
)
install.add_argument("--force", action="store_true", help="Overwrite existing target directory")
install.set_defaults(func=cmd_install)
upgrade = subparsers.add_parser(
"upgrade",
help="Upgrade installed skills based on each skill's config.json update URL",
)
upgrade.add_argument(
"slug",
nargs="?",
default="",
help="Optional skill slug. If omitted, upgrade all skills in lockfile.",
)
upgrade.add_argument(
"--check-only",
action="store_true",
help="Only check and print available upgrades without installing",
)
upgrade.add_argument(
"--timeout",
type=int,
default=20,
help="Timeout in seconds for manifest fetch (default: 20)",
)
upgrade.set_defaults(func=cmd_upgrade)
list_cmd = subparsers.add_parser("list", help="List locally installed skills")
list_cmd.set_defaults(func=cmd_list)
self_upgrade = subparsers.add_parser(
"self-upgrade",
help="Self-upgrade this CLI from update manifest URL in config.json",
)
self_upgrade.add_argument(
"--config",
default=f"{DEFAULT_CLI_HOME}/config.json",
help=(
'Self-upgrade config path (default: "~/.skillhub/config.json"). '
"If missing or no URL configured, falls back to the built-in manifest URL."
),
)
self_upgrade.add_argument(
"--target",
default="",
help="CLI script target path to replace (default: current running script path)",
)
self_upgrade.add_argument(
"--current-version",
default=CLI_VERSION,
help=f'Current CLI version for comparison (default: "{CLI_VERSION}")',
)
self_upgrade.add_argument(
"--timeout",
type=int,
default=20,
help="Timeout in seconds for manifest fetch/download requests (default: 20)",
)
self_upgrade.add_argument(
"--check-only",
action="store_true",
help="Only check and print available CLI upgrade without replacing files",
)
self_upgrade.set_defaults(func=cmd_self_upgrade)
return parser
def main() -> None:
cleanup_legacy_openclaw_plugin_files()
parser = build_parser()
args = parser.parse_args()
config_path = Path(f"{DEFAULT_CLI_HOME}/{CLI_CONFIG_NAME}").expanduser().resolve()
command = str(getattr(args, "command", "")).strip()
# Automatic OTA check runs for all commands except explicit self-upgrade.
should_check_startup_upgrade = (
command != "self-upgrade"
and os.environ.get(SELF_UPGRADE_REEXEC_ENV, "") != "1"
and not bool(getattr(args, "skip_self_upgrade", False))
and should_run_startup_self_upgrade(config_path)
)
if should_check_startup_upgrade:
upgraded = startup_self_upgrade_check(config_path=config_path)
if upgraded:
env = os.environ.copy()
env[SELF_UPGRADE_REEXEC_ENV] = "1"
os.execve(sys.executable, [sys.executable, *sys.argv], env)
args.func(args)
if __name__ == "__main__":
main()