184 lines
6.6 KiB
Python
184 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Skill upgrade flow extracted from the main CLI module."""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Dict, List
|
|
|
|
|
|
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 _extract_update_url(config: Dict[str, Any], skill_dir: Path, resolve_uri_with_base: Callable[[str, Path], str]) -> str:
|
|
direct = _first_non_empty_string(
|
|
config,
|
|
["update_url", "updateUrl", "upgrade_url", "upgradeUrl", "manifest_url", "manifestUrl"],
|
|
)
|
|
if direct:
|
|
return resolve_uri_with_base(direct, skill_dir)
|
|
|
|
for container_key in ("update", "upgrade", "autoupdate"):
|
|
nested = _as_dict(config.get(container_key))
|
|
url_value = _first_non_empty_string(nested, ["url", "uri", "manifest", "manifest_url"])
|
|
if url_value:
|
|
return resolve_uri_with_base(url_value, skill_dir)
|
|
return ""
|
|
|
|
|
|
def _read_installed_skill_version(
|
|
skill_dir: Path,
|
|
lock_meta: Dict[str, Any],
|
|
skill_meta_name: str,
|
|
) -> str:
|
|
lock_version = _first_non_empty_string(lock_meta, ["version"])
|
|
if lock_version:
|
|
return lock_version
|
|
|
|
meta_path = skill_dir / skill_meta_name
|
|
if meta_path.exists():
|
|
try:
|
|
raw = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
if isinstance(raw, dict):
|
|
meta_version = _first_non_empty_string(raw, ["version"])
|
|
if meta_version:
|
|
return meta_version
|
|
except json.JSONDecodeError:
|
|
return ""
|
|
return ""
|
|
|
|
|
|
def cmd_upgrade(args: Any, deps: Dict[str, Any]) -> int:
|
|
load_lockfile = deps["load_lockfile"]
|
|
save_lockfile = deps["save_lockfile"]
|
|
read_json_from_uri = deps["read_json_from_uri"]
|
|
extract_update_manifest_info = deps["extract_update_manifest_info"]
|
|
resolve_uri_with_base = deps["resolve_uri_with_base"]
|
|
version_is_newer = deps["version_is_newer"]
|
|
install_zip_to_target = deps["install_zip_to_target"]
|
|
skill_config_name = deps["skill_config_name"]
|
|
skill_meta_name = deps["skill_meta_name"]
|
|
|
|
install_root = Path(args.dir).expanduser().resolve()
|
|
lock = load_lockfile(install_root)
|
|
skills = lock.get("skills", {})
|
|
if not isinstance(skills, dict):
|
|
skills = {}
|
|
|
|
if args.slug:
|
|
targets = [args.slug]
|
|
else:
|
|
targets = sorted(skills.keys())
|
|
if not targets:
|
|
raise SystemExit(f"No installed skills in lockfile: {install_root / '.skills_store_lock.json'}")
|
|
|
|
checked = 0
|
|
upgraded = 0
|
|
skipped = 0
|
|
failed = 0
|
|
|
|
for slug in targets:
|
|
checked += 1
|
|
target_dir = install_root / slug
|
|
if not target_dir.exists():
|
|
print(f"[{slug}] skip: skill directory not found: {target_dir}")
|
|
skipped += 1
|
|
continue
|
|
|
|
lock_meta = skills.get(slug)
|
|
lock_meta_dict = lock_meta if isinstance(lock_meta, dict) else {}
|
|
config_path = target_dir / skill_config_name
|
|
if not config_path.exists():
|
|
print(f"[{slug}] skip: {skill_config_name} not found")
|
|
skipped += 1
|
|
continue
|
|
|
|
try:
|
|
raw_config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError as exc:
|
|
print(f"[{slug}] fail: invalid {skill_config_name}: {exc}")
|
|
failed += 1
|
|
continue
|
|
if not isinstance(raw_config, dict):
|
|
print(f"[{slug}] fail: {skill_config_name} must be a JSON object")
|
|
failed += 1
|
|
continue
|
|
|
|
update_url = _extract_update_url(raw_config, target_dir, resolve_uri_with_base)
|
|
if not update_url:
|
|
print(f"[{slug}] skip: missing update URL in {skill_config_name}")
|
|
skipped += 1
|
|
continue
|
|
|
|
try:
|
|
preserved_config_text = config_path.read_text(encoding="utf-8")
|
|
manifest = read_json_from_uri(update_url, timeout=args.timeout)
|
|
latest_version, package_uri, expected_sha = extract_update_manifest_info(manifest)
|
|
if not latest_version:
|
|
print(f"[{slug}] fail: update manifest missing version: {update_url}")
|
|
failed += 1
|
|
continue
|
|
if not package_uri:
|
|
print(f"[{slug}] fail: update manifest missing package URL: {update_url}")
|
|
failed += 1
|
|
continue
|
|
|
|
current_version = _read_installed_skill_version(target_dir, lock_meta_dict, skill_meta_name)
|
|
if not version_is_newer(latest_version, current_version):
|
|
print(f"[{slug}] up-to-date: current={current_version or '<unknown>'} latest={latest_version}")
|
|
skipped += 1
|
|
continue
|
|
|
|
package_uri = resolve_uri_with_base(package_uri, target_dir)
|
|
if args.check_only:
|
|
print(
|
|
f"[{slug}] upgrade available: current={current_version or '<unknown>'} "
|
|
f"latest={latest_version} package={package_uri}"
|
|
)
|
|
continue
|
|
|
|
install_zip_to_target(
|
|
slug=slug,
|
|
zip_uri=package_uri,
|
|
target_dir=target_dir,
|
|
force=True,
|
|
expected_sha256=expected_sha,
|
|
)
|
|
restored_config_path = target_dir / skill_config_name
|
|
if not restored_config_path.exists():
|
|
restored_config_path.write_text(preserved_config_text, encoding="utf-8")
|
|
|
|
updated_meta = dict(lock_meta_dict)
|
|
updated_meta["zip_url"] = package_uri
|
|
updated_meta["version"] = latest_version
|
|
updated_meta["update_url"] = update_url
|
|
if not updated_meta.get("name"):
|
|
updated_meta["name"] = slug
|
|
if not updated_meta.get("source"):
|
|
updated_meta["source"] = "unknown"
|
|
skills[slug] = updated_meta
|
|
upgraded += 1
|
|
print(f"[{slug}] upgraded: {current_version or '<unknown>'} -> {latest_version}")
|
|
except SystemExit:
|
|
failed += 1
|
|
continue
|
|
except Exception as exc: # noqa: BLE001
|
|
print(f"[{slug}] fail: {exc}")
|
|
failed += 1
|
|
|
|
lock["skills"] = skills
|
|
save_lockfile(install_root, lock)
|
|
print(
|
|
f"upgrade done: checked={checked} upgraded={upgraded} "
|
|
f"skipped={skipped} failed={failed} dir={install_root}"
|
|
)
|
|
return 2 if failed > 0 else 0
|