#!/usr/bin/env python3 """zwo.py — CLI for creating, validating, previewing and publishing Zwift .zwo workouts. Commands -------- build [-o out.zwo] [--preview] [--publish] from-template [--title T] [-o out.zwo] [--preview] [--publish] validate parse [--json] render templates Exit codes: 0 = ok, 1 = validation/usage error. """ from __future__ import annotations import argparse import json import os import re import subprocess import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import zwo_builder as zb # noqa: E402 HOSTING_SCRIPT = Path("/home/node/mop-skills/mop-skills/skills/hosting/scripts/publish_file.py") def _slug(text: str) -> str: s = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") return s or "workout" def _load_spec(path: str) -> dict: with open(path, encoding="utf-8") as fh: return json.load(fh) def _publish(zwo_path: Path, title: str) -> str | None: """Publish the .zwo via the hosting skill. Returns the public URL or None.""" if not HOSTING_SCRIPT.exists(): print(f"⚠️ hosting publisher not found at {HOSTING_SCRIPT}", file=sys.stderr) return None cmd = [sys.executable, str(HOSTING_SCRIPT), "--file", str(zwo_path), "--category", "docs", "--slug", _slug(title)] try: out = subprocess.run(cmd, capture_output=True, text=True, timeout=120) except Exception as exc: # noqa: BLE001 print(f"⚠️ hosting publish failed: {exc}", file=sys.stderr) return None url = None for line in out.stdout.splitlines(): if line.startswith("URL:"): url = line.split("URL:", 1)[1].strip() if not url: print(out.stdout, out.stderr, file=sys.stderr) return url def cmd_build(args: argparse.Namespace) -> int: spec = _load_spec(args.spec) warnings = zb.validate_spec(spec) out = Path(args.output or f"{_slug(spec['name'])}.zwo") zb.write_zwo(spec, str(out)) print(f"✅ {out} ({out.stat().st_size} bytes)") for w in warnings: print(f"⚠️ {w}") if args.preview: print() print(zb.render_text(spec)) if args.publish: url = _publish(out, spec["name"]) if url: print(f"🔗 {url}") return 0 def cmd_from_template(args: argparse.Namespace) -> int: spec = zb.from_template(args.name, args.title) out = Path(args.output or f"{_slug(spec['name'])}.zwo") zb.write_zwo(spec, str(out)) print(f"✅ {out} ({out.stat().st_size} bytes)") print() print(zb.render_text(spec)) if args.publish: url = _publish(out, spec["name"]) if url: print(f"🔗 {url}") return 0 def cmd_validate(args: argparse.Namespace) -> int: ok, messages = zb.validate_file(args.file) if ok: print(f"✅ {args.file} is a valid .zwo file") for m in messages: print(f"⚠️ {m}") return 0 print(f"❌ {args.file} is invalid:") for m in messages: print(f" - {m}") return 1 def cmd_parse(args: argparse.Namespace) -> int: spec = zb.parse_file(args.file) if args.json: print(json.dumps(spec, ensure_ascii=False, indent=2)) else: print(zb.render_text(spec)) return 0 def cmd_render(args: argparse.Namespace) -> int: if args.file.endswith(".json"): spec = _load_spec(args.file) else: spec = zb.parse_file(args.file) print(zb.render_text(spec)) return 0 def cmd_templates(_: argparse.Namespace) -> int: for t in zb.templates(): print(t) return 0 def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(prog="zwo.py", description="Zwift .zwo workout toolkit (bike-only)") sub = parser.add_subparsers(dest="command", required=True) p = sub.add_parser("build", help="build a .zwo from a JSON spec") p.add_argument("spec") p.add_argument("-o", "--output") p.add_argument("--preview", action="store_true", help="print the human-readable preview") p.add_argument("--publish", action="store_true", help="upload via the hosting skill") p.set_defaults(func=cmd_build) p = sub.add_parser("from-template", help="build from a built-in deterministic template") p.add_argument("name") p.add_argument("--title") p.add_argument("-o", "--output") p.add_argument("--publish", action="store_true") p.set_defaults(func=cmd_from_template) p = sub.add_parser("validate", help="validate an existing .zwo file") p.add_argument("file") p.set_defaults(func=cmd_validate) p = sub.add_parser("parse", help="parse a .zwo into a JSON spec") p.add_argument("file") p.add_argument("--json", action="store_true") p.set_defaults(func=cmd_parse) p = sub.add_parser("render", help="render a human-readable preview") p.add_argument("file") p.set_defaults(func=cmd_render) p = sub.add_parser("templates", help="list built-in templates") p.set_defaults(func=cmd_templates) args = parser.parse_args(argv) try: return args.func(args) except zb.ZwoValidationError as exc: print(f"❌ validation error: {exc}", file=sys.stderr) return 1 except (OSError, json.JSONDecodeError) as exc: print(f"❌ {exc}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())