feat(skills): add zwift-workout skill (.zwo builder, validator, preview, publishing)
Bike-only Zwift .zwo generator with human-readable preview + confirmation loop. - scripts/zwo_builder.py: spec<->.zwo build/validate/parse/render/TSS estimate - scripts/zwo.py: CLI (build, from-template, validate, parse, render, templates) - references/zwo-format.md: condensed offline format reference - assets/example-vo2max.zwo + tests/test_zwo.py (9 tests) - Publishes via hosting skill; files result into Knowledge Wiki Format ground truth: h4l/zwift-workout-file-reference
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""zwo.py — CLI for creating, validating, previewing and publishing Zwift .zwo workouts.
|
||||
|
||||
Commands
|
||||
--------
|
||||
build <spec.json> [-o out.zwo] [--preview] [--publish]
|
||||
from-template <name> [--title T] [-o out.zwo] [--preview] [--publish]
|
||||
validate <file.zwo>
|
||||
parse <file.zwo> [--json]
|
||||
render <file.zwo|spec.json>
|
||||
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())
|
||||
Reference in New Issue
Block a user