@mmmbuto/nexuscrew 0.8.23 → 0.8.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,550 @@
1
+ #!/usr/bin/env python3
2
+ """Safe multi-client CLI for Alibaba Token Plan media generation.
3
+
4
+ The script is dependency-free and can be invoked by Claude Code, Codex,
5
+ Codex-VL, or Pi. Credentials are read only from ALIBABA_CODE_API_KEY and are
6
+ never printed or persisted.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import base64
13
+ import contextlib
14
+ import datetime as dt
15
+ import fcntl
16
+ import ipaddress
17
+ import json
18
+ import mimetypes
19
+ import os
20
+ from pathlib import Path
21
+ import re
22
+ import secrets
23
+ import sys
24
+ import tempfile
25
+ from typing import Any, Iterator
26
+ import urllib.error
27
+ import urllib.parse
28
+ import urllib.request
29
+
30
+
31
+ HOST = "https://token-plan.ap-southeast-1.maas.aliyuncs.com"
32
+ IMAGE_URL = HOST + "/api/v1/services/aigc/multimodal-generation/generation"
33
+ VIDEO_URL = HOST + "/api/v1/services/aigc/video-generation/video-synthesis"
34
+ TASK_URL = HOST + "/api/v1/tasks/{task_id}"
35
+ KEY_ENV = "ALIBABA_CODE_API_KEY"
36
+
37
+ IMAGE_MODELS = ("wan2.7-image", "wan2.7-image-pro")
38
+ VIDEO_MODELS = ("happyhorse-1.1-t2v", "happyhorse-1.1-i2v")
39
+ VIDEO_RATIOS = ("16:9", "9:16", "1:1", "4:3", "3:4", "4:5", "5:4", "9:21", "21:9")
40
+ IMAGE_MIME = {
41
+ ".jpg": "image/jpeg",
42
+ ".jpeg": "image/jpeg",
43
+ ".png": "image/png",
44
+ ".bmp": "image/bmp",
45
+ ".webp": "image/webp",
46
+ }
47
+ I2V_MIME = {key: value for key, value in IMAGE_MIME.items() if key != ".bmp"}
48
+ MAX_INPUT_BYTES = 20 * 1024 * 1024
49
+ MAX_RESPONSE_BYTES = 2 * 1024 * 1024
50
+ MAX_IMAGE_DOWNLOAD = 64 * 1024 * 1024
51
+ MAX_VIDEO_DOWNLOAD = 1024 * 1024 * 1024
52
+ TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
53
+
54
+
55
+ class UsageError(RuntimeError):
56
+ """A safe, user-actionable validation error."""
57
+
58
+
59
+ def _emit(value: Any) -> None:
60
+ print(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True))
61
+
62
+
63
+ def _require_key() -> str:
64
+ key = os.environ.get(KEY_ENV, "")
65
+ if not key:
66
+ raise UsageError(f"{KEY_ENV} is not set in this process environment")
67
+ if any(char.isspace() for char in key):
68
+ raise UsageError(f"{KEY_ENV} contains whitespace and was rejected")
69
+ return key
70
+
71
+
72
+ def _weighted_prompt_length(prompt: str) -> int:
73
+ # HappyHorse documents 5,000 non-Chinese or 2,500 Chinese characters.
74
+ return sum(2 if "\u3400" <= char <= "\u9fff" else 1 for char in prompt)
75
+
76
+
77
+ def _validate_prompt(prompt: str, *, video: bool = False, optional: bool = False) -> str:
78
+ prompt = prompt.strip()
79
+ if not prompt and not optional:
80
+ raise UsageError("prompt must not be empty")
81
+ if video:
82
+ if _weighted_prompt_length(prompt) > 5000:
83
+ raise UsageError("video prompt exceeds the documented weighted limit")
84
+ elif len(prompt) > 5000:
85
+ raise UsageError("image prompt exceeds 5,000 characters")
86
+ return prompt
87
+
88
+
89
+ def _inside_home(path: Path) -> bool:
90
+ home = Path.home().resolve()
91
+ try:
92
+ path.relative_to(home)
93
+ return True
94
+ except ValueError:
95
+ return False
96
+
97
+
98
+ def _reject_symlink_components(path: Path) -> None:
99
+ absolute = path.expanduser().absolute()
100
+ current = Path(absolute.anchor)
101
+ for part in absolute.parts[1:]:
102
+ current = current / part
103
+ if current.is_symlink():
104
+ raise UsageError(f"symlink input path rejected: {path}")
105
+
106
+
107
+ @contextlib.contextmanager
108
+ def _submit_lock() -> Iterator[None]:
109
+ """Permit one generation POST per local user across CLI processes."""
110
+ directory = Path.home() / ".cache" / "alibaba-token-media"
111
+ _reject_symlink_components(directory)
112
+ directory.mkdir(mode=0o700, parents=True, exist_ok=True)
113
+ directory = directory.resolve(strict=True)
114
+ if not _inside_home(directory):
115
+ raise UsageError("submit lock directory escaped the current user's home")
116
+ os.chmod(directory, 0o700)
117
+ lock_path = directory / "submit.lock"
118
+ flags = os.O_RDWR | os.O_CREAT
119
+ if hasattr(os, "O_NOFOLLOW"):
120
+ flags |= os.O_NOFOLLOW
121
+ try:
122
+ fd = os.open(lock_path, flags, 0o600)
123
+ except OSError as exc:
124
+ raise UsageError(f"cannot open private submit lock: {exc}") from None
125
+ locked = False
126
+ try:
127
+ os.fchmod(fd, 0o600)
128
+ try:
129
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
130
+ locked = True
131
+ except BlockingIOError:
132
+ raise UsageError("another media generation submit is already in progress") from None
133
+ yield
134
+ finally:
135
+ if locked:
136
+ fcntl.flock(fd, fcntl.LOCK_UN)
137
+ os.close(fd)
138
+
139
+
140
+ def _detect_image_mime(path: Path, allowed: dict[str, str]) -> str:
141
+ suffix = path.suffix.lower()
142
+ mime = allowed.get(suffix)
143
+ if not mime:
144
+ raise UsageError(f"unsupported image extension: {suffix or '[none]'}")
145
+ with path.open("rb") as handle:
146
+ head = handle.read(16)
147
+ valid = {
148
+ "image/jpeg": head.startswith(b"\xff\xd8\xff"),
149
+ "image/png": head.startswith(b"\x89PNG\r\n\x1a\n"),
150
+ "image/bmp": head.startswith(b"BM"),
151
+ "image/webp": head.startswith(b"RIFF") and head[8:12] == b"WEBP",
152
+ }[mime]
153
+ if not valid:
154
+ raise UsageError(f"file signature does not match {suffix}: {path}")
155
+ return mime
156
+
157
+
158
+ def _local_image_data(raw: str, *, i2v: bool = False) -> str:
159
+ supplied = Path(raw).expanduser()
160
+ _reject_symlink_components(supplied)
161
+ path = supplied.resolve(strict=True)
162
+ if not _inside_home(path):
163
+ raise UsageError("local image must be under the current user's home")
164
+ if not path.is_file():
165
+ raise UsageError(f"local image is not a regular file: {path}")
166
+ size = path.stat().st_size
167
+ if size <= 0 or size > MAX_INPUT_BYTES:
168
+ raise UsageError("local image must be between 1 byte and 20 MiB")
169
+ mime = _detect_image_mime(path, I2V_MIME if i2v else IMAGE_MIME)
170
+ encoded = base64.b64encode(path.read_bytes()).decode("ascii")
171
+ return f"data:{mime};base64,{encoded}"
172
+
173
+
174
+ def _public_https_url(raw: str) -> str:
175
+ parsed = urllib.parse.urlsplit(raw)
176
+ if parsed.scheme != "https" or not parsed.hostname:
177
+ raise UsageError("remote image must use a public HTTPS URL")
178
+ if parsed.username or parsed.password or parsed.port not in (None, 443):
179
+ raise UsageError("remote image URL contains forbidden authority fields")
180
+ host = parsed.hostname.rstrip(".").lower()
181
+ if host == "localhost" or host.endswith((".localhost", ".local", ".internal")):
182
+ raise UsageError("local or internal remote-image host rejected")
183
+ try:
184
+ address = ipaddress.ip_address(host)
185
+ except ValueError:
186
+ address = None
187
+ if address and not address.is_global:
188
+ raise UsageError("non-public remote-image IP rejected")
189
+ return raw
190
+
191
+
192
+ def _image_value(raw: str, *, i2v: bool = False) -> str:
193
+ if raw.startswith(("http://", "https://")):
194
+ return _public_https_url(raw)
195
+ return _local_image_data(raw, i2v=i2v)
196
+
197
+
198
+ def _safe_payload(payload: dict[str, Any]) -> dict[str, Any]:
199
+ clone = json.loads(json.dumps(payload))
200
+ content = clone.get("input", {}).get("messages", [{}])[0].get("content", [])
201
+ for item in content:
202
+ if "image" in item:
203
+ item["image"] = "[image input omitted]"
204
+ for item in clone.get("input", {}).get("media", []):
205
+ if "url" in item:
206
+ item["url"] = "[first-frame input omitted]"
207
+ return clone
208
+
209
+
210
+ def _request_json(
211
+ url: str,
212
+ *,
213
+ key: str,
214
+ payload: dict[str, Any] | None = None,
215
+ asynchronous: bool = False,
216
+ ) -> dict[str, Any]:
217
+ headers = {
218
+ "Authorization": f"Bearer {key}",
219
+ "Accept": "application/json",
220
+ "User-Agent": "nexuscrew-alibaba-token-media-skill/1",
221
+ }
222
+ data = None
223
+ method = "GET"
224
+ if payload is not None:
225
+ method = "POST"
226
+ headers["Content-Type"] = "application/json"
227
+ data = json.dumps(payload, separators=(",", ":")).encode("utf-8")
228
+ if asynchronous:
229
+ headers["X-DashScope-Async"] = "enable"
230
+ request = urllib.request.Request(url, data=data, headers=headers, method=method)
231
+ try:
232
+ with urllib.request.urlopen(request, timeout=300) as response:
233
+ final = urllib.parse.urlsplit(response.geturl())
234
+ expected = urllib.parse.urlsplit(url)
235
+ if final.scheme != "https" or final.hostname != expected.hostname:
236
+ raise UsageError("provider endpoint redirected outside its fixed HTTPS host")
237
+ body = response.read(MAX_RESPONSE_BYTES + 1)
238
+ except urllib.error.HTTPError as exc:
239
+ # Provider bodies can echo request fields or signed URLs. Keep errors
240
+ # intentionally content-free so secrets and ephemeral URLs never leak.
241
+ exc.read(MAX_RESPONSE_BYTES + 1)
242
+ raise UsageError(f"provider HTTP {exc.code}; response body omitted") from None
243
+ except urllib.error.URLError as exc:
244
+ raise UsageError(f"provider connection failed: {exc.reason}") from None
245
+ if len(body) > MAX_RESPONSE_BYTES:
246
+ raise UsageError("provider response exceeded 2 MiB")
247
+ try:
248
+ result = json.loads(body.decode("utf-8"))
249
+ except (UnicodeDecodeError, json.JSONDecodeError):
250
+ raise UsageError("provider returned an invalid JSON response") from None
251
+ if not isinstance(result, dict):
252
+ raise UsageError("provider returned a non-object JSON response")
253
+ return result
254
+
255
+
256
+ def _output_directory() -> Path:
257
+ date = dt.datetime.now().astimezone().date().isoformat()
258
+ return Path.home() / "Downloads" / "alibaba-token-plan" / date
259
+
260
+
261
+ def _validate_output(raw: str | None) -> Path | None:
262
+ if raw is None:
263
+ return None
264
+ supplied = Path(raw).expanduser().absolute()
265
+ _reject_symlink_components(supplied)
266
+ if not _inside_home(supplied.resolve(strict=False)):
267
+ raise UsageError("output path must remain under the current user's home")
268
+ if supplied.exists():
269
+ raise UsageError("output path already exists; overwrite is forbidden")
270
+ parent = supplied.parent.resolve(strict=True)
271
+ if not _inside_home(parent):
272
+ raise UsageError("output parent must remain under the current user's home")
273
+ return supplied
274
+
275
+
276
+ def _result_url(raw: str) -> str:
277
+ parsed = urllib.parse.urlsplit(raw)
278
+ host = (parsed.hostname or "").rstrip(".").lower()
279
+ if parsed.scheme != "https" or not host.endswith(".aliyuncs.com"):
280
+ raise UsageError("provider result URL is outside the approved Aliyun HTTPS domain")
281
+ if parsed.username or parsed.password or parsed.port not in (None, 443):
282
+ raise UsageError("provider result URL contains forbidden authority fields")
283
+ return raw
284
+
285
+
286
+ def _download(raw_url: str, *, kind: str, output: str | None) -> Path:
287
+ url = _result_url(raw_url)
288
+ explicit = _validate_output(output)
289
+ directory = explicit.parent if explicit else _output_directory()
290
+ _reject_symlink_components(directory)
291
+ if not explicit:
292
+ directory.mkdir(mode=0o700, parents=True, exist_ok=True)
293
+ directory = directory.resolve(strict=True)
294
+ if not _inside_home(directory):
295
+ raise UsageError("download directory escaped the current user's home")
296
+ if not explicit:
297
+ os.chmod(directory, 0o700)
298
+ maximum = MAX_IMAGE_DOWNLOAD if kind == "image" else MAX_VIDEO_DOWNLOAD
299
+ request = urllib.request.Request(
300
+ url,
301
+ headers={"User-Agent": "nexuscrew-alibaba-token-media-skill/1"},
302
+ )
303
+ try:
304
+ with urllib.request.urlopen(request, timeout=300) as response:
305
+ _result_url(response.geturl())
306
+ content_type = response.headers.get_content_type()
307
+ length = response.headers.get("Content-Length")
308
+ if length and int(length) > maximum:
309
+ raise UsageError(f"{kind} download exceeds the configured safety limit")
310
+ if kind == "image" and not content_type.startswith("image/"):
311
+ raise UsageError(f"unexpected image content type: {content_type}")
312
+ if kind == "video" and content_type not in ("video/mp4", "application/octet-stream"):
313
+ raise UsageError(f"unexpected video content type: {content_type}")
314
+ extension = ".mp4" if kind == "video" else mimetypes.guess_extension(content_type) or ".img"
315
+ if explicit:
316
+ target = explicit
317
+ else:
318
+ stamp = dt.datetime.now().astimezone().strftime("%Y%m%d_%H%M%S")
319
+ target = directory / f"{kind}_{stamp}_{secrets.token_hex(3)}{extension}"
320
+ if target.exists():
321
+ raise UsageError("output path already exists; overwrite is forbidden")
322
+ fd, temp_name = tempfile.mkstemp(prefix=".download-", dir=directory)
323
+ total = 0
324
+ try:
325
+ with os.fdopen(fd, "wb") as handle:
326
+ while True:
327
+ chunk = response.read(1024 * 1024)
328
+ if not chunk:
329
+ break
330
+ total += len(chunk)
331
+ if total > maximum:
332
+ raise UsageError(f"{kind} download exceeded the configured safety limit")
333
+ handle.write(chunk)
334
+ if total == 0:
335
+ raise UsageError("provider result download was empty")
336
+ os.link(temp_name, target)
337
+ os.chmod(target, 0o600)
338
+ finally:
339
+ Path(temp_name).unlink(missing_ok=True)
340
+ except urllib.error.URLError as exc:
341
+ raise UsageError(f"result download failed: {exc.reason}") from None
342
+ return target
343
+
344
+
345
+ def _status(_: argparse.Namespace) -> None:
346
+ _emit({
347
+ "configured": bool(os.environ.get(KEY_ENV)),
348
+ "credential_env": KEY_ENV,
349
+ "host": HOST,
350
+ "supported_clients": ["claude-code", "codex", "codex-vl", "pi"],
351
+ "image_models": list(IMAGE_MODELS),
352
+ "video_models": list(VIDEO_MODELS),
353
+ "lite_defaults": {"image": "wan2.7-image/1K/n=1", "video": "720P/3s/n=1"},
354
+ "local_limits": {"batch": False, "concurrent_submits": 1, "automatic_post_retry": False},
355
+ })
356
+
357
+
358
+ def _image(args: argparse.Namespace) -> None:
359
+ prompt = _validate_prompt(args.prompt)
360
+ images = [_image_value(item) for item in args.image]
361
+ if len(images) > 9:
362
+ raise UsageError("Wan accepts at most nine input images")
363
+ if args.size == "4K" and (args.model != "wan2.7-image-pro" or images):
364
+ raise UsageError("4K is only valid for wan2.7-image-pro text-to-image")
365
+ if args.model == "wan2.7-image" and args.size == "4K":
366
+ raise UsageError("wan2.7-image does not support 4K")
367
+ high_cost = args.model == "wan2.7-image-pro" or args.size != "1K"
368
+ if high_cost and not args.confirm_high_cost:
369
+ raise UsageError("Wan Pro, 2K, and 4K require --confirm-high-cost")
370
+ content: list[dict[str, str]] = [{"text": prompt}]
371
+ content.extend({"image": item} for item in images)
372
+ parameters: dict[str, Any] = {
373
+ "n": 1,
374
+ "size": args.size,
375
+ "enable_sequential": False,
376
+ "watermark": args.watermark,
377
+ "thinking_mode": not args.no_thinking,
378
+ }
379
+ if args.seed is not None:
380
+ parameters["seed"] = args.seed
381
+ payload = {
382
+ "model": args.model,
383
+ "input": {"messages": [{"role": "user", "content": content}]},
384
+ "parameters": parameters,
385
+ }
386
+ if args.dry_run:
387
+ _emit({"dry_run": True, "method": "POST", "url": IMAGE_URL, "payload": _safe_payload(payload)})
388
+ return
389
+ if not args.confirm_credit_use:
390
+ raise UsageError("real image generation requires --confirm-credit-use")
391
+ with _submit_lock():
392
+ result = _request_json(IMAGE_URL, key=_require_key(), payload=payload)
393
+ urls: list[str] = []
394
+ for choice in result.get("output", {}).get("choices", []):
395
+ for item in choice.get("message", {}).get("content", []):
396
+ if isinstance(item, dict) and isinstance(item.get("image"), str):
397
+ urls.append(item["image"])
398
+ if len(urls) != 1:
399
+ raise UsageError(f"expected one generated image URL, received {len(urls)}")
400
+ target = _download(urls[0], kind="image", output=args.output)
401
+ _emit({
402
+ "status": "SUCCEEDED",
403
+ "file": str(target),
404
+ "request_id": result.get("request_id"),
405
+ "usage": result.get("usage"),
406
+ })
407
+
408
+
409
+ def _video_submit(args: argparse.Namespace) -> None:
410
+ i2v = args.model == "happyhorse-1.1-i2v"
411
+ prompt = _validate_prompt(args.prompt or "", video=True, optional=i2v)
412
+ if i2v and not args.image:
413
+ raise UsageError("happyhorse-1.1-i2v requires exactly one --image")
414
+ if not i2v and args.image:
415
+ raise UsageError("happyhorse-1.1-t2v does not accept --image")
416
+ if i2v and args.ratio != "16:9":
417
+ raise UsageError("image-to-video follows the input image; do not set --ratio")
418
+ high_cost = args.resolution == "1080P" or args.duration > 5
419
+ if high_cost and not args.confirm_high_cost:
420
+ raise UsageError("1080P or duration above five seconds requires --confirm-high-cost")
421
+ input_data: dict[str, Any] = {}
422
+ if prompt:
423
+ input_data["prompt"] = prompt
424
+ parameters: dict[str, Any] = {
425
+ "resolution": args.resolution,
426
+ "duration": args.duration,
427
+ "watermark": args.watermark,
428
+ }
429
+ if i2v:
430
+ input_data["media"] = [{"type": "first_frame", "url": _image_value(args.image, i2v=True)}]
431
+ else:
432
+ parameters["ratio"] = args.ratio
433
+ if args.seed is not None:
434
+ parameters["seed"] = args.seed
435
+ payload = {"model": args.model, "input": input_data, "parameters": parameters}
436
+ if args.dry_run:
437
+ _emit({"dry_run": True, "method": "POST", "url": VIDEO_URL, "payload": _safe_payload(payload)})
438
+ return
439
+ if not args.confirm_credit_use or not args.confirm_expensive:
440
+ raise UsageError("real video generation requires --confirm-credit-use and --confirm-expensive")
441
+ with _submit_lock():
442
+ result = _request_json(
443
+ VIDEO_URL,
444
+ key=_require_key(),
445
+ payload=payload,
446
+ asynchronous=True,
447
+ )
448
+ output = result.get("output", {})
449
+ task_id = output.get("task_id")
450
+ if not isinstance(task_id, str) or not TASK_ID_RE.fullmatch(task_id):
451
+ raise UsageError("provider did not return a valid task ID")
452
+ _emit({
453
+ "task_id": task_id,
454
+ "task_status": output.get("task_status"),
455
+ "request_id": result.get("request_id"),
456
+ })
457
+
458
+
459
+ def _video_status(args: argparse.Namespace) -> None:
460
+ if not TASK_ID_RE.fullmatch(args.task_id):
461
+ raise UsageError("invalid task ID")
462
+ url = TASK_URL.format(task_id=urllib.parse.quote(args.task_id, safe=""))
463
+ result = _request_json(url, key=_require_key())
464
+ output = result.get("output", {})
465
+ status = output.get("task_status")
466
+ response: dict[str, Any] = {
467
+ "task_id": output.get("task_id", args.task_id),
468
+ "task_status": status,
469
+ "request_id": result.get("request_id"),
470
+ }
471
+ if status == "FAILED":
472
+ response["error"] = "provider reported task failure; response details omitted"
473
+ if status == "SUCCEEDED":
474
+ response["usage"] = result.get("usage")
475
+ if args.download:
476
+ raw_url = output.get("video_url")
477
+ if not isinstance(raw_url, str):
478
+ raise UsageError("successful task response omitted video_url")
479
+ response["file"] = str(_download(raw_url, kind="video", output=args.output))
480
+ elif args.download:
481
+ raise UsageError(f"task is {status!r}; download is available only after SUCCEEDED")
482
+ _emit(response)
483
+
484
+
485
+ def _seed(value: str) -> int:
486
+ number = int(value)
487
+ if not 0 <= number <= 2_147_483_647:
488
+ raise argparse.ArgumentTypeError("seed must be from 0 through 2147483647")
489
+ return number
490
+
491
+
492
+ def _parser() -> argparse.ArgumentParser:
493
+ parser = argparse.ArgumentParser(description=__doc__)
494
+ sub = parser.add_subparsers(dest="command", required=True)
495
+
496
+ status = sub.add_parser("status", help="show allowlist and whether the key is configured")
497
+ status.set_defaults(run=_status)
498
+
499
+ image = sub.add_parser("image", help="generate or edit exactly one Wan image")
500
+ image.add_argument("--prompt", required=True)
501
+ image.add_argument("--model", choices=IMAGE_MODELS, default="wan2.7-image")
502
+ image.add_argument("--size", choices=("1K", "2K", "4K"), default="1K")
503
+ image.add_argument("--image", action="append", default=[], metavar="PATH_OR_HTTPS_URL")
504
+ image.add_argument("--watermark", action="store_true")
505
+ image.add_argument("--no-thinking", action="store_true")
506
+ image.add_argument("--seed", type=_seed)
507
+ image.add_argument("--output")
508
+ image.add_argument("--dry-run", action="store_true")
509
+ image.add_argument("--confirm-credit-use", action="store_true")
510
+ image.add_argument("--confirm-high-cost", action="store_true")
511
+ image.set_defaults(run=_image)
512
+
513
+ video = sub.add_parser("video-submit", help="submit one HappyHorse video task")
514
+ video.add_argument("--prompt")
515
+ video.add_argument("--model", choices=VIDEO_MODELS, default="happyhorse-1.1-t2v")
516
+ video.add_argument("--image", metavar="PATH_OR_HTTPS_URL")
517
+ video.add_argument("--resolution", choices=("720P", "1080P"), default="720P")
518
+ video.add_argument("--duration", type=int, choices=range(3, 16), default=3)
519
+ video.add_argument("--ratio", choices=VIDEO_RATIOS, default="16:9")
520
+ video.add_argument("--watermark", action=argparse.BooleanOptionalAction, default=True)
521
+ video.add_argument("--seed", type=_seed)
522
+ video.add_argument("--dry-run", action="store_true")
523
+ video.add_argument("--confirm-credit-use", action="store_true")
524
+ video.add_argument("--confirm-expensive", action="store_true")
525
+ video.add_argument("--confirm-high-cost", action="store_true")
526
+ video.set_defaults(run=_video_submit)
527
+
528
+ task = sub.add_parser("video-status", help="query one HappyHorse task and optionally download it")
529
+ task.add_argument("task_id")
530
+ task.add_argument("--download", action="store_true")
531
+ task.add_argument("--output")
532
+ task.set_defaults(run=_video_status)
533
+ return parser
534
+
535
+
536
+ def main() -> int:
537
+ try:
538
+ args = _parser().parse_args()
539
+ args.run(args)
540
+ return 0
541
+ except UsageError as exc:
542
+ print(f"error: {exc}", file=sys.stderr)
543
+ return 2
544
+ except (OSError, ValueError) as exc:
545
+ print(f"error: local validation failed: {exc}", file=sys.stderr)
546
+ return 2
547
+
548
+
549
+ if __name__ == "__main__":
550
+ raise SystemExit(main())